package main import ( "bufio" "errors" "fmt" "io/fs" "os" "path/filepath" "runtime" "strings" lua "github.com/yuin/gopher-lua" ) const ( configFileName = "config.lua" filesDirName = "files" ) type packageConfig struct { targets map[string]string } func main() { if len(os.Args) < 2 { usage() os.Exit(1) } args := os.Args[1:] var err error switch args[0] { case "apply": err = applyCmd(args[1:]) case "add": err = addCmd(args[1:]) 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 ") } 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.targets) if err != nil { 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); 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) } ok, err := promptYesNo("Found stale links. Prune them?", false) if err != nil { return err } if ok { return removeLinks(stales) } return nil } 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) } reader := bufio.NewReader(os.Stdin) pkgName := promptWithDefault(reader, "Package name", defaultPkg) if pkgName == "" { return errors.New("package name cannot be empty") } targetRootInput := promptWithDefault(reader, "Target path", defaultTarget) targetRoot, err := filepath.Abs(expandHome(targetRootInput)) if err != nil { return err } pkgDir := filepath.Join(repo, pkgName) if _, err := os.Stat(pkgDir); err == nil { return fmt.Errorf("package %q already exists", pkgName) } 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 err := writeConfig(configPath, targetRoot); err != nil { return err } if info.IsDir() { 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.Rename(absPath, destPath); err != nil { return err } } return applyPackage(filesDir, targetRoot) } func applyPackage(filesDir, targetRoot string) error { if err := ensureDir(targetRoot); err != nil { return err } return filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } if path == filesDir { return nil } rel, err := filepath.Rel(filesDir, path) if err != nil { return err } targetPath := filepath.Join(targetRoot, rel) if entry.IsDir() { return ensureDir(targetPath) } srcAbs, err := filepath.Abs(path) if err != nil { return err } return linkFile(srcAbs, targetPath) }) } func linkFile(src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } if info, err := os.Lstat(dst); err == nil { if info.Mode()&os.ModeSymlink != 0 { current, err := os.Readlink(dst) if err != nil { return err } if current == src { return nil } return fmt.Errorf("conflict at %s (points to %s)", dst, current) } return fmt.Errorf("conflict at %s (exists and is not a symlink)", dst) } else if !errors.Is(err, os.ErrNotExist) { return err } return os.Symlink(src, dst) } func ensureDir(path string) error { info, err := os.Lstat(path) if err == nil { if info.IsDir() { return nil } return fmt.Errorf("%s exists and is not a directory", path) } if !errors.Is(err, os.ErrNotExist) { return err } return os.MkdirAll(path, 0o755) } func loadConfig(path string) (*packageConfig, error) { L := lua.NewState() defer L.Close() L.OpenLibs() if err := L.DoFile(path); err != nil { return nil, err } if L.GetTop() == 0 { return nil, errors.New("config.lua must return a table") } value := L.Get(-1) tbl, ok := value.(*lua.LTable) if !ok { return nil, errors.New("config.lua must return a table") } targetVal := tbl.RawGetString("target") targetTbl, ok := targetVal.(*lua.LTable) if !ok { return nil, errors.New("config.target must be a table") } targets := make(map[string]string) targetTbl.ForEach(func(k, v lua.LValue) { ks, ok1 := k.(lua.LString) vs, ok2 := v.(lua.LString) if ok1 && ok2 { targets[string(ks)] = expandHome(string(vs)) } }) if len(targets) == 0 { return nil, errors.New("config.target is empty") } return &packageConfig{targets: targets}, nil } func selectTarget(targets map[string]string) (string, error) { osKey := runtime.GOOS if osKey == "darwin" { osKey = "macos" } if target, ok := targets[osKey]; ok { return expandHome(target), nil } if target, ok := targets["default"]; ok { return expandHome(target), nil } return "", fmt.Errorf("missing target for %s and default", osKey) } func repoPath() (string, error) { if override := os.Getenv("SIGIL_REPO"); override != "" { return filepath.Abs(expandHome(override)) } return filepath.Abs(expandHome("~/.dotfiles")) } func expandHome(path string) string { if path == "~" { home, err := os.UserHomeDir() if err != nil { return path } return home } if strings.HasPrefix(path, "~/") { home, err := os.UserHomeDir() if err != nil { return path } return filepath.Join(home, path[2:]) } return path } func compressHome(path string) string { home, err := os.UserHomeDir() if err != nil { return path } clean := filepath.Clean(path) homeClean := filepath.Clean(home) if clean == homeClean { return "~" } if strings.HasPrefix(clean, homeClean+string(os.PathSeparator)) { rel := strings.TrimPrefix(clean, homeClean+string(os.PathSeparator)) return filepath.Join("~", rel) } return path } func writeConfig(path, targetRoot string) error { osKey := "linux" if runtime.GOOS == "darwin" { osKey = "macos" } prettyTarget := compressHome(targetRoot) content := fmt.Sprintf("return {\n\ttarget = {\n\t\t%s = %q,\n\t\tdefault = %q,\n\t},\n}\n", osKey, prettyTarget, prettyTarget) return os.WriteFile(path, []byte(content), 0o644) } func promptWithDefault(reader *bufio.Reader, label, def string) string { if def != "" { fmt.Printf("%s [%s]: ", label, def) } else { fmt.Printf("%s: ", label) } text, _ := reader.ReadString('\n') text = strings.TrimSpace(text) if text == "" { return def } return text } func promptYesNo(message string, def bool) (bool, error) { reader := bufio.NewReader(os.Stdin) defLabel := "y/N" if def { defLabel = "Y/n" } fmt.Printf("%s [%s]: ", message, defLabel) text, err := reader.ReadString('\n') if err != nil { return false, err } text = strings.TrimSpace(strings.ToLower(text)) if text == "" { return def, nil } return text == "y" || text == "yes", nil } func moveDirContents(srcDir, destDir string) error { entries, err := os.ReadDir(srcDir) if err != nil { return err } for _, entry := range entries { srcPath := filepath.Join(srcDir, entry.Name()) destPath := filepath.Join(destDir, entry.Name()) 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(srcPath, destPath); err != nil { return err } } return nil } func findStaleLinks(filesDir, targetRoot string) ([]string, error) { filesAbs, err := filepath.Abs(filesDir) if err != nil { return nil, err } var stale []string walkErr := filepath.WalkDir(targetRoot, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } if entry.IsDir() { return nil } info, err := entry.Info() if err != nil { return err } if info.Mode()&os.ModeSymlink == 0 { return nil } src, err := os.Readlink(path) if err != nil { return err } if !filepath.IsAbs(src) { src = filepath.Join(filepath.Dir(path), src) } src = filepath.Clean(src) if !strings.HasPrefix(src, filesAbs+string(os.PathSeparator)) && src != filesAbs { return nil } if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) { stale = append(stale, path) } return nil }) if walkErr != nil { return nil, walkErr } return stale, nil } func removeLinks(paths []string) error { for _, path := range paths { if err := os.Remove(path); err != nil { return err } } return nil }