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:
@@ -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
@@ -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 1–4 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.
|
||||
@@ -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,17 +113,22 @@ 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.
|
||||
bootstrap=()
|
||||
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
|
||||
command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils)
|
||||
command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil)
|
||||
command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd)
|
||||
if (( ${#bootstrap[@]} > 0 )); then
|
||||
# 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)
|
||||
command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil)
|
||||
command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd)
|
||||
if (( ${#bootstrap[@]} > 0 )); then
|
||||
echo "Installing what the setup questions are built on: ${bootstrap[*]}"
|
||||
sudo dnf install -y "${bootstrap[@]}" >/dev/null || {
|
||||
echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Keep the machine awake for the duration ──────────────────────────────────
|
||||
@@ -85,17 +181,23 @@ 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.
|
||||
PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)"
|
||||
export PANAMA_ANSWERS
|
||||
#
|
||||
# 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
|
||||
|
||||
if ! "$PANAMA_PATH/setup/scripts/interview"; then
|
||||
if ! "$PANAMA_PATH/setup/scripts/interview"; then
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=/dev/null
|
||||
source "$PANAMA_ANSWERS"
|
||||
export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \
|
||||
fi
|
||||
# shellcheck source=/dev/null
|
||||
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.
|
||||
echo "Panama needs administrator rights for the rest of the run."
|
||||
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 (( ${#failed[@]} == 0 )); then
|
||||
echo "Panama installed. Log out and choose the Hyprland session to start it."
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+258
@@ -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'
|
||||
Reference in New Issue
Block a user