1005 lines
37 KiB
Bash
Executable File
1005 lines
37 KiB
Bash
Executable File
#!/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 contract manifest entries under tests/ (--safe is hermetic only)
|
||
# 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_<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} 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 every contract under tests/. Give it a pattern to run
|
||
a subset: 'panama test dock' runs the ones matching 'dock'.
|
||
--safe runs hermetic contracts only. External capabilities need
|
||
explicit --allow grants when automation invokes them.
|
||
${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 contracts config/dot/quickshell/services/Displays.qml
|
||
$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
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# 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 <<PROMPT
|
||
Something is wrong with this Panama machine and I would like to know what.
|
||
|
||
What I noticed:
|
||
|
||
$complaint_section
|
||
|
||
What panama doctor --summary says:
|
||
|
||
$health
|
||
|
||
The last error-level lines in this boot's user journal:
|
||
|
||
$errors
|
||
|
||
Panama is checked out at $PANAMA_DIR and every dotfile in ~/.config is a symlink
|
||
into it, so anything you find is a tracked file here rather than a copy. Start
|
||
by reading: work out what is actually broken and say so before changing
|
||
anything. If a check is red, 'panama doctor' with no arguments has the long form
|
||
of it. Root work goes through panama-sudo, which shows me your reason.
|
||
PROMPT
|
||
)"
|
||
|
||
# exec: from here on the agent's terminal is the process, and this shell has
|
||
# nothing left to do that the agent is not doing better.
|
||
exec "$launcher" --prompt "$prompt"
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Contract manifest
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# The manifest is the runtime authority for every collected contract. An absent
|
||
# manifest is unsafe: this command must never infer that unclassified tests are
|
||
# hermetic.
|
||
CONTRACT_MANIFEST="tests/contracts.manifest"
|
||
CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network privileged)
|
||
|
||
contract_manifest_entries() {
|
||
local line capabilities path
|
||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||
line="${line%%#*}"
|
||
read -r capabilities path _ <<<"$line"
|
||
[[ -n "${capabilities:-}" && -n "${path:-}" ]] || continue
|
||
printf '%s\t%s\n' "$path" "$capabilities"
|
||
done < "$PANAMA_DIR/$CONTRACT_MANIFEST"
|
||
}
|
||
|
||
require_contract_manifest() {
|
||
[[ -r "$PANAMA_DIR/$CONTRACT_MANIFEST" ]] || {
|
||
err "Contract manifest is missing or unreadable: $PANAMA_DIR/$CONTRACT_MANIFEST"
|
||
return 1
|
||
}
|
||
}
|
||
|
||
test_usage() {
|
||
err "Usage: ${BOLD}$PROGRAM test [--safe] [--allow <capability>] [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 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.
|
||
#
|
||
# --safe exists because a fair number of these contracts ARE the desktop: they
|
||
# open overlays, restart the shell, move your windows. Running the suite while
|
||
# sitting in front of the machine used to mean losing the session for a few
|
||
# minutes, so the honest options were "run everything" or "run nothing". --safe
|
||
# is the third: run only contracts the manifest classifies as hermetic, and say
|
||
# which external capability coverage it skipped.
|
||
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
|
||
}
|
||
|
||
require_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
|
||
local -a failed=() runner=()
|
||
capture_dir="$(mktemp -d)"
|
||
trap 'rm -rf -- "$capture_dir"' EXIT
|
||
trap 'rm -rf -- "$capture_dir"; exit 130' INT
|
||
trap 'rm -rf -- "$capture_dir"; exit 143' TERM
|
||
|
||
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
|
||
run_status=0
|
||
timeout --signal=TERM --kill-after=5 "$timeout_seconds" \
|
||
"${runner[@]}" >"$stdout_file" 2>"$stderr_file" || run_status=$?
|
||
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"
|
||
return 0
|
||
fi
|
||
err "${#failed[@]} of ${#suite[@]} failed:"
|
||
printf ' %s\n' "${failed[@]}" >&2
|
||
return 1
|
||
)
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: contracts
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# "I changed this file -- what should I run?" There are 177 contracts and no
|
||
# index, so the honest answers were "all of them" (minutes, and half of them
|
||
# take the desktop away) or "the ones whose name sounds related" (which is how
|
||
# a covering contract gets skipped).
|
||
#
|
||
# 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 <file>${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
|
||
|
||
require_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 candidate; do
|
||
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
|
||
grep -qF "${patterns[@]}" "$candidate" 2>/dev/null || continue
|
||
hits+=("tests/${candidate#"$PANAMA_DIR"/tests/}")
|
||
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||
|
||
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 <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 "$@" ;;
|
||
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 "$@"
|