commit c8f2ad021f43003746678f606a96e80eb7976223 Author: Thomas G. Lopes Date: Thu Feb 19 16:14:48 2026 +0000 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..b673799 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Sigil + +Minimal dotfile symlink manager with per-package Lua config. + +## Install (dev) + +``` +go run . +``` + +## Commands + +- `sigil apply` - apply symlinks from `~/.dotfiles` +- `sigil add ` - add an existing file or folder + +## Repo layout + +``` +~/.dotfiles/ + nvim/ + config.lua + files/ + init.lua +``` + +## `config.lua` + +```lua +return { + target = { + linux = "~/.config/nvim", + macos = "~/Library/Application Support/nvim", + default = "~/.config/nvim", + }, +} +``` + +## Notes + +- Uses `SIGIL_REPO` env var to override the repo path. +- Conflicts are detected (existing non-symlink files will stop apply). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b7fc8a4 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module sigil + +go 1.25.7 + +require github.com/yuin/gopher-lua v1.1.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e7daa0c --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..89ba280 --- /dev/null +++ b/main.go @@ -0,0 +1,411 @@ +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) + } + + var err error + switch os.Args[1] { + case "apply": + err = applyCmd() + case "add": + err = addCmd(os.Args[2:]) + case "help", "-h", "--help": + usage() + return + default: + err = fmt.Errorf("unknown command %q", os.Args[1]) + } + + 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") + fmt.Println(" sigil add ") +} + +func applyCmd() 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) + 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) + } + } + + return nil +} + +func addCmd(args []string) error { + if len(args) < 1 { + return errors.New("missing path") + } + + absPath, err := filepath.Abs(args[0]) + 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() + lua.OpenLibraries(L) + + 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 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 +}