Files
Panama/bin/panama
T

655 lines
24 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 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/
# 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'.
${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 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" "$@"
}
# ----------------------------------------------------------------------------
# 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: 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 "$@" ;;
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 "$@"