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'