Files
Panama/bin/panama
T
Gabriel Brown f457c1eb9f Build the two applications nobody packages, on purpose rather than in passing
Claude Desktop and ChatGPT Desktop ship for macOS and Windows. The Linux path for
both is a community wrapper that converts the official build into an RPM -- so
what lands is still a package dnf owns and can remove, which is the part of the
dnf/flatpak rule that actually matters. What they need an exception for is the
build itself, and there is no packaged form to prefer over it.

`panama app` builds one by name, and is deliberately not part of ./install. A
source build is slow, wants the network throughout, and depends on an upstream
that moves -- twenty minutes in, an error, with nobody at the keyboard, which is
the exact failure the interview exists to prevent. Asking for one is something
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 reports a failure rather than working around it, leaving
the tree where the error can be read. sunhat pinned versions and every pin was a
404 within a release cycle.

Adding one is adding a file to setup/apps/, and the file has to say why the
exception exists -- the contract fails a definition that does not, because the
guard against this list growing by habit is having to write the reason down.
sunhat had seventy-odd installers and a reason recorded for none of them.

The contract had a bug worth recording: `while read` on the right of a pipe runs
in a subshell, so two of its three per-definition checks recorded findings into
an array that went out of scope at the end of the loop. It reported PASS on a
definition with no description and no build function. Found by standing one in
deliberately and noticing only the third check spoke up.

Also: nautilus-open-any-terminal is now declared, and Panama's copy of the
extension is gone. Fedora packages that extension AND its gsettings schema, and
Panama shipped its own fork of the .py over the same path while declaring
neither -- so a fresh machine got an extension whose schema did not exist. It
worked here only because the RPM has been installed since sunhat. The fork was
also 63 lines behind the packaged version, missing its newer Nautilus and Caja
handling.

Auditing the rest of config/copy for the same shape found nothing else: dnf.conf
is a config file its package expects to be replaced, and the GPU udev rules are
Panama's own.

125 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 23:00:21 -04:00

