Files
sigil/main.go

897 lines
18 KiB
Go

package main
import (
"bufio"
"errors"
"fmt"
"io"
"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 "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")
}
type packageFlags struct {
dryRun bool
}
func parsePackageFlags(args []string) (packageFlags, string, error) {
flags := packageFlags{}
var pkg string
for _, arg := range args {
if arg == "--dry-run" {
flags.dryRun = true
continue
}
if strings.HasPrefix(arg, "-") {
return flags, "", fmt.Errorf("unknown flag %q", arg)
}
if pkg != "" {
return flags, "", errors.New("too many arguments")
}
pkg = arg
}
if pkg == "" {
return flags, "", errors.New("missing package")
}
return flags, pkg, nil
}
func splitPackageSpec(spec string) (string, string, error) {
if spec == "" {
return "", "", errors.New("missing package")
}
parts := strings.SplitN(spec, ":", 2)
pkg := parts[0]
rel := ""
if len(parts) == 2 {
rel = parts[1]
}
pkg = strings.Trim(pkg, "/")
rel = strings.TrimPrefix(rel, "/")
if pkg == "" {
return "", "", errors.New("invalid package")
}
return pkg, rel, nil
}
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, false)
}
fmt.Printf("Stale links found: %d\n", len(stales))
ok, err := promptYesNo("Prune them?", false)
if err != nil {
return err
}
if ok {
return removeLinks(stales, false)
}
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 unlinkCmd(args []string) error {
flags, pkgSpec, err := parsePackageFlags(args)
if err != nil {
return err
}
pkgName, relPath, err := splitPackageSpec(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.targets)
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 := splitPackageSpec(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.targets)
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 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 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.targets)
if err != nil {
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
}
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 := os.Lstat(path)
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, dryRun bool) error {
for _, path := range paths {
if dryRun {
fmt.Printf("dry-run: remove %s\n", path)
continue
}
if err := os.Remove(path); err != nil {
return err
}
fmt.Printf("removed %s\n", path)
}
return nil
}
func restorePackage(filesDir, targetRoot string, dryRun bool) error {
filesAbs, err := filepath.Abs(filesDir)
if 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
}
if entry.IsDir() {
return nil
}
rel, err := filepath.Rel(filesDir, path)
if err != nil {
return err
}
targetPath := filepath.Join(targetRoot, rel)
return restoreOne(path, targetPath, filesAbs, dryRun)
})
}
func restorePath(filesDir, targetRoot, relPath string, dryRun bool) error {
filesAbs, err := filepath.Abs(filesDir)
if err != nil {
return err
}
relPath = filepath.Clean(relPath)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
return fmt.Errorf("invalid relative path %q", relPath)
}
sourcePath := filepath.Join(filesDir, relPath)
info, err := os.Lstat(sourcePath)
if err != nil {
return err
}
if info.IsDir() {
return filepath.WalkDir(sourcePath, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
rel, err := filepath.Rel(filesDir, path)
if err != nil {
return err
}
targetPath := filepath.Join(targetRoot, rel)
return restoreOne(path, targetPath, filesAbs, dryRun)
})
}
rel, err := filepath.Rel(filesDir, sourcePath)
if err != nil {
return err
}
return restoreOne(sourcePath, filepath.Join(targetRoot, rel), filesAbs, dryRun)
}
func restoreOne(sourcePath, targetPath, filesAbs string, dryRun bool) error {
info, err := os.Lstat(targetPath)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink == 0 {
return nil
}
src, err := os.Readlink(targetPath)
if err != nil {
return err
}
if !filepath.IsAbs(src) {
src = filepath.Join(filepath.Dir(targetPath), src)
}
src = filepath.Clean(src)
if !strings.HasPrefix(src, filesAbs+string(os.PathSeparator)) && src != filesAbs {
return nil
}
if dryRun {
fmt.Printf("dry-run: restore %s\n", targetPath)
return nil
}
if err := os.Remove(targetPath); err != nil {
return err
}
fmt.Printf("restored %s\n", targetPath)
return copyFile(sourcePath, targetPath)
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
info, err := srcFile.Stat()
if err != nil {
return err
}
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
return nil
}