From 7a5e9904399a13364678b6581f8de5bd448e3751 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 22 Aug 2026 08:11:49 -0400 Subject: [PATCH] Let somebody extend this without forking it, and say when things die Two of Section F. Hooks are the pressure valve. "Can Panama also do X when the theme changes" is now a five-line file in ~/.config/panama/hooks rather than a fork, a feature request, or a patch somebody rebases forever. Each name takes a single file and a .d directory so several things can react without fighting over one, and a broken hook is reported and stepped over: somebody's script must never cost a theme change, an upgrade or a login. Wired at theme-set, post-upgrade and post-migrate. This is the thirty-line version of the plugin host the upstream ledger defers, and it has no API to keep stable beyond "we will run your script and tell you what happened". Testing it caught a real bug the reading would not have: run_one captured the script path but never shifted it off, so every hook got its own filename as $1 and the real arguments arrived one place late. A hook reading $1 as the colour scheme got a path. The crash watcher notices when a program dumps core and says so. Under GNOME, ABRT does this; here nothing did, and applications died silently, which is most of how "Linux is flaky" gets earned. Once per program per session is the entire design, not a nicety. This machine's portal backend crashes between eleven and sixty times a day, and a notification per crash would be one every few minutes for something nobody can act on. The first is news; the fortieth is why people turn notifications off. The health page keeps the running count. It waits for the notification server before reporting, because the crash most worth hearing about is the one that took the shell with it, and it names the executable rather than the kernel's comm field, which truncates at fifteen characters. Verified against real segfaults. --- README.md | 9 +- bin/panama-crash-watch | 73 +++++++++ bin/panama-hook | 68 ++++++++ bin/panama-migrate | 3 + config/dot/hypr/autostart.lua | 4 + config/dot/panama/hooks/post-migrate.sample | 7 + config/dot/panama/hooks/post-upgrade.sample | 8 + config/dot/panama/hooks/theme-set.sample | 18 +++ .../dot/quickshell/scripts/panama-theme-apps | 8 +- .../systemd/user/panama-crash-watch.service | 15 ++ install | 5 + setup/scripts/link-dotfiles | 20 +++ tests/setup/crash-watch-contract | 118 ++++++++++++++ tests/setup/hooks-contract | 145 ++++++++++++++++++ 14 files changed, 499 insertions(+), 2 deletions(-) create mode 100755 bin/panama-crash-watch create mode 100755 bin/panama-hook create mode 100644 config/dot/panama/hooks/post-migrate.sample create mode 100644 config/dot/panama/hooks/post-upgrade.sample create mode 100644 config/dot/panama/hooks/theme-set.sample create mode 100644 config/local/share/systemd/user/panama-crash-watch.service create mode 100755 tests/setup/crash-watch-contract create mode 100755 tests/setup/hooks-contract diff --git a/README.md b/README.md index 6b280f8..bf83ce7 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -148 of them, under `tests/`. Run the lot, or a subset by pattern: +150 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything @@ -193,6 +193,13 @@ package, a `flatpak:` line is a Flathub id, `| Name` gives the menu something readable, and an indented line belongs to the entry above it — which is how OBS carries its sixteen plugin extensions as one thing to tick. +Hooks are the extension point: drop a script at `~/.config/panama/hooks/theme-set` +and it runs whenever the colour scheme changes, with the scheme and accent as +arguments. Same for `post-upgrade` and `post-migrate`, and a `.d/` +directory beside each so several things can react without fighting over one +file. A broken hook is reported and stepped over, never fatal. Samples are +copied into place on install. + `panama migrate` applies repairs an installed machine has not had yet. Safe to re-run: nothing is applied twice, and a machine with nothing waiting says so. diff --git a/bin/panama-crash-watch b/bin/panama-crash-watch new file mode 100755 index 0000000..8874d95 --- /dev/null +++ b/bin/panama-crash-watch @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +# Tell somebody when a program crashes. +# +# On GNOME, ABRT says so. Under a hand-assembled Hyprland desktop nothing does, +# and applications die silently -- which is most of how "Linux is flaky" gets +# earned. Fedora ships systemd-coredump by default, so the information is +# already there; nobody is reading it. +# +# Follows the journal for systemd-coredump's own message id and reports each +# program once per session. +# +# ONCE PER SESSION IS THE WHOLE DESIGN. This machine's portal backend crashes +# between eleven and sixty times a day -- see the portal-stability check in +# panama-doctor -- and a notification per crash would be a notification every +# few minutes for something the user can do nothing about. The first one is +# news; the fortieth is why people turn notifications off. The health page +# carries the running count for anyone who wants it. + +set -uo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" + +# systemd-coredump's MESSAGE_ID. Matching on this rather than on text keeps +# working when the wording changes and never matches a program that merely +# mentions the word "crash" in its own logs. +readonly COREDUMP_MESSAGE_ID='fc2e22bc6ee647b6b90729ab34a250b1' + +command -v journalctl >/dev/null 2>&1 || exit 0 +command -v notify-send >/dev/null 2>&1 || exit 0 + +# The shell owns org.freedesktop.Notifications, and the crash most worth +# reporting is the one that took the shell with it. Waiting means that report +# arrives rather than vanishing into a bus nobody is serving. +for _ in $(seq 1 60); do + busctl --user status org.freedesktop.Notifications >/dev/null 2>&1 && break + sleep 1 +done + +declare -A reported=() + +# -f from now, not from the boot: a session that starts after a crash should +# not open with a notification about something the user has already lived +# through and cannot act on. +journalctl --user -f -n 0 --output=json MESSAGE_ID="$COREDUMP_MESSAGE_ID" 2>/dev/null \ + | while IFS= read -r line; do + [[ -n "$line" ]] || continue + + uid="$(jq -r '.COREDUMP_UID // empty' <<<"$line" 2>/dev/null)" + exe="$(jq -r '.COREDUMP_EXE // empty' <<<"$line" 2>/dev/null)" + comm="$(jq -r '.COREDUMP_COMM // empty' <<<"$line" 2>/dev/null)" + + # Another user's crash is not this session's business, and reporting it + # would leak what they are running. + [[ "$uid" == "$(id -u)" ]] || continue + [[ -n "$exe" || -n "$comm" ]] || continue + + # The executable name first: COREDUMP_COMM is the kernel's comm field + # and is truncated to fifteen characters, so it reports + # "panama-test-cra" for a program called panama-test-crasher. + if [[ -n "$exe" ]]; then + program="$(basename "$exe")" + else + program="$comm" + fi + [[ -z "${reported[$program]:-}" ]] || continue + reported[$program]=1 + + notify-send --icon=dialog-error-symbolic --app-name=Panama \ + "$program stopped unexpectedly" \ + "It crashed and was not able to recover. System Health has the details." \ + 2>/dev/null || true + done diff --git a/bin/panama-hook b/bin/panama-hook new file mode 100755 index 0000000..ab97612 --- /dev/null +++ b/bin/panama-hook @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +# The pressure valve. +# +# panama-hook theme-set dark orchid +# +# Runs ~/.config/panama/hooks/ and everything executable in +# ~/.config/panama/hooks/.d/, in sorted order, with the hook's arguments. +# +# This exists 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. docs/UPSTREAM-INSPIRATION.md defers a +# plugin host as premature and still should: this is the thirty-line version +# that covers most of what people actually want from one, and it has no API to +# keep stable beyond "we will run your script and tell you what happened". +# +# A failing hook is reported and stepped over. Somebody's broken script must +# never break a theme change, an upgrade, or a login -- which is exactly what +# would happen if this used `set -e` and the caller did too. +# +# Hooks run synchronously, so a slow one delays whatever called it. That is +# deliberate: the alternative is a hook whose output arrives after the thing it +# was reacting to has already finished, which is harder to reason about than a +# pause. + +set -uo pipefail + +HOOK_DIR="${PANAMA_HOOK_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks}" + +name="${1:-}" +if [[ -z "$name" ]]; then + echo 'usage: panama-hook [args...]' >&2 + exit 2 +fi +shift + +# A hook name reaches the filesystem, so it cannot be allowed to leave the +# directory. Callers are all in-repo today, which is exactly when this is +# cheap to add and easy to forget. +if [[ ! "$name" =~ ^[a-z][a-z0-9-]*$ ]]; then + echo "panama-hook: refusing hook name: $name" >&2 + exit 2 +fi + +run_one() { + local script="$1" + # Shifted off before the arguments are forwarded, or every hook receives + # its own path as $1 and the real arguments arrive one place late. + shift + [[ -f "$script" && -x "$script" ]] || return 0 + if ! "$script" "$@"; then + printf 'panama-hook: %s failed (%s); continuing\n' \ + "$(basename "$script")" "$name" >&2 + fi +} + +# The single file first, then the .d directory in sorted order. Both are +# optional and having neither is the normal case. +run_one "$HOOK_DIR/$name" "$@" + +if [[ -d "$HOOK_DIR/$name.d" ]]; then + while IFS= read -r script; do + [[ -n "$script" ]] || continue + run_one "$script" "$@" + done < <(find "$HOOK_DIR/$name.d" -maxdepth 1 -type f | sort) +fi + +exit 0 diff --git a/bin/panama-migrate b/bin/panama-migrate index c8a3f9b..a4743ee 100755 --- a/bin/panama-migrate +++ b/bin/panama-migrate @@ -115,6 +115,9 @@ cmd_run() { return 1 fi ok "This machine now matches the checkout." + # Only after repairs actually ran: a hook that fires on every login when + # there was nothing to do is a hook people disable. + "$PANAMA_PATH/bin/panama-hook" post-migrate || true } # The check the login notifier runs. Exit 0 means work is waiting, so it reads diff --git a/config/dot/hypr/autostart.lua b/config/dot/hypr/autostart.lua index b2515b5..fa217f4 100644 --- a/config/dot/hypr/autostart.lua +++ b/config/dot/hypr/autostart.lua @@ -44,6 +44,10 @@ hl.on("hyprland.start", function() -- than enabled so it belongs to the Hyprland session; see the unit. hl.exec_cmd("systemctl --user start panama-migrate-notify.service") + -- Notices when a program dumps core and says so once per session. Under + -- GNOME, ABRT does this; here nothing did, and applications died silently. + hl.exec_cmd("systemctl --user start panama-crash-watch.service") + -- Text expansion. change-settings runs `espanso service register`, which -- writes and enables espanso's own user unit; the explicit start makes the -- first Hyprland login after a fresh install work rather than the second. diff --git a/config/dot/panama/hooks/post-migrate.sample b/config/dot/panama/hooks/post-migrate.sample new file mode 100644 index 0000000..8ffaaf1 --- /dev/null +++ b/config/dot/panama/hooks/post-migrate.sample @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Runs after `panama migrate` has applied repairs, and only when it applied at +# least one. Takes no arguments. +# +# Useful when a machine keeps local state that a repair might have invalidated. +# +# Copy to ~/.config/panama/hooks/post-migrate and make it executable. diff --git a/config/dot/panama/hooks/post-upgrade.sample b/config/dot/panama/hooks/post-upgrade.sample new file mode 100644 index 0000000..c440535 --- /dev/null +++ b/config/dot/panama/hooks/post-upgrade.sample @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Runs at the end of ./install, after every stage and after migrations. +# +# Takes no arguments. This is where per-machine setup goes that Panama should +# not carry for everyone: a work laptop's VPN client, a private repository +# somebody clones, a package only this machine wants. +# +# Copy to ~/.config/panama/hooks/post-upgrade and make it executable. diff --git a/config/dot/panama/hooks/theme-set.sample b/config/dot/panama/hooks/theme-set.sample new file mode 100644 index 0000000..0723981 --- /dev/null +++ b/config/dot/panama/hooks/theme-set.sample @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Runs whenever the colour scheme or accent changes. +# +# $1 scheme: "dark" or "light" +# $2 accent: blue, orchid, teal, green, amber, orange, rose or slate +# +# Copy to ~/.config/panama/hooks/theme-set and make it executable. Anything in +# theme-set.d/ runs too, in sorted order, so several things can react without +# fighting over one file. +# +# A failure here is reported and stepped over: it will never cost you a theme +# change. + +scheme="$1" +accent="$2" + +# For example: repaint something Panama does not know about. +# printf 'set-theme %s\n' "$scheme" | nc -U "$HOME/.local/share/some-app/socket" diff --git a/config/dot/quickshell/scripts/panama-theme-apps b/config/dot/quickshell/scripts/panama-theme-apps index 62e6660..a666f47 100755 --- a/config/dot/quickshell/scripts/panama-theme-apps +++ b/config/dot/quickshell/scripts/panama-theme-apps @@ -234,7 +234,13 @@ if [[ -r "$theme_file" ]]; then # bottom, so a later active_border_color line wins, and this file is # itself generated -- the tracked per-scheme theme stays scheme-only. if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \ - && printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \ + && # Anything else on this machine that wants to know. Runs after every generator +# above, so a hook sees the finished state rather than a half-applied one, and +# a broken hook cannot cost you a theme change -- panama-hook reports and steps +# over. See bin/panama-hook. +"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hook" theme-set "$scheme" "$accent" || true + +printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \ && mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then status_kitty="written" else diff --git a/config/local/share/systemd/user/panama-crash-watch.service b/config/local/share/systemd/user/panama-crash-watch.service new file mode 100644 index 0000000..2567b0b --- /dev/null +++ b/config/local/share/systemd/user/panama-crash-watch.service @@ -0,0 +1,15 @@ +[Unit] +Description=Notice when a program crashes +Documentation=https://github.com/gibbyb/Panama +# Started per-session by hypr/autostart.lua rather than enabled globally, for +# the same reason as every other Panama unit: graphical-session.target is +# active under GNOME too, and this reports through the Hyprland shell's +# notification server. + +[Service] +Type=simple +# Resolved at runtime so a clone at another location still works; bin/ has no +# symlink into ~/.config the way the shell scripts do. +ExecStart=/usr/bin/env sh -c 'exec "${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-crash-watch"' +Restart=on-failure +RestartSec=30 diff --git a/install b/install index 692fb54..85b2aef 100755 --- a/install +++ b/install @@ -144,6 +144,11 @@ if [[ -x "$doctor" ]]; then "$doctor" --summary || true fi +# Whatever this particular machine wants doing that Panama should not carry for +# everyone. Runs last, after every stage, migrations and the health summary. +hook="$PANAMA_PATH/bin/panama-hook" +[[ -x "$hook" ]] && "$hook" post-upgrade || true + printf '\n' if (( ${#failed[@]} == 0 )); then echo "Panama installed. Log out and choose the Hyprland session to start it." diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index 7020794..926d98d 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -438,6 +438,26 @@ fi # than by symlinking the directory itself, the same way the quadlets and # desktop entries are: Nautilus writes nothing here today, but a directory # symlink into the repository is how machine state ends up in a tracked path. +# Hook samples. Copied rather than symlinked, and only when absent: hooks are +# the user's own scripts, and ~/.config/panama is theirs too -- settings.json +# lives there. A symlinked directory would put their scripts in the repository +# working tree, which is the mistake the gtk bookmarks made. +PANAMA_HOOK_SAMPLES="$PANAMA_DOT/panama/hooks" +USER_HOOK_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks" +if [ -d "$PANAMA_HOOK_SAMPLES" ]; then + mkdir -p "$USER_HOOK_DIR" + for sample in "$PANAMA_HOOK_SAMPLES"/*.sample; do + [ -e "$sample" ] || continue + sample_dst="$USER_HOOK_DIR/$(basename "$sample")" + if [ -e "$sample_dst" ]; then + log "Keeping existing hook sample at $sample_dst" + else + cp "$sample" "$sample_dst" + log "Copied hook sample → $sample_dst" + fi + done +fi + PANAMA_NAUTILUS_DIR="$PANAMA_PATH/config/local/share/nautilus-python/extensions" USER_NAUTILUS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/nautilus-python/extensions" if [ -d "$PANAMA_NAUTILUS_DIR" ]; then diff --git a/tests/setup/crash-watch-contract b/tests/setup/crash-watch-contract new file mode 100755 index 0000000..5a4612a --- /dev/null +++ b/tests/setup/crash-watch-contract @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +# Telling somebody when a program crashes. +# +# On GNOME, ABRT says so. Under a hand-assembled Hyprland desktop nothing did, +# and applications died silently, which is most of how "Linux is flaky" gets +# earned. Fedora ships systemd-coredump by default, so the information was +# already there and nobody was reading it. +# +# What must hold: +# +# 1. Once per program per session. This machine's portal backend crashes +# between eleven and sixty times a day; a notification per crash would be +# one every few minutes for something nobody can act on. The first is +# news, the fortieth is why people turn notifications off. +# 2. Another user's crash is not reported. It is not this session's business +# and it would leak what they are running. +# 3. The program is named by its executable, not by the kernel's comm field, +# which is truncated to fifteen characters -- "panama-test-cra" for a +# program called panama-test-crasher. +# 4. It waits for the notification server. The crash most worth reporting is +# the one that took the shell down with it. +# 5. It follows from now rather than replaying the boot, so a session that +# starts after a crash does not open with a notification about something +# already lived through. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +watcher="$repo_dir/bin/panama-crash-watch" +unit="$repo_dir/config/local/share/systemd/user/panama-crash-watch.service" +autostart="$repo_dir/config/dot/hypr/autostart.lua" + +findings=() +note() { findings+=("$1"); } + +[[ -x "$watcher" ]] || { printf 'crash watch contract: %s is not executable\n' "$watcher" >&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +calls="$work/calls" +stub="$work/bin" +mkdir -p "$stub" + +# Two crashes of one program, one of another, and one belonging to somebody +# else. journalctl is replaced by a stub that emits them and exits, so the +# watcher's follow loop terminates instead of hanging the test. +uid="$(id -u)" +cat >"$stub/journalctl" <"$stub/notify-send" <>"$calls" +STUB +chmod +x "$stub/notify-send" + +# The bus is already up, so the wait loop falls straight through. +cat >"$stub/busctl" <<'STUB' +#!/usr/bin/env bash +exit 0 +STUB +chmod +x "$stub/busctl" + +: >"$calls" +PATH="$stub:$PATH" timeout 20 "$watcher" >/dev/null 2>&1 + +# ── 1. Once per program ───────────────────────────────────────────────────── + +crasher_notices="$(grep -c 'panama-test-crasher' "$calls" || true)" +(( crasher_notices == 1 )) \ + || note "a program that crashed twice produced $crasher_notices notifications; it must produce one per session" + +# A different program is still news. +grep -q 'other-program' "$calls" \ + || note 'a second, different program crashing was not reported' + +# ── 2. Somebody else's crash is not ours ──────────────────────────────────── + +grep -q 'someone-elses' "$calls" \ + && note "another user's crash was reported, which leaks what they are running" + +# ── 3. The name is not the truncated one ──────────────────────────────────── + +grep -q 'panama-test-cra ' "$calls" \ + && note 'the notification uses the truncated kernel comm field rather than the executable name' + +# ── 4 & 5. How it listens ─────────────────────────────────────────────────── + +grep -q 'org.freedesktop.Notifications' "$watcher" \ + || note 'the watcher does not wait for the notification server, so a shell crash would report to nobody' +grep -q -- '-f -n 0' "$watcher" \ + || note 'the watcher replays the journal rather than following from now, so a session would open with old crashes' +grep -q 'MESSAGE_ID=' "$watcher" \ + || note 'the watcher matches on log text rather than the coredump message id' + +# ── Installed and started ─────────────────────────────────────────────────── + +[[ -r "$unit" ]] || note 'there is no user unit for the crash watcher' +grep -q 'panama-crash-watch' "$autostart" \ + || note 'nothing starts the crash watcher at login' +grep -q 'PANAMA_PATH' "$unit" \ + || note 'the unit hardcodes the repository path, so a clone elsewhere would not start' + +if (( ${#findings[@]} > 0 )); then + printf 'crash watch contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'crash watch contract: PASS\n' diff --git a/tests/setup/hooks-contract b/tests/setup/hooks-contract new file mode 100755 index 0000000..74140de --- /dev/null +++ b/tests/setup/hooks-contract @@ -0,0 +1,145 @@ +#!/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'