OpenAI ships an official Linux RPM now, so the community wrapper goes away: `panama app chatgpt-desktop` built codex-desktop from the upstream macOS disk image and ran a local rebuild daemon to keep it current, and the official package comes from a repository that upgrades with everything else. The app file, the help example and the dock's pinned id all move over, and a migration replaces the build on machines that already have it -- official package on before the community one comes off, so a failure part-way still leaves an app. The install itself does not follow upstream's instructions. Those are "download this RPM and install it", and the RPM's own root scriptlet is what writes the repository file and drops the signing key into /etc/pki/rpm-gpg -- so root runs an unverified download and then learns from it what to trust. That is the shape the repository audit forbids: no network response is executed as root without a verified digest or signature first. OpenAI publishes no key and no fingerprint anywhere an install could fetch and check them, so the key is pinned here instead. setup/keys/ carries it and says where it came from, including the honest part -- this is trust established on first use and then held, not trust verified against the publisher. setup/lib/ chatgpt-package verifies that copy's fingerprint, installs it, and writes the repository with gpgcheck and repo_gpgcheck on before anything is installed, so dnf checks the metadata signature and the package signature itself. It is byte for byte the repository the scriptlet would have written, so nothing churns afterwards, and every later upgrade goes through the same key. Both callers use it; a verification failure skips ChatGPT rather than installing it anyway. The contract proves the pinned key is the key the library names, that a missing, unreadable or mismatched key writes nothing at all, that what is written actually turns the checks on, and that neither caller hands root a downloaded RPM. Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
1177 lines
43 KiB
Bash
Executable File
1177 lines
43 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 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_<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" >&2
|
||
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 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 <<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_paths() {
|
||
local candidate
|
||
# The manifest is kept in byte order, so both the discovery sort and the
|
||
# comparison below have to be byte order too. A UTF-8 collation folds the
|
||
# punctuation away -- `calendar_agenda_bridge_test.py` sorts before
|
||
# `calendar-agenda-helper-contract` under en_US and after it under C -- and
|
||
# a gate that passes or fails on the machine's LANG is not a gate.
|
||
while IFS= read -r candidate; do
|
||
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
|
||
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
|
||
done < <(find "$PANAMA_DIR/tests" -type f \
|
||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
|
||
}
|
||
|
||
contract_manifest_entries() {
|
||
local line capabilities path
|
||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||
[[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue
|
||
IFS=$' \t' read -r capabilities path <<<"$line"
|
||
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
|
||
}
|
||
}
|
||
|
||
validate_contract_manifest() {
|
||
require_contract_manifest || return 1
|
||
|
||
local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST"
|
||
# Byte order, for the same reason contract_paths sorts in it.
|
||
local LC_ALL=C
|
||
local line capabilities path extra previous_comment="" previous_was_comment=0
|
||
local previous_path="" capability discovered
|
||
local -a capability_list=() findings=()
|
||
local -A expected_contracts=() manifest_paths=()
|
||
|
||
while IFS= read -r discovered; do
|
||
expected_contracts["$discovered"]=1
|
||
done < <(contract_paths)
|
||
|
||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||
if [[ "$line" =~ ^[[:space:]]*# ]]; then
|
||
previous_comment="${line#*#}"
|
||
previous_comment="${previous_comment#"${previous_comment%%[![:space:]]*}"}"
|
||
previous_comment="${previous_comment%"${previous_comment##*[![:space:]]}"}"
|
||
previous_was_comment=1
|
||
continue
|
||
fi
|
||
|
||
if [[ "$line" =~ ^[[:space:]]*$ ]]; then
|
||
previous_comment=""
|
||
previous_was_comment=0
|
||
continue
|
||
fi
|
||
|
||
IFS=$' \t' read -r capabilities path extra <<<"$line"
|
||
if [[ -z "${capabilities:-}" || -z "${path:-}" || -n "${extra:-}" ]]; then
|
||
findings+=("manifest line is not exactly two fields: $line")
|
||
previous_comment=""
|
||
previous_was_comment=0
|
||
continue
|
||
fi
|
||
|
||
if [[ -n "$previous_path" && "$path" < "$previous_path" ]]; then
|
||
findings+=('paths are not lexicographically sorted')
|
||
fi
|
||
previous_path="$path"
|
||
|
||
if [[ -n "${manifest_paths[$path]:-}" ]]; then
|
||
findings+=("duplicate path $path")
|
||
fi
|
||
manifest_paths["$path"]=1
|
||
|
||
local -A line_capabilities=()
|
||
if [[ "$capabilities" == ,* || "$capabilities" == *, || "$capabilities" == *,,* ]]; then
|
||
findings+=("empty capability on $path")
|
||
fi
|
||
IFS=',' read -r -a capability_list <<<"$capabilities"
|
||
for capability in "${capability_list[@]}"; do
|
||
[[ -n "$capability" ]] || continue
|
||
if [[ -n "${line_capabilities[$capability]:-}" ]]; then
|
||
findings+=("duplicate capability $capability on $path")
|
||
fi
|
||
line_capabilities["$capability"]=1
|
||
is_contract_capability "$capability" \
|
||
|| findings+=("unknown capability $capability on $path")
|
||
done
|
||
|
||
if [[ -n "${line_capabilities[hermetic]:-}" && ${#line_capabilities[@]} -ne 1 ]]; then
|
||
findings+=("hermetic must appear alone on $path")
|
||
fi
|
||
|
||
if [[ "$capabilities" != hermetic ]]; then
|
||
if (( previous_was_comment != 1 )); then
|
||
findings+=("$path is non-hermetic but lacks a directly preceding comment")
|
||
elif [[ -z "$previous_comment" ]]; then
|
||
findings+=("$path is non-hermetic but lacks a non-empty directly preceding comment")
|
||
fi
|
||
fi
|
||
previous_comment=""
|
||
previous_was_comment=0
|
||
done < "$manifest"
|
||
|
||
for discovered in "${!expected_contracts[@]}"; do
|
||
[[ -n "${manifest_paths[$discovered]:-}" ]] \
|
||
|| findings+=("missing contract $discovered")
|
||
done
|
||
for path in "${!manifest_paths[@]}"; do
|
||
[[ -n "${expected_contracts[$path]:-}" ]] \
|
||
|| findings+=("stale manifest path $path")
|
||
done
|
||
|
||
if (( ${#findings[@]} > 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 <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. 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 <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
|
||
|
||
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 <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 "$@"
|