Files
Panama/bin/panama-migrate
T
Gabriel Brown e446a1072c 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.
2026-08-21 21:01:31 -04:00

186 lines
6.6 KiB
Bash
Executable File

#!/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