#!/usr/bin/env bash # Deploy the unified dots for this machine using GNU Stow. # # ./install.sh [host] host defaults to $(hostname -s) # ./install.sh -n [host] dry-run (preview, no changes) # ./install.sh -a [host] first run on a machine that still has real config files: # --adopt them into the repo, then review `git diff` and # `git restore .` to keep the repo (merged) versions. # ./install.sh -D [host] revert: unstow this host's symlinks (repo + real files untouched) # ./install.sh -h show all options # # Layering (later wins): common -> gui -> # - common : shared everywhere # - gui : GUI configs (skipped on headless machines — see the marker below) # - : per-machine overrides (top-level package), stowed last with --override # All packages live at the repo root so they share one stow dir — required for # --override to let a host file take over a common/gui file of the same path. # --no-folding: every file is symlinked individually (dirs stay real) so a host can # override a single file in a shared dir without shadowing the rest. # # Idempotent: re-run any time. Adding/removing files in the repo needs a re-run # (editing an existing file does not — symlinks point straight at the repo file). set -uo pipefail # --- headless (terminal-only, no GUI) machines skip the gui package --- # A machine is "headless" if this marker file exists. It's machine-local (not tracked by the repo) # and hostname-independent, so the same terminal-only setup replicates across any number of machines # — run once on each tty-only box: touch ~/.config/dots-headless HEADLESS_MARKER="${XDG_CONFIG_HOME:-$HOME/.config}/dots-headless" usage() { cat <<'EOF' Deploy this machine's dotfiles with GNU Stow. Usage: ./install.sh [options] [host] host profile to deploy (default: `hostname -s`) -n, --dry-run preview stow actions; change nothing -a, --adopt first run on a machine with existing real config files: adopt them into the repo, then review `git diff` and `git restore .` -D, --unstow revert: remove this host's stow symlinks (repo + real files kept) -h, --help show this help and exit Layering (later wins): common -> gui -> . A machine with ~/.config/dots-headless skips gui (and has any stray gui unstowed). Run from inside the repo (~/.dots). EOF } # --- args --- DRY="" ADOPT="" UNSTOW=0 ARGS=() for a in "$@"; do case "$a" in -n|--dry-run) DRY="-n" ;; -a|--adopt) ADOPT="--adopt" ;; -D|--unstow|--delete) UNSTOW=1 ;; -h|--help) usage; exit 0 ;; -*) echo "unknown option: $a" >&2; usage >&2; exit 2 ;; *) ARGS+=("$a") ;; esac done HOST="${ARGS[0]:-$(hostname -s)}" DOTS="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" command -v stow >/dev/null 2>&1 || { echo "error: GNU stow is not installed (pacman -S stow)" >&2; exit 1; } gui=true [ -e "$HEADLESS_MARKER" ] && gui=false echo "host=$HOST dots=$DOTS gui=$gui${DRY:+ (dry-run)}" # --- revert mode: unstow this host's packages (reverse of deploy), leave repo + real files intact --- if [ "$UNSTOW" -eq 1 ]; then verb="unstowed"; [ -n "$DRY" ] && verb="would unstow" for p in "$HOST" gui common; do [ -d "$DOTS/$p" ] || continue if stow $DRY -d "$DOTS" -t "$HOME" -D "$p" 2>/dev/null; then echo " $verb $p" fi # prune the now-empty dirs this package created under ~/.config (empty only — never real files) if [ -z "$DRY" ] && [ -d "$DOTS/$p/.config" ]; then for d in "$DOTS/$p"/.config/*/; do t="$HOME/.config/$(basename "$d")" [ -d "$t" ] && find "$t" -type d -empty -delete 2>/dev/null || true done fi done if [ -n "$DRY" ]; then echo "(dry-run) nothing changed." else echo "reverted — removed this host's stow symlinks. ~/.dots and any real files are untouched." echo "note: ~/.claude/* links (from link.sh) are separate; remove by hand if you want those gone too." fi exit 0 fi # 0. self-heal symlinks that stow would reject ("not owned by stow"), which otherwise aborts all of # common. Two stale-link classes a prior link.sh/heal can leave behind: # - hooks/skills folded into an ABSOLUTE dir-symlink (they must be real dirs) — drop it entirely. # - a file item re-linked with an ABSOLUTE target instead of stow's relative one — drop it. # Only ever removes a *symlink* here (never a real file/dir), so nothing is lost; stow then # recreates the correct relative link. A drifted *real* file is left for link.sh's drift guard # (or `-a` to adopt) to reconcile, since it may hold un-synced edits. if [ -z "$DRY" ]; then for d in hooks skills; do t="$HOME/.config/claude/$d" [ -L "$t" ] && { rm -f "$t"; echo " cleared stale folded symlink ~/.config/claude/$d"; } done for f in settings.json statusline.py keybindings.json CLAUDE.md; do t="$HOME/.config/claude/$f" case "$(readlink "$t" 2>/dev/null)" in /*) rm -f "$t"; echo " cleared absolute symlink ~/.config/claude/$f (stow will relink)" ;; esac done fi # 1. release the host overlay first so base restows never conflict on overridden files if [ -d "$DOTS/$HOST" ] && [ -z "$DRY" ]; then stow -d "$DOTS" -t "$HOME" -D "$HOST" 2>/dev/null || true fi # stow one package; track failures instead of silently continuing and reporting success FAIL=0 stow_pkg() { # stow_pkg