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.
This commit is contained in:
Gabriel Brown
2026-08-21 21:01:31 -04:00
parent 3c45d63085
commit e446a1072c
11 changed files with 557 additions and 1 deletions
+1 -1
View File
@@ -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
+22
View File
@@ -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 ;;
+56
View File
@@ -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" <<EOF
#!/usr/bin/env bash
# $describe
#
# Rules, because the runner cannot enforce them:
#
# * Safe to run twice. The marker records success, not intent.
# * Tolerant of the repair already being correct -- the user may have fixed
# it by hand, or a later ./install may have put it back.
# * Root work goes through \`panama-sudo --reason "..."\`, never bare sudo,
# so the password prompt names the repair.
# * Exit non-zero to be retried at the next login. Exit zero only when the
# machine is genuinely in the state this describes.
set -euo pipefail
PANAMA_PATH="\${PANAMA_PATH:-\$HOME/.local/share/Panama}"
# ... the repair goes here.
EOF
chmod +x "$file"
printf 'Created %s\n' "$file"
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Bringing an already-installed machine up to what this checkout expects.
#
# `./install` is additive: it copies files over `/`, links dotfiles, and
# installs packages. It has no way to say "remove that file", "disable that
# unit", "that symlink now points at the wrong place". So a machine installed
# in March keeps March's mistakes forever, and the only thing that ever fixes
# them is the person who happens to read a commit message.
#
# A migration is one shell script that performs one such repair, exactly once,
# on machines that need it.
#
# migrations/<unix-timestamp>.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 <name>|--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
+50
View File
@@ -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
+5
View File
@@ -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.
@@ -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
+22
View File
@@ -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
+44
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
# Migrations live here, one repair per file, named by the commit timestamp
# that authored them. See bin/panama-migrate.
+149
View File
@@ -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" <<MIGRATION
#!/usr/bin/env bash
printf '%s\n' "$stamp" >>"$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'