#!/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 } 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 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 # 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 sourced file vanishes (monitors.conf/host.conf, hyprland.conf:58/60 — the two # that come from the overlay): that parse aborts and the session sits on a partial config # until a manual Super+Shift+R. 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 keyword — no state is left behind. if [ -z "$DRY" ] && [ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ] && command -v hyprctl >/dev/null 2>&1; then hyprctl keyword misc:disable_autoreload true >/dev/null 2>&1 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, so the repeat when nothing changed costs nothing. if [ -z "$DRY" ]; then for p in "$HOST" "$RECORDED"; do [ -n "$p" ] && [ -d "$DOTS/$p" ] && stow -d "$DOTS" -t "$HOME" -D "$p" 2>/dev/null done true # a release that found nothing to do must not fail the run fi # stow one package; track failures instead of silently continuing and reporting success FAIL=0 stow_pkg() { # stow_pkg