444 lines
8.7 KiB
Go
444 lines
8.7 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const version = "0.1.0"
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
args := os.Args[1:]
|
|
|
|
if args[0] == "-v" || args[0] == "--version" {
|
|
fmt.Println("sigil", version)
|
|
os.Exit(0)
|
|
}
|
|
|
|
var err error
|
|
switch args[0] {
|
|
case "apply":
|
|
err = applyCmd(args[1:])
|
|
case "add":
|
|
err = addCmd(args[1:])
|
|
case "unlink":
|
|
err = unlinkCmd(args[1:])
|
|
case "remove":
|
|
err = removeCmd(args[1:])
|
|
case "status":
|
|
err = statusCmd()
|
|
case "help", "-h", "--help":
|
|
usage()
|
|
return
|
|
default:
|
|
err = fmt.Errorf("unknown command %q", args[0])
|
|
}
|
|
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "sigil:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Println("sigil: minimal dotfile symlink manager")
|
|
fmt.Println("usage:")
|
|
fmt.Println(" sigil apply [--prune]")
|
|
fmt.Println(" sigil add <path>")
|
|
fmt.Println(" sigil unlink <package> [--dry-run]")
|
|
fmt.Println(" sigil remove <package> [--dry-run]")
|
|
fmt.Println(" sigil status")
|
|
}
|
|
|
|
func applyCmd(args []string) error {
|
|
prune := false
|
|
for _, arg := range args {
|
|
if arg == "--prune" {
|
|
prune = true
|
|
continue
|
|
}
|
|
return fmt.Errorf("unknown flag %q", arg)
|
|
}
|
|
|
|
repo, err := repoPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
entries, err := os.ReadDir(repo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var stales []string
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
|
continue
|
|
}
|
|
|
|
pkgDir := filepath.Join(repo, entry.Name())
|
|
configPath := filepath.Join(pkgDir, configFileName)
|
|
if _, err := os.Stat(configPath); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
|
|
cfg, err := loadConfig(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", entry.Name(), err)
|
|
}
|
|
|
|
targetRoot, err := selectTarget(cfg)
|
|
if err != nil {
|
|
if errors.Is(err, errTargetDisabled) {
|
|
continue
|
|
}
|
|
return fmt.Errorf("%s: %w", entry.Name(), err)
|
|
}
|
|
|
|
filesDir := filepath.Join(pkgDir, filesDirName)
|
|
if _, err := os.Stat(filesDir); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
|
|
if err := applyPackage(filesDir, targetRoot, cfg); err != nil {
|
|
return fmt.Errorf("%s: %w", entry.Name(), err)
|
|
}
|
|
|
|
stale, err := findStaleLinks(filesDir, targetRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", entry.Name(), err)
|
|
}
|
|
stales = append(stales, stale...)
|
|
}
|
|
|
|
if len(stales) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if prune {
|
|
return removeLinks(stales, false)
|
|
}
|
|
|
|
fmt.Printf("Stale links found: %d\n", len(stales))
|
|
return handleStaleLinks(stales)
|
|
}
|
|
|
|
func addCmd(args []string) error {
|
|
if len(args) < 1 {
|
|
return errors.New("missing path")
|
|
}
|
|
|
|
argPath := args[0]
|
|
if !filepath.IsAbs(argPath) {
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
argPath = filepath.Join(cwd, argPath)
|
|
}
|
|
}
|
|
|
|
absPath, err := filepath.Abs(argPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
info, err := os.Lstat(absPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("%s is a symlink; refusing to add", absPath)
|
|
}
|
|
|
|
repo, err := repoPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(repo, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
defaultTarget := absPath
|
|
if !info.IsDir() {
|
|
defaultTarget = filepath.Dir(absPath)
|
|
}
|
|
|
|
defaultPkg := filepath.Base(defaultTarget)
|
|
if info.IsDir() {
|
|
defaultPkg = filepath.Base(absPath)
|
|
}
|
|
|
|
var pkgName string
|
|
|
|
matchedPkg, err := findPackageByTarget(repo, defaultTarget)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if matchedPkg != "" {
|
|
ok, err := promptYesNo(fmt.Sprintf("Merge into existing package %q?", matchedPkg), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ok {
|
|
pkgName = matchedPkg
|
|
}
|
|
}
|
|
|
|
if pkgName == "" {
|
|
pkgName = promptWithDefault("Package name", defaultPkg)
|
|
if pkgName == "" {
|
|
return errors.New("package name cannot be empty")
|
|
}
|
|
}
|
|
|
|
targetRootInput := defaultTarget
|
|
if pkgName != matchedPkg || matchedPkg == "" {
|
|
targetRootInput = promptWithDefault("Target path", defaultTarget)
|
|
}
|
|
targetRoot, err := filepath.Abs(expandHome(targetRootInput))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pkgDir := filepath.Join(repo, pkgName)
|
|
pkgExists := false
|
|
if _, err := os.Stat(pkgDir); err == nil {
|
|
pkgExists = true
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
filesDir := filepath.Join(pkgDir, filesDirName)
|
|
if err := os.MkdirAll(filesDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
configPath := filepath.Join(pkgDir, configFileName)
|
|
if !pkgExists {
|
|
if err := writeConfig(configPath, targetRoot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if info.IsDir() {
|
|
if pkgExists {
|
|
return errors.New("cannot merge a directory into an existing package yet")
|
|
}
|
|
if err := moveDirContents(absPath, filesDir); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
rel, err := filepath.Rel(targetRoot, absPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.HasPrefix(rel, "..") {
|
|
return fmt.Errorf("path %s is outside target %s", absPath, targetRoot)
|
|
}
|
|
|
|
destPath := filepath.Join(filesDir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Stat(destPath); err == nil {
|
|
return fmt.Errorf("destination already exists: %s", destPath)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
if err := os.Rename(absPath, destPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
cfg, err := loadConfig(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return applyPackage(filesDir, targetRoot, cfg)
|
|
}
|
|
|
|
func unlinkCmd(args []string) error {
|
|
flags, pkgSpec, err := parsePackageFlags(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pkgName, relPath, err := resolvePackageSpec(pkgSpec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
repo, err := repoPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pkgDir := filepath.Join(repo, pkgName)
|
|
configPath := filepath.Join(pkgDir, configFileName)
|
|
cfg, err := loadConfig(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
targetRoot, err := selectTarget(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
filesDir := filepath.Join(pkgDir, filesDirName)
|
|
if _, err := os.Stat(filesDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
if relPath == "" {
|
|
if err := restorePackage(filesDir, targetRoot, flags.dryRun); err != nil {
|
|
return err
|
|
}
|
|
if flags.dryRun {
|
|
return nil
|
|
}
|
|
if err := os.RemoveAll(pkgDir); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("removed package %s\n", pkgName)
|
|
return nil
|
|
}
|
|
|
|
if err := restorePath(filesDir, targetRoot, relPath, flags.dryRun); err != nil {
|
|
return err
|
|
}
|
|
if flags.dryRun {
|
|
return nil
|
|
}
|
|
if err := os.RemoveAll(filepath.Join(filesDir, relPath)); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("removed %s:%s\n", pkgName, relPath)
|
|
return nil
|
|
}
|
|
|
|
func removeCmd(args []string) error {
|
|
flags, pkgSpec, err := parsePackageFlags(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pkgName, relPath, err := resolvePackageSpec(pkgSpec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
repo, err := repoPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pkgDir := filepath.Join(repo, pkgName)
|
|
configPath := filepath.Join(pkgDir, configFileName)
|
|
cfg, err := loadConfig(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
targetRoot, err := selectTarget(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
filesDir := filepath.Join(pkgDir, filesDirName)
|
|
if _, err := os.Stat(filesDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
if relPath == "" {
|
|
if err := restorePackage(filesDir, targetRoot, flags.dryRun); err != nil {
|
|
return err
|
|
}
|
|
if flags.dryRun {
|
|
return nil
|
|
}
|
|
if err := os.RemoveAll(pkgDir); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("removed package %s\n", pkgName)
|
|
return nil
|
|
}
|
|
|
|
if err := restorePath(filesDir, targetRoot, relPath, flags.dryRun); err != nil {
|
|
return err
|
|
}
|
|
|
|
if flags.dryRun {
|
|
return nil
|
|
}
|
|
|
|
if err := os.RemoveAll(filepath.Join(filesDir, relPath)); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("removed %s:%s\n", pkgName, relPath)
|
|
return nil
|
|
}
|
|
|
|
func statusCmd() error {
|
|
repo, err := repoPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
entries, err := os.ReadDir(repo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
|
continue
|
|
}
|
|
|
|
pkgDir := filepath.Join(repo, entry.Name())
|
|
configPath := filepath.Join(pkgDir, configFileName)
|
|
cfg, err := loadConfig(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", entry.Name(), err)
|
|
}
|
|
|
|
targetRoot, err := selectTarget(cfg)
|
|
if err != nil {
|
|
if errors.Is(err, errTargetDisabled) {
|
|
continue
|
|
}
|
|
return fmt.Errorf("%s: %w", entry.Name(), err)
|
|
}
|
|
|
|
filesDir := filepath.Join(pkgDir, filesDirName)
|
|
stale, err := findStaleLinks(filesDir, targetRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", entry.Name(), err)
|
|
}
|
|
if len(stale) == 0 {
|
|
fmt.Printf("%s: ok\n", entry.Name())
|
|
continue
|
|
}
|
|
|
|
fmt.Printf("%s: stale links (%d)\n", entry.Name(), len(stale))
|
|
for _, path := range stale {
|
|
fmt.Printf(" %s\n", path)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|