826 lines
31 KiB
Bash
Executable File
826 lines
31 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
|
||
# test Run every contract under tests/ (--safe skips the hijacking ones)
|
||
# contracts Name the contracts that mention a given file
|
||
# upgrade Re-run the installer from anywhere, interview included
|
||
# migrate Apply repairs this machine has not had yet
|
||
# apps Choose applications to install, by category
|
||
# app Build and install an application that no repository packages
|
||
# help Show this help
|
||
#
|
||
# update and sync are deliberately separate verbs. One acts on the machine, the
|
||
# other on the repository, and a single command that guessed between them by
|
||
# looking at whether the tree happened to be dirty would do a different job
|
||
# depending on state nobody can see.
|
||
#
|
||
# Designed to grow: add new subcommands as cmd_<name> functions and
|
||
# register them in the dispatcher / usage block below.
|
||
|
||
set -euo pipefail
|
||
|
||
PROGRAM=$(basename "$0")
|
||
VERSION="1.0"
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Locate the Panama repo (this script lives in <repo>/bin/panama)
|
||
# ----------------------------------------------------------------------------
|
||
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
|
||
PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Pretty output
|
||
# ----------------------------------------------------------------------------
|
||
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
|
||
BOLD=$(tput bold); RESET=$(tput sgr0)
|
||
RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3)
|
||
BLUE=$(tput setaf 4); MAGENTA=$(tput setaf 5); CYAN=$(tput setaf 6)
|
||
else
|
||
BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; MAGENTA=""; CYAN=""
|
||
fi
|
||
|
||
info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; }
|
||
ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; }
|
||
warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; }
|
||
err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
|
||
header(){ printf '\n%s%s%s\n' "${MAGENTA}${BOLD}" "$*" "$RESET"; }
|
||
|
||
# Ask a yes/no question. Returns 0 for yes, 1 for no. Default = no.
|
||
confirm() {
|
||
local prompt="$1" reply
|
||
printf '%s?%s %s %s[y/N]%s ' "${CYAN}${BOLD}" "$RESET" "$prompt" "$BOLD" "$RESET"
|
||
read -r reply || true
|
||
[[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]]
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Usage
|
||
# ----------------------------------------------------------------------------
|
||
usage() {
|
||
cat <<EOF
|
||
${BOLD}$PROGRAM${RESET} – manage the Panama repo (${PANAMA_DIR})
|
||
|
||
${BOLD}Usage:${RESET}
|
||
$PROGRAM <command> [options]
|
||
|
||
${BOLD}Commands:${RESET}
|
||
${GREEN}update${RESET} Bring this machine up to date. Pulls, then runs the stages
|
||
that need no questions asked. The routine command; safe to
|
||
re-run, and it never asks you anything.
|
||
${GREEN}sync${RESET} Review, commit & push your changes to this repo. Uncommitted
|
||
work is committed before anything is fetched, so a moved
|
||
upstream is a rebase rather than a stash conflict.
|
||
${GREEN}edit${RESET} Open the Panama repo in Neovim.
|
||
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
|
||
than what was installed. Takes --summary for one line per check.
|
||
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
|
||
a subset: 'panama test dock' runs the ones matching 'dock'.
|
||
--safe skips the ones that take over the live desktop; what
|
||
they are and why is tests/desktop-hijacking.
|
||
${GREEN}contracts${RESET} Name the contracts that mention a given file, each marked
|
||
safe or desktop. 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}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 test dock
|
||
$PROGRAM test --safe
|
||
$PROGRAM contracts config/dot/quickshell/services/Displays.qml
|
||
$PROGRAM upgrade
|
||
$PROGRAM apps
|
||
$PROGRAM app
|
||
$PROGRAM app chatgpt-desktop
|
||
EOF
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Guard: make sure we're in a git repo
|
||
# ----------------------------------------------------------------------------
|
||
require_git_repo() {
|
||
if ! git -C "$PANAMA_DIR" rev-parse --git-dir >/dev/null 2>&1; then
|
||
err "'$PANAMA_DIR' is not a git repository."
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: update
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# The routine command: bring THIS MACHINE up to date. Pull, then hand the rest
|
||
# to `install --upgrade`, which asks nothing.
|
||
#
|
||
# Most of an update needs no stage at all. Every dotfile is a symlink into this
|
||
# checkout, so an edit to an existing config/dot/** file is live the moment the
|
||
# pull returns -- there is nothing to apply. Stages earn their place when a pull
|
||
# brings something structural: a new dotfile directory to link, a file under
|
||
# config/copy/ to place as root, a new package, a gsettings change. Running the
|
||
# cheap ones every time is idempotent and takes seconds; working out which were
|
||
# needed is guesswork with a silent failure mode.
|
||
#
|
||
# Uncommitted work never blocks an update. It is stashed across the pull and
|
||
# restored afterwards -- and if restoring conflicts, the tree is reset rather
|
||
# than left holding conflict markers, because on this repository those markers
|
||
# are not something you fix at your leisure. They are live in ~/.config the
|
||
# instant they are written, and a half-merged .qml is a shell that will not
|
||
# parse.
|
||
cmd_update() {
|
||
require_git_repo
|
||
cd "$PANAMA_DIR"
|
||
|
||
info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"
|
||
|
||
local stashed=0 conflict_stash=""
|
||
if [[ -n "$(git status --porcelain)" ]]; then
|
||
info "Local changes; stashing them across the pull."
|
||
if git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null; then
|
||
stashed=1
|
||
else
|
||
err "Could not stash local changes, so the pull would overwrite them."
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
# --ff-only on purpose. A diverged branch is something to resolve
|
||
# deliberately, not something an update command should merge on your behalf.
|
||
# It is a warning rather than an error: the stages below are still worth
|
||
# running against whatever is checked out.
|
||
if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
|
||
info "Pulling..."
|
||
if git pull --ff-only; then
|
||
ok "Checkout is current."
|
||
else
|
||
warn "Could not fast-forward — continuing with what is checked out."
|
||
fi
|
||
else
|
||
warn "No upstream configured for this branch; nothing to pull."
|
||
fi
|
||
|
||
if (( stashed )); then
|
||
if git stash pop >/dev/null 2>&1; then
|
||
ok "Local changes restored."
|
||
else
|
||
# A conflicted pop keeps the stash entry -- git says so itself, and the
|
||
# contract proves it -- so resetting here loses nothing. The work stays
|
||
# in the stash, where nothing is reading it, instead of in your live
|
||
# config as merge markers.
|
||
git reset --hard HEAD >/dev/null 2>&1
|
||
conflict_stash="$(git stash list --format='%gd: %gs' 2>/dev/null | head -1)"
|
||
warn "Your local changes conflict with what was pulled; they stay stashed."
|
||
fi
|
||
fi
|
||
|
||
local installer="$PANAMA_DIR/install"
|
||
if [[ ! -x "$installer" ]]; then
|
||
err "The installer is missing from $installer"
|
||
exit 1
|
||
fi
|
||
|
||
local rc=0
|
||
"$installer" --upgrade "$@" || rc=$?
|
||
|
||
# Repeated at the very end rather than only where it happened. A warning
|
||
# printed before twenty minutes of dnf output is a warning nobody read.
|
||
if [[ -n "$conflict_stash" ]]; then
|
||
echo
|
||
warn "Your local changes were NOT restored — they conflicted with the pull."
|
||
printf ' They are safe at %s%s%s\n' "$BOLD" "$conflict_stash" "$RESET"
|
||
printf ' Restore them with: %sgit stash pop%s\n' "$BOLD" "$RESET"
|
||
fi
|
||
|
||
return $rc
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: sync
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# The other half of what `update` used to mean: commit and push MY EDITS.
|
||
# Panama is a working tree people edit in place -- every dotfile is a symlink
|
||
# into it -- so "I changed something, put it upstream" is a daily action and
|
||
# deserves its own verb rather than sharing one with "update my machine".
|
||
#
|
||
# The commit happens BEFORE anything is fetched, which is why there is no stash
|
||
# in here. By the time upstream is consulted the work is a commit, so a moved
|
||
# upstream is a rebase over committed history -- recoverable, ordinary, and
|
||
# nothing like a stash pop conflicting into a live config.
|
||
cmd_sync() {
|
||
require_git_repo
|
||
cd "$PANAMA_DIR"
|
||
|
||
info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"
|
||
|
||
if [[ -z "$(git status --porcelain)" ]]; then
|
||
ok "Nothing to commit — the working tree is clean."
|
||
info "To update this machine, run: ${BOLD}${PROGRAM} update${RESET}"
|
||
return
|
||
fi
|
||
|
||
header "Changed files"
|
||
git -c color.status=always status --short
|
||
|
||
header "Diff"
|
||
# Tracked changes (staged + unstaged) relative to the last commit.
|
||
git --no-pager -c color.diff=always diff HEAD
|
||
|
||
# Untracked files won't appear in 'git diff', so show them as new files.
|
||
local untracked
|
||
untracked=$(git ls-files --others --exclude-standard)
|
||
if [[ -n "$untracked" ]]; then
|
||
while IFS= read -r f; do
|
||
[[ -z "$f" ]] && continue
|
||
git --no-pager -c color.diff=always diff --no-index -- /dev/null "$f" || true
|
||
done <<< "$untracked"
|
||
fi
|
||
|
||
echo
|
||
if ! confirm "Commit these changes?"; then
|
||
warn "Aborted — no changes committed."
|
||
return
|
||
fi
|
||
|
||
local msg
|
||
printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET"
|
||
read -r msg || true
|
||
if [[ -z "${msg// }" ]]; then
|
||
msg="Update $(date '+%Y-%m-%d %H:%M:%S')"
|
||
warn "No message given — using: ${BOLD}${msg}${RESET}"
|
||
fi
|
||
|
||
info "Committing changes..."
|
||
git add -A
|
||
git commit -m "$msg"
|
||
ok "Committed: ${BOLD}${msg}${RESET}"
|
||
|
||
if ! git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
|
||
warn "No upstream configured for this branch — committed locally only."
|
||
return
|
||
fi
|
||
|
||
# Only now, with the work safely committed, is it worth looking upstream.
|
||
info "Checking whether upstream has moved..."
|
||
git fetch --quiet
|
||
local remote_rev base_rev
|
||
remote_rev=$(git rev-parse '@{u}')
|
||
base_rev=$(git merge-base @ '@{u}')
|
||
|
||
if [[ "$base_rev" != "$remote_rev" ]]; then
|
||
warn "Upstream has moved — rebasing your commit onto it."
|
||
if ! git pull --rebase; then
|
||
err "The rebase stopped on a conflict."
|
||
err "Resolve it, then: git rebase --continue"
|
||
exit 1
|
||
fi
|
||
ok "Rebased onto upstream."
|
||
else
|
||
ok "Upstream has not moved."
|
||
fi
|
||
|
||
echo
|
||
if confirm "Push the changes now?"; then
|
||
info "Pushing..."
|
||
if git push; then
|
||
ok "Pushed to remote."
|
||
else
|
||
err "git push failed."
|
||
exit 1
|
||
fi
|
||
else
|
||
info "Done — changes committed locally but not pushed."
|
||
fi
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: edit
|
||
# ----------------------------------------------------------------------------
|
||
cmd_edit() {
|
||
if ! command -v nvim >/dev/null 2>&1; then
|
||
err "Neovim (nvim) is not installed or not on PATH."
|
||
exit 1
|
||
fi
|
||
info "Opening ${BOLD}${PANAMA_DIR}${RESET} in Neovim."
|
||
cd "$PANAMA_DIR"
|
||
exec nvim .
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: doctor
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# The health check already exists and the installer already runs it; what it did
|
||
# not have was a way to reach it from a terminal. Everything is passed straight
|
||
# through, so --summary and anything added later work without this knowing about
|
||
# them.
|
||
cmd_doctor() {
|
||
local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor"
|
||
if [[ ! -x "$doctor" ]]; then
|
||
err "panama-doctor is missing from $doctor"
|
||
exit 1
|
||
fi
|
||
exec "$doctor" "$@"
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# The desktop-hijacking ledger
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# tests/desktop-hijacking lists, one repo-relative path per line with a '#'
|
||
# comment saying what it does to the live session, the contracts that drive the
|
||
# real shell, compositor or machine rather than a harness. Read by `test --safe`
|
||
# to decide what to skip, and by `contracts` to mark each hit.
|
||
#
|
||
# Prints the paths, comments and blank lines stripped. A missing ledger prints
|
||
# nothing: no ledger means nothing is known to hijack, which is the honest
|
||
# reading of an absent file and keeps `--safe` from failing on a fresh checkout.
|
||
DESKTOP_HIJACKING_LEDGER="tests/desktop-hijacking"
|
||
|
||
hijacking_entries() {
|
||
local ledger="$PANAMA_DIR/$DESKTOP_HIJACKING_LEDGER" line
|
||
[[ -r "$ledger" ]] || return 0
|
||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||
line="${line%%#*}"
|
||
line="${line#"${line%%[![:space:]]*}"}"
|
||
line="${line%"${line##*[![:space:]]}"}"
|
||
[[ -n "$line" ]] && printf '%s\n' "$line"
|
||
done < "$ledger"
|
||
return 0
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: test
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# The contracts are the main safety net in this repository and had no entry
|
||
# point: 121 executables with no runner and no mention in the README, which is
|
||
# most of the way to not having them.
|
||
#
|
||
# Each runs in its own process and a failure does not stop the rest, because the
|
||
# useful output is the whole list of what is broken rather than the first thing
|
||
# that broke. The exit code is what a caller can act on.
|
||
#
|
||
# --safe exists because a fair number of these contracts ARE the desktop: they
|
||
# open overlays, restart the shell, move your windows. Running the suite while
|
||
# sitting in front of the machine used to mean losing the session for a few
|
||
# minutes, so the honest options were "run everything" or "run nothing". --safe
|
||
# is the third: skip exactly what tests/desktop-hijacking names, and say how
|
||
# many were skipped, so the gap is stated rather than implied.
|
||
cmd_test() {
|
||
local pattern="" safe=0 arg
|
||
# Position-independent, because 'panama test --safe dock' and
|
||
# 'panama test dock --safe' are the same intent and nobody should have to
|
||
# remember which one this accepts.
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
--safe) safe=1 ;;
|
||
*) pattern="$arg" ;;
|
||
esac
|
||
done
|
||
|
||
local -a suite=()
|
||
local -A hijacking=()
|
||
local skipped=0 entry rel
|
||
|
||
if (( safe )); then
|
||
while IFS= read -r entry; do
|
||
hijacking["$entry"]=1
|
||
done < <(hijacking_entries)
|
||
fi
|
||
|
||
# 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"* ]] || continue
|
||
rel="tests/${path#"$PANAMA_DIR"/tests/}"
|
||
if (( safe )) && [[ -n "${hijacking[$rel]:-}" ]]; then
|
||
(( ++skipped ))
|
||
continue
|
||
fi
|
||
suite+=("$path")
|
||
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||
|
||
if (( ${#suite[@]} == 0 )); then
|
||
# "Nothing matched" and "everything that matched was skipped" are different
|
||
# answers, and reporting the first for the second is how --safe would come
|
||
# to look like a broken pattern.
|
||
if (( skipped > 0 )); then
|
||
err "Every contract matching '${pattern}' is desktop-hijacking; --safe skipped all ${skipped}."
|
||
printf ' What they do to the session: %s/%s\n' "$PANAMA_DIR" "$DESKTOP_HIJACKING_LEDGER" >&2
|
||
else
|
||
err "No contracts match '${pattern}'"
|
||
fi
|
||
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"
|
||
if (( safe )); then
|
||
printf 'Skipped %d desktop-hijacking contract(s) (%s).\n' "$skipped" "$DESKTOP_HIJACKING_LEDGER"
|
||
fi
|
||
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}"
|
||
if (( safe )); then
|
||
printf 'Skipped %d desktop-hijacking contract(s) (%s).\n' "$skipped" "$DESKTOP_HIJACKING_LEDGER"
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: contracts
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# "I changed this file -- what should I run?" There are 177 contracts and no
|
||
# index, so the honest answers were "all of them" (minutes, and half of them
|
||
# take the desktop away) or "the ones whose name sounds related" (which is how
|
||
# a covering contract gets skipped).
|
||
#
|
||
# This is a grep, and says so. A contract that names the file, or a
|
||
# parent-trimmed suffix of it, or just its basename, is a contract worth
|
||
# running; one that reaches the file through a harness or a generated artifact
|
||
# is not found, which is why the empty answer says "coverage may be indirect"
|
||
# rather than "nothing covers this". Naming a file the suite does not mention is
|
||
# a real answer -- exit 1 so a script can tell the difference -- but it is a
|
||
# statement about this search, not about the file.
|
||
#
|
||
# Each hit is marked from tests/desktop-hijacking, so the output also answers
|
||
# "and can I run them right now".
|
||
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
|
||
|
||
local -A hijacking=()
|
||
local entry
|
||
while IFS= read -r entry; do
|
||
hijacking["$entry"]=1
|
||
done < <(hijacking_entries)
|
||
|
||
# The same collection `test` runs, so anything named here is something the
|
||
# runner would actually execute.
|
||
local -a hits=()
|
||
local candidate rel
|
||
while IFS= read -r candidate; do
|
||
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
|
||
grep -qF "${patterns[@]}" "$candidate" 2>/dev/null || continue
|
||
hits+=("tests/${candidate#"$PANAMA_DIR"/tests/}")
|
||
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||
|
||
if (( ${#hits[@]} == 0 )); then
|
||
printf 'No contract mentions %s — coverage may be indirect (a harness or a generated artifact); nothing verified.\n' "$path" >&2
|
||
exit 1
|
||
fi
|
||
|
||
for rel in "${hits[@]}"; do
|
||
if [[ -n "${hijacking[$rel]:-}" ]]; then
|
||
printf '%s [desktop]\n' "$rel"
|
||
else
|
||
printf '%s [safe]\n' "$rel"
|
||
fi
|
||
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 "$@" ;;
|
||
test) shift; cmd_test "$@" ;;
|
||
contracts) shift; cmd_contracts "$@" ;;
|
||
upgrade) shift; cmd_upgrade "$@" ;;
|
||
migrate) shift; cmd_migrate "$@" ;;
|
||
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 "$@"
|