Files
Panama/bin/panama
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

561 lines
20 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# panama Helper for managing the Panama dotfiles/repo
# Version 1.0
# Author: Gabriel Brown
#
# Commands:
# update Commit & sync local changes (or just pull if clean)
# edit Open the Panama repo in Neovim
# doctor Report what is actually running on this machine
# test Run every contract under tests/
# upgrade Re-run the installer from anywhere
# apps Choose applications to install, by category
# app Build and install an application that no repository packages
# help Show this help
#
# Designed to grow: add new subcommands as cmd_<name> functions and
# register them in the dispatcher / usage block below.
set -euo pipefail
PROGRAM=$(basename "$0")
VERSION="1.0"
# ----------------------------------------------------------------------------
# Locate the Panama repo (this script lives in <repo>/bin/panama)
# ----------------------------------------------------------------------------
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)
# ----------------------------------------------------------------------------
# Pretty output
# ----------------------------------------------------------------------------
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
BOLD=$(tput bold); RESET=$(tput sgr0)
RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3)
BLUE=$(tput setaf 4); MAGENTA=$(tput setaf 5); CYAN=$(tput setaf 6)
else
BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; MAGENTA=""; CYAN=""
fi
info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; }
ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; }
warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; }
err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
header(){ printf '\n%s%s%s\n' "${MAGENTA}${BOLD}" "$*" "$RESET"; }
# Ask a yes/no question. Returns 0 for yes, 1 for no. Default = no.
confirm() {
local prompt="$1" reply
printf '%s?%s %s %s[y/N]%s ' "${CYAN}${BOLD}" "$RESET" "$prompt" "$BOLD" "$RESET"
read -r reply || true
[[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]]
}
# ----------------------------------------------------------------------------
# Usage
# ----------------------------------------------------------------------------
usage() {
cat <<EOF
${BOLD}$PROGRAM${RESET} manage the Panama repo (${PANAMA_DIR})
${BOLD}Usage:${RESET}
$PROGRAM <command> [options]
${BOLD}Commands:${RESET}
${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is
clean it simply runs 'git pull'.
${GREEN}edit${RESET} Open the Panama repo in Neovim.
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
than what was installed. Takes --summary for one line per check.
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
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.
${GREEN}app${RESET} Build and install an application that neither dnf nor
Flathub carries. With no name, lists what is available.
${GREEN}help${RESET} Show this help (also -h, --help).
${BOLD}Options:${RESET}
-h, --help Show this help and exit
--version Show version and exit
${BOLD}Examples:${RESET}
$PROGRAM update
$PROGRAM edit
$PROGRAM doctor --summary
$PROGRAM test dock
$PROGRAM upgrade
$PROGRAM apps
$PROGRAM app
$PROGRAM app chatgpt-desktop
EOF
}
# ----------------------------------------------------------------------------
# Guard: make sure we're in a git repo
# ----------------------------------------------------------------------------
require_git_repo() {
if ! git -C "$PANAMA_DIR" rev-parse --git-dir >/dev/null 2>&1; then
err "'$PANAMA_DIR' is not a git repository."
exit 1
fi
}
# ----------------------------------------------------------------------------
# Command: update
# ----------------------------------------------------------------------------
cmd_update() {
require_git_repo
cd "$PANAMA_DIR"
info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"
# Any changes in the working tree? (modified, staged, or untracked)
if [[ -z "$(git status --porcelain)" ]]; then
info "Working tree is clean — pulling latest changes."
if git pull --ff-only; then
ok "Already in sync."
else
err "git pull failed."
exit 1
fi
return
fi
# Show what changed
header "Changed files"
git -c color.status=always status --short
header "Diff"
# Tracked changes (staged + unstaged) relative to the last commit.
git --no-pager -c color.diff=always diff HEAD
# Untracked files won't appear in 'git diff', so show them as new files.
local untracked
untracked=$(git ls-files --others --exclude-standard)
if [[ -n "$untracked" ]]; then
while IFS= read -r f; do
[[ -z "$f" ]] && continue
git --no-pager -c color.diff=always diff --no-index -- /dev/null "$f" || true
done <<< "$untracked"
fi
echo
if ! confirm "Commit these changes?"; then
warn "Aborted — no changes committed."
return
fi
# Commit message
local msg
printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET"
read -r msg || true
if [[ -z "${msg// }" ]]; then
msg="Update $(date '+%Y-%m-%d %H:%M:%S')"
warn "No message given — using: ${BOLD}${msg}${RESET}"
fi
# Is the local branch up to date with its upstream?
info "Checking whether the repo is up to date..."
if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
git fetch --quiet
local local_rev remote_rev base_rev
local_rev=$(git rev-parse @)
remote_rev=$(git rev-parse '@{u}')
base_rev=$(git merge-base @ '@{u}')
if [[ "$local_rev" == "$remote_rev" ]]; then
ok "Repo is up to date."
elif [[ "$local_rev" == "$base_rev" ]]; then
warn "Repo is behind upstream — stashing, pulling, then re-applying."
info "Stashing local changes..."
git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null
if ! git pull --ff-only; then
err "git pull failed — restoring your changes."
git stash pop || true
exit 1
fi
info "Re-applying stashed changes..."
if ! git stash pop; then
err "Conflict while re-applying changes. Resolve it, then commit manually."
exit 1
fi
else
warn "Local branch has diverged from upstream — committing locally only."
fi
else
warn "No upstream configured for this branch — committing locally only."
fi
# Commit everything
info "Committing changes..."
git add -A
git commit -m "$msg"
ok "Committed: ${BOLD}${msg}${RESET}"
echo
if confirm "Push the changes now?"; then
info "Pushing..."
if git push; then
ok "Pushed to remote."
else
err "git push failed."
exit 1
fi
else
info "Done — changes committed locally but not pushed."
fi
}
# ----------------------------------------------------------------------------
# Command: edit
# ----------------------------------------------------------------------------
cmd_edit() {
if ! command -v nvim >/dev/null 2>&1; then
err "Neovim (nvim) is not installed or not on PATH."
exit 1
fi
info "Opening ${BOLD}${PANAMA_DIR}${RESET} in Neovim."
cd "$PANAMA_DIR"
exec nvim .
}
# ----------------------------------------------------------------------------
# Command: doctor
# ----------------------------------------------------------------------------
#
# The health check already exists and the installer already runs it; what it did
# not have was a way to reach it from a terminal. Everything is passed straight
# through, so --summary and anything added later work without this knowing about
# them.
cmd_doctor() {
local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor"
if [[ ! -x "$doctor" ]]; then
err "panama-doctor is missing from $doctor"
exit 1
fi
exec "$doctor" "$@"
}
# ----------------------------------------------------------------------------
# Command: test
# ----------------------------------------------------------------------------
#
# The contracts are the main safety net in this repository and had no entry
# point: 121 executables with no runner and no mention in the README, which is
# most of the way to not having them.
#
# Each runs in its own process and a failure does not stop the rest, because the
# useful output is the whole list of what is broken rather than the first thing
# that broke. The exit code is what a caller can act on.
cmd_test() {
local pattern="${1:-}"
local -a suite=()
# Executables, plus the Python suites. Those are unittest files rather than
# executables, and collecting only what has the executable bit would skip them
# without saying so -- which is how all three came to be run by nothing at all.
# A runner with a blind spot is worse than no runner, because it reports PASS.
while IFS= read -r path; do
[[ -x "$path" || "$path" == *_test.py ]] || continue
[[ -z "$pattern" || "$path" == *"$pattern"* ]] && suite+=("$path")
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
if (( ${#suite[@]} == 0 )); then
err "No contracts match '${pattern}'"
exit 1
fi
info "Running ${#suite[@]} contract(s)"
local -a failed=()
local path name
local -a runner
for path in "${suite[@]}"; do
name="${path#"$PANAMA_DIR"/tests/}"
if [[ "$path" == *_test.py ]]; then
runner=(python3 "$path")
else
runner=("$path")
fi
if "${runner[@]}" >/dev/null 2>&1; then
ok "$name"
else
err "$name"
failed+=("$name")
fi
done
header "Result"
if (( ${#failed[@]} == 0 )); then
ok "${#suite[@]} contract(s) passed"
return 0
fi
err "${#failed[@]} of ${#suite[@]} failed:"
printf ' %s\n' "${failed[@]}" >&2
warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}"
return 1
}
# ----------------------------------------------------------------------------
# Command: upgrade
# ----------------------------------------------------------------------------
#
# ./install is the upgrade path -- every stage is idempotent and re-running is
# the documented way to repair a machine. This only saves remembering where the
# repository lives.
cmd_upgrade() {
local installer="$PANAMA_DIR/install"
if [[ ! -x "$installer" ]]; then
err "The installer is missing from $installer"
exit 1
fi
info "Re-running ${BOLD}${installer}${RESET}"
cd "$PANAMA_DIR"
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
# ----------------------------------------------------------------------------
#
# The applications that neither dnf nor Flathub carries, built from source into
# a package dnf can still own and remove.
#
# Deliberately NOT part of ./install. A source build is slow, wants the network
# for the whole of it, and depends on an upstream that moves -- which is exactly
# the failure the interview exists to prevent: twenty minutes in, a prompt or an
# error, with nobody at the keyboard. Asking for one of these is a thing you do
# on purpose, and it is also the rebuild path when a new version ships.
#
# Nothing is pinned. Each build takes the current default branch and the current
# upstream release, and says so when it fails. A recorded version is a 404
# waiting to happen -- sunhat proved that three times over.
APPS_DIR="$PANAMA_DIR/setup/apps"
APPS_WORK="${XDG_CACHE_HOME:-$HOME/.cache}/panama/apps"
cmd_app() {
local name="${1:-}"
if [[ -z "$name" ]]; then
header "Applications"
printf 'Built from source, because no repository carries them.\n\n'
local file
for file in "$APPS_DIR"/*; do
[[ -f "$file" ]] || continue
local description=""
# shellcheck source=/dev/null
source "$file"
printf ' %s%-18s%s %s\n' "$GREEN" "$(basename "$file")" "$RESET" "$description"
done
printf '\nBuild one with: %s%s app <name>%s\n' "$BOLD" "$PROGRAM" "$RESET"
return 0
fi
local definition="$APPS_DIR/$name"
if [[ ! -f "$definition" ]]; then
err "No such application: '$name'"
printf "Run '%s app' to see what is available.\n" "$PROGRAM" >&2
exit 1
fi
local description="" repo=""
# shellcheck source=/dev/null
source "$definition"
[[ -n "$repo" ]] || { err "$name declares no repository"; exit 1; }
# The checkout lives in the cache because it is entirely rebuildable and
# should never be mistaken for something to keep. Existing checkouts are
# reset to upstream rather than merged: a local edit in a build tree is not
# something to preserve silently.
local tree="$APPS_WORK/$name"
if [[ -d "$tree/.git" ]]; then
info "Updating $name"
git -C "$tree" fetch --depth 1 origin HEAD || { err "Could not reach $repo"; exit 1; }
git -C "$tree" reset --hard FETCH_HEAD >/dev/null
else
info "Cloning $name"
mkdir -p "$APPS_WORK"
rm -rf "$tree"
git clone --depth 1 "$repo" "$tree" || { err "Could not clone $repo"; exit 1; }
fi
info "Building ${BOLD}${name}${RESET} — this takes a while and needs the network"
if ( cd "$tree" && build ); then
ok "$name installed"
printf 'Built from %s\n' "$(git -C "$tree" rev-parse --short HEAD)"
else
err "$name failed to build"
printf 'The tree is left at %s so the failure can be read.\n' "$tree" >&2
printf 'This builds against upstream HEAD, so a break there breaks this.\n' >&2
exit 1
fi
}
# ----------------------------------------------------------------------------
# Command: apps
# ----------------------------------------------------------------------------
# The optional applications, chosen a few at a time rather than all at once.
#
# ./install offers the same catalog as whole categories, because during a first
# install you want coarse and fast. This is the other end of it: pick a
# category, then tick the applications inside, and install just those. Both read
# setup/lib/extras-catalog, so neither can drift from the other.
#
# Source-built applications appear here too, as their own category. They are not
# part of ./install for good reason -- a build is slow, wants the network
# throughout, and can fail on an upstream that moved -- but "choose, then
# install" is exactly the right shape for them, which is what this is.
cmd_apps() {
if ! command -v gum >/dev/null 2>&1; then
err "gum is not installed. It is in setup/packages/initial-packages; run 'panama upgrade'."
exit 1
fi
# shellcheck source=../setup/lib/extras-catalog
source "$PANAMA_DIR/setup/lib/extras-catalog"
local extras_dir="$PANAMA_DIR/setup/packages/extras"
local apps_dir="$PANAMA_DIR/setup/apps"
local -a categories=()
mapfile -t categories < <(catalog_categories "$extras_dir")
[[ -d "$apps_dir" ]] && categories+=("built from source")
(( ${#categories[@]} > 0 )) || { err "No application categories found."; exit 1; }
local category
category="$(gum choose --header "Which kind of application?" "${categories[@]}")" || return 0
[[ -n "$category" ]] || return 0
if [[ "$category" == "built from source" ]]; then
cmd_apps_source "$apps_dir"
return
fi
local file="$extras_dir/$category"
local -a labels=() targets=()
local target label marker
while IFS=$'\t' read -r target label; do
[[ -n "$target" ]] || continue
# Marked, not hidden: reinstalling what is present is harmless, but a menu
# that silently omits it leaves you wondering where it went.
if catalog_installed "$target"; then marker=" (installed)"; else marker=""; fi
targets+=("$target")
labels+=("$label$marker")
done < <(catalog_entries "$file")
local chosen
chosen="$(gum choose --no-limit --header "$category — space to select, enter to accept" "${labels[@]}")" || return 0
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
# Back from labels to install targets, then out to everything each one carries.
local -a install_targets=()
local pick i
while IFS= read -r pick; do
[[ -n "$pick" ]] || continue
for i in "${!labels[@]}"; do
if [[ "${labels[$i]}" == "$pick" ]]; then
mapfile -t -O "${#install_targets[@]}" install_targets < <(catalog_targets "$file" "${targets[$i]}")
break
fi
done
done <<< "$chosen"
local -a dnf_packages=() flatpak_ids=()
for target in "${install_targets[@]}"; do
if [[ "$target" == flatpak:* ]]; then flatpak_ids+=("${target#flatpak:}"); else dnf_packages+=("$target"); fi
done
header "About to install"
(( ${#dnf_packages[@]} > 0 )) && printf ' dnf %s\n' "${dnf_packages[*]}"
(( ${#flatpak_ids[@]} > 0 )) && printf ' flatpak %s\n' "${flatpak_ids[*]}"
echo
confirm "Install these?" || { warn "Nothing installed."; return 0; }
if (( ${#dnf_packages[@]} > 0 )); then
info "Installing ${#dnf_packages[@]} package(s) with dnf"
sudo dnf install -y "${dnf_packages[@]}" || warn "Some packages did not install"
fi
if (( ${#flatpak_ids[@]} > 0 )); then
info "Installing ${#flatpak_ids[@]} flatpak(s)"
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo >/dev/null
sudo flatpak install -y flathub "${flatpak_ids[@]}" || warn "Some flatpaks did not install"
fi
ok "Done."
}
# The source-built applications, offered by the same two-step flow and handed to
# the existing `panama app` so there is one build path, not two.
cmd_apps_source() {
local apps_dir="$1"
local -a names=()
mapfile -t names < <(for f in "$apps_dir"/*; do [[ -f "$f" ]] && basename "$f"; done)
(( ${#names[@]} > 0 )) || { err "No applications in $apps_dir."; return 1; }
local chosen
chosen="$(gum choose --no-limit --header "Built from source — these take a while" "${names[@]}")" || return 0
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
local name
while IFS= read -r name; do
[[ -n "$name" ]] || continue
header "Building $name"
cmd_app "$name" || warn "$name did not build"
done <<< "$chosen"
}
# ----------------------------------------------------------------------------
# Dispatcher
# ----------------------------------------------------------------------------
main() {
local cmd="${1:-}"
case "$cmd" in
update) shift; cmd_update "$@" ;;
edit) shift; cmd_edit "$@" ;;
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 ;;
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
*)
err "Unknown command: '$cmd'"
echo
usage
exit 1
;;
esac
}
main "$@"