#!/usr/bin/env bash # The pressure valve. # # Hooks exist so "can Panama also do X when the theme changes" is a five-line # file somebody drops in a directory rather than a fork, a feature request, or # a patch that has to be rebased forever. The upstream ledger defers a plugin # host as premature and still should; this covers most of what people actually # want from one and has no API to keep stable beyond "we will run your script # and tell you what happened". # # What must hold: # # 1. A failing hook is reported and stepped over. Somebody's broken script # must never break a theme change, an upgrade, or a login -- and the # hooks after it still run. # 2. Arguments arrive as written. The first version passed each hook its own # path as $1, so every argument landed one place late; a hook reading $1 # as the colour scheme got a filename. # 3. Both the single file and the .d directory run, .d in sorted order, so # several things can react without fighting over one file. # 4. A hook name cannot escape the hook directory. # 5. No hooks at all is the normal case and exits cleanly. # 6. Samples are copied, never symlinked. ~/.config/panama is the user's -- # settings.json lives there -- and a symlinked directory would put their # scripts in the repository working tree. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" hook="$repo_dir/bin/panama-hook" samples="$repo_dir/config/dot/panama/hooks" linker="$repo_dir/setup/scripts/link-dotfiles" findings=() note() { findings+=("$1"); } [[ -x "$hook" ]] || { printf 'hooks contract: %s is not executable\n' "$hook" >&2; exit 1; } work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT hooks="$work/hooks" out="$work/out" mkdir -p "$hooks" run() { PANAMA_HOOK_DIR="$hooks" "$hook" "$@" 2>"$work/err"; } # ── 5. Nothing to run ─────────────────────────────────────────────────────── run theme-set dark blue || note 'a hook name with no hooks behind it did not exit cleanly' # ── 2 & 3. Arguments, and both places hooks live ──────────────────────────── : >"$out" cat >"$hooks/theme-set" <>"$out" EOF mkdir -p "$hooks/theme-set.d" cat >"$hooks/theme-set.d/20-second" <>"$out" EOF cat >"$hooks/theme-set.d/10-first" <>"$out" EOF chmod +x "$hooks/theme-set" "$hooks/theme-set.d"/* run theme-set dark orchid || note 'running hooks reported failure when none failed' grep -qx 'single:dark,orchid' "$out" \ || note "the single hook did not receive its arguments as written (got: $(grep '^single' "$out" || echo none))" grep -qx 'first:dark,orchid' "$out" \ || note 'a hook in the .d directory did not receive its arguments as written' [[ "$(grep -c . "$out")" == "3" ]] \ || note "expected three hooks to run, got $(grep -c . "$out")" [[ "$(sed -n '2p' "$out")" == first:* && "$(sed -n '3p' "$out")" == second:* ]] \ || note 'the .d hooks did not run in sorted order' # A file that is not executable is not a hook. Dropping a note in the # directory should not be an error. printf 'not a script\n' >"$hooks/theme-set.d/30-readme" : >"$out" run theme-set dark orchid || note 'a non-executable file in the .d directory caused a failure' [[ "$(grep -c . "$out")" == "3" ]] \ || note 'a non-executable file was treated as a hook' rm -f "$hooks/theme-set.d/30-readme" # ── 1. A broken hook is stepped over ──────────────────────────────────────── cat >"$hooks/theme-set.d/05-broken" <<'EOF' #!/usr/bin/env bash exit 7 EOF chmod +x "$hooks/theme-set.d/05-broken" : >"$out" run theme-set dark orchid \ || note 'a failing hook made the whole run fail, so a broken script would break a theme change' grep -q 'failed' "$work/err" \ || note 'a failing hook was silent, so nobody would know their script is broken' [[ "$(grep -c . "$out")" == "3" ]] \ || note 'a failing hook stopped the hooks after it from running' # ── 4. A hook name cannot escape ──────────────────────────────────────────── mkdir -p "$work/outside" printf '#!/usr/bin/env bash\ntouch "%s/escaped"\n' "$work/outside" >"$work/outside/evil" chmod +x "$work/outside/evil" run ../outside/evil >/dev/null 2>&1 \ && note 'a hook name containing a path separator was accepted' [[ -e "$work/outside/escaped" ]] \ && note 'a hook outside the hook directory was executed' # ── 6. Samples ship, and are copied rather than symlinked ─────────────────── [[ -d "$samples" ]] || note 'no hook samples are shipped, so the mechanism is undiscoverable' for sample in "$samples"/*.sample; do [[ -e "$sample" ]] || continue head -1 "$sample" | grep -q '^#!' \ || note "$(basename "$sample") has no shebang, so copying it and adding +x would not run" done grep -q 'PANAMA_HOOK_SAMPLES' "$linker" \ || note 'link-dotfiles does not install the hook samples' grep -A12 'PANAMA_HOOK_SAMPLES' "$linker" | grep -q 'cp "\$sample"' \ || note 'the hook samples are not copied; a symlinked hook directory would put user scripts in the repository' # ── The call sites ────────────────────────────────────────────────────────── for pair in "config/dot/quickshell/scripts/panama-theme-apps:theme-set" \ "bin/panama-migrate:post-migrate" \ "install:post-upgrade"; do file="${pair%%:*}"; name="${pair##*:}" grep -q "panama-hook\" $name\|panama-hook\" \"$name\"\|hook\" $name" "$repo_dir/$file" \ || note "$file does not fire the $name hook" done if (( ${#findings[@]} > 0 )); then printf 'hooks contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'hooks contract: PASS\n'