Files
Panama/bin/panama
T
Gabriel Brown 215da285f3 Let applications be chosen a few at a time
The catalog held fourteen applications. This machine runs thirty-four flatpaks,
so most of what is actually used had no way to be installed from here at all --
Zoom, Slack, Obsidian, Spotify, LibreOffice, OBS and its sixteen plugins.

So the catalog is seeded from the machine, and `panama apps` opens it: pick a
category, tick what you want, install just those. ./install still offers the
same catalog as whole categories, because during a first install you want coarse
and fast. Both read setup/lib/extras-catalog. Two parsers would eventually
disagree about what a category contains, and the one that disagreed quietly
would be the one that runs unattended.

Two pieces of syntax earn their keep. A `| Name` suffix gives the menu something
readable, since com.obsproject.Studio is not a name anybody wants to pick from a
list. An indented line belongs to the entry above it, which is how OBS carries
its plugins as one thing to tick rather than seventeen -- they are extensions of
the flatpak, useless alone.

That is also why creative moved from dnf to Flathub: the plugins attach only to
the flatpak, so the dnf build cannot have them. The rest of the category
followed rather than leave one machine with GIMP from dnf and its neighbour from
Flathub.

The contract now reads the catalog through the same parser instead of keeping a
third idea of the format, and checks the two things this syntax can break
silently: a label leaking into an install command, and a bundle that installs
the application without its plugins. It caught a typo in the Pixelorama id on
the first run.

It also got slow enough to be worth fixing -- fifty-one names, each its own
network call. One bulk query per manager took it from minutes to four seconds.
That query needs `flatpak remote-ls --all`: without it, end-of-life applications
are hidden and read as missing, which reported yuzu as gone from Flathub when it
installs perfectly well.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 23:58:36 -04:00

