docs: the versioned install — upgrade and uninstall as first-class flows

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 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-18 16:02:37 +00:00
parent 82f7f9d51a
commit 34bf39be86
3 changed files with 271 additions and 23 deletions

View file

@ -5,6 +5,64 @@ which records not just what changed but what each drill run proved.
## Unreleased ## Unreleased
### Added
- **Versioned installs** (#66's stance, made livable) — install.sh now lands
each version side by side at `<root>/versions/<v>` (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 <version>` (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 [<version>] [--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=<dir-or-tarball>`** — 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/<uid>` 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/<v>` itself; symlinks
are converged with `ln -sfn`, never trusted as the signal.
## 0.6.0 — 2026-07-18 ## 0.6.0 — 2026-07-18
### Added ### Added

View file

@ -42,29 +42,48 @@ design rationale.
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash 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, It asks first — **"Install box?"** — then downloads the tree into a
downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and **versioned** install (the way plenty of CLIs manage theirs), links `box` onto
asks a second question: **"Set up this machine as a box host now?"** Say yes and your `PATH`, and on a fresh host asks a second question: **"Set up this
it builds the whole isolation stack for you (it may ask for `sudo`); say no and machine as a box host now?"** Say yes and it builds the whole isolation stack
you can run `box setup-host` later. (No `git clone` needed.) 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 The layout, under the install root (`~/.local/share/box`, or `/opt/box` for a
you so and changes nothing — a stray re-run can never clobber your install or root install):
rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall
what you have and install fresh. Preserve any boxes first — `box down <box>`, ```
copy out anything you need (a portable `box export` is versions/<version>/ one full tree per installed version
current -> versions/<v> 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 <box>`, copy out
anything you need (a portable `box export` is
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>` [#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
(which deletes the box _and_ its snapshots) — then: (which deletes the box _and_ its snapshots) — then:
```sh ```sh
rm -rf ~/.local/share/box ~/.local/bin/box # uninstall box versions # what is installed, which is current, which is running
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash box use <version> # flip the default (same refusal while boxes exist)
``` ```
A version-aware upgrade that migrates boxes instead of asking you to is A pre-0.7.0 flat install is migrated into `versions/` automatically on the
[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs next installer run — the tree is moved, not re-downloaded, and your boxes are
(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1` untouched. A version-aware upgrade that migrates boxes instead of asking you
declines the host-setup step. 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=<dir-or-tarball>` 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 ### 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 `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 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) ## 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 so `shell`, `exec` and `tmux` land in the right user — and a clone still
knows, because `incus copy` carries the metadata. knows, because `incus copy` carries the metadata.
## Log in once, reuse via snapshotsrm -rf ~/.local/share/box ~/.local/bin/box # per-user install ## Log in once, reuse via snapshots
sudo rm -rf /opt/box /usr/local/bin/box # global (root) install
Because every fresh box is creds-free, re-authenticating each time would be Because every fresh box is creds-free, re-authenticating each time would be
toil. Snapshot an authenticated box and clone from it instead: toil. Snapshot an authenticated box and clone from it instead:
@ -388,13 +408,29 @@ documentation, not a host-executed script. See
## Uninstall ## 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 ```sh
box teardown-host # boxes, network, ACL, profile, firewall box uninstall <version> # one non-current version (side-by-side cleanup)
box teardown-host --purge-incus # ...and Incus itself box uninstall --all --purge-host # everything: teardown-host (all boxes, the
rm -rf ~/.local/share/box ~/.local/bin/box # per-user install # boxnet stack, the firewall), then every
sudo rm -rf /opt/box /usr/local/bin/box # global (root) install # 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 <user> --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 ## Non-goals
- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask - **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask

View file

@ -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
```
<root>/ /opt/box (root) or ~/.local/share/box (user);
BOX_HOME overrides — both unchanged from #71
versions/<version>/ one full tree per version, each with its own
VERSION + INSTALLED_FROM
current -> versions/<v> the tracked default (a relative symlink, so the
root can move as a unit)
$BINDIR/box -> <root>/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/<v>` 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/<v>`; 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 <v>`.
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/<its-VERSION>`, 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/<v>` 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=<dir-or-tarball>` 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 <version>` — 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 [<version>] [--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/<uid>` 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).