#!/usr/bin/env bash # # panama – Helper for managing the Panama dotfiles/repo # Version 1.0 # Author: Gabriel Brown # # Commands: # update Bring this machine up to date: pull, then install --upgrade # sync Review, commit & push local changes to this repo # edit Open the Panama repo in Neovim # doctor Report what is actually running on this machine # diagnose Hand this machine's health and recent errors to your agent # test Run contracts classified by tests/contracts.manifest # contracts Name the contracts that mention a given file # upgrade Re-run the installer from anywhere, interview included # migrate Apply repairs this machine has not had yet # apps Choose applications to install, by category # app Build and install an application that no repository packages # help Show this help # # update and sync are deliberately separate verbs. One acts on the machine, the # other on the repository, and a single command that guessed between them by # looking at whether the tree happened to be dirty would do a different job # depending on state nobody can see. # # Designed to grow: add new subcommands as cmd_ 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 /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" >&2 read -r reply || true [[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]] } # ---------------------------------------------------------------------------- # Usage # ---------------------------------------------------------------------------- usage() { cat < [options] ${BOLD}Commands:${RESET} ${GREEN}update${RESET} Bring this machine up to date. Pulls, then runs the stages that need no questions asked. The routine command; safe to re-run, and it never asks you anything. ${GREEN}sync${RESET} Review, commit & push your changes to this repo. Uncommitted work is committed before anything is fetched, so a moved upstream is a rebase rather than a stash conflict. ${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}diagnose${RESET} Hand the health summary, the recent journal errors and whatever you say is wrong to your coding agent, in a terminal. Needs an agent chosen on Settings › System › Agents. ${GREEN}test${RESET} Run contracts classified by tests/contracts.manifest. Give it a pattern to run a subset. --safe selects hermetic contracts only. Plain terminal runs prompt before non-hermetic work. Automation must grant each required capability with a repeatable --allow. Each non-hermetic contract announces its exact capabilities before it starts. Failures print captured stdout/stderr. Successful stdout stays quiet; successful stderr is a warning. The default outer timeout is 180 seconds. Set PANAMA_TEST_TIMEOUT_SECONDS to a positive integer to override it. ${GREEN}contracts${RESET} Name the contracts that mention a given file, each labeled with manifest capabilities. A heuristic over the text of tests/, so it answers "what should I run" rather than "what covers this". ${GREEN}upgrade${RESET} Re-run ./install from anywhere, interview and all. For a new machine, or to change an answer you gave. Routine updates are '$PROGRAM update', which asks nothing. ${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}server${RESET} The compose services a server machine runs: list, enable, disable, status, relink. See 'panama server help'. ${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 update --packages $PROGRAM sync $PROGRAM edit $PROGRAM doctor --summary $PROGRAM diagnose $PROGRAM diagnose the bar disappears after unplugging the monitor $PROGRAM test dock $PROGRAM test --safe $PROGRAM test --allow live-host updates $PROGRAM test --allow live-compositor keybinds PANAMA_TEST_TIMEOUT_SECONDS=300 $PROGRAM test --safe $PROGRAM contracts config/dot/quickshell/services/Displays.qml $PROGRAM upgrade $PROGRAM apps $PROGRAM app 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 # ---------------------------------------------------------------------------- # # The routine command: bring THIS MACHINE up to date. Pull, then hand the rest # to `install --upgrade`, which asks nothing. # # Most of an update needs no stage at all. Every dotfile is a symlink into this # checkout, so an edit to an existing config/dot/** file is live the moment the # pull returns -- there is nothing to apply. Stages earn their place when a pull # brings something structural: a new dotfile directory to link, a file under # config/copy/ to place as root, a new package, a gsettings change. Running the # cheap ones every time is idempotent and takes seconds; working out which were # needed is guesswork with a silent failure mode. # # Uncommitted work never blocks an update. It is stashed across the pull and # restored afterwards -- and if restoring conflicts, the tree is reset rather # than left holding conflict markers, because on this repository those markers # are not something you fix at your leisure. They are live in ~/.config the # instant they are written, and a half-merged .qml is a shell that will not # parse. cmd_update() { require_git_repo cd "$PANAMA_DIR" info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}" local stashed=0 conflict_stash="" if [[ -n "$(git status --porcelain)" ]]; then info "Local changes; stashing them across the pull." if git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null; then stashed=1 else err "Could not stash local changes, so the pull would overwrite them." exit 1 fi fi # --ff-only on purpose. A diverged branch is something to resolve # deliberately, not something an update command should merge on your behalf. # It is a warning rather than an error: the stages below are still worth # running against whatever is checked out. if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then info "Pulling..." if git pull --ff-only; then ok "Checkout is current." else warn "Could not fast-forward — continuing with what is checked out." fi else warn "No upstream configured for this branch; nothing to pull." fi if (( stashed )); then if git stash pop >/dev/null 2>&1; then ok "Local changes restored." else # A conflicted pop keeps the stash entry -- git says so itself, and the # contract proves it -- so resetting here loses nothing. The work stays # in the stash, where nothing is reading it, instead of in your live # config as merge markers. git reset --hard HEAD >/dev/null 2>&1 conflict_stash="$(git stash list --format='%gd: %gs' 2>/dev/null | head -1)" warn "Your local changes conflict with what was pulled; they stay stashed." fi fi local installer="$PANAMA_DIR/install" if [[ ! -x "$installer" ]]; then err "The installer is missing from $installer" exit 1 fi local rc=0 "$installer" --upgrade "$@" || rc=$? # Repeated at the very end rather than only where it happened. A warning # printed before twenty minutes of dnf output is a warning nobody read. if [[ -n "$conflict_stash" ]]; then echo warn "Your local changes were NOT restored — they conflicted with the pull." printf ' They are safe at %s%s%s\n' "$BOLD" "$conflict_stash" "$RESET" printf ' Restore them with: %sgit stash pop%s\n' "$BOLD" "$RESET" fi return $rc } # ---------------------------------------------------------------------------- # Command: sync # ---------------------------------------------------------------------------- # # The other half of what `update` used to mean: commit and push MY EDITS. # Panama is a working tree people edit in place -- every dotfile is a symlink # into it -- so "I changed something, put it upstream" is a daily action and # deserves its own verb rather than sharing one with "update my machine". # # The commit happens BEFORE anything is fetched, which is why there is no stash # in here. By the time upstream is consulted the work is a commit, so a moved # upstream is a rebase over committed history -- recoverable, ordinary, and # nothing like a stash pop conflicting into a live config. cmd_sync() { require_git_repo cd "$PANAMA_DIR" info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}" if [[ -z "$(git status --porcelain)" ]]; then ok "Nothing to commit — the working tree is clean." info "To update this machine, run: ${BOLD}${PROGRAM} update${RESET}" return fi 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 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 info "Committing changes..." git add -A git commit -m "$msg" ok "Committed: ${BOLD}${msg}${RESET}" if ! git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then warn "No upstream configured for this branch — committed locally only." return fi # Only now, with the work safely committed, is it worth looking upstream. info "Checking whether upstream has moved..." git fetch --quiet local remote_rev base_rev remote_rev=$(git rev-parse '@{u}') base_rev=$(git merge-base @ '@{u}') if [[ "$base_rev" != "$remote_rev" ]]; then warn "Upstream has moved — rebasing your commit onto it." if ! git pull --rebase; then err "The rebase stopped on a conflict." err "Resolve it, then: git rebase --continue" exit 1 fi ok "Rebased onto upstream." else ok "Upstream has not moved." fi 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: diagnose # ---------------------------------------------------------------------------- # # The by-hand rung of the escalation ladder. Every other rung starts from an # event -- a crash, a failed reload, a red check -- and this one starts from a # person who can tell that something is wrong but not what. # # It gathers the two things anybody would be asked for first anyway (what the # health check says, what the journal has been complaining about) and whatever # words follow the command, then hands the lot to the configured agent. The free # text is the valuable part: "the bar disappears after unplugging the monitor" # is a symptom no collector reports, and it is the difference between an agent # reading a health summary and an agent looking for something. cmd_diagnose() { local launcher="$PANAMA_DIR/bin/panama-agent" if [[ ! -x "$launcher" ]]; then err "The agent launcher is missing from $launcher" exit 1 fi local complaint="$*" local health="(the health check did not run)" local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor" if [[ -x "$doctor" ]]; then health="$("$doctor" --summary 2>&1)" || true fi # Bounded twice, and not out of tidiness. journalctl counts entries, not # lines, and thirty entries on this machine came to 2,430 lines and a quarter # of a megabyte -- one multi-line traceback each. The prompt leaves as a # single argv element, which the kernel caps at 128KB, so an unbounded excerpt # turns this command into "Argument list too long" rather than a diagnosis. local errors="(nothing at error level in this boot's user journal)" if command -v journalctl >/dev/null 2>&1; then local recent recent="$(journalctl --user -b -p err -n 30 --no-pager --output=short 2>/dev/null \ | cut -c 1-300 | tail -80)" || true [[ -n "${recent// }" ]] && errors="$recent" fi local complaint_section="Nothing in particular was reported; this was run to look around." [[ -n "${complaint// }" ]] && complaint_section="$complaint" local prompt prompt="$(cat < 0 )); then err "Contract manifest validation failed with ${#findings[@]} finding(s):" printf ' - %s\n' "${findings[@]}" >&2 return 1 fi } test_usage() { err "Usage: ${BOLD}$PROGRAM test [--safe] [--allow ] [pattern]${RESET}" return 2 } is_contract_capability() { local capability="$1" known for known in "${CONTRACT_CAPABILITIES[@]}"; do [[ "$capability" == "$known" ]] && return 0 done return 1 } # ---------------------------------------------------------------------------- # Command: test # ---------------------------------------------------------------------------- # # The contracts are the main safety net in this repository. The manifest is the # single list of what the runner executes and which external boundaries each # contract reaches. # # 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. # # --safe runs only contracts the manifest classifies as hermetic and reports # each external capability it skipped. A plain terminal run asks before any # selected non-hermetic work. Automation must grant every required capability # with repeatable --allow flags. Non-hermetic contracts announce their exact # capability list before execution. Each contract gets an outer timeout, 180 # seconds by default. PANAMA_TEST_TIMEOUT_SECONDS accepts a positive integer # override. Failures include captured stdout and stderr. Successful stdout # stays quiet, while successful stderr is surfaced as a warning. PANAMA_ACTIVE_CONTRACT_PID="" PANAMA_CONTRACT_CAPTURE_DIR="" cleanup_contract_capture() { if [[ -n "$PANAMA_CONTRACT_CAPTURE_DIR" && -d "$PANAMA_CONTRACT_CAPTURE_DIR" ]]; then rm -rf -- "$PANAMA_CONTRACT_CAPTURE_DIR" || true fi PANAMA_CONTRACT_CAPTURE_DIR="" } terminate_active_contract() { local pid="$PANAMA_ACTIVE_CONTRACT_PID" PANAMA_ACTIVE_CONTRACT_PID="" [[ "$pid" =~ ^[1-9][0-9]*$ && "$pid" != "$$" ]] || return 0 # GNU timeout owns a process group whose ID is its PID. Signal that complete # group so a contract cannot leave descendants behind, with a direct-PID # fallback for implementations that do not create the group. kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true } handle_contract_signal() { local signal_status="$1" trap - INT TERM terminate_active_contract cleanup_contract_capture trap - EXIT exit "$signal_status" } prepare_contract_capture() { local capture_dir="" if ! capture_dir="$(mktemp -d)"; then err 'Could not create contract capture directory.' return 1 fi if [[ -z "$capture_dir" || ! -d "$capture_dir" ]]; then err 'Could not create contract capture directory.' return 1 fi PANAMA_CONTRACT_CAPTURE_DIR="$capture_dir" trap cleanup_contract_capture EXIT trap 'handle_contract_signal 130' INT trap 'handle_contract_signal 143' TERM } cmd_test() { local timeout_seconds="${PANAMA_TEST_TIMEOUT_SECONDS:-180}" [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || { err 'PANAMA_TEST_TIMEOUT_SECONDS must be a positive integer.' return 2 } validate_contract_manifest || return 1 cmd_test_impl "$timeout_seconds" "$@" } cmd_test_impl() { local timeout_seconds="$1" shift local pattern="" safe=0 arg capability capabilities rel path local -A grants=() manifest_capabilities=() skipped_counts=() missing_grants=() selected_capabilities=() local -a suite=() missing_capability_list=() selected_capability_list=() capability_list=() # Position-independent: flags can precede or follow the optional pattern. while (( $# > 0 )); do arg="$1" shift case "$arg" in --safe) safe=1 ;; --allow) (( $# > 0 )) || { test_usage; return 2; } capability="$1" shift is_contract_capability "$capability" || { err "Unknown contract capability: $capability" return 2 } [[ "$capability" != hermetic ]] || { err 'hermetic contracts do not need --allow.' return 2 } grants["$capability"]=1 ;; --*) test_usage; return 2 ;; *) [[ -z "$pattern" ]] || { test_usage; return 2; } pattern="$arg" ;; esac done (( safe == 0 || ${#grants[@]} == 0 )) || { err '--safe cannot be combined with --allow.' return 2 } while IFS=$'\t' read -r rel capabilities; do manifest_capabilities["$rel"]="$capabilities" [[ -z "$pattern" || "$rel" == *"$pattern"* ]] || continue if (( safe )) && [[ "$capabilities" != hermetic ]]; then IFS=',' read -r -a capability_list <<<"$capabilities" for capability in "${capability_list[@]}"; do (( ++skipped_counts["$capability"] )) done continue fi suite+=("$rel") done < <(contract_manifest_entries) if (( ${#suite[@]} == 0 )); then if (( safe )) && (( ${#skipped_counts[@]} > 0 )); then err "Every contract matching '${pattern}' needs an external capability; --safe skipped all of them." else err "No contracts match '${pattern}'" fi return 1 fi if (( safe )); then for capability in "${CONTRACT_CAPABILITIES[@]}"; do [[ "$capability" == hermetic ]] && continue printf 'Skipped %d %s contract(s).\n' "${skipped_counts[$capability]:-0}" "$capability" done else for rel in "${suite[@]}"; do capabilities="${manifest_capabilities[$rel]}" [[ "$capabilities" == hermetic ]] && continue IFS=',' read -r -a capability_list <<<"$capabilities" for capability in "${capability_list[@]}"; do selected_capabilities["$capability"]=1 [[ -n "${grants[$capability]:-}" ]] || missing_grants["$capability"]=1 done done for capability in "${CONTRACT_CAPABILITIES[@]}"; do [[ "$capability" == hermetic ]] && continue [[ -n "${selected_capabilities[$capability]:-}" ]] && selected_capability_list+=("$capability") [[ -n "${missing_grants[$capability]:-}" ]] && missing_capability_list+=("$capability") done if (( ${#missing_capability_list[@]} > 0 )); then if [[ -t 0 && -t 2 ]]; then confirm "Run ${#suite[@]} contract(s) requiring: ${selected_capability_list[*]}?" || { warn 'No contracts were run.' return 1 } else err "Selected contracts require: ${missing_capability_list[*]}." for capability in "${missing_capability_list[@]}"; do printf ' Automation: pass --allow %s\n' "$capability" >&2 done return 1 fi fi fi local capture_dir stdout_file stderr_file name run_status index=0 final_status=0 local -a failed=() runner=() prepare_contract_capture || return 1 capture_dir="$PANAMA_CONTRACT_CAPTURE_DIR" info "Running ${#suite[@]} contract(s)" for index in "${!suite[@]}"; do rel="${suite[$index]}" path="$PANAMA_DIR/$rel" name="${rel#tests/}" stdout_file="$capture_dir/$index.stdout" stderr_file="$capture_dir/$index.stderr" if [[ "$path" == *_test.py ]]; then runner=(python3 "$path") else runner=("$path") fi capabilities="${manifest_capabilities[$rel]}" if [[ "$capabilities" != hermetic ]]; then info "Running $name [$capabilities]" fi run_status=0 timeout --signal=TERM --kill-after=5 "$timeout_seconds" \ "${runner[@]}" >"$stdout_file" 2>"$stderr_file" & PANAMA_ACTIVE_CONTRACT_PID=$! wait "$PANAMA_ACTIVE_CONTRACT_PID" || run_status=$? PANAMA_ACTIVE_CONTRACT_PID="" if (( run_status == 0 )); then ok "$name" if [[ -s "$stderr_file" ]]; then warn "$name wrote to stderr:" cat "$stderr_file" >&2 fi continue fi if (( run_status == 124 || run_status == 137 )); then err "$name timed out after ${timeout_seconds}s" else err "$name failed (exit $run_status)" fi [[ -s "$stdout_file" ]] && { printf '%s stdout:\n' "$name" >&2 cat "$stdout_file" >&2 } [[ -s "$stderr_file" ]] && { printf '%s stderr:\n' "$name" >&2 cat "$stderr_file" >&2 } failed+=("$name") done header "Result" if (( ${#failed[@]} == 0 )); then ok "${#suite[@]} contract(s) passed" else err "${#failed[@]} of ${#suite[@]} failed:" printf ' %s\n' "${failed[@]}" >&2 final_status=1 fi cleanup_contract_capture trap - EXIT INT TERM return "$final_status" } # ---------------------------------------------------------------------------- # Command: contracts # ---------------------------------------------------------------------------- # # "I changed this file -- what should I run?" The suite is large enough that # running all of it or guessing from contract names are both poor answers. # # This is a grep, and says so. A contract that names the file, or a # parent-trimmed suffix of it, or just its basename, is a contract worth # running; one that reaches the file through a harness or a generated artifact # is not found, which is why the empty answer says "coverage may be indirect" # rather than "nothing covers this". Naming a file the suite does not mention is # a real answer -- exit 1 so a script can tell the difference -- but it is a # statement about this search, not about the file. # # Each hit is labeled from tests/contracts.manifest, so the output also answers # which boundary the matching contract reaches. cmd_contracts() { local target="${1:-}" if [[ -z "$target" ]]; then err "Which file? Usage: ${BOLD}$PROGRAM contracts ${RESET}" exit 1 fi # Absolute, relative to where you are standing, or repo-relative -- all three # are how somebody refers to a file in this tree, and readlink resolves the # symlinked dotfile in ~/.config back into the checkout it points at. local absolute="" if [[ -e "$target" ]]; then absolute="$(readlink -f "$target")" elif [[ -e "$PANAMA_DIR/$target" ]]; then absolute="$(readlink -f "$PANAMA_DIR/$target")" else err "No such file: '$target'" printf 'Give a path, absolute or relative to here or to %s.\n' "$PANAMA_DIR" >&2 exit 1 fi local path case "$absolute" in "$PANAMA_DIR"/*) path="${absolute#"$PANAMA_DIR"/}" ;; *) err "'$target' is outside the Panama repo (${PANAMA_DIR})." exit 1 ;; esac # The repo-relative path, then each parent trimmed off in turn, ending at the # basename. Contracts refer to their subject every one of these ways: by the # full path from the repo root, by the path from the shell directory, and by # name alone. local -a patterns=() local suffix="$path" while :; do patterns+=(-e "$suffix") [[ "$suffix" == */* ]] || break suffix="${suffix#*/}" done validate_contract_manifest || return 1 local -A manifest_capabilities=() local capabilities while IFS=$'\t' read -r rel capabilities; do manifest_capabilities["$rel"]="$capabilities" done < <(contract_manifest_entries) # The same collection `test` runs, so anything named here is something the # runner would actually execute. local -a hits=() local candidate rel while IFS= read -r rel; do candidate="$PANAMA_DIR/$rel" grep -qF "${patterns[@]}" "$candidate" 2>/dev/null || continue hits+=("$rel") done < <(contract_paths) if (( ${#hits[@]} == 0 )); then printf 'No contract mentions %s — coverage may be indirect (a harness or a generated artifact); nothing verified.\n' "$path" >&2 exit 1 fi for rel in "${hits[@]}"; do [[ -n "${manifest_capabilities[$rel]:-}" ]] || { err "Contract has no manifest capability label: $rel" return 1 } printf '%s [%s]\n' "$rel" "${manifest_capabilities[$rel]}" done } # ---------------------------------------------------------------------------- # 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 %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 "$@" ;; sync) shift; cmd_sync "$@" ;; edit) shift; cmd_edit "$@" ;; doctor) shift; cmd_doctor "$@" ;; diagnose) shift; cmd_diagnose "$@" ;; test) shift; cmd_test "$@" ;; contracts) shift; cmd_contracts "$@" ;; upgrade) shift; cmd_upgrade "$@" ;; migrate) shift; cmd_migrate "$@" ;; app) shift; cmd_app "$@" ;; apps) shift; cmd_apps "$@" ;; server) shift; exec "$PANAMA_DIR/bin/panama-server" "$@" ;; help|-h|--help|"") usage ;; --version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;; *) err "Unknown command: '$cmd'" echo usage exit 1 ;; esac } main "$@"