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