420 lines
14 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# panama Helper for managing the Panama dotfiles/repo
# Version 1.0
# Author: Gabriel Brown
#
# Commands:
# update Commit & sync local changes (or just pull if clean)
# edit Open the Panama repo in Neovim
# doctor Report what is actually running on this machine
# test Run every contract under tests/
# upgrade Re-run the installer from anywhere
# app Build and install an application that no repository packages
# help Show this help
#
# Designed to grow: add new subcommands as cmd_<name> functions and
# register them in the dispatcher / usage block below.
set -euo pipefail
PROGRAM=$(basename "$0")
VERSION="1.0"
# ----------------------------------------------------------------------------
# Locate the Panama repo (this script lives in <repo>/bin/panama)
# ----------------------------------------------------------------------------
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)
# ----------------------------------------------------------------------------
# Pretty output
# ----------------------------------------------------------------------------
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
BOLD=$(tput bold); RESET=$(tput sgr0)
RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3)
BLUE=$(tput setaf 4); MAGENTA=$(tput setaf 5); CYAN=$(tput setaf 6)
else
BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; MAGENTA=""; CYAN=""
fi
info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; }
ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; }
warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; }
err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
header(){ printf '\n%s%s%s\n' "${MAGENTA}${BOLD}" "$*" "$RESET"; }
# Ask a yes/no question. Returns 0 for yes, 1 for no. Default = no.
confirm() {
local prompt="$1" reply
printf '%s?%s %s %s[y/N]%s ' "${CYAN}${BOLD}" "$RESET" "$prompt" "$BOLD" "$RESET"
read -r reply || true
[[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]]
}
# ----------------------------------------------------------------------------
# Usage
# ----------------------------------------------------------------------------
usage() {
cat <<EOF
${BOLD}$PROGRAM${RESET} manage the Panama repo (${PANAMA_DIR})
${BOLD}Usage:${RESET}
$PROGRAM <command> [options]
${BOLD}Commands:${RESET}
${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is
clean it simply runs 'git pull'.
${GREEN}edit${RESET} Open the Panama repo in Neovim.
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
than what was installed. Takes --summary for one line per check.
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
a subset: 'panama test dock' runs the ones matching 'dock'.
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
idempotent and this is the documented upgrade path.
${GREEN}app${RESET} Build and install an application that neither dnf nor
Flathub carries. With no name, lists what is available.
${GREEN}help${RESET} Show this help (also -h, --help).
${BOLD}Options:${RESET}
-h, --help Show this help and exit
--version Show version and exit
${BOLD}Examples:${RESET}
$PROGRAM update
$PROGRAM edit
$PROGRAM doctor --summary
$PROGRAM test dock
$PROGRAM upgrade
$PROGRAM app
$PROGRAM app claude-desktop
EOF
}
# ----------------------------------------------------------------------------
# Guard: make sure we're in a git repo
# ----------------------------------------------------------------------------
require_git_repo() {
if ! git -C "$PANAMA_DIR" rev-parse --git-dir >/dev/null 2>&1; then
err "'$PANAMA_DIR' is not a git repository."
exit 1
fi
}
# ----------------------------------------------------------------------------
# Command: update
# ----------------------------------------------------------------------------
cmd_update() {
require_git_repo
cd "$PANAMA_DIR"
info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"
# Any changes in the working tree? (modified, staged, or untracked)
if [[ -z "$(git status --porcelain)" ]]; then
info "Working tree is clean — pulling latest changes."
if git pull --ff-only; then
ok "Already in sync."
else
err "git pull failed."
exit 1
fi
return
fi
# Show what changed
header "Changed files"
git -c color.status=always status --short
header "Diff"
# Tracked changes (staged + unstaged) relative to the last commit.
git --no-pager -c color.diff=always diff HEAD
# Untracked files won't appear in 'git diff', so show them as new files.
local untracked
untracked=$(git ls-files --others --exclude-standard)
if [[ -n "$untracked" ]]; then
while IFS= read -r f; do
[[ -z "$f" ]] && continue
git --no-pager -c color.diff=always diff --no-index -- /dev/null "$f" || true
done <<< "$untracked"
fi
echo
if ! confirm "Commit these changes?"; then
warn "Aborted — no changes committed."
return
fi
# Commit message
local msg
printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET"
read -r msg || true
if [[ -z "${msg// }" ]]; then
msg="Update $(date '+%Y-%m-%d %H:%M:%S')"
warn "No message given — using: ${BOLD}${msg}${RESET}"
fi
# Is the local branch up to date with its upstream?
info "Checking whether the repo is up to date..."
if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
git fetch --quiet
local local_rev remote_rev base_rev
local_rev=$(git rev-parse @)
remote_rev=$(git rev-parse '@{u}')
base_rev=$(git merge-base @ '@{u}')
if [[ "$local_rev" == "$remote_rev" ]]; then
ok "Repo is up to date."
elif [[ "$local_rev" == "$base_rev" ]]; then
warn "Repo is behind upstream — stashing, pulling, then re-applying."
info "Stashing local changes..."
git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null
if ! git pull --ff-only; then
err "git pull failed — restoring your changes."
git stash pop || true
exit 1
fi
info "Re-applying stashed changes..."
if ! git stash pop; then
err "Conflict while re-applying changes. Resolve it, then commit manually."
exit 1
fi
else
warn "Local branch has diverged from upstream — committing locally only."
fi
else
warn "No upstream configured for this branch — committing locally only."
fi
# Commit everything
info "Committing changes..."
git add -A
git commit -m "$msg"
ok "Committed: ${BOLD}${msg}${RESET}"
echo
if confirm "Push the changes now?"; then
info "Pushing..."
if git push; then
ok "Pushed to remote."
else
err "git push failed."
exit 1
fi
else
info "Done — changes committed locally but not pushed."
fi
}
# ----------------------------------------------------------------------------
# Command: edit
# ----------------------------------------------------------------------------
cmd_edit() {
if ! command -v nvim >/dev/null 2>&1; then
err "Neovim (nvim) is not installed or not on PATH."
exit 1
fi
info "Opening ${BOLD}${PANAMA_DIR}${RESET} in Neovim."
cd "$PANAMA_DIR"
exec nvim .
}
# ----------------------------------------------------------------------------
# Command: doctor
# ----------------------------------------------------------------------------
#
# The health check already exists and the installer already runs it; what it did
# not have was a way to reach it from a terminal. Everything is passed straight
# through, so --summary and anything added later work without this knowing about
# them.
cmd_doctor() {
local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor"
if [[ ! -x "$doctor" ]]; then
err "panama-doctor is missing from $doctor"
exit 1
fi
exec "$doctor" "$@"
}
# ----------------------------------------------------------------------------
# Command: test
# ----------------------------------------------------------------------------
#
# The contracts are the main safety net in this repository and had no entry
# point: 121 executables with no runner and no mention in the README, which is
# most of the way to not having them.
#
# Each runs in its own process and a failure does not stop the rest, because the
# useful output is the whole list of what is broken rather than the first thing
# that broke. The exit code is what a caller can act on.
cmd_test() {
local pattern="${1:-}"
local -a suite=()
# Executables, plus the Python suites. Those are unittest files rather than
# executables, and collecting only what has the executable bit would skip them
# without saying so -- which is how all three came to be run by nothing at all.
# A runner with a blind spot is worse than no runner, because it reports PASS.
while IFS= read -r path; do
[[ -x "$path" || "$path" == *_test.py ]] || continue
[[ -z "$pattern" || "$path" == *"$pattern"* ]] && suite+=("$path")
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
if (( ${#suite[@]} == 0 )); then
err "No contracts match '${pattern}'"
exit 1
fi
info "Running ${#suite[@]} contract(s)"
local -a failed=()
local path name
local -a runner
for path in "${suite[@]}"; do
name="${path#"$PANAMA_DIR"/tests/}"
if [[ "$path" == *_test.py ]]; then
runner=(python3 "$path")
else
runner=("$path")
fi
if "${runner[@]}" >/dev/null 2>&1; then
ok "$name"
else
err "$name"
failed+=("$name")
fi
done
header "Result"
if (( ${#failed[@]} == 0 )); then
ok "${#suite[@]} contract(s) passed"
return 0
fi
err "${#failed[@]} of ${#suite[@]} failed:"
printf ' %s\n' "${failed[@]}" >&2
warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}"
return 1
}
# ----------------------------------------------------------------------------
# Command: upgrade
# ----------------------------------------------------------------------------
#
# ./install is the upgrade path -- every stage is idempotent and re-running is
# the documented way to repair a machine. This only saves remembering where the
# repository lives.
cmd_upgrade() {
local installer="$PANAMA_DIR/install"
if [[ ! -x "$installer" ]]; then
err "The installer is missing from $installer"
exit 1
fi
info "Re-running ${BOLD}${installer}${RESET}"
cd "$PANAMA_DIR"
exec "$installer" "$@"
}
# ----------------------------------------------------------------------------
# Command: 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
}
# ----------------------------------------------------------------------------
# Dispatcher
# ----------------------------------------------------------------------------
main() {
local cmd="${1:-}"
case "$cmd" in
update) shift; cmd_update "$@" ;;
edit) shift; cmd_edit "$@" ;;
doctor) shift; cmd_doctor "$@" ;;
test) shift; cmd_test "$@" ;;
upgrade) shift; cmd_upgrade "$@" ;;
app) shift; cmd_app "$@" ;;
help|-h|--help|"") usage ;;
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
*)
err "Unknown command: '$cmd'"
echo
usage
exit 1
;;
esac
}
main "$@"