#!/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_ functions and # register them in the dispatcher / usage block below. set -euo pipefail PROGRAM=$(basename "$0") VERSION="1.0" # ---------------------------------------------------------------------------- # Locate the Panama repo (this script lives in /bin/panama) # ---------------------------------------------------------------------------- SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}") PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd) # ---------------------------------------------------------------------------- # Pretty output # ---------------------------------------------------------------------------- if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then BOLD=$(tput bold); RESET=$(tput sgr0) RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3) BLUE=$(tput setaf 4); MAGENTA=$(tput setaf 5); CYAN=$(tput setaf 6) else BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; MAGENTA=""; CYAN="" fi info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; } ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; } warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; } err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; } header(){ printf '\n%s%s%s\n' "${MAGENTA}${BOLD}" "$*" "$RESET"; } # Ask a yes/no question. Returns 0 for yes, 1 for no. Default = no. confirm() { local prompt="$1" reply printf '%s?%s %s %s[y/N]%s ' "${CYAN}${BOLD}" "$RESET" "$prompt" "$BOLD" "$RESET" read -r reply || true [[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]] } # ---------------------------------------------------------------------------- # Usage # ---------------------------------------------------------------------------- usage() { cat < [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/${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 %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 "$@"