panama update: one command, no questions, and no gap it cannot see

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 07:22:12 -04:00
parent 8105849151
commit beed44dd87
7 changed files with 894 additions and 99 deletions
+32 -5
View File
@@ -18,7 +18,16 @@ git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama
```
Both are safe to run again: an existing clone is fast-forwarded rather than
replaced, and `install` is the upgrade path.
replaced. Once a machine exists, though, the command that keeps it current is
`panama update` — one command, and it never asks you anything:
```sh
panama update
```
It pulls, applies any repairs this machine has not had, and runs the stages
below that need no answers. `./install` remains what it is: how a machine is
built, and how you change an answer you gave.
`install` asks its questions first and then runs the stages in `setup/scripts/`
in order, without stopping again:
@@ -190,21 +199,39 @@ name it; **Open Project** lays it out again.
Workspaces are recorded as positions rather than numbers, and opening a project
claims free ones, so it never lands on top of what you are already doing. An
application that refuses to open twice — Slack, Thunderbird, the browser — is
moved into place rather than launched again. Saved layouts are listed on the
Desktop settings page, which is also where they are removed.
moved into place rather than launched again. Saved layouts are listed on
Shell Workspaces in Settings, which is also where they are removed.
## The `panama` command
```sh
panama update # review, commit and sync this repo
panama update # bring this machine up to date; asks nothing
panama sync # review, commit and push your changes to this repo
panama edit # open it in Neovim
panama doctor # what is actually running, not what was installed
panama test # every contract, or a subset by pattern
panama upgrade # re-run ./install from anywhere
panama migrate # apply repairs this machine has not had yet
panama upgrade # re-run ./install from anywhere, interview and all
panama apps # choose applications to install, by category
panama app # applications no repository carries; build one by name
```
`panama update` and `panama sync` are separate verbs on purpose. One acts on
the machine, the other on the repository. A single command that chose between
them by checking whether the working tree happened to be dirty would do a
different job depending on state nobody can see — and, worse, would never
update a machine belonging to somebody who had left a file edited.
`panama update` stashes uncommitted work across the pull and restores it
afterwards. If restoring conflicts it resets the checkout and leaves the work
in the stash, saying so at the end of the run: every dotfile here is a symlink
into this repository, so a conflict marker is not a thing to fix at leisure. It
is live in `~/.config` the moment it is written.
Only two things still need `./install`: a machine that does not exist yet, and
an answer you want to change. Adding a package to a list you already have is
`panama update`; adding an optional category is `panama apps`.
`panama apps` is the optional-application catalog, opened after the fact. The
interview offers the same categories during `./install`, whole; this picks a
category and then the applications inside it, so a machine can acquire Slack in
+143 -49
View File
@@ -5,15 +5,22 @@
# Author: Gabriel Brown
#
# Commands:
# update Commit & sync local changes (or just pull if clean)
# 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
# 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.
@@ -64,15 +71,20 @@ ${BOLD}Usage:${RESET}
$PROGRAM <command> [options]
${BOLD}Commands:${RESET}
${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is
clean it simply runs 'git pull'.
${GREEN}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. Safe: every stage is
idempotent and this is the documented upgrade path.
${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.
@@ -89,6 +101,8 @@ ${BOLD}Options:${RESET}
${BOLD}Examples:${RESET}
$PROGRAM update
$PROGRAM update --packages
$PROGRAM sync
$PROGRAM edit
$PROGRAM doctor --summary
$PROGRAM test dock
@@ -112,25 +126,116 @@ require_git_repo() {
# ----------------------------------------------------------------------------
# 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}"
# 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."
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 "git pull failed."
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
# Show what changed
header "Changed files"
git -c color.status=always status --short
@@ -154,7 +259,6 @@ cmd_update() {
return
fi
# Commit message
local msg
printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET"
read -r msg || true
@@ -163,46 +267,35 @@ cmd_update() {
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}"
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..."
@@ -539,6 +632,7 @@ 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 "$@" ;;
@@ -34,10 +34,10 @@ screen and the system monitor at once — not just the shell's own windows.
### Build your own
The **Theme editor** tab is four colours: primary, secondary, background and
foreground. Each has a swatch, a hex field you can type into, a colour wheel
The **Theme editor** tab is four colors: primary, secondary, background and
foreground. Each has a swatch, a hex field you can type into, a color wheel
and an eyedropper for sampling anything on screen. Background and foreground
are not single colours — the panels, popovers, dividers and dimmed text are all
are not single colors — the panels, popovers, dividers and dimmed text are all
mixed from them, so moving the background moves the whole family with it.
Under that, a saturation slider for the whole palette at once, a fine-tune for
@@ -67,7 +67,7 @@ titlebar can be turned off entirely, which leaves the Settings window bare:
## Shell
Everything Panama draws on the screen has a tab under **Shell**: the bar, the
dock, Control Center, tiling and workspaces. Appearance decides the colours;
dock, Control Center, tiling and workspaces. Appearance decides the colors;
this decides what is there at all.
### The bar
@@ -125,14 +125,14 @@ behaviour, how long a focus session runs, and your saved projects.
## Carrying settings between machines
System **Sync & Backup**. Export writes your settings to a file; importing
System **[Sync & Backup](panama://settings/sync)**. Export writes your settings to a file; importing
one shows you exactly what would change before anything does, and you decide
then. Below it, **Settings backups** keeps dated copies you can restore from —
these are your settings, not btrfs Snapshots, which is a different tab and
covers the whole filesystem. **Restore defaults** is at the bottom: it resets
appearance, dock, clock, focus and display policy and clears your Home
accessory arrangement, and leaves your pinned applications, files and paired
devices alone.
appearance, the dock and the applications pinned to it, clock, focus and
display policy, removes any themes you saved, and clears your Home accessory
arrangement. Your files, paired devices and settings backups are left alone.
## Applications
@@ -158,4 +158,4 @@ without editing a file the repository will update.
The shell is QML under `~/.config/quickshell`. Both directories are symlinks
into the Panama repository, so an edit is a change to your checkout and
`panama update` will offer to commit it.
`panama sync` will offer to commit it.
@@ -0,0 +1,261 @@
# Panama: one command to update a machine
## Why now
Updating a machine currently takes three commands, and one of them asks the
full first-install questionnaire every time:
```sh
panama update # actually: commit & push my edits, or pull if clean
panama migrate # the repairs
panama upgrade # ./install, interview and all
```
Nobody will do that routinely, which means machines drift. The interview in
particular is pure ceremony on an already-configured machine: it asks about
NVIDIA, Secure Boot, git identity and extras categories, none of which have
changed since the machine was built.
## What is broken
**1. The interview runs on every upgrade, and nothing needs it.**
Five of the seven stages read no interview answer at all:
| Stage | Reads from the interview |
|---|---|
| `install-packages` | `PANAMA_EXTRAS` only, for the *optional* categories. Empty means base lists still install. |
| `link-dotfiles` | nothing |
| `link-user` | `PANAMA_USER_CONTENT`, and falls back to the decision recorded at `$XDG_STATE_HOME/panama/user-content` |
| `change-settings` | nothing |
| `link-vicinae-scripts` | nothing |
| `setup-identity` | git name/email/editor, gh login, ssh key — genuinely first-run |
| `install-hardware` | NVIDIA, MOK hash, debloat, firmware — genuinely first-run |
`link-user` already anticipates this in its own comment: *"Running this stage by
hand outside an install honours the recorded answer instead, so `panama upgrade`
on an already-configured machine does not need re-asking."* The capability is
there; `install:91` just calls the interview unconditionally with no way past it.
**2. `panama update` is named for the wrong job.**
It means "review, commit & sync local changes" — a git workflow command. README
and `config/dot/quickshell/manual/05-making-it-yours.md` both document it that
way. The command a person reaches for to update their machine is therefore the
one command that does not.
**3. Most updates need no stages at all, and there is no way to tell.**
Dotfiles are symlinks into the checkout, so an edit to any existing
`config/dot/**` file is live the instant `git pull` returns. A pull only needs
stages when it brings something structurally new: a new `config/dot/<name>/`
directory, anything under `config/copy/`, a new package, or a gsettings change.
Today the only way to be sure is to run everything.
## Decisions
1. **`panama update` becomes the machine-update command.** The git workflow
moves to `panama sync`, unchanged in intent. One verb per job; nothing
branches on state the user cannot see.
2. **`install` gains `--upgrade`**, which skips the interview and trims `STAGES`
to the five that need no answers. One stage list in the repository, so the
two paths cannot drift.
3. **`install-packages` runs when a content hash of `setup/packages/**` differs
from the recorded one.** Not a git range: Panama is developed in place, and a
hand-edited uncommitted package list must still trigger an install.
4. **A dirty tree is stashed, pulled over, and popped.** The machine update is
never blocked by uncommitted work.
5. **A conflicted pop resets the tree, keeps the stash, warns, and continues.**
Conflict markers must never reach a live config; the update proceeds against
the clean upstream checkout.
6. **The migrate notification is untouched.** It says "repairs are waiting" and
it will keep doing exactly that.
7. **`panama sync` commits before pulling**, so it needs no stash at all.
## Design
### Command surface
| Command | Job |
|---|---|
| `panama update` | Bring **this machine** up to date. The routine command. |
| `panama sync` | Commit & push **my edits**. Today's `cmd_update`, renamed and simplified. |
| `panama upgrade` | Full `./install`, interview included. New machine, or to change an answer. |
| `panama migrate` | Repairs only. Unchanged; still what the login notification runs. |
### `panama update`
```
1. Record pre-pull HEAD.
2. Dirty tree? git stash push --include-untracked -m "panama-update-<epoch>"
3. git pull --ff-only (failure is reported, never fatal)
4. Stashed? git stash pop
on conflict: git reset --hard HEAD
leave the stash in place
record a warning for the summary
5. exec install --upgrade
```
Steps 14 are the only new logic in `panama update`; the packages gate below is
the only new logic in `install`. Everything after step 5 is `install`'s existing
tail.
The pull is `--ff-only`. A diverged branch is a thing to resolve deliberately,
not something an update command should paper over; it reports and continues to
the stages, because the machine should still be brought in line with what is
checked out.
### `install --upgrade`
`install` has no argument parsing today, so this adds the first. `cmd_upgrade`
already forwards `"$@"`, so `panama upgrade --upgrade` would work but is
pointless and undocumented.
`--upgrade` changes exactly four things:
- The interview block (`install:91-98`) is skipped. No `PANAMA_ANSWERS` file is
created, so every `PANAMA_*` answer is unset and each stage takes its
documented empty-answer path.
- `hostnamectl` is skipped — there is no answer to apply.
- `STAGES` drops `setup-identity` and `install-hardware`.
- The closing message says the machine was updated rather than *"Panama
installed. Log out and choose the Hyprland session to start it."*
Everything else is inherited unchanged and deliberately so: the `sudo -v`
keepalive (`change-settings` and `install-packages` both need root), the
per-stage failure collection, the migrations block, `panama-doctor --summary`,
the `post-upgrade` hook, and the exit code.
One correction to the migrations block is required. It currently baselines when
the marker directory is absent, on the reasoning that a machine with no markers
was just built from this checkout. That inference is only sound during a real
install. Under `--upgrade` it must always take `run`; the migrations are all
self-guarding and a no-op on a machine that does not need them.
### The packages hash
```sh
STATE="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
hash_packages() {
find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -exec sha256sum {} + \
| sort | sha256sum | cut -d' ' -f1
}
```
`install-packages` is skipped when the hash matches `$STATE/packages-hash`.
`-maxdepth 1` is deliberate and excludes `setup/packages/extras/`. An
answer-free run has an empty `PANAMA_EXTRAS` and installs no optional category,
so hashing those files would flip the hash, run the stage, install nothing extra,
and record the new hash as though it had. **Optional categories cannot be
re-applied by `panama update` at all**, because which ones this machine chose is
never recorded — the interview's answers are transient by design. Adding to a
category you already have is `panama apps`, which is what it is for. This is a
known limitation, not an oversight.
The hash is written **only after the stage succeeds**, mirroring the rule
`panama-migrate` already documents for its markers: a step that did not complete
has not happened, and recording it as done hides it forever.
Two consequences worth stating: a full `./install` always runs the stage and
writes the hash regardless of any recorded value, and the *first* `panama update`
after this ships will run `install-packages` once, because no hash exists yet.
Both are correct.
`panama update --packages` forces the stage regardless of the hash, forwarding
`--packages` to `install --upgrade`. This is the one flag worth adding, because
the hash cannot see a package that dnf removed behind Panama's back.
### `panama sync`
Today's `cmd_update` with the stash dance removed. Because the changes are
committed before the pull, there is nothing to stash:
```
1. Clean tree? Say so and stop. Pulling is `panama update`'s job now.
2. Show status + diff, including untracked files as new-file diffs.
3. Confirm, read a commit message, git add -A && git commit.
4. Behind upstream? git pull --rebase
5. Offer to push.
```
A rebase conflict lands on committed work, which is an ordinary and recoverable
place to be, rather than on a stash.
### Conflict handling
Verified against a throwaway repository rather than assumed:
- A conflicted `git stash pop` **keeps** the stash entry.
- Conflict markers are written into the working file. On this repository that
means directly into a live `~/.config` path via the symlink — a broken `.qml`
can cost the running shell.
- `git reset --hard HEAD` clears the markers, and `stash@{0}` survives it.
So on conflict: reset, keep the stash, and report it in the **final summary**
rather than only in scrollback, where twenty minutes of dnf output will bury it.
```
! Your local changes conflicted with what was pulled.
They are safe at stash@{0}: panama-update-1787680000
Restore with: git stash pop
```
## What does not change
- `bin/panama-migrate` — no change of any kind.
- `bin/panama-migrate-notify` — still runs `panama-migrate run` in a terminal.
- The seven stage scripts — none of them are edited. This works precisely
because they already handle absent answers.
- `panama upgrade` — same behaviour, same interview.
## Files touched
| File | Change |
|---|---|
| `install` | Argument parsing; `--upgrade` skips interview + hostname, trims `STAGES`, forces migrate `run`, alters the closing message. Packages-hash gate around `install-packages`. |
| `bin/panama` | `cmd_update` rewritten; `cmd_sync` added (the old body, minus the stash); dispatcher, usage block and header comment updated. |
| `README.md` | The upgrade-path paragraph and the command table. |
| `config/dot/quickshell/manual/05-making-it-yours.md` | The line telling the reader `panama update` will offer to commit. |
| `tests/setup/` | New contracts, below. |
## Tests
Four contracts, each pinning a decision that would otherwise rot silently:
1. **`install --upgrade` never invokes the interview.** The regression that
started this. Assert `setup/scripts/interview` is not executed.
2. **`--upgrade` runs exactly the five answer-free stages**, and never
`setup-identity` or `install-hardware`.
3. **The packages hash gates the stage**, and is not written when the stage
exits non-zero. Note this is a narrow guarantee: `install-packages` logs and
steps over individual package failures internally and still exits 0, so a
single missing package does not hold the hash back. Only a stage-level
failure does.
4. **A conflicted pop leaves no conflict markers in the tree and keeps the
stash.** Runs against a temporary repository, as the verification above did.
Contract 2 is the one that matters most over time: it fails the moment a stage
is added to `STAGES` without a decision about which path owns it.
## Verification
On this machine, after implementation:
```sh
panama test update # the new contracts
panama update # expect: no questions, one sudo prompt, packages skipped
panama update # expect: idempotent, still no questions
```
The second run is the real check. Everything must be a no-op, and
`install-packages` must be skipped on both.
## Out of scope
- Any change to how migrations work. They are correct.
- A GUI entry point for updates. The notification covers repairs; a full update
is a terminal action because it needs a password and prints a summary worth
reading.
- Rewriting the seven stage scripts. Their empty-answer behaviour is what makes
this design a flag rather than a refactor.
+152 -2
View File
@@ -2,12 +2,103 @@
# Panama's installer. Safe to re-run: every stage is idempotent, and this is
# also the upgrade path.
#
# ./install A machine being built. Asks the interview, runs
# every stage, enrolls hardware.
# ./install --upgrade A machine that already exists. Asks nothing.
#
# Two entry points, one stage list, deliberately in one file. `panama update`
# passes --upgrade; if the upgrade path owned a second copy of STAGES the two
# would drift the first time somebody added a stage to one of them, and the
# symptom would be a stage that silently never runs. Keeping the lists together
# means the decision about which path owns a new stage is made in view of the
# other one.
#
# What --upgrade changes, and nothing else:
#
# * The interview is skipped, so every PANAMA_* answer is unset and each
# stage takes its documented empty-answer path. Five of the seven need no
# answer at all; link-user falls back to the decision it recorded.
# * setup-identity and install-hardware are dropped. They exist only to
# consume interview answers -- git identity, NVIDIA, Secure Boot, firmware
# -- and every one of those is a first-run decision.
# * install-packages runs only when the package lists actually changed.
# * Migrations always run rather than baseline. See the migrations block.
#
# Everything else is shared on purpose: the sudo keepalive, the per-stage
# failure collection, migrations, the health summary and the post-upgrade hook.
set -uo pipefail
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
UPGRADE=0
FORCE_PACKAGES=0
for arg in "$@"; do
case "$arg" in
--upgrade) UPGRADE=1 ;;
--packages) FORCE_PACKAGES=1 ;;
-h|--help)
cat <<'USAGE'
usage: install [--upgrade] [--packages]
(no arguments) Build this machine. Asks the interview, runs every stage.
--upgrade Update a machine that already exists. Asks nothing, and
skips setup-identity and install-hardware.
--packages Run install-packages even when the lists are unchanged.
Only meaningful with --upgrade; a full install always runs it.
USAGE
exit 0 ;;
*)
printf 'install: unknown argument: %s\n' "$arg" >&2
printf "Run './install --help' to see what it takes.\n" >&2
exit 2 ;;
esac
done
source "$PANAMA_PATH/bin/ascii"
# ── Have the package lists changed? ──────────────────────────────────────────
#
# install-packages is the slow stage -- a dnf metadata refresh, a Flathub
# round-trip, and a transaction that resolves to "nothing to do" almost every
# time. On an upgrade it is worth running only when the lists it reads actually
# changed, so this hashes them and remembers the result.
#
# A content hash rather than a git range, because Panama is developed in place:
# a package added to a list and not yet committed must still install. A range
# check would see nothing, and the package would arrive whenever the commit
# happened to be pulled somewhere else.
#
# -maxdepth 1 excludes setup/packages/extras/. An answer-free run has an empty
# PANAMA_EXTRAS and installs no optional category, so hashing those files would
# flip the hash, run the stage, install nothing, and record the new hash as
# though it had. Optional categories cannot be re-applied by an upgrade at all
# -- which ones this machine chose is nowhere on disk, because the interview's
# answers are deliberately transient -- and `panama apps` is the tool for that.
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
PACKAGES_HASH="$STATE_DIR/packages-hash"
hash_packages() {
find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -exec sha256sum {} + \
| sort | sha256sum | cut -d' ' -f1
}
packages_needed() {
(( FORCE_PACKAGES )) && return 0
(( UPGRADE )) || return 0
[[ -r "$PACKAGES_HASH" ]] || return 0
[[ "$(hash_packages)" != "$(cat "$PACKAGES_HASH")" ]]
}
# Written only after the stage succeeds, mirroring the rule panama-migrate
# documents for its markers: a step that did not complete has not happened, and
# recording it as done hides it forever.
record_packages_hash() {
mkdir -p "$STATE_DIR"
hash_packages >"$PACKAGES_HASH"
}
# ── The interview ────────────────────────────────────────────────────────────
#
# Everything Panama needs to be told is asked here, before a single package is
@@ -22,6 +113,10 @@ source "$PANAMA_PATH/bin/ascii"
# BEFORE install-packages runs, and a missing probe tool degrades the answer
# silently to "no" -- which for Secure Boot once meant installing a driver
# that could never load. Workstation ships all four; a minimal base does not.
# Gated exactly like the interview itself: under --upgrade no questions are
# asked, so nothing here is used, and a machine that cannot install gum must
# not have that stop an upgrade that never needed it.
if (( ! UPGRADE )); then
bootstrap=()
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils)
@@ -34,6 +129,7 @@ if (( ${#bootstrap[@]} > 0 )); then
exit 1
}
fi
fi
# ── Keep the machine awake for the duration ──────────────────────────────────
# Package installation takes long enough to hit an idle lock, and being locked
@@ -85,6 +181,11 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
# nothing personal reaches a durable path, which is what keeps this repository
# something somebody else could clone. Created here rather than earlier so the
# trap that deletes it is already armed before the file exists.
#
# Skipped entirely under --upgrade. Nothing is exported, so every answer below
# is unset and each stage takes the empty-answer path it already documents --
# which is why this is a flag rather than a rewrite of seven stage scripts.
if (( ! UPGRADE )); then
PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)"
export PANAMA_ANSWERS
@@ -96,6 +197,7 @@ source "$PANAMA_ANSWERS"
export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \
PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA PANAMA_MOK_HASH \
PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS PANAMA_USER_CONTENT
fi
# One password, before anything long runs, and then never again. The stages
# call sudo dozens of times across twenty-plus minutes, and the timestamp
@@ -104,7 +206,11 @@ export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \
# there to answer. The refresher holds the timestamp open for exactly as long
# as this script lives; cleanup() kills it on every exit path, so nothing
# outlives the install with ambient credentials.
if (( UPGRADE )); then
echo "Panama needs administrator rights to apply system settings and packages."
else
echo "Panama needs administrator rights for the rest of the run."
fi
sudo -v || exit 1
( while kill -0 "$$" 2>/dev/null; do sudo -n true 2>/dev/null || true; sleep 60; done ) &
SUDO_KEEPALIVE=$!
@@ -117,14 +223,37 @@ if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then
fi
STAGES=(install-packages link-dotfiles link-user change-settings link-vicinae-scripts setup-identity install-hardware)
# The two an upgrade drops. Both exist only to act on interview answers, and
# both are first-run decisions: who you are and what hardware this is. Filtered
# by name rather than by position so reordering STAGES cannot silently change
# which stages an upgrade runs.
if (( UPGRADE )); then
upgrade_stages=()
for stage in "${STAGES[@]}"; do
case "$stage" in
setup-identity|install-hardware) continue ;;
esac
upgrade_stages+=("$stage")
done
STAGES=("${upgrade_stages[@]}")
fi
failed=()
for stage in "${STAGES[@]}"; do
script="$PANAMA_PATH/setup/scripts/$stage"
[[ -x "$script" ]] || continue
printf '\n=== %s ===\n' "$stage"
if [[ "$stage" == install-packages ]] && ! packages_needed; then
echo "The package lists have not changed since the last run; skipping."
echo "Run with --packages to install them anyway."
continue
fi
if ! "$script"; then
failed+=("$stage")
printf '!!! %s failed\n' "$stage" >&2
elif [[ "$stage" == install-packages ]]; then
record_packages_hash
fi
done
@@ -140,10 +269,17 @@ done
# already true of it -- they are marked applied without running, exactly as
# Migrations.qml stamps a pre-versioning settings file at its baseline rather
# than replaying upgrades it never needed. Otherwise the pending ones run.
#
# That inference is only sound during a real install. Under --upgrade the
# machine demonstrably existed before this run, so an absent marker directory
# means it predates migrations entirely -- exactly the machine the repairs were
# written for -- and baselining would skip every one of them forever. Every
# migration is self-guarding and a no-op where it does not apply, so running
# them is the safe direction.
migrate="$PANAMA_PATH/bin/panama-migrate"
if [[ -x "$migrate" ]]; then
printf '\n=== migrations ===\n'
if [[ -d "${XDG_STATE_HOME:-$HOME/.local/state}/panama/migrations" ]]; then
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
"$migrate" run || failed+=(migrations)
else
"$migrate" --baseline || true
@@ -172,10 +308,24 @@ hook="$PANAMA_PATH/bin/panama-hook"
[[ -x "$hook" ]] && "$hook" post-upgrade || true
printf '\n'
if (( UPGRADE )); then
retry='panama update'
else
retry='./install'
fi
if (( ${#failed[@]} == 0 )); then
if (( UPGRADE )); then
echo "Panama is up to date."
else
echo "Panama installed. Log out and choose the Hyprland session to start it."
fi
else
if (( UPGRADE )); then
printf 'Panama updated with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2
else
printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2
printf 'Re-running ./install is safe and will retry them.\n' >&2
fi
printf 'Re-running %s is safe and will retry them.\n' "$retry" >&2
exit 1
fi
+15 -10
View File
@@ -232,10 +232,10 @@ if [[ -s /etc/profile.d/nvm.sh ]]; then
source /etc/profile.d/nvm.sh
if nvm install --lts >/dev/null 2>&1; then
nvm alias default 'lts/*' >/dev/null 2>&1 || true
npm install -g pnpm >/dev/null 2>&1 || log "pnpm did not install"
npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); }
log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)"
else
log "nvm could not install Node; skipping"
log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)")
fi
set -u
else
@@ -265,7 +265,7 @@ if [[ -x "$HOME/.bun/bin/bun" ]]; then
log "Bun already installed at \"$HOME/.bun/bin/bun\""
else
log "Installing Bun via curl..."
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || log "Bun install failed; skipping"
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); }
fi
# Claude Code: Anthropic's CLI. The official installer keeps itself updated
@@ -274,7 +274,7 @@ if command -v claude >/dev/null 2>&1; then
log "Claude Code already installed at \"$(command -v claude)\""
else
log "Installing Claude Code via the official installer..."
curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || log "Claude Code install failed; skipping"
curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || { log "Claude Code install failed; skipping"; softly_failed+=("Claude Code"); }
fi
# Claude Desktop: Anthropic ships macOS and Windows only, so this is a community
@@ -307,7 +307,7 @@ else
fi
log "Installing Claude Desktop..."
sudo dnf install -y claude-desktop-extra > /dev/null \
|| log "Claude Desktop install failed; skipping"
|| { log "Claude Desktop install failed; skipping"; softly_failed+=("Claude Desktop"); }
fi
# RustDesk: remote desktop. The flatpak cannot register the root-owned system
@@ -328,9 +328,9 @@ else
log "Installing RustDesk from $rustdesk_url"
# The RPM ships rustdesk.service already enabled, which is what provides
# unattended access; Panama deliberately does not start it a second time.
sudo dnf install -y "$rustdesk_url" > /dev/null || log "RustDesk install failed; skipping"
sudo dnf install -y "$rustdesk_url" > /dev/null || { log "RustDesk install failed; skipping"; softly_failed+=("RustDesk"); }
else
log "Could not resolve a RustDesk release; skipping"
log "Could not resolve a RustDesk release; skipping"; softly_failed+=("RustDesk")
fi
fi
@@ -388,12 +388,12 @@ install_extra_category() {
if [[ -n "${dnf_packages// /}" ]]; then
log "Installing $name: $dnf_packages"
sudo dnf install -y $dnf_packages > /dev/null || log "Some $name packages did not install"
sudo dnf install -y $dnf_packages > /dev/null || { log "Some $name packages did not install"; softly_failed+=("$name packages"); }
fi
if [[ -n "${flatpak_ids// /}" ]]; then
log "Installing $name flatpaks: $flatpak_ids"
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo > /dev/null
sudo flatpak install -y flathub $flatpak_ids > /dev/null || log "Some $name flatpaks did not install"
sudo flatpak install -y flathub $flatpak_ids > /dev/null || { log "Some $name flatpaks did not install"; softly_failed+=("$name flatpaks"); }
fi
}
@@ -416,5 +416,10 @@ done
if (( ${#softly_failed[@]} > 0 )); then
log "Installed, but these were stepped over:"
printf ' - %s\n' "${softly_failed[@]}"
log "None of them stops the desktop. Re-run this stage to try them again."
log "None of them stops the desktop, but this run is not recorded as"
log "complete, so the next 'panama update' tries them again."
# A step that did not complete has not happened. Exiting non-zero is what
# keeps ./install from stamping the packages hash over the gaps -- stamped,
# they would never be retried (the hash-skip would say nothing changed).
exit 1
fi
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env bash
# `panama update`: the routine command, and the promises that make it routine.
#
# An update that asks questions is an update nobody runs, and a machine nobody
# updates drifts until the next reinstall. So the properties below are the
# whole point of the command rather than details of it:
#
# 1. --upgrade NEVER runs the interview. This is the regression that started
# the redesign: ./install asked the full first-install questionnaire every
# time, including on a machine whose answers could not have changed.
# 2. --upgrade runs exactly the stages that need no answer, and never
# setup-identity or install-hardware. Both act only on interview answers.
# 3. install-packages is gated on a content hash of the package lists, and
# the hash is NOT recorded when the stage fails -- the same rule
# panama-migrate applies to its markers, for the same reason.
# 4. A conflicted `git stash pop` leaves no conflict markers in the tree.
# Every dotfile here is a symlink into the checkout, so a half-merged file
# is not something to fix later: it is live in ~/.config immediately, and
# a broken .qml costs the running shell.
#
# Driven against fixture stages in a throwaway PANAMA_PATH, with sudo and
# gsettings shimmed, so this never touches the machine running it.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
installer="$repo_dir/install"
panama="$repo_dir/bin/panama"
findings=()
note() { findings+=("$1"); }
[[ -x "$installer" ]] || { printf 'update command contract: no installer at %s\n' "$installer" >&2; exit 1; }
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
trap 'rm -rf "$tmp"' EXIT
STAGE_NAMES=(install-packages link-dotfiles link-user change-settings
link-vicinae-scripts setup-identity install-hardware)
# A PANAMA_PATH that looks enough like the real one for install to run, and
# records what it was asked to do instead of doing it.
build_fixture() {
local root="$1" packages_rc="${2:-0}"
rm -rf "$root"
mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \
"$root/config/dot/quickshell/scripts"
cp "$installer" "$root/install"
: >"$root/bin/ascii"
printf 'base-package\n' >"$root/setup/packages/base"
local stage
for stage in "${STAGE_NAMES[@]}"; do
cat >"$root/setup/scripts/$stage" <<EOF
#!/usr/bin/env bash
printf '%s\n' "$stage" >>"\$PANAMA_RAN"
EOF
chmod +x "$root/setup/scripts/$stage"
done
# The one stage whose exit code the caller wants to control.
cat >"$root/setup/scripts/install-packages" <<EOF
#!/usr/bin/env bash
printf 'install-packages\n' >>"\$PANAMA_RAN"
exit $packages_rc
EOF
chmod +x "$root/setup/scripts/install-packages"
cat >"$root/setup/scripts/interview" <<'EOF'
#!/usr/bin/env bash
printf 'interview\n' >>"$PANAMA_RAN"
: >"$PANAMA_ANSWERS"
EOF
chmod +x "$root/setup/scripts/interview"
cat >"$root/bin/panama-migrate" <<'EOF'
#!/usr/bin/env bash
printf 'migrate %s\n' "${1:-run}" >>"$PANAMA_RAN"
EOF
chmod +x "$root/bin/panama-migrate"
cat >"$root/config/dot/quickshell/scripts/panama-doctor" <<'EOF'
#!/usr/bin/env bash
printf 'doctor\n' >>"$PANAMA_RAN"
EOF
chmod +x "$root/config/dot/quickshell/scripts/panama-doctor"
# Nothing that reaches the real machine. sudo would prompt in a test run,
# and gsettings would genuinely change the tester's screensaver.
mkdir -p "$root/shim"
cat >"$root/shim/sudo" <<'EOF'
#!/usr/bin/env bash
[[ "${1:-}" == -v || "${1:-}" == -n ]] && exit 0
exit 0
EOF
cat >"$root/shim/gsettings" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
cat >"$root/shim/hostnamectl" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$root/shim"/*
}
# Run the fixture installer and echo what ran, one stage per line.
run_install() {
local root="$1"; shift
: >"$root/ran"
PATH="$root/shim:$PATH" \
PANAMA_PATH="$root" \
PANAMA_RAN="$root/ran" \
XDG_STATE_HOME="$root/state" \
bash "$root/install" "$@" >"$root/out" 2>&1
printf '%s' "$?" >"$root/rc"
cat "$root/ran"
}
# ── 1. The interview never runs on an upgrade ────────────────────────────────
build_fixture "$tmp/a"
ran="$(run_install "$tmp/a" --upgrade)"
if grep -qx 'interview' <<<"$ran"; then
note 'install --upgrade ran the interview, which is the whole regression this prevents'
fi
# And the control: a real install must still ask.
build_fixture "$tmp/b"
ran_install="$(run_install "$tmp/b")"
if ! grep -qx 'interview' <<<"$ran_install"; then
note 'a plain ./install no longer asks the interview, so a new machine is never configured'
fi
# ── 2. Exactly the answer-free stages ────────────────────────────────────────
for stage in install-packages link-dotfiles link-user change-settings link-vicinae-scripts; do
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
done
for stage in setup-identity install-hardware; do
grep -qx "$stage" <<<"$ran" \
&& note "install --upgrade ran $stage, which exists only to act on interview answers"
done
# A stage added to STAGES without a decision about which path owns it shows up
# here, because this list is written down twice on purpose.
mapfile -t declared < <(python3 - "$installer" <<'PY'
import re, sys
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES="))
print("\n".join(re.findall(r"[\w-]+", line)[1:]))
PY
)
for stage in "${declared[@]}"; do
printf '%s\n' "${STAGE_NAMES[@]}" | grep -qx "$stage" \
|| note "install declares a stage this contract has never heard of: $stage"
done
# ── 3. The packages hash gates the stage, and a failure does not record it ───
# Second run, nothing changed: the stage must be skipped.
ran_again="$(run_install "$tmp/a" --upgrade)"
grep -qx 'install-packages' <<<"$ran_again" \
&& note 'install-packages ran again with the package lists unchanged'
# --packages overrides the hash.
ran_forced="$(run_install "$tmp/a" --upgrade --packages)"
grep -qx 'install-packages' <<<"$ran_forced" \
|| note '--packages did not force install-packages to run'
# A changed list brings the stage back.
printf 'another-package\n' >>"$tmp/a/setup/packages/base"
ran_changed="$(run_install "$tmp/a" --upgrade)"
grep -qx 'install-packages' <<<"$ran_changed" \
|| note 'a changed package list did not bring install-packages back'
# A failing stage must not record the hash, or the failure is hidden forever.
build_fixture "$tmp/c" 1
run_install "$tmp/c" --upgrade >/dev/null
if [[ -r "$tmp/c/state/panama/packages-hash" ]]; then
note 'install-packages failed but its hash was recorded, so it will never be retried'
fi
# A full install always runs the stage, whatever any recorded hash says.
build_fixture "$tmp/d"
run_install "$tmp/d" --upgrade >/dev/null
ran_full="$(run_install "$tmp/d")"
grep -qx 'install-packages' <<<"$ran_full" \
|| note 'a full ./install skipped install-packages because of a recorded hash'
# ── 4. A conflicted pop never leaves markers in a live config ────────────────
#
# Two halves. The first checks that git still behaves the way the design
# depends on; the second checks that panama acts on it. Neither is worth much
# without the other.
conflict="$tmp/conflict"
mkdir -p "$conflict"
(
set -e
cd "$conflict"
git init -q up && cd up
git config user.email contract@panama && git config user.name contract
printf 'one\n' >f; git add -A; git commit -qm one
cd "$conflict"; git clone -q up work; cd work
git config user.email contract@panama && git config user.name contract
cd "$conflict/up"; printf 'upstream\n' >f; git commit -qam two
cd "$conflict/work"; printf 'local\n' >f
git stash push --include-untracked -m contract >/dev/null
git pull -q --ff-only
git stash pop >/dev/null 2>&1 && exit 3 # a conflict was the point
git reset -q --hard HEAD
[[ -n "$(git stash list)" ]] || exit 4 # the stash must survive
grep -q '<<<<<<<' f && exit 5 # and no markers may remain
exit 0
) >/dev/null 2>&1
case $? in
0) ;;
3) note 'the conflict fixture did not conflict, so this check proves nothing' ;;
4) note 'git no longer keeps the stash after a conflicted pop; panama update would lose work' ;;
5) note 'git reset --hard left conflict markers behind' ;;
*) note 'the stash conflict fixture could not be built' ;;
esac
# panama update must act on that: reset the tree rather than leave the markers.
body="$(sed -n '/^cmd_update()/,/^}/p' "$panama")"
if [[ -z "$body" ]]; then
note 'bin/panama has no cmd_update to check'
else
grep -q 'git stash push' <<<"$body" \
|| note 'cmd_update does not stash local changes, so a pull can fail on a dirty tree'
grep -q 'git reset --hard' <<<"$body" \
|| note 'cmd_update does not reset after a failed pop, so conflict markers reach ~/.config'
grep -q 'git pull --ff-only' <<<"$body" \
|| note 'cmd_update does not pull with --ff-only'
grep -q -- '--upgrade' <<<"$body" \
|| note 'cmd_update does not hand off to install --upgrade, so it would ask the interview'
fi
# The two verbs stay separate: sync must never run the installer.
sync_body="$(sed -n '/^cmd_sync()/,/^}/p' "$panama")"
if [[ -z "$sync_body" ]]; then
note 'bin/panama has no cmd_sync, so the git workflow lost its home'
else
grep -q 'install' <<<"$sync_body" \
&& note 'cmd_sync runs the installer; committing and updating are separate jobs'
grep -q 'git stash' <<<"$sync_body" \
&& note 'cmd_sync stashes, which the commit-before-pull order exists to avoid'
fi
if (( ${#findings[@]} > 0 )); then
printf 'update command contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'update command contract: PASS\n'