Files
dots/install.sh
T

356 lines
17 KiB
Bash
Executable File

#!/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 -> <host>
# - common : shared everywhere
# - gui : GUI configs (skipped on headless machines — see the marker below)
# - <host> : 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 -> <host>. 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 <host>) 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 <name> — is <name> 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 <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 <label> <stow args...>
local label="$1"
shift
if stow_cmd $DRY $ADOPT "$@"; then
echo " stowed $label"
else
FAIL=1
fi
}
# 2. (re)stow base layers
# In dry-run the host overlay is NOT released first (step 1 is real-run-only), so simulating
# a base restow would report conflicts on host-overridden files that a real run never hits.
# --override in the simulation makes its outcome (exit status) match the real sequence.
DRYOVR=""
[ -n "$DRY" ] && DRYOVR="--override=.*"
stow_pkg common $DRYOVR -R common
if [ "$gui" = true ]; then
stow_pkg gui $DRYOVR -R gui
else
# headless: ensure no stray gui is left deployed (self-heals a prior wrong run)
unstow_pkg gui && test -z "$DRY" && echo " ensured gui not deployed (headless)"
fi
# 3. stow the host overlay last, overriding base files
if [ -d "$DOTS/$HOST" ]; then
stow_pkg "$HOST" --override='.*' -S "$HOST"
else
echo " no '$HOST' overlay — using common only (add a top-level '$HOST/' package for host overrides)"
if [ "$HOST" != "${ARGS[0]:-}" ] && [ "$HOST" != "$RECORDED" ]; then
echo " ('$HOST' is only this machine's hostname — if it should deploy a named host package," >&2
echo " run ./install.sh <name> once; that choice is remembered and hostname stops mattering)" >&2
fi
fi
# 3.5 seed waybar's theme.css where no generator has ever written one. layouts/custom.css
# does `@import "../theme.css"` and a missing import target kills waybar outright (bar dead
# at login, Ctrl+Alt+W looks kill-only). On themed hosts HyDE/wallbash owns the real file —
# [ ! -e ] never touches those; it fires only when the file is absent or the pre-2026-08-14
# stow link dangles (-e follows symlinks), covering fresh machines and never-themed ones.
if [ "$gui" = true ]; then
wbtheme="$HOME/.config/waybar/theme.css"
if [ ! -e "$wbtheme" ]; then
if [ -n "$DRY" ]; then
echo " would seed waybar theme.css from gui/.config/waybar/theme-fallback.css"
else
mkdir -p "${wbtheme%/*}"
cp --remove-destination "$DOTS/gui/.config/waybar/theme-fallback.css" "$wbtheme" \
&& echo " seeded waybar theme.css from fallback (wallbash overwrites it when HyDE themes run)"
fi
fi
fi
# 3.6 fill the dots-icons overlay (flat tray icons at every size) with links into this host's
# Tela-circle-dracula — the repo can't ship Tela's SVGs (no upstream licence). A failure only
# warns: the overlay inherits Tela, so the worst case is the coloured 44px circles again.
if [ "$gui" = true ]; then
"$DOTS/bin/dots-icons-link.sh" $DRY || echo " WARN: dots-icons overlay incomplete (see above)" >&2
fi
# 3.7 download the upstream theme files the configs use but the repo doesn't ship (aichat's
# One Half Dark), pinned by commit + sha256. Every host; offline only warns (built-in theme).
"$DOTS/bin/dots-themes-fetch.sh" $DRY || echo " WARN: theme download incomplete (see above)" >&2
# 4. surface Claude config into ~/.claude. Deliberately NOT gated on stow success: link.sh's
# drift heal is what clears the usual stow failure (a ~/.config/claude file severed by one
# of Claude's atomic rewrites), so running it only after a clean stow made that failure
# permanent. Its exit matters too — swallowing it reported "linked" over a refused heal.
if [ -z "$DRY" ] && [ -f "$HOME/.config/claude/link.sh" ]; then
if bash "$HOME/.config/claude/link.sh"; then
echo " linked ~/.claude"
else
FAIL=1
echo " link.sh failed (see above) — ~/.claude not fully linked" >&2
fi
fi
# fail loudly with a non-zero exit so callers / re-runs can tell it did not apply
if [ "$FAIL" -ne 0 ]; then
echo "FAILED: some packages could not be stowed or unstowed (see above)." >&2
echo " first run on a machine with existing real files? re-run with -a to adopt them" >&2
echo " (-a rewrites the repo working tree with the live copies — it refuses to run while" >&2
echo " the repo has uncommitted edits, so commit/stash first)." >&2
exit 1
fi
echo "done."
if [ -n "$ADOPT" ] && [ -z "$DRY" ]; then
echo
echo "adopted this machine's existing files INTO the repo working tree — heads up: they can appear"
echo "under common/ or gui/ even when they are really host-specific. Review, then normally discard:"
echo " git -C \"$DOTS\" diff # inspect the adopted drift"
echo " git -C \"$DOTS\" restore . # discard it, keep the merged repo versions (recommended)"
echo "To KEEP a change, hand-copy it into the right package (common/gui/$HOST) — do not blanket-commit"
echo "the adopted diff, or host-specific content leaks into shared packages. (restore . drops ALL"
echo "uncommitted edits — fine on a fresh clone.)"
fi