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 ") fmt.Println(" sigil unlink [--dry-run]") fmt.Println(" sigil remove [--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 { return removeOrUnlink(args, false) } func removeCmd(args []string) error { return removeOrUnlink(args, true) } func removeOrUnlink(args []string, isRemove bool) error { _ = isRemove // commands are identical, flag kept for clarity 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 }