From 34bf39be863118b57803670daaf0f69034739cdf Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 16:02:37 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20the=20versioned=20install=20=E2=80=94?= =?UTF-8?q?=20upgrade=20and=20uninstall=20as=20first-class=20flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README's install/upgrade/uninstall sections rewritten for the versioned layout (side-by-side installs, 'box use' flips, 'box uninstall' replaces the rm -rf prose); CHANGELOG Unreleased entries; the plan doc. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 58 ++++++++ README.md | 82 ++++++++--- docs/plans/2026-07-18-versioned-install.md | 154 +++++++++++++++++++++ 3 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 docs/plans/2026-07-18-versioned-install.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e4d22..bac2f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ which records not just what changed but what each drill run proved. ## Unreleased +### Added + +- **Versioned installs** (#66's stance, made livable) — install.sh now lands + each version side by side at `/versions/` (its own `VERSION` + + `INSTALLED_FROM`), with a `current` symlink tracking the default and + `$BINDIR/box` riding the chain, the way plenty of CLIs manage theirs. New + verbs: `box versions` (lists installs, marks the current default and the + running tree), `box use ` (flips the default, converges the PATH + symlinks, and *asserts the effective result* — `current` must resolve to + the asked-for version and the chain's `box --version` must answer it). + Re-running the installer with an installed version is a converging no-op + (`BOX_REINSTALL=1` replaces that version's tree); a **new** version installs + side-by-side and flips `current` only when no boxes exist — under existing + boxes the flip is refused loudly, naming the boxes (#66: never change + versions under a user's boxes; `box use` keeps the same refusal). A + pre-0.7.0 **flat tree is migrated in place** (two renames, the operator's + tree preserved bit for bit), so upgrading from 0.6.0 is seamless; a stale + or dangling `$BINDIR/box` is healed instead of wedging the install; and the + installer warns when the *other* tier's install (/opt/box vs ~/.local) + coexists, since PATH order decides which wins. +- **A real uninstall** — `box uninstall [] [--all] [--purge-host]` + replaces the "rm -rf two paths" prose. One version: refuses the current one + (`box use` off it first). Everything: runs in the safe order — refuses + while boxes exist (naming them) unless `--purge-host` runs teardown-host + first — then removes every version, the `current` and PATH symlinks, and + the legacy claudebox crumbs (both name generations), and **ends with an + absence assert**: every removed path is re-checked, and any survivor makes + it exit 1 as `uninstall INCOMPLETE` naming the leftovers (the + `revoke --purge` discipline). `teardown-host.sh` gains `--yes`/`BOX_YES=1` + for automation and now points at `box uninstall` when done. +- **`BOX_INSTALL_SOURCE=`** — installs from a local tree, + bypassing the download. CI's rehearsal job now installs via install.sh + itself (proving the installer under review, not a `cp -r` mimic of it), and + ends with an **uninstall drill**: grant + `revoke --purge` a throwaway + user, `teardown-host`, `box uninstall --all`, then assert **zero residue** + — no networks, profiles, ACLs, nft tables, systemd units, files or + symlinks. +- **test/cli.sh drives real installs** — still dependency-free, non-root, no + daemon: `BOX_INSTALL_SOURCE` + throwaway `BOX_HOME`/`BOX_BIN` roots and a + fake `incus` on PATH (`$FAKE_BOXES`) turn layout, chain, no-op/converge, + reinstall, side-by-side upgrade, the three #66 refusals (install flip, + `use`, `uninstall` — boxes named), flat-tree migration, symlink healing, + single-version and zero-residue uninstalls, and the `INCOMPLETE` scream + into *driven* tests instead of greps (154 checks). + +### Fixed + +- **`revoke --purge` re-checks the incus-user state** — the purge removed + `/var/lib/incus/users/` without ever asserting its absence, the one + path its own absence block did not cover; and the stat now rides + `$SUDO test -d` (`/var/lib/incus` is not traversable by a non-root admin, + so a bare `[ -d ]` answered "absent" for a directory that was there). +- **A wedged `$BINDIR/box` no longer blocks installing** — the old + no-op-if-installed check keyed off the symlink's existence OR the tree's, + so a stale symlink (or a half-removed tree) could fake "already installed" + forever. Installed-ness is now judged from `versions/` itself; symlinks + are converged with `ln -sfn`, never trusted as the signal. + ## 0.6.0 — 2026-07-18 ### Added diff --git a/README.md b/README.md index 6bbb2ba..d73db51 100644 --- a/README.md +++ b/README.md @@ -42,29 +42,48 @@ design rationale. curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash ``` -It asks first — **"Install box?"** — then, if box is not already installed, -downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and -asks a second question: **"Set up this machine as a box host now?"** Say yes and -it builds the whole isolation stack for you (it may ask for `sudo`); say no and -you can run `box setup-host` later. (No `git clone` needed.) +It asks first — **"Install box?"** — then downloads the tree into a +**versioned** install (the way plenty of CLIs manage theirs), links `box` onto +your `PATH`, and on a fresh host asks a second question: **"Set up this +machine as a box host now?"** Say yes and it builds the whole isolation stack +for you (it may ask for `sudo`); say no and you can run `box setup-host` +later. (No `git clone` needed.) -**Re-running is a safe no-op.** If box is already installed, the installer tells -you so and changes nothing — a stray re-run can never clobber your install or -rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall -what you have and install fresh. Preserve any boxes first — `box down `, -copy out anything you need (a portable `box export` is +The layout, under the install root (`~/.local/share/box`, or `/opt/box` for a +root install): + +``` +versions// one full tree per installed version +current -> versions/ the tracked default +$BINDIR/box -> current/bin/box the PATH entry, riding the chain +``` + +**Re-running is a safe converge.** Installing a version you already have +changes nothing and says so (`BOX_REINSTALL=1` replaces that version's tree); +a stray re-run can never clobber your install or rebuild the stack under your +boxes. Installing a **new** version lands it side by side and flips `current` +only when you have **no boxes** — under existing boxes the flip is refused +(never change versions under a user's boxes, +[#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a +deliberate act: preserve what you care about — `box down `, copy out +anything you need (a portable `box export` is [#70](https://github.com/heavy-duty/box/issues/70)), then `box rm ` (which deletes the box _and_ its snapshots) — then: ```sh -rm -rf ~/.local/share/box ~/.local/bin/box # uninstall -curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash +box versions # what is installed, which is current, which is running +box use # flip the default (same refusal while boxes exist) ``` -A version-aware upgrade that migrates boxes instead of asking you to is -[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs -(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1` -declines the host-setup step. +A pre-0.7.0 flat install is migrated into `versions/` automatically on the +next installer run — the tree is moved, not re-downloaded, and your boxes are +untouched. A version-aware upgrade that migrates boxes instead of asking you +to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended +installs (CI, images), `BOX_YES=1` answers every prompt yes, +`BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and +`BOX_INSTALL_SOURCE=` installs from a local tree instead of +downloading (how CI proves the installer under review, and how the drill can +install an unpushed branch). ### Global vs per-user install @@ -83,7 +102,9 @@ box's tree is _executed by other users_ — so it cannot hide in one user's home `BOX_HOME` / `BOX_BIN` override the destination on either path. A per-user install under `/root` would be `0700` and unreadable to everyone else — which -is exactly the bug the root branch fixes. +is exactly the bug the root branch fixes. When both tiers are installed, PATH +order decides which `box` wins — the installer warns when it sees the other +tier's tree. ## One-time host setup (Ubuntu 24.04 / Debian 13) @@ -206,8 +227,7 @@ form; flags win). The template's identity (name, user) is stamped onto the insta so `shell`, `exec` and `tmux` land in the right user — and a clone still knows, because `incus copy` carries the metadata. -## Log in once, reuse via snapshotsrm -rf ~/.local/share/box ~/.local/bin/box # per-user install -sudo rm -rf /opt/box /usr/local/bin/box # global (root) install +## Log in once, reuse via snapshots Because every fresh box is creds-free, re-authenticating each time would be toil. Snapshot an authenticated box and clone from it instead: @@ -388,13 +408,29 @@ documentation, not a host-executed script. See ## Uninstall +`box uninstall` is the real uninstall, and it runs in the safe order — boxes +first, then the stack, then the tree — and **ends with an absence assert**: +every path it removed is re-checked, and any survivor makes it exit 1 naming +the leftovers instead of reporting a clean uninstall that wasn't (the same +discipline as `box revoke --purge`). + ```sh -box teardown-host # boxes, network, ACL, profile, firewall -box teardown-host --purge-incus # ...and Incus itself -rm -rf ~/.local/share/box ~/.local/bin/box # per-user install -sudo rm -rf /opt/box /usr/local/bin/box # global (root) install +box uninstall # one non-current version (side-by-side cleanup) +box uninstall --all --purge-host # everything: teardown-host (all boxes, the + # boxnet stack, the firewall), then every + # version, the symlinks, legacy claudebox crumbs +box uninstall # just the install — refuses while boxes exist + # (and names them); run teardown-host first, + # or use --purge-host ``` +The full-removal order on a multi-user host: `box revoke --purge` each +granted user (it asserts its own zero-residue, including the incus-user state +under `/var/lib/incus/users/`), then `box teardown-host` (add `--purge-incus` +to drop Incus itself, `--yes`/`BOX_YES=1` for automation), then +`box uninstall`. CI drills exactly this sequence and asserts zero residue — +no networks, profiles, nft tables, systemd units, files or symlinks. + ## Non-goals - **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask diff --git a/docs/plans/2026-07-18-versioned-install.md b/docs/plans/2026-07-18-versioned-install.md new file mode 100644 index 0000000..07d1bc1 --- /dev/null +++ b/docs/plans/2026-07-18-versioned-install.md @@ -0,0 +1,154 @@ +# Versioned installs, and a real uninstall (0.7.0 core) + +**Status: implemented and tested.** 154/154 in `test/cli.sh` (which now +*drives* real installer runs, not greps of them), shellcheck clean, and CI's +rehearsal job installs via `install.sh` itself and ends with a zero-residue +uninstall drill on a live Incus. This doc records the design and why each +decision fell where it did. + +## What was asked + +Two maintainer requests, one PR: + +1. **Install should be versioned** — each box version goes to its own folder + and a tracked default names the one you run, like plenty of CLIs manage + theirs. Before this, `install.sh` refused to touch an existing install at + all: changing versions meant uninstalling by hand (`rm -rf` two paths from + the README) and re-running the installer. +2. **Uninstall is flaky** — there was no uninstall verb at all, only prose; + `teardown-host.sh` deliberately leaves the install tree; and nothing + encoded the safe full-removal order (revoke users → teardown-host → remove + trees/symlinks). Add thorough tests for uninstall, and for grant/revoke + that they are clean. + +## The layout + +``` +/ /opt/box (root) or ~/.local/share/box (user); + BOX_HOME overrides — both unchanged from #71 + versions// one full tree per version, each with its own + VERSION + INSTALLED_FROM + current -> versions/ the tracked default (a relative symlink, so the + root can move as a unit) +$BINDIR/box -> /current/bin/box +``` + +The version key is the tree's own `VERSION` file — the identity of what was +installed, and the name `box versions` lists. `bin/box` needed **no change** +to run from here: line 8 already derives `$root` via `readlink -f`, which +resolves the whole `$BINDIR/box → current → versions/` chain, so +`VERSION`, `templates/`, `host/` and `drill/` all resolve inside the version +tree that is actually running. That same fact is how the new verbs detect +their world: a versioned install always runs from `.../versions/`; a git +checkout does not, and the verbs refuse instead of uninstalling somebody's +working copy. + +## Install semantics (#66's stance, kept — at the flip) + +#66 established: a stray installer re-run must never clobber a working +install or rebuild the stack under existing boxes. The old enforcement was a +blanket "refuse if anything is installed", which also blocked upgrades. The +versioned layout splits the two concerns: + +- **Same version present** → converging no-op ("already installed", exit 0); + `BOX_REINSTALL=1` replaces that version's tree via two renames (never a + partial overlay). A converge/reinstall of a non-current version never + moves the default — switching is `box use`, a deliberate act. +- **Different version** → installs side-by-side, then flips `current` **only + when no boxes exist**. With boxes present (both tag generations, checked at + the caller's tier via a shared `existing_boxes()` — byte-identical in + `install.sh` and `bin/box`, diffed by the tests so the two #66 stances + cannot drift), the flip is refused loudly, the boxes are *named*, and the + operator is pointed at the remedy: down/copy-out/rm, then `box use `. + A daemon that is absent or not answering has no boxes to protect — the + stance guards boxes, not daemons. +- **Pre-0.7.0 flat tree** → migrated before anything else: `mv` the root + aside, `mkdir versions/`, `mv` it to `versions/`, link + `current` and `$BINDIR/box`. Two renames inside one parent directory — no + copy, no window with no install, the operator's tree preserved bit for bit + (the tests assert the migrated tree's own `INSTALLED_FROM` survives). +- **Wedged symlinks** → healed, never trusted. The old no-op check keyed off + `$BINDIR/box` *or* `$DEST/bin/box` existing, so a stale symlink (or a + half-removed tree) faked "already installed" forever. Installed-ness is now + judged from `versions/` itself; `ln -sfn` converges the links. +- **Tier coexistence** → a root and a per-user install shadow each other by + PATH order alone; the installer warns when it sees the other tier's tree. +- Host setup is offered on **fresh** hosts only — an upgraded host has made + that decision (and may have live boxes the stack must not be rebuilt + under); `box setup-host` re-applies stack changes deliberately. + +`BOX_INSTALL_SOURCE=` bypasses the download (a directory is +tar-copied with `--exclude=.git`). This exists for CI and the drill — the +code under review is what lands — and it is what turned the test suite's +install coverage from greps into real runs. + +## The new verbs + +Table rows like every other verb (the CMDS table stays the single source of +truth); `uninstall` joins the host-verb flag passthrough so `--all` / +`--purge-host` reach it. + +- `box versions` — lists `versions/*`, marking the current default and the + tree answering the command (they differ when another install shadows yours + on PATH). +- `box use ` — same existing-boxes refusal as the installer's flip + (shared helper, boxes named), then repoints `current`, converges every PATH + symlink that resolves into this install root (never one that is somebody + else's), and **asserts the effective result**: `current` must resolve to + the asked-for version and `current/bin/box --version` must answer it. A + flip that "worked" while the operator still runs the old tree is exactly + the flakiness this verb exists to end. +- `box uninstall [] [--all] [--purge-host]` — + - one version: refuses the current one; removes the dir; re-checks it. + - full: the safe order — refuse while boxes exist (naming them) unless + `--purge-host` runs `teardown-host.sh` first (its own confirmation; a + note names granted users' surviving projects and `revoke --purge` as the + clean path); confirm (`--force` / `BOX_YES=1` — installer-family consent, + deliberately *not* the lifecycle `confirm()`, which must never + auto-accept from the environment); gather the removal set (root, every + PATH symlink pointing into it, claudebox crumbs of both name + generations); remove; then **the absence assert**: every path re-checked + for file/dir/symlink existence, any survivor → exit 1 + `uninstall INCOMPLETE` naming the leftovers. `rm`'s exit code is not the + verdict — the re-check is (a half-removed tree is INCOMPLETE, not a + crash). + +## Grant/revoke cleanliness + +Reading `revoke-user.sh` against its own absence assert found the gap: the +purge removes `/var/lib/incus/users/` but never re-checks it — and the +stat was a bare `[ -d ]`, which lies for a non-root admin (`/var/lib/incus` +is not traversable, so the directory reads as absent while it is there). +Both fixed: the check rides `$SUDO test -d`, and the absence block now covers +the state dir. Grepped-and-guarded in `test/cli.sh`; drilled live in CI. + +## Tests (the heart of this PR) + +`test/cli.sh` stays dependency-free, non-root, daemon-free. New machinery: a +fake `incus` on PATH whose `list` prints `$FAKE_BOXES`, throwaway +`BOX_HOME`/`BOX_BIN` roots, and fabricated second/third sources with +different `VERSION`s. Driven end to end: fresh layout + chain +(`box --version` through both symlinks), no-op/canary, `BOX_REINSTALL`, +side-by-side + no-boxes flip, all three #66 refusals (install flip, `use`, +`uninstall` — boxes named, remedies named), `versions` markers, `use` +flip-and-assert, flat-tree migration (alone, and combined with an upgrade), +dangling- and stale-symlink healing, single-version uninstall (current +refused), full uninstall with planted legacy crumbs and a zero-residue +assert (files *and* symlinks *and* legacy names), the INCOMPLETE scream +(a chmod-pinned survivor), and refusals from a working tree. The existing +DEST/BINDIR-branch tests are kept unchanged (the branch itself is unchanged). + +CI's rehearsal job now installs via `install.sh` +(`BOX_INSTALL_SOURCE=$GITHUB_WORKSPACE`), asserts the layout it left, runs +the stack from `/opt/box/current/...`, and appends the uninstall drill: +grant + `revoke --purge` a throwaway user (asserting the incus-user state +dir is gone), `teardown-host` (new `--yes`/`BOX_YES` support), `box +uninstall --all`, then zero residue — networks, profiles, ACLs, nft tables, +systemd units, files, symlinks, both name generations. + +## What this is not + +- Not #67: boxes still do not migrate across versions — this PR delivers the + version-agnostic upgrade *path* (side-by-side installs, an explicit flip); + data migration remains #67. +- Not a release: `VERSION` is untouched (a release PR bumps it).