Files
Panama/bin/panama
T

826 lines
31 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/ (--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 "$@"