From e446a1072c11c6663e4fc42ed757e0f8d30f1cd1 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 21 Aug 2026 21:01:31 -0400 Subject: [PATCH] Give an installed machine a way to catch up ./install only ever adds. It copies over /, links dotfiles, installs packages -- and has no way to say "remove that file", "disable that unit", "that symlink points nowhere now". So a machine set up months ago keeps whatever this repository has since decided was wrong, and the only thing that ever fixes it is somebody reading a commit message. With a curl installer in the README, that stopped being hypothetical. A migration is one script that performs one repair, exactly once, on the machines that need it. Named by the commit timestamp that authored it, so glob order is chronological without a sequence number two branches could both pick. Marked in ~/.local/state on success and only on success, so a repair that failed stays pending rather than being recorded as done and hidden forever. Ordered, and stopped at the first failure, because a later repair may assume an earlier one landed. A fresh install marks everything without running it, the way Migrations.qml stamps a pre-versioning settings file at its baseline. The first real one removes the dangling ~/.config/forge symlink left behind when the GNOME session was cut: link-dotfiles could link it but never unlink it. Verified both ways -- a no-op on a machine that never had it, an actual repair on one that did. Root work goes through panama-sudo --reason so the password prompt names the repair, and the contract fails any migration reaching for bare sudo. --- README.md | 2 +- bin/panama | 22 +++ bin/panama-dev-migration | 56 ++++++ bin/panama-migrate | 185 ++++++++++++++++++ bin/panama-migrate-notify | 50 +++++ config/dot/hypr/autostart.lua | 5 + .../user/panama-migrate-notify.service | 21 ++ install | 22 +++ migrations/1787359736.sh | 44 +++++ migrations/README.md | 2 + tests/setup/migrations-contract | 149 ++++++++++++++ 11 files changed, 557 insertions(+), 1 deletion(-) create mode 100755 bin/panama-dev-migration create mode 100755 bin/panama-migrate create mode 100755 bin/panama-migrate-notify create mode 100644 config/local/share/systemd/user/panama-migrate-notify.service create mode 100755 migrations/1787359736.sh create mode 100644 migrations/README.md create mode 100755 tests/setup/migrations-contract diff --git a/README.md b/README.md index edf9dde..dc23d4c 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -135 of them, under `tests/`. Run the lot, or a subset by pattern: +136 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/bin/panama b/bin/panama index 734fa7b..0d837ee 100755 --- a/bin/panama +++ b/bin/panama @@ -73,6 +73,9 @@ ${BOLD}Commands:${RESET} a subset: 'panama test dock' runs the ones matching 'dock'. ${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is idempotent and this is the documented upgrade path. + ${GREEN}migrate${RESET} Apply repairs this machine has not had yet. The half of an + upgrade that ./install cannot do, because installing only ever + adds. Safe to re-run; nothing is applied twice. ${GREEN}apps${RESET} Choose applications to install: pick a category, then tick what you want. The same catalog ./install offers, minus the install. @@ -321,6 +324,24 @@ cmd_upgrade() { exec "$installer" "$@" } +# ---------------------------------------------------------------------------- +# Command: migrate +# ---------------------------------------------------------------------------- +# +# What ./install cannot do. The installer only ever adds -- it copies over /, +# links dotfiles, installs packages -- so a machine set up months ago keeps +# whatever this repository has since decided was wrong. Migrations are the one +# mechanism that can remove a file, disable a unit, or repair a symlink on a +# machine that already exists. See bin/panama-migrate. +cmd_migrate() { + local runner="$PANAMA_DIR/bin/panama-migrate" + if [[ ! -x "$runner" ]]; then + err "The migration runner is missing from $runner" + exit 1 + fi + exec "$runner" "$@" +} + # ---------------------------------------------------------------------------- # Command: app # ---------------------------------------------------------------------------- @@ -522,6 +543,7 @@ main() { doctor) shift; cmd_doctor "$@" ;; test) shift; cmd_test "$@" ;; upgrade) shift; cmd_upgrade "$@" ;; + migrate) shift; cmd_migrate "$@" ;; app) shift; cmd_app "$@" ;; apps) shift; cmd_apps "$@" ;; help|-h|--help|"") usage ;; diff --git a/bin/panama-dev-migration b/bin/panama-dev-migration new file mode 100755 index 0000000..f41829b --- /dev/null +++ b/bin/panama-dev-migration @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Stamps a new migration, so the name is never chosen by hand. +# +# panama-dev-migration "remove the stale settings-ssh-keys.sh launcher command" +# +# The filename is the commit timestamp of HEAD, which makes glob order +# chronological without a sequence number that two branches could pick at the +# same time. Two migrations authored against the same commit would collide, so +# a taken name gets the next free second rather than silently overwriting. +# +# Developer tool, not part of any install path. See bin/panama-migrate for what +# runs these and what rules they have to follow. + +set -euo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}" +MIGRATIONS_DIR="$PANAMA_PATH/migrations" + +describe="${1:-}" +if [[ -z "$describe" ]]; then + echo 'usage: panama-dev-migration "what this repairs"' >&2 + exit 2 +fi + +stamp="$(git -C "$PANAMA_PATH" log -1 --format=%cd --date=unix 2>/dev/null || date +%s)" +mkdir -p "$MIGRATIONS_DIR" +while [[ -e "$MIGRATIONS_DIR/$stamp.sh" ]]; do + stamp=$(( stamp + 1 )) +done +file="$MIGRATIONS_DIR/$stamp.sh" + +cat >"$file" <.sh +# +# The name is the commit timestamp of HEAD when it was authored, so glob order +# over fixed-width epoch seconds IS chronological order -- no sequence numbers +# to collide on across branches. `panama-dev-migration` stamps them. +# +# State is one empty marker file per migration under +# $XDG_STATE_HOME/panama/migrations. Present means applied. There is no +# database and no version integer, because the failure mode of a version +# integer is that one bad migration strands every later one behind it. +# +# The rules a migration must follow are in the template that +# `panama-dev-migration` writes, and they are worth repeating here because +# this runner cannot enforce them: +# +# * Safe to run twice. The marker only records that it succeeded once. +# * Tolerant of the repair already being correct -- a user may have fixed it +# by hand, or a later `./install` may have overwritten it back. +# * Root work goes through `panama-sudo --reason "..."`, never bare sudo, +# so the prompt names the repair. See bin/panama-sudo. +# +# The marker is written ONLY on success, so a migration that fails stays +# pending and is retried at the next login. That is deliberate: a repair that +# could not complete has not happened, and recording it as done would hide it +# forever. +# +# This mirrors config/dot/quickshell/config/Migrations.qml, which does the same +# job for the settings JSON and documents the same reasoning. That one handles +# renamed preference keys; this one handles everything else. + +set -uo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}" +MIGRATIONS_DIR="$PANAMA_PATH/migrations" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama/migrations" + +export PANAMA_PATH + +info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!\033[0m %s\n' "$*" >&2; } +err() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; } + +# Every migration this checkout ships, oldest first. Empty is a valid state. +all_migrations() { + [[ -d "$MIGRATIONS_DIR" ]] || return 0 + local file + for file in "$MIGRATIONS_DIR"/*.sh; do + [[ -e "$file" ]] || continue + basename "$file" + done | sort +} + +pending_migrations() { + local name + while read -r name; do + [[ -n "$name" ]] || continue + [[ -e "$STATE_DIR/$name" ]] || printf '%s\n' "$name" + done < <(all_migrations) +} + +run_one() { + local name="$1" file="$MIGRATIONS_DIR/$1" + info "$name" + # A subshell with its own strictness: a migration that forgets `set -e` is + # still stopped by its first failing command, and one that sets shell + # options cannot leak them into the next migration. + if bash -euo pipefail "$file"; then + mkdir -p "$STATE_DIR" + : >"$STATE_DIR/$name" + ok "$name applied" + return 0 + fi + err "$name failed and will be retried at the next login" + return 1 +} + +cmd_run() { + local pending + pending="$(pending_migrations)" + if [[ -z "$pending" ]]; then + ok "Nothing to migrate; this machine matches the checkout." + return 0 + fi + + local count failed=0 name + count="$(grep -c . <<<"$pending")" + info "$count migration(s) to apply" + while read -r name; do + [[ -n "$name" ]] || continue + # Stop at the first failure rather than continuing. Migrations are + # ordered, and a later one may assume an earlier one landed; running + # it anyway turns one stuck repair into an unpredictable machine. + if ! run_one "$name"; then + failed=1 + break + fi + done <<<"$pending" + + if (( failed )); then + warn "Re-running 'panama migrate' is safe and will retry from the failure." + return 1 + fi + ok "This machine now matches the checkout." +} + +# The check the login notifier runs. Exit 0 means work is waiting, so it reads +# as `if panama-migrate --pending; then notify; fi`. +cmd_pending() { + local pending + pending="$(pending_migrations)" + [[ -n "$pending" ]] || return 1 + grep -c . <<<"$pending" +} + +cmd_list() { + local name + while read -r name; do + [[ -n "$name" ]] || continue + if [[ -e "$STATE_DIR/$name" ]]; then + printf 'applied %s\n' "$name" + else + printf 'pending %s\n' "$name" + fi + done < <(all_migrations) +} + +# Re-run one that already succeeded. For developing a migration, and for the +# rare case where a repair was undone by something else. +cmd_force() { + local name="${1:-}" + [[ -n "$name" ]] || { err "force needs a migration name"; return 2; } + [[ -e "$MIGRATIONS_DIR/$name" ]] || { err "no such migration: $name"; return 2; } + rm -f "$STATE_DIR/$name" + run_one "$name" +} + +# Mark everything applied without running it. This is what a fresh install +# does: the machine was just built from this checkout, so every repair those +# migrations describe is already true of it, and running them would apply +# fixes for versions it never had. +cmd_baseline() { + mkdir -p "$STATE_DIR" + local name count=0 + while read -r name; do + [[ -n "$name" ]] || continue + [[ -e "$STATE_DIR/$name" ]] && continue + : >"$STATE_DIR/$name" + count=$(( count + 1 )) + done < <(all_migrations) + ok "Marked $count migration(s) as already applied." +} + +case "${1:-run}" in + run) shift || true; cmd_run "$@" ;; + --pending) shift || true; cmd_pending "$@" ;; + --list|list) shift || true; cmd_list "$@" ;; + --force) shift || true; cmd_force "$@" ;; + --baseline) shift || true; cmd_baseline "$@" ;; + -h|--help) + cat <<'USAGE' +usage: panama-migrate [run|--pending|--list|--force |--baseline] + + run Apply every pending migration, oldest first (default) + --pending Exit 0 and print the count when work is waiting, else exit 1 + --list Show every migration and whether it has been applied + --force Re-run one migration that already succeeded + --baseline Mark everything applied without running it (fresh installs) +USAGE + ;; + *) err "unknown argument: $1"; exit 2 ;; +esac diff --git a/bin/panama-migrate-notify b/bin/panama-migrate-notify new file mode 100755 index 0000000..b8aad92 --- /dev/null +++ b/bin/panama-migrate-notify @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# "Panama has repairs waiting for this machine." +# +# A migration that nobody knows about is a migration that never runs. This +# checks once per session and, when work is pending, sends one notification +# whose action opens a terminal running `panama migrate` -- so the repair is +# always something the user chose, never something that happened to them. +# +# Waiting for the notification server first is not politeness. Quickshell owns +# org.freedesktop.Notifications, and a shell that has not started yet would +# swallow this silently -- which is precisely the session where a pending +# migration is most likely to matter. + +set -uo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}" +TERMINAL="${PANAMA_TERMINAL:-kitty}" + +command -v notify-send >/dev/null 2>&1 || exit 1 + +count="$("$PANAMA_PATH/bin/panama-migrate" --pending)" || exit 1 +[[ -n "$count" ]] || exit 1 + +# Up to ~15s for the shell to claim the bus name. Longer than a healthy start +# needs, short enough that a session without a shell gives up rather than +# lingering. +for _ in $(seq 1 30); do + if busctl --user status org.freedesktop.Notifications >/dev/null 2>&1; then + break + fi + sleep 0.5 +done + +if (( count == 1 )); then + body="One repair is waiting for this machine." +else + body="$count repairs are waiting for this machine." +fi + +# Critical so it waits to be read: a notification that expires while the user +# is elsewhere has told nobody anything. The action is the whole point -- there +# is no instruction to remember and nothing to type. +action="$(notify-send --urgency=critical --icon=system-software-update \ + --app-name=Panama \ + --action=migrate="Apply now" --action=later="Later" \ + "Panama updates" "$body" 2>/dev/null)" || exit 0 + +[[ "$action" == "migrate" ]] || exit 0 +exec "$TERMINAL" --hold "$PANAMA_PATH/bin/panama-migrate" run diff --git a/config/dot/hypr/autostart.lua b/config/dot/hypr/autostart.lua index bdf8426..b2515b5 100644 --- a/config/dot/hypr/autostart.lua +++ b/config/dot/hypr/autostart.lua @@ -39,6 +39,11 @@ hl.on("hyprland.start", function() -- prompt if Panama's ever fails to come up. hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service") + -- Pending repairs for this machine, if any. Oneshot: it checks, tells the + -- user when there is something to tell, and exits. Started here rather + -- than enabled so it belongs to the Hyprland session; see the unit. + hl.exec_cmd("systemctl --user start panama-migrate-notify.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/local/share/systemd/user/panama-migrate-notify.service b/config/local/share/systemd/user/panama-migrate-notify.service new file mode 100644 index 0000000..7bb8409 --- /dev/null +++ b/config/local/share/systemd/user/panama-migrate-notify.service @@ -0,0 +1,21 @@ +[Unit] +Description=Tell the user when Panama has repairs waiting +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 a notification about the Hyprland desktop has no +# business appearing in a GNOME session. + +[Service] +Type=oneshot +# Resolved at runtime rather than written as a literal path: ./install and the +# stages honor an exported PANAMA_PATH, so a clone anywhere else still works. +# The sibling units reach their scripts through ~/.config/quickshell, which is +# a symlink link-dotfiles creates; bin/ has no such symlink. +ExecStart=/usr/bin/env sh -c 'exec "${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-migrate-notify"' +# The notification carries an action, so notify-send waits for the user to +# click or dismiss it. Bounded so an ignored notification does not leave this +# activating for the life of the session -- the next login asks again anyway. +TimeoutStartSec=1h +# Nothing to say is the overwhelmingly common case and is not a failure. +SuccessExitStatus=0 1 diff --git a/install b/install index c1d3469..692fb54 100755 --- a/install +++ b/install @@ -106,6 +106,28 @@ for stage in "${STAGES[@]}"; do fi done +# ── Migrations ─────────────────────────────────────────────────────────────── +# +# Repairs for machines that installed an older Panama: removing a file this +# repository stopped shipping, disabling a unit it stopped wanting. The +# installer itself cannot do any of that, because it only ever adds. +# +# A machine that has never seen migrations before is one of two things, and +# the difference matters. If it has no marker directory at all it was just +# built from THIS checkout, so every repair those migrations describe is +# already true of it -- they are marked applied without running, exactly as +# Migrations.qml stamps a pre-versioning settings file at its baseline rather +# than replaying upgrades it never needed. Otherwise the pending ones run. +migrate="$PANAMA_PATH/bin/panama-migrate" +if [[ -x "$migrate" ]]; then + printf '\n=== migrations ===\n' + if [[ -d "${XDG_STATE_HOME:-$HOME/.local/state}/panama/migrations" ]]; then + "$migrate" run || failed+=(migrations) + else + "$migrate" --baseline || true + fi +fi + # ── Did it actually work? ──────────────────────────────────────────────────── # # A failed-stage count only reports what exited non-zero. It says nothing about a diff --git a/migrations/1787359736.sh b/migrations/1787359736.sh new file mode 100755 index 0000000..eaa07b9 --- /dev/null +++ b/migrations/1787359736.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# Remove the dangling ~/.config/forge symlink left by the GNOME session cut. +# +# `forge` was in link-dotfiles' `dirs` array while the GNOME session was still +# the fallback and Hyprland was being built beside it. Commit 47f29f9 deleted +# config/dot/forge along with the rest of that hedge, but link-dotfiles has no +# way to un-link what it once linked: a machine that ran the installer before +# that commit still has ~/.config/forge pointing into the repository at a path +# that no longer exists. +# +# Nothing reads it, so the cost is only that `ls ~/.config` shows a broken link +# forever. That is exactly the kind of small permanent wrongness migrations +# exist to clear. +# +# This is the shape every future "a dotfile directory was removed" repair takes: +# check that the link is dangling AND points into Panama, then remove it. Both +# halves matter -- a dangling link somebody else made is not ours to delete. + +set -euo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" + +link="${XDG_CONFIG_HOME:-$HOME/.config}/forge" + +# Not a symlink, or not there at all: already correct. Both are the common case +# on a machine installed after the cut, and neither is a failure. +[[ -L "$link" ]] || exit 0 + +# Still resolves? Then something else owns this path now and it is not ours to +# remove. +[[ -e "$link" ]] && exit 0 + +target="$(readlink "$link")" +case "$target" in + "$PANAMA_PATH"/*) ;; + *) + echo "Leaving $link alone: it points at $target, which is not Panama's." + exit 0 + ;; +esac + +rm -f "$link" +echo "Removed the dangling $link" diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..6ab94aa --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,2 @@ +# Migrations live here, one repair per file, named by the commit timestamp +# that authored them. See bin/panama-migrate. diff --git a/tests/setup/migrations-contract b/tests/setup/migrations-contract new file mode 100755 index 0000000..758e422 --- /dev/null +++ b/tests/setup/migrations-contract @@ -0,0 +1,149 @@ +#!/usr/bin/env bash + +# Migrations: the half of an upgrade `./install` cannot do. +# +# The installer only ever adds. Migrations are the one mechanism that can +# remove a file, disable a unit, or repair a symlink on a machine that already +# exists -- which means they run, unattended, against machines nobody testing +# them has ever seen. The properties that make that safe: +# +# 1. Applied exactly once. A marker is written on success and never again. +# 2. A FAILURE LEAVES NO MARKER. A repair that could not complete has not +# happened, and recording it as done would hide it forever. +# 3. Ordered, and stopped at the first failure. Later migrations may assume +# earlier ones landed; running them anyway turns one stuck repair into an +# unpredictable machine. +# 4. A fresh install runs none of them. The machine was just built from this +# checkout, so every repair they describe is already true of it. +# 5. Root work goes through `panama-sudo --reason`, never bare sudo, so the +# password prompt names the repair. +# +# Driven against fixture migrations in a throwaway state directory. The real +# migrations are only checked for the properties every one of them must have. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +runner="$repo_dir/bin/panama-migrate" +authoring="$repo_dir/bin/panama-dev-migration" +installer="$repo_dir/install" + +findings=() +note() { findings+=("$1"); } + +[[ -x "$runner" ]] || { printf 'migrations contract: %s is not executable\n' "$runner" >&2; exit 1; } +[[ -x "$authoring" ]] || note 'panama-dev-migration is missing or not executable' + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +fake_repo="$work/repo" +state="$work/state/panama/migrations" +mkdir -p "$fake_repo/bin" "$fake_repo/migrations" +cp "$runner" "$fake_repo/bin/panama-migrate" + +# Fixture migrations, named the way the real ones are: epoch seconds, so glob +# order is chronological. +ran="$work/ran" +write_migration() { + local stamp="$1" body="$2" + cat >"$fake_repo/migrations/$stamp.sh" <>"$ran" +$body +MIGRATION +} + +run() { + PANAMA_PATH="$fake_repo" XDG_STATE_HOME="$work/state" \ + "$fake_repo/bin/panama-migrate" "$@" 2>&1 +} + +# ── 1. Applied exactly once, in order ──────────────────────────────────────── + +: >"$ran" +write_migration 1000000001 'exit 0' +write_migration 1000000002 'exit 0' + +run run >/dev/null || note 'a run of two good migrations failed' +[[ "$(cat "$ran")" == $'1000000001\n1000000002' ]] \ + || note "migrations did not run oldest-first (got: $(tr '\n' ' ' <"$ran"))" + +: >"$ran" +run run >/dev/null || note 'a second run failed' +[[ -s "$ran" ]] && note 'a migration ran twice' + +# ── 2 & 3. A failure leaves no marker, and stops the ones after it ─────────── + +: >"$ran" +write_migration 1000000003 'exit 1' +write_migration 1000000004 'exit 0' + +run run >/dev/null && note 'a failing migration reported success' +grep -qx 1000000003 "$ran" || note 'the failing migration did not run at all' +grep -qx 1000000004 "$ran" && note 'a migration after a failure still ran' +[[ -e "$state/1000000003.sh" ]] && note 'a failed migration was marked applied' + +# It retries, and the one behind it is still waiting. +: >"$ran" +write_migration 1000000003 'exit 0' +run run >/dev/null || note 'a repaired migration did not succeed on retry' +grep -qx 1000000003 "$ran" || note 'the previously failed migration was not retried' +grep -qx 1000000004 "$ran" || note 'the migration behind the failure never ran' + +# ── 4. Fresh installs run nothing ──────────────────────────────────────────── + +rm -rf "$work/state" +: >"$ran" +run --baseline >/dev/null || note 'baseline failed' +[[ -s "$ran" ]] && note 'baseline ran a migration instead of marking it' +: >"$ran" +run run >/dev/null || note 'a run after baseline failed' +[[ -s "$ran" ]] && note 'a migration ran on a machine that was baselined' + +# ── --pending is the notifier's question ───────────────────────────────────── + +run --pending >/dev/null && note '--pending reported work when everything is applied' +write_migration 1000000005 'exit 0' +run --pending >/dev/null || note '--pending reported nothing when a migration is waiting' + +# ── 5. Everything the repository actually ships ────────────────────────────── + +shopt -s nullglob +for migration in "$repo_dir"/migrations/*.sh; do + name="$(basename "$migration")" + [[ -x "$migration" ]] || note "$name is not executable" + [[ "$name" =~ ^[0-9]{10,}\.sh$ ]] \ + || note "$name is not named with an epoch timestamp, so its order is undefined" + + # Bare sudo would prompt with polkit's generic text, in a script the user + # did not run by hand. panama-sudo makes the prompt name the repair. + if grep -qE '(^|[^-[:alnum:]])sudo ' "$migration" && ! grep -q 'panama-sudo' "$migration"; then + note "$name calls bare sudo; root work goes through panama-sudo --reason" + fi +done + +# ── The installer wires it in ──────────────────────────────────────────────── + +grep -q 'panama-migrate' "$installer" \ + || note 'install never runs or baselines migrations, so a fresh machine is never marked' +grep -q -- '--baseline' "$installer" \ + || note 'install does not baseline a fresh machine, so it would run repairs it never needed' + +notifier="$repo_dir/bin/panama-migrate-notify" +if [[ ! -x "$notifier" ]]; then + note 'panama-migrate-notify is missing, so pending repairs announce themselves to nobody' +else + grep -q 'org.freedesktop.Notifications' "$notifier" \ + || note 'the notifier does not wait for the notification server, so an early session swallows it' +fi +grep -q 'panama-migrate-notify' "$repo_dir/config/dot/hypr/autostart.lua" \ + || note 'nothing starts the migration notifier at login' + +if (( ${#findings[@]} > 0 )); then + printf 'migrations contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'migrations contract: PASS\n'