539 lines
19 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# panama Helper for managing the Panama dotfiles/repo
# Version 1.0
# Author: Gabriel Brown
#
# Commands:
# update Commit & sync local changes (or just pull if clean)
# edit Open the Panama repo in Neovim
# doctor Report what is actually running on this machine
# test Run every contract under tests/
# upgrade Re-run the installer from anywhere
# apps Choose applications to install, by category
# app Build and install an application that no repository packages
# help Show this help
#
# Designed to grow: add new subcommands as cmd_<name> functions and
# register them in the dispatcher / usage block below.
set -euo pipefail
PROGRAM=$(basename "$0")
VERSION="1.0"
# ----------------------------------------------------------------------------
# Locate the Panama repo (this script lives in <repo>/bin/panama)
# ----------------------------------------------------------------------------
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)
# ----------------------------------------------------------------------------
# Pretty output
# ----------------------------------------------------------------------------
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
BOLD=$(tput bold); RESET=$(tput sgr0)
RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3)
BLUE=$(tput setaf 4); MAGENTA=$(tput setaf 5); CYAN=$(tput setaf 6)
else
BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; MAGENTA=""; CYAN=""
fi
info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; }
ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; }
warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; }
err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
header(){ printf '\n%s%s%s\n' "${MAGENTA}${BOLD}" "$*" "$RESET"; }
# Ask a yes/no question. Returns 0 for yes, 1 for no. Default = no.
confirm() {
local prompt="$1" reply
printf '%s?%s %s %s[y/N]%s ' "${CYAN}${BOLD}" "$RESET" "$prompt" "$BOLD" "$RESET"
read -r reply || true
[[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]]
}
# ----------------------------------------------------------------------------
# Usage
# ----------------------------------------------------------------------------
usage() {
cat <<EOF
${BOLD}$PROGRAM${RESET} manage the Panama repo (${PANAMA_DIR})
${BOLD}Usage:${RESET}
$PROGRAM <command> [options]
${BOLD}Commands:${RESET}
${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is
clean it simply runs 'git pull'.
${GREEN}edit${RESET} Open the Panama repo in Neovim.
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
than what was installed. Takes --summary for one line per check.
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
a subset: 'panama test dock' runs the ones matching 'dock'.
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
idempotent and this is the documented upgrade path.
${GREEN}apps${RESET} Choose applications to install: pick a category, then tick
what you want. The same catalog ./install offers, minus the
install.
${GREEN}app${RESET} Build and install an application that neither dnf nor
Flathub carries. With no name, lists what is available.
${GREEN}help${RESET} Show this help (also -h, --help).
${BOLD}Options:${RESET}
-h, --help Show this help and exit
--version Show version and exit
${BOLD}Examples:${RESET}
$PROGRAM update
$PROGRAM edit
$PROGRAM doctor --summary
$PROGRAM test dock
$PROGRAM upgrade
$PROGRAM apps
$PROGRAM app
$PROGRAM app 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
}
# ----------------------------------------------------------------------------
# Command: apps
# ----------------------------------------------------------------------------
# The optional applications, chosen a few at a time rather than all at once.
#
# ./install offers the same catalog as whole categories, because during a first
# install you want coarse and fast. This is the other end of it: pick a
# category, then tick the applications inside, and install just those. Both read
# setup/lib/extras-catalog, so neither can drift from the other.
#
# Source-built applications appear here too, as their own category. They are not
# part of ./install for good reason -- a build is slow, wants the network
# throughout, and can fail on an upstream that moved -- but "choose, then
# install" is exactly the right shape for them, which is what this is.
cmd_apps() {
if ! command -v gum >/dev/null 2>&1; then
err "gum is not installed. It is in setup/packages/initial-packages; run 'panama upgrade'."
exit 1
fi
# shellcheck source=../setup/lib/extras-catalog
source "$PANAMA_DIR/setup/lib/extras-catalog"
local extras_dir="$PANAMA_DIR/setup/packages/extras"
local apps_dir="$PANAMA_DIR/setup/apps"
local -a categories=()
mapfile -t categories < <(catalog_categories "$extras_dir")
[[ -d "$apps_dir" ]] && categories+=("built from source")
(( ${#categories[@]} > 0 )) || { err "No application categories found."; exit 1; }
local category
category="$(gum choose --header "Which kind of application?" "${categories[@]}")" || return 0
[[ -n "$category" ]] || return 0
if [[ "$category" == "built from source" ]]; then
cmd_apps_source "$apps_dir"
return
fi
local file="$extras_dir/$category"
local -a labels=() targets=()
local target label marker
while IFS=$'\t' read -r target label; do
[[ -n "$target" ]] || continue
# Marked, not hidden: reinstalling what is present is harmless, but a menu
# that silently omits it leaves you wondering where it went.
if catalog_installed "$target"; then marker=" (installed)"; else marker=""; fi
targets+=("$target")
labels+=("$label$marker")
done < <(catalog_entries "$file")
local chosen
chosen="$(gum choose --no-limit --header "$category — space to select, enter to accept" "${labels[@]}")" || return 0
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
# Back from labels to install targets, then out to everything each one carries.
local -a install_targets=()
local pick i
while IFS= read -r pick; do
[[ -n "$pick" ]] || continue
for i in "${!labels[@]}"; do
if [[ "${labels[$i]}" == "$pick" ]]; then
mapfile -t -O "${#install_targets[@]}" install_targets < <(catalog_targets "$file" "${targets[$i]}")
break
fi
done
done <<< "$chosen"
local -a dnf_packages=() flatpak_ids=()
for target in "${install_targets[@]}"; do
if [[ "$target" == flatpak:* ]]; then flatpak_ids+=("${target#flatpak:}"); else dnf_packages+=("$target"); fi
done
header "About to install"
(( ${#dnf_packages[@]} > 0 )) && printf ' dnf %s\n' "${dnf_packages[*]}"
(( ${#flatpak_ids[@]} > 0 )) && printf ' flatpak %s\n' "${flatpak_ids[*]}"
echo
confirm "Install these?" || { warn "Nothing installed."; return 0; }
if (( ${#dnf_packages[@]} > 0 )); then
info "Installing ${#dnf_packages[@]} package(s) with dnf"
sudo dnf install -y "${dnf_packages[@]}" || warn "Some packages did not install"
fi
if (( ${#flatpak_ids[@]} > 0 )); then
info "Installing ${#flatpak_ids[@]} flatpak(s)"
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo >/dev/null
sudo flatpak install -y flathub "${flatpak_ids[@]}" || warn "Some flatpaks did not install"
fi
ok "Done."
}
# The source-built applications, offered by the same two-step flow and handed to
# the existing `panama app` so there is one build path, not two.
cmd_apps_source() {
local apps_dir="$1"
local -a names=()
mapfile -t names < <(for f in "$apps_dir"/*; do [[ -f "$f" ]] && basename "$f"; done)
(( ${#names[@]} > 0 )) || { err "No applications in $apps_dir."; return 1; }
local chosen
chosen="$(gum choose --no-limit --header "Built from source — these take a while" "${names[@]}")" || return 0
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
local name
while IFS= read -r name; do
[[ -n "$name" ]] || continue
header "Building $name"
cmd_app "$name" || warn "$name did not build"
done <<< "$chosen"
}
# ----------------------------------------------------------------------------
# Dispatcher
# ----------------------------------------------------------------------------
main() {
local cmd="${1:-}"
case "$cmd" in
update) shift; cmd_update "$@" ;;
edit) shift; cmd_edit "$@" ;;
doctor) shift; cmd_doctor "$@" ;;
test) shift; cmd_test "$@" ;;
upgrade) shift; cmd_upgrade "$@" ;;
app) shift; cmd_app "$@" ;;
apps) shift; cmd_apps "$@" ;;
help|-h|--help|"") usage ;;
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
*)
err "Unknown command: '$cmd'"
echo
usage
exit 1
;;
esac
}
main "$@"