Initial commit
This commit is contained in:
41
README.md
Normal file
41
README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Sigil
|
||||||
|
|
||||||
|
Minimal dotfile symlink manager with per-package Lua config.
|
||||||
|
|
||||||
|
## Install (dev)
|
||||||
|
|
||||||
|
```
|
||||||
|
go run . <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `sigil apply` - apply symlinks from `~/.dotfiles`
|
||||||
|
- `sigil add <path>` - 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).
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module sigil
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require github.com/yuin/gopher-lua v1.1.1
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -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=
|
||||||
411
main.go
Normal file
411
main.go
Normal file
@@ -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 <path>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user