diff --git a/README.md b/README.md index 297967a..f67d180 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ just rollback [id] # Rollback to previous state just rollback [id] --with-brew # Also rollback packages just rollback [id] --dry-run # Preview changes just licenses # Manage application licenses +just manifest ... # Manage config manifest (add, remove, list) just secrets ... # Secrets management (init, backup, restore) ``` @@ -70,6 +71,7 @@ just secrets ... # Secrets management (init, backup, restore) ``` ~/.dotfiles/ ├── config/ # Base configurations +│ ├── manifest # Declares what gets linked where │ ├── ghostty/ │ ├── git/ │ ├── gnupg/ @@ -86,7 +88,8 @@ just secrets ... # Secrets management (init, backup, restore) │ │ ├── core/ │ │ │ ├── Brewfile │ │ │ ├── bundle.conf -│ │ │ └── setup.sh +│ │ │ ├── setup.sh +│ │ │ └── manifest # (optional) Config links │ │ ├── develop/ │ │ ├── personal/ │ │ ├── work/ @@ -106,6 +109,7 @@ just secrets ... # Secrets management (init, backup, restore) │ ├── secrets-init # Initialize encryption │ ├── licenses # License manager (Python) │ ├── daemons # Daemon/agent manager (Python) +│ ├── manifest # Manifest management (add/remove/list) │ └── platform # Platform operations (configure/link) ├── secrets/ # Encrypted secrets (age) ├── loaded/ # Symlinks to active bundles (for glob discovery) @@ -164,6 +168,19 @@ apply_config_overrides "$BUNDLE_DIR" chmod +x platforms/macos/bundles/mybundle/setup.sh ``` +6. (Optional) Add a `manifest` file for config linking instead of hardcoding in `setup.sh`: + +``` +# Link individual files +myconfig/settings.toml -> $HOME/.config/myapp/settings.toml + +# Link entire directories (trailing /) +myconfig/ -> $HOME/.config/myapp/ + +# Set directory permissions +@chmod 700 $HOME/.config/myapp +``` + The bundle will automatically appear in the selection menu. ## Requirements diff --git a/TODO.md b/TODO.md index a1f9656..8a2c816 100644 --- a/TODO.md +++ b/TODO.md @@ -4,15 +4,15 @@ Planned improvements and ideas for the dotfiles system. ## High Priority -- [x] **gum integration** - Auto-download and cache Charmbracelet's gum for interactive TUI menus. `ensure_gum` in common.sh with GitHub release helpers in `scripts/lib/github.sh`. Used in spotlight picker with plain fallback. +- [x] **gum integration:** Auto-download and cache Charmbracelet's gum for interactive TUI menus. `ensure_gum` in common.sh with GitHub release helpers in `scripts/lib/github.sh`. Used in spotlight picker with plain fallback. -- [ ] **brew wrapper** - Create a wrapper script that intercepts `brew install` and prompts to add manually installed packages to a bundle's Brewfile. Helps maintain Brewfile hygiene. +- [ ] **brew wrapper:** Create a wrapper script that intercepts `brew install` and prompts to add manually installed packages to a bundle's Brewfile. Helps maintain Brewfile hygiene. -- [ ] **Test installation end-to-end** - Run through full install on a fresh VM to verify bundle structure, dependency resolution, and package installation. +- [ ] **Test installation end-to-end:** Run through full install on a fresh VM to verify bundle structure, dependency resolution, and package installation. -- [x] **Global dotfiles command (`rune`)** - Run dotfiles commands from anywhere without `cd ~/.dotfiles` first. Implemented as a shell function wrapping `just`. +- [x] **Global dotfiles command (`rune`):** Run dotfiles commands from anywhere without `cd ~/.dotfiles` first. Implemented as a shell function wrapping `just`. -- [ ] **`runectl` + shell wrapper architecture** - Split the CLI into `runectl` (backing implementation) and `rune()` (thin shell function wrapper). Enables shell-level operations a binary can't do (source, exec, env export) while keeping the implementation portable across shells and rewrite targets. +- [ ] **`runectl` + shell wrapper architecture:** Split the CLI into `runectl` (backing implementation) and `rune()` (thin shell function wrapper). Enables shell-level operations a binary can't do (source, exec, env export) while keeping the implementation portable across shells and rewrite targets. **Architecture:** - `runectl` — the actual implementation. Today: shell script wrapping `just`. Later: Rust/Deno binary. Available in all shell states via PATH (set in zshenv). Handles all real work including secrets decryption. @@ -69,11 +69,13 @@ Planned improvements and ideas for the dotfiles system. **Implementation phases:** 1. **Now (shell scripts):** Rename backing to `runectl` shell script, thin `rune()` wrapper with reload mechanism, conflict detection as a comment 2. **Rust rewrite:** `runectl` becomes compiled Rust binary (clap + inquire + ratatui), gains `init`/`shim` subcommands, multi-shell support, context-aware secrets auth. -- [x] **Spotlight volume management** - UUID-based local config for multi-boot macOS Spotlight indexing control. Each macOS installation independently manages shared volumes via `platforms/macos/daemons/spotlight` with LaunchDaemon (`RunAtLoad` + `WatchPaths` on `/Volumes`), boot volume detection via device ID comparison, gum/plain interactive picker, and automatic boot volume safety net. Managed via `rune daemons` TUI or `rune daemons spotlight` CLI. +- [x] **Spotlight volume management:** UUID-based local config for multi-boot macOS Spotlight indexing control. Each macOS installation independently manages shared volumes via `platforms/macos/daemons/spotlight` with LaunchDaemon (`RunAtLoad` + `WatchPaths` on `/Volumes`), boot volume detection via device ID comparison, gum/plain interactive picker, and automatic boot volume safety net. Managed via `rune daemons` TUI or `rune daemons spotlight` CLI. -- [ ] **Spotlight dependency hygiene** - `mdfind` sweep during `rune upgrade` to drop `.metadata_never_index` into dependency directories (`node_modules`, `.venv`, `target/`, `build/`, `.gradle/`, `__pycache__`). Static `.metadata_never_index` for `/opt/homebrew`. No FSEvents watcher — the sweep during upgrade is sufficient. +- [ ] **Collapse update+upgrade and manifest state tracking:** `rune update` (git pull) and `rune upgrade` (re-link + brew) are separate, so pulling a config rename leaves dangling symlinks until the user runs upgrade. Collapse into a single atomic operation. Store the last-applied manifest in `.state/manifest.applied` so the upgrade step can diff old vs new, cleaning up symlinks for entries that were removed or changed. The manifest file becomes the record of intent with no separate symlink tracking needed. First concrete case: the mise rename (`config.toml` -> `conf.d/defaults.toml`) leaves an orphan at `~/.config/mise/config.toml`. Related: "Migration runner" (Medium Priority) covers one-time scripts for breaking changes; "Dotfiles health check" covers broken symlink detection. This item covers the structural fix that prevents orphans from being created in the first place. -- [ ] **Dotfiles health check** - A self-healing `rune doctor` command (like `brew doctor`) that diagnoses AND auto-fixes common issues. Should detect problems, explain what's wrong, and offer to fix them automatically. +- [ ] **Spotlight dependency hygiene:** `mdfind` sweep during `rune upgrade` to drop `.metadata_never_index` into dependency directories (`node_modules`, `.venv`, `target/`, `build/`, `.gradle/`, `__pycache__`). Static `.metadata_never_index` for `/opt/homebrew`. No FSEvents watcher — the sweep during upgrade is sufficient. + +- [ ] **Dotfiles health check:** A self-healing `rune doctor` command (like `brew doctor`) that diagnoses AND auto-fixes common issues. Should detect problems, explain what's wrong, and offer to fix them automatically. **Checks and auto-fixes:** - Broken symlinks → re-link or remove orphans @@ -93,7 +95,19 @@ Planned improvements and ideas for the dotfiles system. ## Medium Priority -- [ ] **Global git hooks with trusted repos** - Set up global git hooks that optionally run repo-specific hooks only for trusted repos. +- [ ] **Remove legacy config override case-statement:** Once all machines (personal + work volumes) are up-to-date with manifest-based config overrides, remove `_apply_config_overrides_legacy()` from `scripts/lib/common.sh`. The hardcoded case-statement mapping (`atuin/*`, `ghostty/*`, `ssh/*`, etc.) is only needed for bundles without a `manifest` file. Once all bundles use manifests, this dead code can go. Blocked on: all active installations running current code. + +- [ ] **Migration runner:** Track per-installation state via git hash in `.state/migrations/` (or similar). Each installation records the last git hash it was upgraded from, enabling automatic migration scripts that run once per breaking change. Migration scripts live in a `migrations/` directory, named by commit hash or sequence number, and execute only if the installation's last-known hash predates them. Example: renaming `~/.config/mise/config.toml` to `~/.config/mise/conf.d/defaults.toml` could be handled by a migration script that runs automatically during `rune upgrade`. The runner should be idempotent, log what it ran, and skip already-applied migrations. + +- [ ] **Manifest templates:** Variable substitution in manifest-linked files (inspired by chezmoi's `.tmpl` files). Would allow configs to contain `{{ .email }}` or `{{ .hostname }}` placeholders resolved at link time. Useful for gitconfig email per machine, SSH hosts, etc. Needs a data source (likely `.dotfiles.toml` or bundle vars). + +- [ ] **Manifest ignore patterns:** Exclude specific files/subdirectories from recursive directory linking (inspired by `.chezmoiignore`). Could use `@ignore ` directives scoped to the preceding directory entry in `manifest`. Useful when a directory is mostly shared but has machine-specific files to skip. + +- [ ] **Manifest attributes:** Per-file permission and ownership directives beyond `@chmod` on directories (inspired by `.chezmoiattr`). Would support setting file modes, e.g., `@mode 600 ssh/config`. Currently only directory-level `@chmod` exists. + +- [ ] **Manifest externals:** Pull config files from external URLs during apply (inspired by `.chezmoiexternal.toml`). Would allow declaring remote resources (e.g., themes, completions) that get fetched and placed alongside local configs. Lower priority since most external resources come via Homebrew or git submodules. + +- [ ] **Global git hooks with trusted repos:** Set up global git hooks that optionally run repo-specific hooks only for trusted repos. **Design:** - Set `core.hooksPath = ~/.config/git/hooks` globally @@ -186,25 +200,25 @@ Planned improvements and ideas for the dotfiles system. - Add `git-trust --revoke` to remove current secrets from `.trusted` - Add `git-trust-scan` command to discover repos with untrusted hooks -- [ ] **Decouple scripts from dotfiles** - Extract `just` commands and scripts into standalone tools that can be invoked from anywhere without being in the dotfiles directory. Partially addressed by `rune` shell function; fully solved by the `runectl` binary architecture (see High Priority) and Rust/Deno rewrite. +- [ ] **Decouple scripts from dotfiles:** Extract `just` commands and scripts into standalone tools that can be invoked from anywhere without being in the dotfiles directory. Partially addressed by `rune` shell function; fully solved by the `runectl` binary architecture (see High Priority) and Rust/Deno rewrite. -- [ ] **Linux support** - Add `platforms/linux/` with bundles. The shared library (common.sh) and bundle system are designed to support this. +- [ ] **Linux support:** Add `platforms/linux/` with bundles. The shared library (common.sh) and bundle system are designed to support this. -- [ ] **Bundle-specific config overrides** - Utilize the `config/` directory within bundles for context-specific overrides: gitconfig (different email per bundle), SSH configs, environment variables, additional tooling, etc. Bundles that need specialized configuration beyond their Brewfile and setup.sh can layer overrides without affecting other bundles. +- [ ] **Bundle-specific config overrides:** Utilize the `config/` directory within bundles for context-specific overrides: gitconfig (different email per bundle), SSH configs, environment variables, additional tooling, etc. Bundles that need specialized configuration beyond their Brewfile and setup.sh can layer overrides without affecting other bundles. -- [ ] **Shell completions** - Add zsh completions for `rune` commands and custom scripts. +- [ ] **Shell completions:** Add zsh completions for `rune` commands and custom scripts. -- [ ] **Secrets workflow documentation** - Document the full age/sops secrets workflow: init, encrypt, decrypt, and how to add new secrets. +- [ ] **Secrets workflow documentation:** Document the full age/sops secrets workflow: init, encrypt, decrypt, and how to add new secrets. ## Development -- [x] **Brew lock for safe testing** - `rune dev lock` / `rune dev unlock` to skip Brewfile installations during upgrades while showing what would change. Respects lock during rollback with `--with-brew`. +- [x] **Brew lock for safe testing:** `rune dev lock` / `rune dev unlock` to skip Brewfile installations during upgrades while showing what would change. Respects lock during rollback with `--with-brew`. -- [x] **PR/branch preview** - `rune dev preview` for snapshot-backed preview of PRs, branches, and commits with dirty state preservation. Smart detection, auto brew lock, activity tracking with 24h stale warnings. +- [x] **PR/branch preview:** `rune dev preview` for snapshot-backed preview of PRs, branches, and commits with dirty state preservation. Smart detection, auto brew lock, activity tracking with 24h stale warnings. -- [ ] **Expandable dev locks** - Extend `rune dev lock` to support `--prefs`, `--dock`, `--all` to skip preferences, dock, or everything except symlinks/configs during testing. +- [ ] **Expandable dev locks:** Extend `rune dev lock` to support `--prefs`, `--dock`, `--all` to skip preferences, dock, or everything except symlinks/configs during testing. -- [ ] **APFS snapshot integration** - Full-system rollback for test installations (SIP disabled only). Separate repo for dangerous tooling, gated behind `rune dev init` + SIP check. +- [ ] **APFS snapshot integration:** Full-system rollback for test installations (SIP disabled only). Separate repo for dangerous tooling, gated behind `rune dev init` + SIP check. **Design:** - `tmutil localsnapshot` before changes, mount + `rsync --itemize-changes` to diff, restore changed files programmatically @@ -220,7 +234,7 @@ Planned improvements and ideas for the dotfiles system. - Restore via `defaults import` + service restart (`cfprefsd`, `Dock`, `Finder`, `SystemUIServer`) - Classify changes: `[safe]` restorable, `[sudo]` needs elevation, `[blocked]` SIP-protected -- [ ] **Pre-push git hook for CI checks** - Run the same checks GitHub CI runs locally before pushing, catching errors before they require fix-up commits. On failure, prompt with `[y/N]` to push anyway (defaulting to abort). +- [ ] **Pre-push git hook for CI checks:** Run the same checks GitHub CI runs locally before pushing, catching errors before they require fix-up commits. On failure, prompt with `[y/N]` to push anyway (defaulting to abort). **Checks to run locally (matching CI):** - ShellCheck (severity: warning) @@ -238,11 +252,11 @@ Planned improvements and ideas for the dotfiles system. ## Long Term -- [ ] **Rust rewrite (`runectl`)** - Rewrite `runectl` in Rust using clap + inquire + ratatui. Starts as a dispatch table (Phase 1: clap routes subcommands to existing bash scripts), adds interactive prompts (Phase 2: inquire replaces gum/bash menus), then full-screen TUI (Phase 3: ratatui for bootmenu zones, daemons TUI), then incremental logic migration (Phase 4). No intermediate language step. The `runectl` + shell wrapper architecture (see High Priority) is designed for this — `runectl` becomes the binary, `rune()` shell function stays unchanged. Gains `runectl init ` for multi-shell support, `runectl shim` for non-interactive aliasing, and context-aware secrets auth. +- [ ] **Rust rewrite (`runectl`):** Rewrite `runectl` in Rust using clap + inquire + ratatui. Starts as a dispatch table (Phase 1: clap routes subcommands to existing bash scripts), adds interactive prompts (Phase 2: inquire replaces gum/bash menus), then full-screen TUI (Phase 3: ratatui for bootmenu zones, daemons TUI), then incremental logic migration (Phase 4). No intermediate language step. The `runectl` + shell wrapper architecture (see High Priority) is designed for this — `runectl` becomes the binary, `rune()` shell function stays unchanged. Gains `runectl init ` for multi-shell support, `runectl shim` for non-interactive aliasing, and context-aware secrets auth. -- [ ] **Dotfiles config file** - Add `.dotfiles.toml` for user preferences and feature toggles. Gate features (secrets, cloud backup, GPG keychain import) via config so users can opt out. Built on the Rust rewrite, design the schema to support extension selection. +- [ ] **Dotfiles config file:** Add `.dotfiles.toml` for user preferences and feature toggles. Gate features (secrets, cloud backup, GPG keychain import) via config so users can opt out. Built on the Rust rewrite, design the schema to support extension selection. -- [ ] **Extension architecture** - Extensible extension system built on the Rust rewrite. Extensions are standalone executables (any language) communicating via stdin/stdout JSON protocol — same pattern as git, Docker, and Terraform. +- [ ] **Extension architecture:** Extensible extension system built on the Rust rewrite. Extensions are standalone executables (any language) communicating via stdin/stdout JSON protocol — same pattern as git, Docker, and Terraform. **Design:** - Core defines capabilities (secrets, trust, cloud-backup, etc.) as trait interfaces @@ -272,13 +286,13 @@ Planned improvements and ideas for the dotfiles system. ## Low Priority / Ideas -- [ ] **Auto-generated bundle docs** - Generate documentation showing what each bundle installs (packages, configs, symlinks) for quick reference. +- [ ] **Auto-generated bundle docs:** Generate documentation showing what each bundle installs (packages, configs, symlinks) for quick reference. -- [ ] **CI on macOS runners** - Add GitHub Actions to test install.sh in a macOS VM on PRs, not just linting. +- [ ] **CI on macOS runners:** Add GitHub Actions to test install.sh in a macOS VM on PRs, not just linting. -- [ ] **Backup/sync integration** - Integrate rclone/restic for automated config backups beyond git + cloud. +- [ ] **Backup/sync integration:** Integrate rclone/restic for automated config backups beyond git + cloud. -- [ ] **Office productivity bundle** - Consolidate office tools (Word, Excel, PowerPoint) into a dedicated bundle. Currently split across personal and work bundles. +- [ ] **Office productivity bundle:** Consolidate office tools (Word, Excel, PowerPoint) into a dedicated bundle. Currently split across personal and work bundles. ## Completed diff --git a/config/manifest b/config/manifest new file mode 100644 index 0000000..8061638 --- /dev/null +++ b/config/manifest @@ -0,0 +1,31 @@ +# Zsh (individual files — defaults.zsh and rune.zsh are sourced, not linked) +zsh/zshrc -> $HOME/.zshrc +zsh/zshenv -> $HOME/.zshenv +zsh/zprofile -> $HOME/.zprofile + +# Git +git/gitconfig -> $HOME/.gitconfig +git/gitignore -> $HOME/.gitignore + +# Ghostty (entire directory: config, themes, shaders) +ghostty/ -> $HOME/.config/ghostty/ + +# Starship +starship/starship.toml -> $HOME/.config/starship.toml + +# Mise (conf.d/ so mise use -g writes to config.toml without dirtying git) +mise/conf.d/ -> $HOME/.config/mise/conf.d/ + +# Atuin (shell history sync) +atuin/config.toml -> $HOME/.config/atuin/config.toml + +# WezTerm +wezterm/wezterm.lua -> $HOME/.config/wezterm/wezterm.lua + +# SSH +ssh/config -> $HOME/.ssh/config +@chmod 700 $HOME/.ssh + +# GnuPG +gnupg/gpg-agent.conf -> $HOME/.gnupg/gpg-agent.conf +@chmod 700 $HOME/.gnupg diff --git a/config/mise/config.toml b/config/mise/conf.d/defaults.toml similarity index 85% rename from config/mise/config.toml rename to config/mise/conf.d/defaults.toml index b60135f..e462bbc 100644 --- a/config/mise/config.toml +++ b/config/mise/conf.d/defaults.toml @@ -15,19 +15,25 @@ compile = false MISE_DEFAULT_CONFIG_FILENAME = "mise.local.toml" [tools] +# Languages node = "lts" -pnpm = "latest" +python = "latest" rust = "latest" go = "latest" -python = "latest" zig = "latest" lua = "latest" -uv = "latest" java = "latest" ruby = "latest" dotnet = "latest" -gradle = "latest" -cmake = "latest" +deno = "latest" +bun = "latest" +dart = "latest" +flutter = "latest" + +# Tools +pnpm = "latest" +uv = "latest" +cargo-binstall = "latest" cocogitto = "latest" lefthook = "latest" task = "latest" diff --git a/justfile b/justfile index fbad06c..aade141 100644 --- a/justfile +++ b/justfile @@ -41,6 +41,10 @@ licenses *args: daemons *args: ./scripts/daemons {{args}} +# Manage config manifest (add, remove, list, chmod) +manifest *args: + ./scripts/manifest {{args}} + # Dev utilities for faster testing (hidden from `just --list`). # Uses a separate justfile + [private] recipe because: # - `[private]` only hides recipes, not module namespaces diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 852b902..b8f3db5 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -39,15 +39,22 @@ safe_link() { ## Directory Setup # Create standard config directories with proper permissions +# Reads @chmod directives from base manifest, also ensures .config and .ssh/sockets ensure_config_dirs() { mkdir -p "$HOME/.config" - mkdir -p "$HOME/.config/mise" - mkdir -p "$HOME/.config/ghostty" - mkdir -p "$HOME/.config/wezterm" mkdir -p "$HOME/.ssh/sockets" - mkdir -p "$HOME/.gnupg" - chmod 700 "$HOME/.ssh" - chmod 700 "$HOME/.gnupg" + + local manifest="$DOTFILES_DIR/config/manifest" + if [[ -f "$manifest" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + # Strip leading/trailing whitespace + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + case "$line" in + @chmod\ *) _apply_chmod_directive "$line" ;; + esac + done < "$manifest" + fi } ## Homebrew @@ -78,28 +85,117 @@ install_brewfile() { } } +## Manifest-Driven Config Linking + +# Apply a manifest file: link files, recurse directories, set permissions +# Usage: apply_manifest +apply_manifest() { + local manifest="$1" + local base_dir="$2" + + [[ ! -f "$manifest" ]] && return 0 + + while IFS= read -r line || [[ -n "$line" ]]; do + # Strip leading/trailing whitespace + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + + # Skip comments and blank lines + [[ -z "$line" || "$line" == \#* ]] && continue + + # Permission directive + if [[ "$line" == @chmod\ * ]]; then + _apply_chmod_directive "$line" + continue + fi + + # Parse: source -> destination + local src dest + src="${line%% ->*}" + dest="${line##*-> }" + + # Strip whitespace from src/dest + src="${src%"${src##*[![:space:]]}"}" + dest="${dest#"${dest%%[![:space:]]*}"}" + + # Expand $HOME in destination + dest="${dest/\$HOME/$HOME}" + + # Directory linking (trailing /) + if [[ "$src" == */ && "$dest" == */ ]]; then + _link_directory "$base_dir/$src" "$dest" + continue + fi + + # File linking + local full_src="$base_dir/$src" + [[ -f "$full_src" ]] && safe_link "$full_src" "$dest" + done < "$manifest" +} + +# Recursively link all files from source directory to destination directory +# Skips .DS_Store files +# Usage: _link_directory +_link_directory() { + local src_dir="${1%/}" + local dest_dir="${2%/}" + + [[ ! -d "$src_dir" ]] && return 0 + + while IFS= read -r -d '' file; do + local rel_path="${file#"$src_dir"}" + # Skip .DS_Store + local basename + basename=$(basename "$file") + [[ "$basename" == ".DS_Store" ]] && continue + + safe_link "$file" "$dest_dir$rel_path" + done < <(find "$src_dir" -type f -print0 2>/dev/null) +} + +# Parse and apply a @chmod directive +# Usage: _apply_chmod_directive "@chmod 700 $HOME/.ssh" +_apply_chmod_directive() { + local line="$1" + local mode path + + # Strip @chmod prefix + line="${line#@chmod }" + mode="${line%% *}" + path="${line#* }" + + # Expand $HOME + path="${path/\$HOME/$HOME}" + + mkdir -p "$path" + chmod "$mode" "$path" +} + ## Config Symlinks # Link base configs from DOTFILES_DIR/config to home link_base_configs() { - local config_dir="$DOTFILES_DIR/config" - - [[ -f "$config_dir/zsh/zshrc" ]] && safe_link "$config_dir/zsh/zshrc" "$HOME/.zshrc" - [[ -f "$config_dir/zsh/zshenv" ]] && safe_link "$config_dir/zsh/zshenv" "$HOME/.zshenv" - [[ -f "$config_dir/zsh/zprofile" ]] && safe_link "$config_dir/zsh/zprofile" "$HOME/.zprofile" - [[ -f "$config_dir/starship/starship.toml" ]] && safe_link "$config_dir/starship/starship.toml" "$HOME/.config/starship.toml" - [[ -f "$config_dir/git/gitconfig" ]] && safe_link "$config_dir/git/gitconfig" "$HOME/.gitconfig" - [[ -f "$config_dir/git/gitignore" ]] && safe_link "$config_dir/git/gitignore" "$HOME/.gitignore" - [[ -f "$config_dir/mise/config.toml" ]] && safe_link "$config_dir/mise/config.toml" "$HOME/.config/mise/config.toml" - [[ -f "$config_dir/ghostty/config" ]] && safe_link "$config_dir/ghostty/config" "$HOME/.config/ghostty/config" - [[ -f "$config_dir/wezterm/wezterm.lua" ]] && safe_link "$config_dir/wezterm/wezterm.lua" "$HOME/.config/wezterm/wezterm.lua" - [[ -f "$config_dir/ssh/config" ]] && safe_link "$config_dir/ssh/config" "$HOME/.ssh/config" - [[ -f "$config_dir/gnupg/gpg-agent.conf" ]] && safe_link "$config_dir/gnupg/gpg-agent.conf" "$HOME/.gnupg/gpg-agent.conf" + apply_manifest "$DOTFILES_DIR/config/manifest" "$DOTFILES_DIR/config" } # Apply bundle-specific config overrides +# Checks for bundle manifest first, falls back to legacy case-statement apply_config_overrides() { local bundle_dir="$1" + + # Manifest-based: bundle has its own manifest file + if [[ -f "$bundle_dir/manifest" ]]; then + apply_manifest "$bundle_dir/manifest" "$DOTFILES_DIR/config" + return 0 + fi + + # Legacy: scan bundle config/ directory with case-statement mapping + _apply_config_overrides_legacy "$bundle_dir" +} + +# Legacy config override logic for bundles without a manifest +_apply_config_overrides_legacy() { + local bundle_dir="$1" local config_dir="$bundle_dir/config" [[ ! -d "$config_dir" ]] && return 0 @@ -115,6 +211,7 @@ apply_config_overrides() { git/gitconfig) dest="$HOME/.gitconfig" ;; git/gitignore) dest="$HOME/.gitignore" ;; mise/*) dest="$HOME/.config/mise/${rel_path#mise/}" ;; + atuin/*) dest="$HOME/.config/atuin/${rel_path#atuin/}" ;; ghostty/*) dest="$HOME/.config/ghostty/${rel_path#ghostty/}" ;; wezterm/*) dest="$HOME/.config/wezterm/${rel_path#wezterm/}" ;; ssh/*) dest="$HOME/.ssh/${rel_path#ssh/}" ;; diff --git a/scripts/manifest b/scripts/manifest new file mode 100755 index 0000000..e867f25 --- /dev/null +++ b/scripts/manifest @@ -0,0 +1,262 @@ +#!/usr/bin/env bash + +# Manifest management CLI +# Usage: scripts/manifest [args] +# +# Commands: +# list Show all manifest entries +# add Add a file or directory link +# remove Remove an entry by source +# chmod Add a @chmod directive +# +# Flags: +# --bundle Target a bundle's manifest instead of base + +set -e + +DOTFILES_DIR="${DOTFILES_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" + +# shellcheck source=scripts/lib/common.sh +source "$DOTFILES_DIR/scripts/lib/common.sh" + +BUNDLE="" +COMMAND="" +ARGS=() + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --bundle) + BUNDLE="$2" + shift 2 + ;; + --bundle=*) + BUNDLE="${1#--bundle=}" + shift + ;; + -*) + log_error "Unknown flag: $1" + exit 1 + ;; + *) + if [[ -z "$COMMAND" ]]; then + COMMAND="$1" + else + ARGS+=("$1") + fi + shift + ;; + esac +done + +# Resolve manifest path +resolve_manifest() { + if [[ -n "$BUNDLE" ]]; then + local bundle_dir="$DOTFILES_DIR/platforms/macos/bundles/$BUNDLE" + if [[ ! -d "$bundle_dir" ]]; then + log_error "Bundle not found: $BUNDLE" + exit 1 + fi + echo "$bundle_dir/manifest" + else + echo "$DOTFILES_DIR/config/manifest" + fi +} + +MANIFEST_FILE=$(resolve_manifest) + +# List all entries +cmd_list() { + if [[ ! -f "$MANIFEST_FILE" ]]; then + log_warn "No manifest found at $MANIFEST_FILE" + return 0 + fi + + local label="base" + [[ -n "$BUNDLE" ]] && label="$BUNDLE" + log_info "Manifest entries ($label):" + echo "" + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + + # Print comments as-is for context + if [[ "$line" == \#* ]]; then + echo " $line" + continue + fi + + [[ -z "$line" ]] && continue + + if [[ "$line" == @chmod\ * ]]; then + echo " $line" + else + echo " $line" + fi + done < "$MANIFEST_FILE" +} + +# Check if an entry already exists (by source path) +entry_exists() { + local source="$1" + [[ ! -f "$MANIFEST_FILE" ]] && return 1 + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "$line" || "$line" == \#* ]] && continue + + local line_src="${line%% ->*}" + line_src="${line_src%"${line_src##*[![:space:]]}"}" + + [[ "$line_src" == "$source" ]] && return 0 + done < "$MANIFEST_FILE" + + return 1 +} + +# Find the longest source field for alignment +max_source_width() { + local max=0 + [[ ! -f "$MANIFEST_FILE" ]] && echo 0 && return + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "$line" || "$line" == \#* || "$line" == @chmod\ * ]] && continue + + local src="${line%% ->*}" + src="${src%"${src##*[![:space:]]}"}" + local len=${#src} + [[ $len -gt $max ]] && max=$len + done < "$MANIFEST_FILE" + + echo "$max" +} + +# Add an entry +cmd_add() { + if [[ ${#ARGS[@]} -lt 2 ]]; then + log_error "Usage: manifest add " + exit 1 + fi + + local source="${ARGS[0]}" + local dest="${ARGS[1]}" + + # Normalize ~ to $HOME in destination for storage + dest="${dest/#\~/$HOME}" + # Convert back to $HOME for manifest storage + dest="${dest/#$HOME/\$HOME}" + + if entry_exists "$source"; then + log_warn "Entry already exists for: $source" + return 0 + fi + + # Validate source path exists (for base manifest) + if [[ -z "$BUNDLE" ]]; then + local full_source="$DOTFILES_DIR/config/$source" + if [[ "$source" == */ ]]; then + if [[ ! -d "$full_source" ]]; then + log_warn "Source directory not found: config/$source" + fi + else + if [[ ! -f "$full_source" ]]; then + log_warn "Source file not found: config/$source" + fi + fi + fi + + # Create manifest if it doesn't exist + [[ ! -f "$MANIFEST_FILE" ]] && touch "$MANIFEST_FILE" + + # Calculate alignment width + local width + width=$(max_source_width) + local src_len=${#source} + [[ $src_len -gt $width ]] && width=$src_len + + # Append entry with aligned -> + printf '%-*s -> %s\n' "$width" "$source" "$dest" >> "$MANIFEST_FILE" + + log_success "Added: $source -> $dest" +} + +# Remove an entry +cmd_remove() { + if [[ ${#ARGS[@]} -lt 1 ]]; then + log_error "Usage: manifest remove " + exit 1 + fi + + local source="${ARGS[0]}" + + if [[ ! -f "$MANIFEST_FILE" ]]; then + log_error "No manifest found" + exit 1 + fi + + if ! entry_exists "$source"; then + log_warn "No entry found for: $source" + return 0 + fi + + # Remove matching line (by source path) + local tmp + tmp=$(mktemp) + while IFS= read -r line || [[ -n "$line" ]]; do + local trimmed="${line#"${line%%[![:space:]]*}"}" + if [[ "$trimmed" == "$source "* && "$trimmed" == *" -> "* ]]; then + continue + fi + echo "$line" + done < "$MANIFEST_FILE" > "$tmp" + + mv "$tmp" "$MANIFEST_FILE" + log_success "Removed: $source" +} + +# Add a chmod directive +cmd_chmod() { + if [[ ${#ARGS[@]} -lt 2 ]]; then + log_error "Usage: manifest chmod " + exit 1 + fi + + local mode="${ARGS[0]}" + local path="${ARGS[1]}" + + # Normalize ~ to $HOME + path="${path/#\~/$HOME}" + path="${path/#$HOME/\$HOME}" + + # Create manifest if it doesn't exist + [[ ! -f "$MANIFEST_FILE" ]] && touch "$MANIFEST_FILE" + + # Check for existing chmod on same path + if grep -q "^@chmod [0-9]* ${path}$" "$MANIFEST_FILE" 2>/dev/null; then + log_warn "@chmod directive already exists for: $path" + return 0 + fi + + echo "@chmod $mode $path" >> "$MANIFEST_FILE" + log_success "Added: @chmod $mode $path" +} + +# Dispatch +case "$COMMAND" in + list) cmd_list ;; + add) cmd_add ;; + remove) cmd_remove ;; + chmod) cmd_chmod ;; + "") + log_error "No command specified" + echo "Usage: manifest [args]" + exit 1 + ;; + *) + log_error "Unknown command: $COMMAND" + echo "Usage: manifest [args]" + exit 1 + ;; +esac