#!/usr/bin/env bash # Deploy the unified dots for this machine using GNU Stow. # # ./install.sh [host] host: the arg, else ~/.config/dots-host, else $(hostname -s); # the resolved host is recorded in ~/.config/dots-host # ./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" # --- which host package this machine deploys --- # Machine-local like the marker above, and deliberately NOT derived from `hostname -s`: several # boxes may deploy the same overlay without sharing a hostname, and a hostname that happens not # to match a package name would otherwise half-deploy (base layers restow, the overlay is # skipped). Resolution order: # the explicit arg -> this file -> `hostname -s` (a guess, not an invariant) # The resolved host is recorded here, so later bare runs — and `bin/dotsync`, which re-links # without being able to pass an argument — reuse it. Hand-editable: one word, e.g. `wm`. HOST_MARKER="${XDG_CONFIG_HOME:-$HOME/.config}/dots-host" usage() { cat <<'EOF' Deploy this machine's dotfiles with GNU Stow. Usage: ./install.sh [options] [host] host host package to deploy. Resolved as: this arg, else the name in ~/.config/dots-host, else `hostname -s`. The resolved host is remembered in ~/.config/dots-host, so later runs (and dotsync, which cannot pass one) reuse it — the hostname need not match. -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 # resolve the host package (see HOST_MARKER above). RECORDED is read BEFORE the marker is # rewritten below, so a profile switch still knows which overlay to release first. RECORDED="" [ -r "$HOST_MARKER" ] && RECORDED="$(head -1 "$HOST_MARKER" | tr -d '[:space:]')" HOST="${ARGS[0]:-${RECORDED:-$(hostname -s)}}" # an empty HOST must never reach stow: `stow -S ""` treats the repo ROOT as the package # and links common/, gui/, install.sh… straight into $HOME (and `-D ""` unlinks likewise) [ -n "$HOST" ] || { echo "error: no host resolved — pass one (./install.sh ) or write it to ~/.config/dots-host" >&2 exit 1 } # a host must be a plain top-level package name. common/gui are base LAYERS: as a host, the layer # is released, restowed and then re-stowed with --override, and the name is recorded so every later # run (and dotsync) repeats it. bin/ is tooling, never a package. Anything with a leading dot or a # slash (`.`, `..`, `.git`, `a/b`) would let stow treat the repo root or a non-package as one — # the same hazard as the empty HOST above. host_ok() { # host_ok — is something install.sh may stow as a host overlay? case "$1" in common | gui | bin | .* | */* | -* | "") return 1 ;; esac } if ! host_ok "$HOST"; then case "$HOST" in common | gui) echo "error: '$HOST' is a base layer, not a host package — a terminal-only machine runs" >&2 echo " 'touch ~/.config/dots-headless; ./install.sh' with no host argument" >&2 ;; *) echo "error: '$HOST' is not a host package name (top-level dir like wm, lw, fl)" >&2 ;; esac [ "$HOST" = "$RECORDED" ] && echo " (it is recorded in ~/.config/dots-host — rm that file first)" >&2 exit 2 fi # a bad recorded name with a valid explicit host: don't release it in step 1, just replace it host_ok "$RECORDED" || RECORDED="" 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 } # --adopt copies LIVE files over the repo working tree; uncommitted edits there would be # silently overwritten (and the post-adopt `git restore .` advice would discard whatever # survived). Refuse until the tree is clean, so nothing is lost without a git safety net. if [ -n "$ADOPT" ] && [ -z "$DRY" ] && [ -n "$(git -C "$DOTS" status --porcelain 2>/dev/null)" ]; then echo "error: -a/--adopt rewrites repo files with this machine's live copies, and the repo has" >&2 echo " uncommitted changes that would be lost — commit or stash them first." >&2 exit 1 fi gui=true [ -e "$HEADLESS_MARKER" ] && gui=false # Every stow call goes through stow_cmd, so --no-folding is never forgotten on one of them. It # matters on -D as much as on -S: an unstow REFOLDS each dir it walks whose remaining links all # come from one package — even a package that was never deployed. A bare `-D gui` on headless # hosts collapsed ~/.config/fish/functions and fish/conf.d (gui ships files in both) into single # links to common/, so anything written there (funcsave, fisher) landed in the repo. stow_cmd() { stow --no-folding -d "$DOTS" -t "$HOME" "$@"; } FAIL=0 # unstow one package, then prune the dirs it would have created under ~/.config if they are now # empty. Only the package's own .config dirs are candidates (deepest first, rmdir = empty only), # so an empty dir the user or an app made elsewhere is never touched. A failed unstow is reported # with stow's own message and fails the run — it never passes silently as "done". unstow_pkg() { # unstow_pkg ; returns stow's status local p="$1" out rel [ -d "$DOTS/$p" ] || return 0 if ! out=$(stow_cmd $DRY -D "$p" 2>&1); then echo " FAILED to unstow $p:" >&2 printf '%s\n' "$out" | sed 's/^/ /' >&2 FAIL=1 return 1 fi if [ -z "$DRY" ] && [ -d "$DOTS/$p/.config" ]; then while IFS= read -r rel; do [ -d "$HOME/$rel" ] && [ ! -L "$HOME/$rel" ] && rmdir "$HOME/$rel" 2>/dev/null done < <(cd "$DOTS/$p" && find .config -mindepth 1 -depth -type d) fi return 0 } 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 unstow_pkg "$p" && echo " $verb $p" done if [ "$FAIL" -ne 0 ]; then echo "FAILED: some packages could not be unstowed (see above)." >&2 exit 1 elif [ -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 # remember the resolved host, so bare re-runs — and dotsync, which re-links without being able to # pass an argument — deploy the same overlay on this machine whatever its hostname is. Only ever # records a name that IS a package (never a typo, never a hostname with no overlay behind it), and # records the *choice*, not the outcome: a run whose stow later fails is still remembered. if [ -z "$DRY" ] && [ "$HOST" != "$RECORDED" ] && [ -d "$DOTS/$HOST" ]; then printf '%s\n' "$HOST" >"$HOST_MARKER" && echo " recorded host '$HOST' in ${HOST_MARKER/#$HOME/~}" fi # 0. self-heal symlinks that stow would reject ("not owned by stow"), which otherwise aborts all of # common. Three stale-link classes a prior link.sh/heal/migration 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. # - a DANGLING link whose target is gone (e.g. a relative link into the deleted ~/.dotfiles) — # stow rejects it just the same, so drop it too. # 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" if [ -L "$t" ] && [ ! -e "$t" ]; then rm -f "$t" echo " cleared dangling symlink ~/.config/claude/$f (stow will relink)" continue fi case "$(readlink "$t" 2>/dev/null)" in /*) rm -f "$t" echo " cleared absolute symlink ~/.config/claude/$f (stow will relink)" ;; esac done fi # --- keep a live Hyprland session quiet while symlinks are removed/recreated --- # Step 1 releases the host overlay before restowing, and Hyprland's config watcher reloads # the moment a watched file flickers (under the Lua chain that's ~/.config/hypr/*.lua — # hyprland.lua/host.lua both come from packages this run re-links): every re-parse makes the # session visibly jump/resize. So pause the watcher and reload once at the end, when every # link is back. The final reload also re-reads misc:disable_autoreload from the config, # undoing the runtime override — no state is left behind. # The pause command differs by config mode (same branch as HyDE's color.set.sh): in Lua mode # `hyprctl keyword` is gone — runtime config goes through `hyprctl eval` + hl.config. if [ -z "$DRY" ] && [ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ] && command -v hyprctl >/dev/null 2>&1; then case "${HYPRLAND_CONFIG:-}" in *.lua) hyprctl eval 'hl.config({misc = {disable_autoreload = true}})' >/dev/null 2>&1 ;; *) hyprctl keyword misc:disable_autoreload true >/dev/null 2>&1 ;; esac trap 'hyprctl reload >/dev/null 2>&1' EXIT fi # 1. release the host overlay first so base restows never conflict on overridden files. # The PREVIOUSLY recorded host is released too, so switching a machine to another overlay # works: its leftover links are "stowed to a different package" as far as the step-2 restows # are concerned, which would abort those whole packages. Releasing an already-released # package is a harmless no-op (exit 0), so a failure here is real and is reported. if [ -z "$DRY" ]; then unstow_pkg "$HOST" [ -n "$RECORDED" ] && [ "$RECORDED" != "$HOST" ] && unstow_pkg "$RECORDED" fi # stow one package; track failures instead of silently continuing and reporting success stow_pkg() { # stow_pkg