forked from heavy-duty/box
Merge pull request #79 from dan-claude-bot/feat/versioned-install
feat: versioned installs, and a real uninstall
This commit is contained in:
commit
d879530a7a
10 changed files with 1197 additions and 129 deletions
60
.github/workflows/ci.yml
vendored
60
.github/workflows/ci.yml
vendored
|
|
@ -40,20 +40,60 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus
|
||||||
- name: global install, from this checkout (the #71 layout)
|
- name: global install, via install.sh itself (the #71 layout, versioned)
|
||||||
# Not install.sh: it installs a RELEASE (REPO@REF), and CI must prove
|
# install.sh, not a cp -r mimic: BOX_INSTALL_SOURCE points it at this
|
||||||
# the code under review. Same tree, same layout, same a+rX.
|
# checkout, so CI proves the INSTALLER under review — the versioned
|
||||||
|
# layout, the current symlink, the PATH chain — not a hand-built
|
||||||
|
# imitation of it. Setup is run explicitly in the next step, so its
|
||||||
|
# output is its own CI section.
|
||||||
run: |
|
run: |
|
||||||
sudo cp -r . /opt/box
|
sudo BOX_YES=1 BOX_SKIP_SETUP_HOST=1 BOX_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh
|
||||||
sudo rm -rf /opt/box/.git
|
# assert what landed: the layout, the chain, and that it answers
|
||||||
sudo chmod -R a+rX /opt/box
|
readlink -f /usr/local/bin/box | grep '^/opt/box/versions/'
|
||||||
sudo ln -sf /opt/box/bin/box /usr/local/bin/box
|
/usr/local/bin/box --version
|
||||||
|
/usr/local/bin/box versions
|
||||||
- name: setup-host
|
- name: setup-host
|
||||||
run: sudo bash /opt/box/host/setup-host.sh
|
run: sudo bash /opt/box/current/host/setup-host.sh
|
||||||
- name: doctor — the baseline is provable before anything is judged
|
- name: doctor — the baseline is provable before anything is judged
|
||||||
run: sudo BOX_TIER=admin bash /opt/box/drill/doctor.sh
|
run: sudo BOX_TIER=admin bash /opt/box/current/drill/doctor.sh
|
||||||
- name: multi-user rehearsal (criteria a-l, container mode)
|
- name: multi-user rehearsal (criteria a-l, container mode)
|
||||||
run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/drill/multiuser.sh --yes --container
|
run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/current/drill/multiuser.sh --yes --container
|
||||||
|
- name: uninstall drill — revoke clean, teardown, uninstall, ZERO residue
|
||||||
|
# The full-removal order, end to end on the real daemon: revoke a
|
||||||
|
# granted user (--purge asserts its own absence, incl. the incus-user
|
||||||
|
# state dir), tear the stack down, uninstall the tree — then assert
|
||||||
|
# NOTHING survived: no networks, profiles, ACLs, nft tables, systemd
|
||||||
|
# units, files or symlinks. The uninstall was flaky exactly because
|
||||||
|
# nobody measured this.
|
||||||
|
run: |
|
||||||
|
set -x
|
||||||
|
sudo useradd -m -s /bin/bash uninstdrill
|
||||||
|
sudo BOX_YES=1 /usr/local/bin/box grant uninstdrill
|
||||||
|
uid="$(id -u uninstdrill)"
|
||||||
|
sudo BOX_YES=1 /usr/local/bin/box revoke uninstdrill --purge
|
||||||
|
sudo test ! -e "/var/lib/incus/users/$uid"
|
||||||
|
! sudo incus project show "user-$uid"
|
||||||
|
! sudo incus config trust list --format csv | grep -q "incus-user-$uid"
|
||||||
|
# The COMBINED verb, --force only, deliberately no BOX_YES and no
|
||||||
|
# TTY: this is the exact invocation that used to die at teardown's
|
||||||
|
# own prompt when consent was not forwarded (--purge-host now
|
||||||
|
# passes --yes through under --force/BOX_YES).
|
||||||
|
sudo /usr/local/bin/box uninstall --all --purge-host --force
|
||||||
|
# zero residue: the daemon's state...
|
||||||
|
! sudo incus network show boxnet
|
||||||
|
! sudo incus profile show box-net
|
||||||
|
! sudo incus network acl show box-isolate
|
||||||
|
# ...the firewall and its boot persistence...
|
||||||
|
! sudo nft list table inet box
|
||||||
|
! sudo nft list table bridge box
|
||||||
|
sudo test ! -e /etc/systemd/system/box-firewall.service
|
||||||
|
sudo test ! -e /usr/local/sbin/box-firewall
|
||||||
|
# ...and the install itself: files AND symlinks, both name generations
|
||||||
|
sudo test ! -e /opt/box
|
||||||
|
sudo test ! -e /usr/local/bin/box
|
||||||
|
sudo test ! -L /usr/local/bin/box
|
||||||
|
sudo test ! -e /usr/local/bin/claudebox
|
||||||
|
sudo test ! -L /usr/local/bin/claudebox
|
||||||
|
|
||||||
# NOT run here: the full drill (drill/drill.sh). It rehearses the whole
|
# NOT run here: the full drill (drill/drill.sh). It rehearses the whole
|
||||||
# surface — cold template mints, expose, migration — and wants a real host
|
# surface — cold template mints, expose, migration — and wants a real host
|
||||||
|
|
|
||||||
58
CHANGELOG.md
58
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
82
README.md
82
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
310
bin/box
310
bin/box
|
|
@ -77,6 +77,9 @@ CMDS=(
|
||||||
"revoke^<user> [--purge]^^Admin: take the restricted tier back (--purge also deletes their boxes)^fn:cmd_revoke^"
|
"revoke^<user> [--purge]^^Admin: take the restricted tier back (--purge also deletes their boxes)^fn:cmd_revoke^"
|
||||||
"teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
|
"teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
|
||||||
"migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
|
"migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
|
||||||
|
"versions^^^List the installed box versions — the current default and the running one^fn:cmd_versions^"
|
||||||
|
"use^<version>^^Switch the default box version (refuses while boxes exist)^fn:cmd_use^"
|
||||||
|
"uninstall^[<version>] [--all] [--purge-host]^^Remove one installed version, or the whole install — asks first, asserts the absence^fn:cmd_uninstall^"
|
||||||
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
||||||
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
|
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
|
||||||
)
|
)
|
||||||
|
|
@ -480,6 +483,56 @@ the profile, then verifies the box works on its new network leg.
|
||||||
box migrate-host --all-boxes # re-home every legacy box
|
box migrate-host --all-boxes # re-home every legacy box
|
||||||
box migrate-host --retire-legacy # remove the old stack (once no legacy box remains)
|
box migrate-host --retire-legacy # remove the old stack (once no legacy box remains)
|
||||||
EOF
|
EOF
|
||||||
|
;;
|
||||||
|
versions) cat <<'EOF'
|
||||||
|
List the versions installed under this install root — install.sh lands each
|
||||||
|
one side by side at <root>/versions/<v>, and a 'current' symlink tracks the
|
||||||
|
default (what the box on your PATH runs). The default is marked (current);
|
||||||
|
the tree answering THIS command is marked (running) — they differ when your
|
||||||
|
PATH resolves a different install (say, a global /opt/box shadowing yours).
|
||||||
|
|
||||||
|
box versions
|
||||||
|
box use <version> # switch the default
|
||||||
|
# install another version side by side: re-run install.sh
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
use) cat <<'EOF'
|
||||||
|
Switch the default box version — repoint the 'current' symlink (and the PATH
|
||||||
|
symlink riding it) at an installed version. Refuses while ANY box exists:
|
||||||
|
never change versions under a user's boxes (#66) — 'box down' what you keep,
|
||||||
|
copy out what you need via 'box shell'/'box exec' (a portable 'box export'
|
||||||
|
is #70), 'box rm' each box, then switch. The flip is asserted afterwards:
|
||||||
|
current must resolve to the version you asked for, and the chain's
|
||||||
|
'box --version' must answer it.
|
||||||
|
|
||||||
|
box versions # what is installed
|
||||||
|
box use 0.6.0
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
uninstall) cat <<'EOF'
|
||||||
|
Remove one installed version, or the whole install — the real uninstall,
|
||||||
|
replacing the old "rm -rf two paths by hand" instructions.
|
||||||
|
|
||||||
|
box uninstall <version> one NON-current version ('box use' another
|
||||||
|
first if you are on it)
|
||||||
|
box uninstall everything: every version, the current and
|
||||||
|
box uninstall --all PATH symlinks, and any legacy claudebox crumbs
|
||||||
|
box uninstall --purge-host run teardown-host first (all boxes, the boxnet
|
||||||
|
stack, the firewall — its own confirmation),
|
||||||
|
then remove the install
|
||||||
|
|
||||||
|
The full uninstall runs in the safe order: boxes first — it refuses while
|
||||||
|
any exist (and names them) unless --purge-host tears them down; then the
|
||||||
|
trees and symlinks; and it ENDS with an absence assert — every removed path
|
||||||
|
is re-checked, and anything still present makes it exit 1 naming the
|
||||||
|
leftovers instead of reporting a clean uninstall that wasn't (the same
|
||||||
|
discipline as 'box revoke --purge'). Asks before removing; --force or
|
||||||
|
BOX_YES=1 skips the prompt. On a multi-user host, revoke granted users
|
||||||
|
first: 'box revoke <user> --purge'.
|
||||||
|
|
||||||
|
box uninstall 0.5.0
|
||||||
|
box uninstall --all --purge-host
|
||||||
|
EOF
|
||||||
;;
|
;;
|
||||||
status) cat <<'EOF'
|
status) cat <<'EOF'
|
||||||
Deprecated alias for 'box list'. It ignored the <box> argument it
|
Deprecated alias for 'box list'. It ignored the <box> argument it
|
||||||
|
|
@ -536,8 +589,9 @@ while [ $# -gt 0 ]; do
|
||||||
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
|
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
|
||||||
# expose's own flags (--list, --remove) are positional to it, not box's
|
# expose's own flags (--list, --remove) are positional to it, not box's
|
||||||
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
|
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
|
||||||
# the host verbs delegate their flags to the scripts they wrap
|
# the host verbs delegate their flags to the scripts they wrap;
|
||||||
case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke) args+=("$1"); shift; continue ;; esac
|
# uninstall parses its own (--all, --purge-host) the same way
|
||||||
|
case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke|uninstall) args+=("$1"); shift; continue ;; esac
|
||||||
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
||||||
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
||||||
fi
|
fi
|
||||||
|
|
@ -1045,6 +1099,258 @@ cmd_migrate_host() { host_script migrate-host.sh; }
|
||||||
cmd_grant() { host_script grant-user.sh; }
|
cmd_grant() { host_script grant-user.sh; }
|
||||||
cmd_revoke() { host_script revoke-user.sh; }
|
cmd_revoke() { host_script revoke-user.sh; }
|
||||||
|
|
||||||
|
# --- the versioned install (#66's stance, made livable in 0.7.0) ------------
|
||||||
|
# install.sh lands each version at <install-root>/versions/<v>, with a
|
||||||
|
# 'current' symlink naming the default and $BINDIR/box pointing through it.
|
||||||
|
# $root (readlink -f, line 8) already resolved the whole chain, so a versioned
|
||||||
|
# install always runs from .../versions/<v> — and a git checkout does not,
|
||||||
|
# which is how these verbs know to refuse instead of uninstalling somebody's
|
||||||
|
# working copy.
|
||||||
|
install_root() {
|
||||||
|
local vdir; vdir="$(dirname "$root")"
|
||||||
|
[ "$(basename "$vdir")" = versions ] || return 1
|
||||||
|
dirname "$vdir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
|
||||||
|
# gate for every caller that builds a path from one (the installer's new_ver,
|
||||||
|
# migration's flat_ver, and bin/box's 'use'/single-version uninstall): only
|
||||||
|
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
|
||||||
|
# spaces and option-lookalikes by construction — a crafted version dies HERE,
|
||||||
|
# never in an rm -rf or an ln. install.sh carries a byte-identical copy;
|
||||||
|
# test/cli.sh diffs the two so the gates cannot drift.
|
||||||
|
valid_version() {
|
||||||
|
case "$1" in
|
||||||
|
''|.*|-*) return 1 ;;
|
||||||
|
*[!A-Za-z0-9._+-]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Which boxes exist on this host, at THIS caller's tier? Prints their names
|
||||||
|
# (both tag generations) and succeeds when at least one exists; fails when
|
||||||
|
# none are visible — including when incus is absent or not answering, because
|
||||||
|
# #66's stance protects BOXES from a version change, and a daemon that cannot
|
||||||
|
# answer has none to protect. install.sh carries a byte-identical copy (it
|
||||||
|
# runs before any install tree exists); test/cli.sh diffs the two so they
|
||||||
|
# cannot drift.
|
||||||
|
existing_boxes() {
|
||||||
|
command -v incus >/dev/null 2>&1 || return 1
|
||||||
|
{ timeout 10 incus list user.box=1 --format csv --columns n </dev/null
|
||||||
|
timeout 10 incus list user.claudebox=1 --format csv --columns n </dev/null
|
||||||
|
} 2>/dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep .
|
||||||
|
}
|
||||||
|
|
||||||
|
# #66, kept at flip time: never change (or remove) the version under existing
|
||||||
|
# boxes. Names every box and the remedy, then dies — a refusal that does not
|
||||||
|
# say which boxes block it sends the operator off to rediscover 'box list'.
|
||||||
|
die_under_boxes() { # $1 = the act being refused, $2 = the retry command
|
||||||
|
local names n
|
||||||
|
names="$(existing_boxes)" || return 0
|
||||||
|
{
|
||||||
|
echo "box: this host has existing boxes:"
|
||||||
|
while IFS= read -r n; do echo "box: · $n"; done <<<"$names"
|
||||||
|
echo "box: refusing to $1 under them (#66: never change versions under a user's boxes)."
|
||||||
|
echo "box: preserve what you care about — 'box down <box>', copy out via 'box shell'/'box exec'"
|
||||||
|
echo "box: (a portable 'box export' is #70) — then 'box rm <box>' each, and re-run: $2"
|
||||||
|
} >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The PATH symlinks that could ride this install: the one this invocation came
|
||||||
|
# in on, BOX_BIN's, and the tier default's. Candidates only — every consumer
|
||||||
|
# checks where a link actually points before touching it, so a symlink that is
|
||||||
|
# somebody else's (another install root, a hand-rolled wrapper) is never moved.
|
||||||
|
bin_links() {
|
||||||
|
local c=()
|
||||||
|
[ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}")
|
||||||
|
[ -n "${BOX_BIN:-}" ] && c+=("$BOX_BIN/box")
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/box); else c+=("$HOME/.local/bin/box"); fi
|
||||||
|
printf '%s\n' "${c[@]}" | awk '!seen[$0]++'
|
||||||
|
}
|
||||||
|
|
||||||
|
converge_bin_links() { # $1 = install root: point our PATH symlinks through current
|
||||||
|
local ir="$1" p t
|
||||||
|
while IFS= read -r p; do
|
||||||
|
[ -L "$p" ] || continue
|
||||||
|
t="$(readlink -f "$p" 2>/dev/null || true)"
|
||||||
|
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
|
||||||
|
case "$t" in
|
||||||
|
"$ir"/*) ln -sfn "$ir/current/bin/box" "$p" ;;
|
||||||
|
esac
|
||||||
|
done < <(bin_links)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_versions() {
|
||||||
|
local ir cur d v mark
|
||||||
|
ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to list"
|
||||||
|
cur="$(readlink -f "$ir/current" 2>/dev/null || true)"
|
||||||
|
echo "VERSIONS ($ir)"
|
||||||
|
for d in "$ir/versions"/*/; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
v="$(basename "$d")"
|
||||||
|
mark=""
|
||||||
|
[ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)"
|
||||||
|
[ "$(readlink -f "$d")" = "$root" ] && mark="$mark (running)"
|
||||||
|
printf ' %s%s\n' "$v" "$mark"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo "switch the default: box use <version>"
|
||||||
|
echo "install another: re-run install.sh (versions land side by side)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_use() {
|
||||||
|
local v="${args[0]:-}" ir eff expect out
|
||||||
|
[ -n "$v" ] || usage_error "usage: $(synopsis_of use)"
|
||||||
|
ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to switch"
|
||||||
|
valid_version "$v" || die "not a sane version name: '$v' (a version is a directory name under versions/ — see 'box versions')"
|
||||||
|
[ -d "$ir/versions/$v" ] || die "no such version: $v (see 'box versions')"
|
||||||
|
die_under_boxes "switch the default box version" "box use $v"
|
||||||
|
# An atomic flip, not unlink+create: ln -sfn leaves a window where current
|
||||||
|
# is missing; a rename over it does not.
|
||||||
|
ln -sfn "versions/$v" "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current"
|
||||||
|
converge_bin_links "$ir"
|
||||||
|
# Assert the EFFECTIVE result, not the intent: current must resolve to the
|
||||||
|
# version asked for, and the chain's own binary must answer that version —
|
||||||
|
# a flip that "worked" while the operator's box still runs the old tree is
|
||||||
|
# exactly the flakiness this verb exists to end.
|
||||||
|
eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
|
||||||
|
[ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v"
|
||||||
|
expect="$(cat "$ir/versions/$v/VERSION" 2>/dev/null || true)"
|
||||||
|
if [ -n "$expect" ]; then
|
||||||
|
out="$("$ir/current/bin/box" --version 2>&1 || true)"
|
||||||
|
case "$out" in
|
||||||
|
*"$expect"*) : ;;
|
||||||
|
*) die "current/bin/box answers '$out', not version $expect — the symlink chain is broken" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
echo "box: switched to $v (current -> versions/$v)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The uninstall's own confirmation. NOT confirm() above: that one is for box
|
||||||
|
# lifecycle verbs and must never auto-accept from the environment (the drill
|
||||||
|
# exports BOX_YES=1 for the installer and still expects 'box rm' to refuse
|
||||||
|
# without --force). Uninstalling is installer-family, and BOX_YES is the
|
||||||
|
# installer-family consent contract — same as install.sh and revoke --purge.
|
||||||
|
uninstall_confirm() { # $1 = question. --force, or BOX_YES=1, or a TTY.
|
||||||
|
[ "$force" -eq 1 ] && return 0
|
||||||
|
[ -n "${BOX_YES:-}" ] && return 0
|
||||||
|
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)"
|
||||||
|
local reply
|
||||||
|
printf 'box: %s? [y/N] ' "$1"
|
||||||
|
read -r reply
|
||||||
|
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# 'box uninstall' — the real uninstall #66 left as two rm -rf lines of prose.
|
||||||
|
# The full removal runs in the documented order: boxes first (refuse while
|
||||||
|
# they exist, or --purge-host tears the stack down with them), then trees and
|
||||||
|
# symlinks, and it ENDS by PROVING the absence — like revoke --purge, the
|
||||||
|
# last word is a re-check, not a hope.
|
||||||
|
cmd_uninstall() {
|
||||||
|
local ir a ver="" all=0 purge_host=0 cur p t granted leftover=""
|
||||||
|
local targets=()
|
||||||
|
for a in ${args[@]+"${args[@]}"}; do
|
||||||
|
case "$a" in
|
||||||
|
--all) all=1 ;;
|
||||||
|
--purge-host) purge_host=1 ;;
|
||||||
|
-*) usage_error "unknown option: $a (see 'box help uninstall')" ;;
|
||||||
|
*) [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall)"; ver="$a" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)"
|
||||||
|
[ -w "$ir" ] || die "cannot write $ir — a global install is uninstalled as root: sudo box uninstall"
|
||||||
|
|
||||||
|
# -- one version -----------------------------------------------------------
|
||||||
|
if [ -n "$ver" ] && [ "$all" -eq 0 ]; then
|
||||||
|
[ "$purge_host" -eq 0 ] || usage_error "--purge-host goes with the full uninstall, not a single version"
|
||||||
|
valid_version "$ver" || die "not a sane version name: '$ver' (a version is a directory name under versions/ — see 'box versions')"
|
||||||
|
[ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'box versions')"
|
||||||
|
cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
|
||||||
|
# A broken current makes the CURRENT guard below unfireable (cur empty
|
||||||
|
# when the link is missing; cur naming a non-directory when it dangles —
|
||||||
|
# readlink -f resolves a link whose last component does not exist). Heal
|
||||||
|
# first, then decide; never delete around a broken default.
|
||||||
|
{ [ -n "$cur" ] && [ -d "$ir/versions/$cur" ]; } \
|
||||||
|
|| die "current is dangling — 'box use <version>' to repoint the default first (refusing to remove versions while it is broken)"
|
||||||
|
[ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'box use <other>' first, or 'box uninstall --all' for everything"
|
||||||
|
uninstall_confirm "remove box version $ver from $ir"
|
||||||
|
# rm's exit code is not the verdict — the absence re-check below is (a
|
||||||
|
# half-removed tree must be reported as INCOMPLETE, not as a crash).
|
||||||
|
rm -rf "$ir/versions/$ver" || true
|
||||||
|
if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then
|
||||||
|
echo "box: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "box: removed version $ver (the default stays $cur)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall) — a version and --all together is ambiguous"
|
||||||
|
|
||||||
|
# -- everything ------------------------------------------------------------
|
||||||
|
if [ "$purge_host" -eq 1 ]; then
|
||||||
|
# Granted users' worlds are not ours to erase silently — name them first;
|
||||||
|
# 'box revoke <user> --purge' is the clean path (and asserts its absence).
|
||||||
|
granted="$(timeout 10 incus project list --format csv 2>/dev/null </dev/null | cut -d, -f1 | grep '^user-' | tr '\n' ' ' || true)"
|
||||||
|
[ -n "${granted% }" ] && echo "box: NOTE — granted users still have projects (${granted% }) — 'box revoke <user> --purge' removes each world cleanly first" >&2
|
||||||
|
# Consent forwards: --force and BOX_YES are this verb's installer-family
|
||||||
|
# yes, and teardown-host must hear it too — otherwise a non-interactive
|
||||||
|
# 'uninstall --all --purge-host --force' dies at teardown's own prompt
|
||||||
|
# (EOF on read) with the tree untouched but the promise broken.
|
||||||
|
if [ "$force" -eq 1 ] || [ -n "${BOX_YES:-}" ]; then
|
||||||
|
bash "$root/host/teardown-host.sh" --yes \
|
||||||
|
|| die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)"
|
||||||
|
else
|
||||||
|
bash "$root/host/teardown-host.sh" \
|
||||||
|
|| die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
die_under_boxes "uninstall box" "box uninstall (or 'box uninstall --purge-host' to tear the host stack down with them)"
|
||||||
|
fi
|
||||||
|
uninstall_confirm "remove the ENTIRE box install at $ir (every version)"
|
||||||
|
|
||||||
|
# The removal set, gathered BEFORE anything is deleted, so the absence
|
||||||
|
# assert below re-checks exactly what was promised gone. PATH symlinks are
|
||||||
|
# removed only when they resolve into (or dangle at) THIS install root.
|
||||||
|
targets+=("$ir")
|
||||||
|
while IFS= read -r p; do
|
||||||
|
[ -L "$p" ] || continue
|
||||||
|
t="$(readlink -f "$p" 2>/dev/null || true)"
|
||||||
|
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
|
||||||
|
case "$t" in "$ir"/*) targets+=("$p") ;; esac
|
||||||
|
done < <(bin_links)
|
||||||
|
# Legacy crumbs: the pre-0.4.0 command name and the pre-0.5.0 tree. A real
|
||||||
|
# uninstall leaves neither generation behind.
|
||||||
|
while IFS= read -r p; do
|
||||||
|
p="$(dirname "$p")/claudebox"
|
||||||
|
[ -L "$p" ] && targets+=("$p")
|
||||||
|
done < <(bin_links)
|
||||||
|
[ -d "$HOME/.local/share/claudebox" ] && targets+=("$HOME/.local/share/claudebox")
|
||||||
|
mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++')
|
||||||
|
|
||||||
|
# rm's exit code is not the verdict — the absence assert below is (a
|
||||||
|
# half-removed tree must be reported as INCOMPLETE by name, not as a crash).
|
||||||
|
for p in "${targets[@]}"; do rm -rf "$p" || true; done
|
||||||
|
|
||||||
|
# END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink.
|
||||||
|
# A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims
|
||||||
|
# get verified (the revoke --purge discipline).
|
||||||
|
for p in "${targets[@]}"; do
|
||||||
|
if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi
|
||||||
|
done
|
||||||
|
if [ -n "$leftover" ]; then
|
||||||
|
echo "box: uninstall INCOMPLETE — still present:$leftover" >&2
|
||||||
|
echo "box: remove them by hand, and re-check each path is really gone." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "box: uninstalled — removed:"
|
||||||
|
for p in "${targets[@]}"; do echo "box: · $p"; done
|
||||||
|
if [ "$purge_host" -eq 0 ]; then
|
||||||
|
echo "box: note — the host stack (boxnet, firewall), if this host has one, was NOT touched:"
|
||||||
|
echo "box: run teardown-host from a checkout (host/teardown-host.sh), or use --purge-host next time."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
cmd_help() { show_help "${args[0]:-}"; }
|
cmd_help() { show_help "${args[0]:-}"; }
|
||||||
|
|
||||||
# The escape hatch. The box is resolved and tag-checked; everything else is
|
# The escape hatch. The box is resolved and tag-checked; everything else is
|
||||||
|
|
|
||||||
154
docs/plans/2026-07-18-versioned-install.md
Normal file
154
docs/plans/2026-07-18-versioned-install.md
Normal 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).
|
||||||
|
|
@ -202,10 +202,11 @@ EOF
|
||||||
export BOX_SKIP_SETUP_HOST=1
|
export BOX_SKIP_SETUP_HOST=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The installer is a no-op when box is already installed — upgrading is
|
# The installer converges when a version is already installed (0.7.0's
|
||||||
# uninstall-then-install, by design. The drill re-proves a tree from scratch
|
# versioned layout: re-running the same version is a no-op, and an upgrade
|
||||||
# every run, so it does the uninstall itself: clear any prior tree and symlink
|
# lands side-by-side without flipping under boxes). The drill re-proves a
|
||||||
# before installing, or install.sh would correctly refuse to touch them.
|
# tree from SCRATCH every run — a fresh host, not a converged one — so it
|
||||||
|
# removes the whole install root and symlink first.
|
||||||
rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box"
|
rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box"
|
||||||
|
|
||||||
# The installer prompts (install? set up host?) and reads /dev/tty. The drill
|
# The installer prompts (install? set up host?) and reads /dev/tty. The drill
|
||||||
|
|
@ -224,7 +225,7 @@ EOF
|
||||||
# reads BOX_* — the vars were ignored, main was installed, and the run drilled
|
# reads BOX_* — the vars were ignored, main was installed, and the run drilled
|
||||||
# the wrong tree while reporting success. A drill that silently drills the
|
# the wrong tree while reporting success. A drill that silently drills the
|
||||||
# wrong code is worse than one that fails.
|
# wrong code is worse than one that fails.
|
||||||
got="$(cat "$HOME/.local/share/box/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
|
got="$(cat "$HOME/.local/share/box/current/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
|
||||||
if [ "$got" != "$REPO@$REF" ]; then
|
if [ "$got" != "$REPO@$REF" ]; then
|
||||||
echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2
|
echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2
|
||||||
echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2
|
echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2
|
||||||
|
|
@ -302,7 +303,7 @@ if [ "${DRILL_OWNS_SETUP:-0}" != 1 ]; then
|
||||||
echo " install.sh is supposed to run the host setup itself (#64), and setup-host" >&2
|
echo " install.sh is supposed to run the host setup itself (#64), and setup-host" >&2
|
||||||
echo " is supposed to converge in one run (#63). One of those did not happen." >&2
|
echo " is supposed to converge in one run (#63). One of those did not happen." >&2
|
||||||
echo " reproduce with the output visible:" >&2
|
echo " reproduce with the output visible:" >&2
|
||||||
echo " ~/.local/share/box/host/setup-host.sh" >&2
|
echo " ~/.local/share/box/current/host/setup-host.sh" >&2
|
||||||
echo " or hand setup back to the drill: DRILL_OWNS_SETUP=1 $SELF" >&2
|
echo " or hand setup back to the drill: DRILL_OWNS_SETUP=1 $SELF" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
@ -361,7 +362,7 @@ left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')"
|
||||||
# proves is idempotency: a second run over a cleaned host is a no-op that
|
# proves is idempotency: a second run over a cleaned host is a no-op that
|
||||||
# restores the stack rather than a fresh build.
|
# restores the stack rather than a fresh build.
|
||||||
inf "running setup-host.sh (post-clean convergence: restores dns.mode and any reverted mutations)…"
|
inf "running setup-host.sh (post-clean convergence: restores dns.mode and any reverted mutations)…"
|
||||||
if ! timeout -k 10 300 ~/.local/share/box/host/setup-host.sh; then
|
if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then
|
||||||
echo "drill: setup-host.sh failed or timed out (>5 min)." >&2
|
echo "drill: setup-host.sh failed or timed out (>5 min)." >&2
|
||||||
echo " it should take seconds on a host that already has incus. usual causes:" >&2
|
echo " it should take seconds on a host that already has incus. usual causes:" >&2
|
||||||
echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2
|
echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2
|
||||||
|
|
@ -469,7 +470,7 @@ phase "B. The box surface"
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Compare against the installed tree's VERSION file, not a hardcoded number —
|
# Compare against the installed tree's VERSION file, not a hardcoded number —
|
||||||
# a pinned literal here would fail the drill on every release.
|
# a pinned literal here would fail the drill on every release.
|
||||||
expected="$(cat "$HOME/.local/share/box/VERSION" 2>/dev/null || echo '?')"
|
expected="$(cat "$HOME/.local/share/box/current/VERSION" 2>/dev/null || echo '?')"
|
||||||
v="$(box --version 2>&1)"
|
v="$(box --version 2>&1)"
|
||||||
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
|
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
|
||||||
|
|
||||||
|
|
@ -498,7 +499,7 @@ box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
|
||||||
# The one rule that keeps templates honest: no key can name a network. Plant a
|
# The one rule that keeps templates honest: no key can name a network. Plant a
|
||||||
# bad template in the installed tree (the drill owns this host), expect the
|
# bad template in the installed tree (the drill owns this host), expect the
|
||||||
# parser to reject it BY NAME, remove it.
|
# parser to reject it BY NAME, remove it.
|
||||||
badt="$HOME/.local/share/box/templates/cbdrill-bad"
|
badt="$HOME/.local/share/box/current/templates/cbdrill-bad"
|
||||||
mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml"
|
mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml"
|
||||||
box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \
|
box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \
|
||||||
&& ok "a template cannot name a network — BOX_NETWORK rejected by name" \
|
&& ok "a template cannot name a network — BOX_NETWORK rejected by name" \
|
||||||
|
|
@ -889,7 +890,7 @@ phase "M. Migration — the pre-0.4.0 → box transition (host/migrate-host.sh)"
|
||||||
# tag on the OLD network — exactly what a pre-0.4.0 host carries. Then prove
|
# tag on the OLD network — exactly what a pre-0.4.0 host carries. Then prove
|
||||||
# migrate-host.sh moves it onto the new stack with its identity intact, and
|
# migrate-host.sh moves it onto the new stack with its identity intact, and
|
||||||
# retires the legacy stack only once it is empty.
|
# retires the legacy stack only once it is empty.
|
||||||
MIG="$HOME/.local/share/box/host/migrate-host.sh"
|
MIG="$HOME/.local/share/box/current/host/migrate-host.sh"
|
||||||
if [ ! -f "$MIG" ]; then
|
if [ ! -f "$MIG" ]; then
|
||||||
no "migrate-host.sh not installed — cannot drill the transition"
|
no "migrate-host.sh not installed — cannot drill the transition"
|
||||||
else
|
else
|
||||||
|
|
@ -985,5 +986,5 @@ fi
|
||||||
echo
|
echo
|
||||||
inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules"
|
inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules"
|
||||||
inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)."
|
inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)."
|
||||||
inf "to undo: ~/.local/share/box/host/teardown-host.sh [--purge-incus]"
|
inf "to undo: box uninstall --purge-host (or ~/.local/share/box/current/host/teardown-host.sh)"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|
|
||||||
|
|
@ -135,19 +135,26 @@ done < <(incus config trust list --format csv --columns nf 2>/dev/null)
|
||||||
|
|
||||||
# incus-user's per-user client state (their key pair). Removed so a future
|
# incus-user's per-user client state (their key pair). Removed so a future
|
||||||
# re-grant starts clean instead of trusting a key the purge revoked.
|
# re-grant starts clean instead of trusting a key the purge revoked.
|
||||||
if [ -d "/var/lib/incus/users/$uid" ]; then
|
# $SUDO test, not a bare [ -d ]: /var/lib/incus is not traversable by a
|
||||||
|
# non-root admin, so an unprivileged stat answers "absent" for a directory
|
||||||
|
# that is very much there — the same lie the absence assert below must dodge.
|
||||||
|
if $SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null; then
|
||||||
$SUDO rm -rf "/var/lib/incus/users/$uid"
|
$SUDO rm -rf "/var/lib/incus/users/$uid"
|
||||||
echo "purge: incus-user state for uid $uid removed"
|
echo "purge: incus-user state for uid $uid removed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Assert absence rather than trusting exit codes — the wipe.sh discipline.
|
# Assert absence rather than trusting exit codes — the wipe.sh discipline.
|
||||||
# The certificate included: its removal above is set -e-exempt (left of &&),
|
# The certificate included: its removal above is set -e-exempt (left of &&),
|
||||||
# and a promise the header makes is a promise this block checks.
|
# and a promise the header makes is a promise this block checks. The
|
||||||
|
# incus-user state directory too — it was purged for releases without being
|
||||||
|
# re-checked, which is exactly the gap this block exists to close.
|
||||||
leftover=""
|
leftover=""
|
||||||
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
|
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
|
||||||
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
|
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
|
||||||
incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \
|
incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \
|
||||||
&& leftover="$leftover cert:incus-user-$uid"
|
&& leftover="$leftover cert:incus-user-$uid"
|
||||||
|
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
|
||||||
|
&& leftover="$leftover /var/lib/incus/users/$uid"
|
||||||
if [ -n "$leftover" ]; then
|
if [ -n "$leftover" ]; then
|
||||||
echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2
|
echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,34 @@
|
||||||
# ancestor created, so one teardown cleans a host of any generation: all boxes
|
# ancestor created, so one teardown cleans a host of any generation: all boxes
|
||||||
# (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev
|
# (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev
|
||||||
# profiles, and both generations of firewall units and nft tables.
|
# profiles, and both generations of firewall units and nft tables.
|
||||||
# Usage: ./host/teardown-host.sh [--purge-incus]
|
# Usage: ./host/teardown-host.sh [--purge-incus] [--yes]
|
||||||
# --purge-incus also apt-purge Incus itself (skipped if non-box
|
# --purge-incus also apt-purge Incus itself (skipped if non-box
|
||||||
# instances still exist on this host)
|
# instances still exist on this host)
|
||||||
|
# --yes skip the confirmation (BOX_YES=1 does the same) — for
|
||||||
|
# automation: CI's uninstall drill and 'box uninstall
|
||||||
|
# --purge-host' run this unattended
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
purge=false
|
purge=false; yes=0
|
||||||
[ "${1:-}" = "--purge-incus" ] && purge=true
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--purge-incus) purge=true ;;
|
||||||
|
--yes|-y) yes=1 ;;
|
||||||
|
*) echo "teardown-host: unknown option: $arg" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "${BOX_YES:-}" ] && yes=1
|
||||||
|
|
||||||
echo "This removes ALL boxes (uncommitted work in them is lost), the"
|
echo "This removes ALL boxes (uncommitted work in them is lost), the"
|
||||||
echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
|
echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
|
||||||
echo "(both current and pre-0.4.0 names)."
|
echo "(both current and pre-0.4.0 names)."
|
||||||
$purge && echo "Incus itself will also be uninstalled (--purge-incus)."
|
$purge && echo "Incus itself will also be uninstalled (--purge-incus)."
|
||||||
read -rp "Continue? [y/N] " a
|
if [ "$yes" -eq 1 ]; then
|
||||||
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
|
echo "(confirmed non-interactively: --yes/BOX_YES)"
|
||||||
|
else
|
||||||
|
read -rp "Continue? [y/N] " a
|
||||||
|
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
|
||||||
|
fi
|
||||||
|
|
||||||
# Instances — both tag generations, one delete at a time (a multi-name
|
# Instances — both tag generations, one delete at a time (a multi-name
|
||||||
# 'incus delete' aborts at the first missing name).
|
# 'incus delete' aborts at the first missing name).
|
||||||
|
|
@ -72,4 +86,4 @@ if $purge; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/box remain — remove by hand if wanted.)"
|
echo "Teardown complete. (The box install tree itself remains — 'box uninstall' removes it, with a zero-residue check.)"
|
||||||
|
|
|
||||||
330
install.sh
330
install.sh
|
|
@ -3,10 +3,25 @@ set -euo pipefail
|
||||||
|
|
||||||
# box installer — intended for: curl -fsSL .../install.sh | bash
|
# box installer — intended for: curl -fsSL .../install.sh | bash
|
||||||
#
|
#
|
||||||
# Downloads the box source tarball from its GitHub repo (heavy-duty/box),
|
# Downloads the box source tarball from its GitHub repo (heavy-duty/box) and
|
||||||
# installs the whole tree under $DEST, and puts a `box` symlink on PATH via
|
# installs it into the VERSIONED layout under $DEST:
|
||||||
# $BINDIR. (GitHub redirects the repo's pre-rename URLs, so an old install
|
#
|
||||||
# script keeps working; BOX_REPO overrides.)
|
# $DEST/versions/<version>/ one full tree per installed version
|
||||||
|
# $DEST/current -> versions/<version> the default version
|
||||||
|
# $BINDIR/box -> $DEST/current/bin/box the PATH entry
|
||||||
|
#
|
||||||
|
# Versions install side by side, the way plenty of CLIs manage theirs: `box
|
||||||
|
# versions` lists them, `box use <v>` switches the default, `box uninstall`
|
||||||
|
# removes them. Re-running with an already-installed version is a converging
|
||||||
|
# no-op (BOX_REINSTALL=1 replaces that version's tree); a NEW version installs
|
||||||
|
# beside the old one and becomes the default only when NO boxes exist — #66's
|
||||||
|
# stance (never change versions under a user's boxes) now guards the FLIP, not
|
||||||
|
# the whole install. A pre-0.7.0 flat tree is migrated in place, so upgrading
|
||||||
|
# from 0.6.0 is seamless. (GitHub redirects the repo's pre-rename URLs, so an
|
||||||
|
# old install script keeps working; BOX_REPO overrides.)
|
||||||
|
#
|
||||||
|
# BOX_INSTALL_SOURCE=<dir-or-tarball> installs from a local tree instead of
|
||||||
|
# downloading — for CI and the drill, so what lands is the code under review.
|
||||||
|
|
||||||
REPO="${BOX_REPO:-heavy-duty/box}"
|
REPO="${BOX_REPO:-heavy-duty/box}"
|
||||||
REF="${BOX_REF:-main}"
|
REF="${BOX_REF:-main}"
|
||||||
|
|
@ -47,30 +62,96 @@ confirm() { # $1 = question
|
||||||
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
|
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
|
||||||
|
# gate for every caller that builds a path from one (the installer's new_ver,
|
||||||
|
# migration's flat_ver, and bin/box's 'use'/single-version uninstall): only
|
||||||
|
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
|
||||||
|
# spaces and option-lookalikes by construction — a crafted version dies HERE,
|
||||||
|
# never in an rm -rf or an ln. bin/box carries a byte-identical copy;
|
||||||
|
# test/cli.sh diffs the two so the gates cannot drift.
|
||||||
|
valid_version() {
|
||||||
|
case "$1" in
|
||||||
|
''|.*|-*) return 1 ;;
|
||||||
|
*[!A-Za-z0-9._+-]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Which boxes exist on this host, at THIS caller's tier? Prints their names
|
||||||
|
# (both tag generations) and succeeds when at least one exists; fails when
|
||||||
|
# none are visible — including when incus is absent or not answering, because
|
||||||
|
# #66's stance protects BOXES from a version change, and a daemon that cannot
|
||||||
|
# answer has none to protect. bin/box carries a byte-identical copy (the CLI
|
||||||
|
# needs the same gate for 'box use' / 'box uninstall'); test/cli.sh diffs the
|
||||||
|
# two so they cannot drift.
|
||||||
|
existing_boxes() {
|
||||||
|
command -v incus >/dev/null 2>&1 || return 1
|
||||||
|
{ timeout 10 incus list user.box=1 --format csv --columns n </dev/null
|
||||||
|
timeout 10 incus list user.claudebox=1 --format csv --columns n </dev/null
|
||||||
|
} 2>/dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep .
|
||||||
|
}
|
||||||
|
|
||||||
# --- prerequisites ---------------------------------------------------------
|
# --- prerequisites ---------------------------------------------------------
|
||||||
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
|
# curl only when something must be downloaded — a local BOX_INSTALL_SOURCE
|
||||||
|
# needs none, which is what lets test/cli.sh drive REAL installs offline.
|
||||||
|
if [ -z "${BOX_INSTALL_SOURCE:-}" ]; then
|
||||||
|
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
|
||||||
|
fi
|
||||||
command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run."
|
command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run."
|
||||||
|
|
||||||
# --- confirm, then no-op if already installed ------------------------------
|
if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then
|
||||||
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is
|
SRCDESC="local source $BOX_INSTALL_SOURCE"
|
||||||
# ask whether you meant to. Then, if box is already installed, this run changes
|
else
|
||||||
# nothing and says so — a re-run is a safe no-op, which dissolves the whole
|
SRCDESC="$REPO@$REF"
|
||||||
# "curl clobbered my working install / rebuilt the stack under my boxes" class
|
fi
|
||||||
# of failures. Upgrading is deliberately NOT an in-place overwrite: you uninstall
|
|
||||||
# what you have (dealing with your boxes as you do) and install fresh.
|
|
||||||
confirm "Install box from $REPO@$REF?" || die "cancelled — nothing was changed."
|
|
||||||
|
|
||||||
if [ -e "$BINDIR/box" ] || [ -x "$DEST/bin/box" ]; then
|
# --- confirm first ---------------------------------------------------------
|
||||||
cur="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')"
|
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is
|
||||||
cur_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo '?')"
|
# ask whether you meant to. Everything after this converges: re-running with a
|
||||||
log "box is already installed ($cur, version $cur_ver) — nothing to do."
|
# version that is already installed changes nothing and says so, which
|
||||||
log "To install a different version, remove the current one first:"
|
# dissolves the whole "curl clobbered my working install / rebuilt the stack
|
||||||
log " · preserve any boxes you care about — 'box down <box>', then keep them"
|
# under my boxes" class of failures (#66).
|
||||||
log " (a portable 'box export' is #70; for now copy what you need OUT via"
|
confirm "Install box from $SRCDESC?" || die "cancelled — nothing was changed."
|
||||||
log " 'box shell'/'box exec'), and 'box rm <box>' when you are done"
|
|
||||||
log " · uninstall: rm -rf \"$DEST\" \"$BINDIR/box\""
|
# Flip $DEST/current to versions/<v> atomically: build the new link beside it,
|
||||||
log " · then re-run this installer"
|
# rename over. Plain ln -sfn is unlink+create — a window where current names
|
||||||
exit 0
|
# nothing and a concurrent 'box' invocation dies mid-chain. bin/box's cmd_use
|
||||||
|
# flips with the same pattern.
|
||||||
|
flip_current() {
|
||||||
|
ln -sfn "versions/$1" "$DEST/current.new.$$"
|
||||||
|
mv -Tf "$DEST/current.new.$$" "$DEST/current"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- migrate a pre-0.7.0 flat install --------------------------------------
|
||||||
|
# 0.6.0 and earlier installed the tree FLAT at $DEST (bin/box directly under
|
||||||
|
# it). Move such a tree to versions/<its-VERSION> BEFORE anything else, so an
|
||||||
|
# upgrade from 0.6.0 is seamless and the version comparison below sees the
|
||||||
|
# truth. The move is two renames inside one parent directory — no copying, no
|
||||||
|
# window with no install — and the operator's tree is preserved bit for bit.
|
||||||
|
if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
|
||||||
|
flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)"
|
||||||
|
# The flat tree's VERSION is data from disk, not from this installer — the
|
||||||
|
# same trust boundary as the new_ver check, so the same gate: a corrupted
|
||||||
|
# (or hostile) VERSION must not steer the mv/ln below out of versions/.
|
||||||
|
valid_version "$flat_ver" || die "the flat install's VERSION is not a sane directory name: '$flat_ver' — fix $DEST/VERSION (one line, e.g. 0.6.0), then re-run"
|
||||||
|
log "found a pre-0.7.0 flat install at $DEST (version $flat_ver) — migrating it into the versioned layout"
|
||||||
|
staging="$DEST.migrating.$$"
|
||||||
|
mv "$DEST" "$staging"
|
||||||
|
mkdir -p "$DEST/versions"
|
||||||
|
mv "$staging" "$DEST/versions/$flat_ver"
|
||||||
|
flip_current "$flat_ver"
|
||||||
|
mkdir -p "$BINDIR"
|
||||||
|
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
|
||||||
|
log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Whether ANY version was installed before this run — read before we add one.
|
||||||
|
# It gates the host-setup offer below: a host that already ran box has made
|
||||||
|
# that decision (and may have live boxes the stack must not be rebuilt under,
|
||||||
|
# #66); after an upgrade, 'box setup-host' re-applies stack changes on purpose.
|
||||||
|
had_install=0
|
||||||
|
if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then
|
||||||
|
had_install=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- temp workspace --------------------------------------------------------
|
# --- temp workspace --------------------------------------------------------
|
||||||
|
|
@ -78,50 +159,131 @@ TMPDIR="$(mktemp -d)"
|
||||||
cleanup() { rm -rf "$TMPDIR"; }
|
cleanup() { rm -rf "$TMPDIR"; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
|
# --- acquire the tree ------------------------------------------------------
|
||||||
|
if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then
|
||||||
log "installing box from $REPO@$REF"
|
SRC="$BOX_INSTALL_SOURCE"
|
||||||
log "downloading $URL"
|
INSTALLED_FROM="local:$SRC"
|
||||||
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|
if [ -d "$SRC" ]; then
|
||||||
|
log "copying local tree $SRC"
|
||||||
|
mkdir -p "$TMPDIR/tree"
|
||||||
|
# tar, not cp -a: --exclude=.git, so a working checkout never carries its
|
||||||
|
# VCS state (or its size) into the install tree.
|
||||||
|
tar -C "$SRC" --exclude=.git -cf - . | tar -xf - -C "$TMPDIR/tree"
|
||||||
|
EXTRACTED="$TMPDIR/tree"
|
||||||
|
elif [ -f "$SRC" ]; then
|
||||||
|
log "extracting local tarball $SRC"
|
||||||
|
tar -xzf "$SRC" -C "$TMPDIR" || die "failed to extract $SRC"
|
||||||
|
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
|
||||||
|
else
|
||||||
|
die "BOX_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
INSTALLED_FROM="$REPO@$REF"
|
||||||
|
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
|
||||||
|
log "installing box from $REPO@$REF"
|
||||||
|
log "downloading $URL"
|
||||||
|
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|
||||||
|| die "failed to download $URL"
|
|| die "failed to download $URL"
|
||||||
|
|
||||||
log "extracting archive"
|
log "extracting archive"
|
||||||
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
|
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
|
||||||
|| die "failed to extract archive"
|
|| die "failed to extract archive"
|
||||||
|
|
||||||
# GitHub names the archive's top dir <repo>-<ref> (slashes in a ref become
|
# GitHub names the archive's top dir <repo>-<ref> (slashes in a ref become
|
||||||
# dashes) — deriving that name is guesswork, and it broke for real at the
|
# dashes) — deriving that name is guesswork, and it broke for real at the
|
||||||
# claudebox → box rename, when this glob kept looking for claudebox-* and the
|
# claudebox → box rename, when this glob kept looking for claudebox-* and the
|
||||||
# installer died on every host. The tarball has exactly ONE top-level
|
# installer died on every host. The tarball has exactly ONE top-level
|
||||||
# directory: take the directory, whatever it is called, and let the bin/box
|
# directory: take the directory, whatever it is called, and let the bin/box
|
||||||
# check below judge whether it is the right tree.
|
# check below judge whether it is the right tree.
|
||||||
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
|
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
|
||||||
[ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive"
|
fi
|
||||||
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
|
[ -n "${EXTRACTED:-}" ] || die "could not find the source tree in $SRCDESC"
|
||||||
|
[ -f "$EXTRACTED/bin/box" ] || die "source does not contain bin/box — is $SRCDESC correct?"
|
||||||
|
|
||||||
# --- install into $DEST ----------------------------------------------------
|
# The tree's own VERSION file names the directory it lands in — the version IS
|
||||||
# Reached only on a host with no existing install (the no-op check above
|
# the identity of what is being installed, and 'box versions' lists these names.
|
||||||
# exits otherwise), so this is always a fresh tree, never an overwrite.
|
new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || true)"
|
||||||
log "installing into $DEST"
|
[ -n "$new_ver" ] || die "source has no VERSION file — cannot install it as a version"
|
||||||
mkdir -p "$(dirname "$DEST")"
|
valid_version "$new_ver" || die "the source's VERSION is not a sane directory name: '$new_ver'"
|
||||||
mv "$EXTRACTED" "$DEST"
|
|
||||||
|
|
||||||
chmod +x "$DEST/bin/box"
|
# --- install into $DEST/versions/<version> ---------------------------------
|
||||||
|
VDIR="$DEST/versions/$new_ver"
|
||||||
|
newly_installed=0
|
||||||
|
if [ -d "$VDIR" ]; then
|
||||||
|
if [ -n "${BOX_REINSTALL:-}" ]; then
|
||||||
|
# Replace THIS version's tree, as atomically as two renames allow — never
|
||||||
|
# a partial overlay of new files onto an old tree.
|
||||||
|
log "BOX_REINSTALL=1 — replacing the installed $new_ver tree"
|
||||||
|
stage="$VDIR.new.$$"; old="$VDIR.old.$$"
|
||||||
|
rm -rf "$stage" "$old"
|
||||||
|
chmod +x "$EXTRACTED/bin/box"
|
||||||
|
mv "$EXTRACTED" "$stage"
|
||||||
|
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole
|
||||||
|
# length of the delete where current -> this version resolves to nothing.
|
||||||
|
mv "$VDIR" "$old"
|
||||||
|
mv "$stage" "$VDIR"
|
||||||
|
rm -rf "$old"
|
||||||
|
printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM"
|
||||||
|
log "reinstalled $new_ver"
|
||||||
|
else
|
||||||
|
cur_from="$(cat "$VDIR/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')"
|
||||||
|
log "box $new_ver is already installed ($cur_from) — nothing to do."
|
||||||
|
log "(BOX_REINSTALL=1 replaces this version's tree; 'box versions' lists what is installed.)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "installing $new_ver into $VDIR"
|
||||||
|
mkdir -p "$DEST/versions"
|
||||||
|
chmod +x "$EXTRACTED/bin/box"
|
||||||
|
mv "$EXTRACTED" "$VDIR"
|
||||||
|
newly_installed=1
|
||||||
|
# Record WHAT was installed, so a caller can assert it got what it asked for.
|
||||||
|
# Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names
|
||||||
|
# retired in 0.5.0) silently falls back to the defaults and installs main —
|
||||||
|
# and the caller drills the wrong tree, believing it drilled its branch.
|
||||||
|
printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM"
|
||||||
|
fi
|
||||||
|
|
||||||
# A global (root) install is run by OTHER users, but mv preserves the tarball's
|
# --- which version is the default? -----------------------------------------
|
||||||
# root:root ownership and GitHub's archives carry no world bits on some paths — so
|
# 'current' is the tracked default; flipping it is the ONLY step that changes
|
||||||
# without this, a non-root caller cannot even traverse into $DEST to reach bin/box.
|
# what an operator's `box` runs. #66's stance, kept exactly here: never change
|
||||||
# Root owns the tree, nobody else writes it, everybody reads it. a+rX: read on
|
# versions under existing boxes. A fresh host (or a dangling current) is
|
||||||
# files, +search (x) on directories only. Guarded on root so the per-user install
|
# claimed outright; an upgrade flips only when no box exists — otherwise the
|
||||||
# stays byte-identical to before.
|
# new version sits installed side-by-side and 'box use' is the deliberate act.
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
cur="$(readlink -f "$DEST/current" 2>/dev/null || true)"
|
||||||
chmod -R a+rX "$DEST"
|
want="$(readlink -f "$VDIR")"
|
||||||
|
if [ -z "$cur" ] || [ ! -d "$cur" ]; then
|
||||||
|
flip_current "$new_ver"
|
||||||
|
log "default version: $new_ver"
|
||||||
|
elif [ "$cur" = "$want" ]; then
|
||||||
|
: # already the default — nothing to flip
|
||||||
|
elif [ "$newly_installed" -eq 0 ]; then
|
||||||
|
# A converge/no-op (or BOX_REINSTALL) of a version that is NOT the default
|
||||||
|
# never moves the default — a re-run must change nothing (#66); switching is
|
||||||
|
# 'box use', a deliberate act.
|
||||||
|
log "the default stays $(basename "$cur") — 'box use $new_ver' switches."
|
||||||
|
else
|
||||||
|
old_ver="$(basename "$cur")"
|
||||||
|
if names="$(existing_boxes)"; then
|
||||||
|
warn "this host has existing boxes:"
|
||||||
|
while IFS= read -r n; do warn " · $n"; done <<<"$names"
|
||||||
|
warn "refusing to change the default box version under them (#66) — the default stays at $old_ver."
|
||||||
|
log "box $new_ver is installed side-by-side. To switch:"
|
||||||
|
log " · preserve what you care about — 'box down <box>', copy out via 'box shell'/'box exec'"
|
||||||
|
log " (a portable 'box export' is #70), then 'box rm <box>' when you are done"
|
||||||
|
log " · then flip the default: box use $new_ver"
|
||||||
|
else
|
||||||
|
flip_current "$new_ver"
|
||||||
|
log "default version switched: $old_ver -> $new_ver ('box use $old_ver' switches back)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- put box on PATH -------------------------------------------------------
|
# --- put box on PATH -------------------------------------------------------
|
||||||
|
# ln -sfn converges, and that includes HEALING: a stale or dangling
|
||||||
|
# $BINDIR/box (say, its tree half-removed by hand) must never block or wedge
|
||||||
|
# an install — it gets repointed at the current chain, whatever it said before.
|
||||||
mkdir -p "$BINDIR"
|
mkdir -p "$BINDIR"
|
||||||
ln -sf "$DEST/bin/box" "$BINDIR/box"
|
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
|
||||||
log "linked $BINDIR/box -> $DEST/bin/box"
|
log "linked $BINDIR/box -> $DEST/current/bin/box"
|
||||||
# 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it
|
# 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it
|
||||||
# cannot dangle at the old bin path forever. Old BOXES keep working — the CLI
|
# cannot dangle at the old bin path forever. Old BOXES keep working — the CLI
|
||||||
# honors their legacy tag — it is only the old command name that retires.
|
# honors their legacy tag — it is only the old command name that retires.
|
||||||
|
|
@ -137,6 +299,34 @@ if [ -d "$OLD_DEST" ] && [ "$OLD_DEST" != "$DEST" ]; then
|
||||||
log "removed the old install tree at $OLD_DEST (it now lives at $DEST)"
|
log "removed the old install tree at $OLD_DEST (it now lives at $DEST)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# A global (root) install is run by OTHER users, but mv preserves the tarball's
|
||||||
|
# root:root ownership and GitHub's archives carry no world bits on some paths — so
|
||||||
|
# without this, a non-root caller cannot even traverse into $DEST to reach bin/box.
|
||||||
|
# Root owns the tree, nobody else writes it, everybody reads it. a+rX: read on
|
||||||
|
# files, +search (x) on directories only. Guarded on root so the per-user install
|
||||||
|
# stays byte-identical to before.
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
chmod -R a+rX "$DEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- the OTHER tier's install, if any --------------------------------------
|
||||||
|
# A root (/opt/box) and a per-user (~/.local/share/box) install coexist by
|
||||||
|
# PATH order alone, which is easy to be surprised by — say so out loud rather
|
||||||
|
# than let two versions silently shadow each other (#71's layout, both sides).
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
if [ -e /opt/box/current/bin/box ] || [ -e /opt/box/bin/box ]; then
|
||||||
|
warn "a GLOBAL install also exists at /opt/box — PATH order decides which 'box' you run (check: command -v box)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
sudo_home=""
|
||||||
|
if [ -n "${SUDO_USER:-}" ]; then
|
||||||
|
sudo_home="$(getent passwd "$SUDO_USER" | cut -d: -f6)" || sudo_home=""
|
||||||
|
fi
|
||||||
|
if [ -n "$sudo_home" ] && { [ -e "$sudo_home/.local/share/box/current/bin/box" ] || [ -e "$sudo_home/.local/share/box/bin/box" ]; }; then
|
||||||
|
warn "a PER-USER install also exists at $sudo_home/.local/share/box — PATH order decides which 'box' $SUDO_USER runs"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- PATH check ------------------------------------------------------------
|
# --- PATH check ------------------------------------------------------------
|
||||||
case ":$PATH:" in
|
case ":$PATH:" in
|
||||||
*":$BINDIR:"*) : ;;
|
*":$BINDIR:"*) : ;;
|
||||||
|
|
@ -147,29 +337,27 @@ case ":$PATH:" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Record WHAT was installed, so a caller can assert it got what it asked for.
|
|
||||||
# Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names
|
|
||||||
# retired in 0.5.0) silently falls back to the defaults and installs main —
|
|
||||||
# and the caller drills the wrong tree, believing it drilled its branch.
|
|
||||||
# Written BEFORE host setup: this records the install, which has now happened,
|
|
||||||
# and it must not hinge on whether the host stack came up.
|
|
||||||
printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM"
|
|
||||||
|
|
||||||
# --- host setup (second prompt) --------------------------------------------
|
# --- host setup (second prompt) --------------------------------------------
|
||||||
# The tool is installed; the machine is not yet a box host. Offer to finish the
|
# The tool is installed; the machine is not yet a box host. Offer to finish the
|
||||||
# job — build Incus and the isolation stack — rather than leave 'box new' to die
|
# job — build Incus and the isolation stack — rather than leave 'box new' to die
|
||||||
# later on a host with no boxnet and no profile (#64). This is its own decision:
|
# later on a host with no boxnet and no profile (#64). This is its own decision:
|
||||||
# you might be installing the CLI on a workstation and hosting boxes elsewhere.
|
# you might be installing the CLI on a workstation and hosting boxes elsewhere.
|
||||||
|
# Offered on a FRESH host only: a host that already had a box install has made
|
||||||
|
# this decision (and may have live boxes the stack must not be rebuilt under);
|
||||||
|
# 'box setup-host' re-applies stack changes deliberately, after an upgrade.
|
||||||
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
|
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
|
||||||
# up by hand); BOX_YES answers "yes".
|
# up by hand); BOX_YES answers "yes".
|
||||||
setup_ok=""
|
setup_ok=""
|
||||||
setup_declined=""
|
setup_declined=""
|
||||||
if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
|
if [ "$had_install" -eq 1 ]; then
|
||||||
|
log "this host already had a box install — skipping host setup (re-apply stack changes any time: box setup-host)"
|
||||||
|
setup_declined=1
|
||||||
|
elif [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
|
||||||
log "skipping host setup (BOX_SKIP_SETUP_HOST is set)."
|
log "skipping host setup (BOX_SKIP_SETUP_HOST is set)."
|
||||||
setup_declined=1
|
setup_declined=1
|
||||||
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
|
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
|
||||||
warn "cannot set up the host: it needs root and sudo was not found."
|
warn "cannot set up the host: it needs root and sudo was not found."
|
||||||
warn " run this as root to finish: $DEST/host/setup-host.sh"
|
warn " run this as root to finish: $DEST/current/host/setup-host.sh"
|
||||||
setup_declined=1
|
setup_declined=1
|
||||||
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
|
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
|
||||||
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
|
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
|
||||||
|
|
@ -177,7 +365,7 @@ elif confirm "Set up this machine as a box host now? (installs Incus + the isola
|
||||||
# it prompts on /dev/tty, so an interactive host can still authenticate.
|
# it prompts on /dev/tty, so an interactive host can still authenticate.
|
||||||
# setup-host re-execs itself under sg incus-admin if it must add you to the
|
# setup-host re-execs itself under sg incus-admin if it must add you to the
|
||||||
# group; that re-exec is a child here and completes the whole setup in one go.
|
# group; that re-exec is a child here and completes the whole setup in one go.
|
||||||
if bash "$DEST/host/setup-host.sh" </dev/null; then
|
if bash "$DEST/current/host/setup-host.sh" </dev/null; then
|
||||||
setup_ok=1
|
setup_ok=1
|
||||||
else
|
else
|
||||||
warn "host setup did not complete — box is installed, the host is not ready."
|
warn "host setup did not complete — box is installed, the host is not ready."
|
||||||
|
|
@ -189,9 +377,9 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$setup_ok" ]; then
|
if [ -n "$setup_ok" ]; then
|
||||||
log "done ($REPO@$REF) — try: box new --name test"
|
log "done ($SRCDESC, version $new_ver) — try: box new --name test"
|
||||||
elif [ -n "$setup_declined" ]; then
|
elif [ -n "$setup_declined" ]; then
|
||||||
log "done ($REPO@$REF) — when you want this machine to host boxes: box setup-host"
|
log "done ($SRCDESC, version $new_ver) — when you want this machine to host boxes: box setup-host"
|
||||||
else
|
else
|
||||||
log "done ($REPO@$REF) — finish with 'box setup-host', then: box new --name test"
|
log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
268
test/cli.sh
268
test/cli.sh
|
|
@ -227,7 +227,7 @@ check "revoke: purge refuses under unkillable sessions" 0 "" \
|
||||||
check "revoke: bare revoke warns about held sessions" 0 "" \
|
check "revoke: bare revoke warns about held sessions" 0 "" \
|
||||||
grep -qF 'live sessions' "$ROOT/host/revoke-user.sh"
|
grep -qF 'live sessions' "$ROOT/host/revoke-user.sh"
|
||||||
check "revoke: the purge asserts the certificate's absence too" 0 "" \
|
check "revoke: the purge asserts the certificate's absence too" 0 "" \
|
||||||
bash -c 'grep -A6 "Assert absence" "'"$ROOT"'/host/revoke-user.sh" | grep -q "config trust list"'
|
bash -c 'awk "/Assert absence/,0" "'"$ROOT"'/host/revoke-user.sh" | grep -q "config trust list"'
|
||||||
# A failed grant must not leave a half-granted user: if THIS run added the
|
# A failed grant must not leave a half-granted user: if THIS run added the
|
||||||
# group, the exit path takes it back (and the trap disarms only on success).
|
# group, the exit path takes it back (and the trap disarms only on success).
|
||||||
check "grant: backs out its own group-add on failure" 0 "" \
|
check "grant: backs out its own group-add on failure" 0 "" \
|
||||||
|
|
@ -276,8 +276,272 @@ check "multiuser.sh refuses without the env gate" 2 "opt in" \
|
||||||
bash "$ROOT/drill/multiuser.sh" --yes
|
bash "$ROOT/drill/multiuser.sh" --yes
|
||||||
check "grant-user.sh is valid bash" 0 "" bash -n "$ROOT/host/grant-user.sh"
|
check "grant-user.sh is valid bash" 0 "" bash -n "$ROOT/host/grant-user.sh"
|
||||||
check "revoke-user.sh is valid bash" 0 "" bash -n "$ROOT/host/revoke-user.sh"
|
check "revoke-user.sh is valid bash" 0 "" bash -n "$ROOT/host/revoke-user.sh"
|
||||||
|
check "teardown-host.sh is valid bash" 0 "" bash -n "$ROOT/host/teardown-host.sh"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Revoke leaves NOTHING (the grant/revoke cleanliness pass). The gap this
|
||||||
|
# closes: --purge removed /var/lib/incus/users/<uid> but never RE-CHECKED it —
|
||||||
|
# the one path its own absence assert did not cover. And the stat must ride
|
||||||
|
# $SUDO: /var/lib/incus is not traversable by a non-root admin, so a bare
|
||||||
|
# [ -d ] answers "absent" for a directory that is very much there.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
check "revoke: purge removes the incus-user state directory" 0 "" \
|
||||||
|
grep -qF '/var/lib/incus/users/' "$ROOT/host/revoke-user.sh"
|
||||||
|
check "revoke: the absence assert covers the incus-user state too" 0 "" \
|
||||||
|
bash -c 'awk "/Assert absence/,0" "'"$ROOT"'/host/revoke-user.sh" | grep -q "/var/lib/incus/users/"'
|
||||||
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||||||
|
check "revoke: the state checks go through \$SUDO test (an unprivileged stat lies)" 0 "" \
|
||||||
|
grep -qF '$SUDO test -d "/var/lib/incus/users/$uid"' "$ROOT/host/revoke-user.sh"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The versioned install (#66 → 0.7.0). BOX_INSTALL_SOURCE bypasses the network,
|
||||||
|
# so these are REAL runs of install.sh against throwaway BOX_HOME/BOX_BIN
|
||||||
|
# roots — layout, symlink chain, flat-tree migration, symlink healing, use and
|
||||||
|
# uninstall are all DRIVEN, not grepped. A fake `incus` on PATH answers the
|
||||||
|
# existing-boxes gate ($FAKE_BOXES names them), so the #66 refusals — refuse
|
||||||
|
# to flip, refuse to switch, refuse to uninstall under boxes — run for real
|
||||||
|
# too, with no daemon anywhere near this suite.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
VER="$(cat "$ROOT/VERSION")"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
||||||
|
|
||||||
|
ISHIM="$WORK/ishim"; mkdir -p "$ISHIM"
|
||||||
|
cat > "$ISHIM/incus" <<'SHIM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fake incus: 'list' prints $FAKE_BOXES (whitespace-separated names, one per
|
||||||
|
# line); everything else succeeds silently. Just enough for the existing-boxes
|
||||||
|
# gate that guards version flips.
|
||||||
|
case " $* " in
|
||||||
|
*" list "*) for b in ${FAKE_BOXES:-}; do printf '%s\n' "$b"; done ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
SHIM
|
||||||
|
chmod +x "$ISHIM/incus"
|
||||||
|
|
||||||
|
# A fabricated "newer release": the same CLI, a different VERSION — what an
|
||||||
|
# upgrade actually is, from the installer's point of view.
|
||||||
|
SRC9="$WORK/src-9.9.9"; mkdir -p "$SRC9/bin"
|
||||||
|
cp "$ROOT/bin/box" "$SRC9/bin/box"; chmod +x "$SRC9/bin/box"
|
||||||
|
echo "9.9.9-drill" > "$SRC9/VERSION"
|
||||||
|
SRC8="$WORK/src-8.8.8"; mkdir -p "$SRC8/bin"
|
||||||
|
cp "$ROOT/bin/box" "$SRC8/bin/box"; chmod +x "$SRC8/bin/box"
|
||||||
|
echo "8.8.8-drill" > "$SRC8/VERSION"
|
||||||
|
|
||||||
|
inst() { # inst <box_home> <box_bin> [VAR=val ...] — run install.sh for real
|
||||||
|
local h="$1" b="$2"; shift 2
|
||||||
|
env HOME="$FAKEHOME" PATH="$ISHIM:$PATH" FAKE_BOXES= \
|
||||||
|
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
|
||||||
|
BOX_INSTALL_SOURCE="$ROOT" "$@" bash "$ROOT/install.sh"
|
||||||
|
}
|
||||||
|
ibox() { # ibox [VAR=val ...] <cmd...> — run an installed box under the shim
|
||||||
|
env HOME="$FAKEHOME" PATH="$ISHIM:$PATH" FAKE_BOXES= "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- fresh install: the layout and the chain --------------------------------
|
||||||
|
H1="$WORK/h1"; B1="$WORK/b1"
|
||||||
|
check "install: a fresh install runs clean" 0 "done" inst "$H1" "$B1"
|
||||||
|
check "install: the tree lands in versions/<v>" 0 "" test -x "$H1/versions/$VER/bin/box"
|
||||||
|
check "install: 'current' points at versions/<v>" 0 "versions/$VER" readlink "$H1/current"
|
||||||
|
check "install: the PATH symlink rides the chain" 0 "$H1/current/bin/box" readlink "$B1/box"
|
||||||
|
check "install: box --version answers through the whole chain" 0 "box $VER" ibox "$B1/box" --version
|
||||||
|
check "install: INSTALLED_FROM records the local source" 0 "local:" cat "$H1/versions/$VER/INSTALLED_FROM"
|
||||||
|
|
||||||
|
# --- converge, don't clobber ------------------------------------------------
|
||||||
|
touch "$H1/versions/$VER/CANARY"
|
||||||
|
check "install: a same-version re-run is a no-op that says so (#66)" 0 "already installed" inst "$H1" "$B1"
|
||||||
|
check "install: the no-op left the tree untouched" 0 "" test -e "$H1/versions/$VER/CANARY"
|
||||||
|
check "install: BOX_REINSTALL=1 replaces that version's tree" 0 "reinstalled" inst "$H1" "$B1" BOX_REINSTALL=1
|
||||||
|
check "install: the reinstall really replaced it (canary gone)" 1 "" test -e "$H1/versions/$VER/CANARY"
|
||||||
|
|
||||||
|
# --- a second version: side-by-side, and the no-boxes flip ------------------
|
||||||
|
check "install: a second version installs side-by-side" 0 "" inst "$H1" "$B1" BOX_INSTALL_SOURCE="$SRC9"
|
||||||
|
check "install: ...into its own versions dir" 0 "" test -x "$H1/versions/9.9.9-drill/bin/box"
|
||||||
|
check "install: ...and the old version stays" 0 "" test -d "$H1/versions/$VER"
|
||||||
|
check "install: with no boxes, the default flips to the new version" 0 "box 9.9.9-drill" ibox "$B1/box" --version
|
||||||
|
|
||||||
|
# --- box versions -----------------------------------------------------------
|
||||||
|
check "versions: lists the installed versions" 0 "$VER" ibox "$B1/box" versions
|
||||||
|
check "versions: marks the current default" 0 "(current)" ibox "$B1/box" versions
|
||||||
|
check "versions: marks the running one" 0 "(running)" ibox "$B1/box" versions
|
||||||
|
|
||||||
|
# --- box use ----------------------------------------------------------------
|
||||||
|
check "use: no argument is a usage error" 2 "usage: box use" ibox "$B1/box" use
|
||||||
|
check "use: an unknown version is refused by name" 1 "no such version" ibox "$B1/box" use 1.2.3
|
||||||
|
# A version is a directory NAME — a crafted one must die at the gate, never
|
||||||
|
# reach the ln (current pointing outside the root) or an rm -rf.
|
||||||
|
check "use: a path-traversal version dies at the gate" 1 "not a sane version name" \
|
||||||
|
ibox "$B1/box" use '../../tmp/evil'
|
||||||
|
check "use: refuses under existing boxes, naming them (#66)" 1 "wedged" \
|
||||||
|
ibox FAKE_BOXES="wedged stuck" "$B1/box" use "$VER"
|
||||||
|
check "use: the refusal points at the remedy (box rm, then re-run)" 1 "box rm" \
|
||||||
|
ibox FAKE_BOXES=wedged "$B1/box" use "$VER"
|
||||||
|
check "use: with no boxes, flips the default" 0 "switched to $VER" ibox "$B1/box" use "$VER"
|
||||||
|
check "use: the flip is effective through the PATH chain" 0 "box $VER" ibox "$B1/box" --version
|
||||||
|
check "install: an installed-but-not-current version is a no-op too" 0 "already installed" \
|
||||||
|
inst "$H1" "$B1" BOX_INSTALL_SOURCE="$SRC9"
|
||||||
|
check "install: ...and does not move the default" 0 "box $VER" ibox "$B1/box" --version
|
||||||
|
|
||||||
|
# --- the upgrade-under-boxes refusal, driven end to end ---------------------
|
||||||
|
H2="$WORK/h2"; B2="$WORK/b2"
|
||||||
|
check "refusal drill: baseline install" 0 "done" inst "$H2" "$B2"
|
||||||
|
check "upgrade under boxes: REFUSES the default flip (#66)" 0 "refusing to change the default box version" \
|
||||||
|
inst "$H2" "$B2" BOX_INSTALL_SOURCE="$SRC9" FAKE_BOXES=work
|
||||||
|
check "upgrade under boxes: the new version IS installed side-by-side" 0 "" \
|
||||||
|
test -d "$H2/versions/9.9.9-drill"
|
||||||
|
check "upgrade under boxes: the default stayed put" 0 "box $VER" ibox "$B2/box" --version
|
||||||
|
check "upgrade under boxes: the blocking boxes are NAMED" 0 "· work" \
|
||||||
|
inst "$H2" "$B2" BOX_INSTALL_SOURCE="$SRC8" FAKE_BOXES=work
|
||||||
|
check "upgrade under boxes: the refusal names the deliberate flip" 0 "" \
|
||||||
|
bash -c 'grep -q "then flip the default: box use" "'"$ROOT"'/install.sh"'
|
||||||
|
|
||||||
|
# --- migration: a 0.6.0 flat tree becomes a versioned one -------------------
|
||||||
|
H3="$WORK/h3"; B3="$WORK/b3"; mkdir -p "$H3/bin" "$B3"
|
||||||
|
cp "$ROOT/bin/box" "$H3/bin/box"; chmod +x "$H3/bin/box"
|
||||||
|
cp "$ROOT/VERSION" "$H3/VERSION"
|
||||||
|
echo "test@flat" > "$H3/INSTALLED_FROM"
|
||||||
|
ln -s "$H3/bin/box" "$B3/box"
|
||||||
|
check "migrate: a pre-0.7.0 flat tree is moved into versions/" 0 "migrating" inst "$H3" "$B3"
|
||||||
|
check "migrate: the OPERATOR'S tree moved (not a fresh copy)" 0 "test@flat" \
|
||||||
|
cat "$H3/versions/$VER/INSTALLED_FROM"
|
||||||
|
check "migrate: nothing flat remains at the root" 1 "" test -e "$H3/bin"
|
||||||
|
check "migrate: current points at the migrated version" 0 "versions/$VER" readlink "$H3/current"
|
||||||
|
check "migrate: the PATH symlink was re-pointed through current" 0 "$H3/current/bin/box" readlink "$B3/box"
|
||||||
|
check "migrate: the migrated install answers --version" 0 "box $VER" ibox "$B3/box" --version
|
||||||
|
|
||||||
|
# ...and the seamless 0.6.0 → 0.7.0 upgrade: flat tree in, new version beside it.
|
||||||
|
H4="$WORK/h4"; B4="$WORK/b4"; mkdir -p "$H4/bin" "$B4"
|
||||||
|
cp "$ROOT/bin/box" "$H4/bin/box"; chmod +x "$H4/bin/box"
|
||||||
|
cp "$ROOT/VERSION" "$H4/VERSION"
|
||||||
|
ln -s "$H4/bin/box" "$B4/box"
|
||||||
|
check "migrate+upgrade: flat 0.6.0 in, new version installed beside it" 0 "" \
|
||||||
|
inst "$H4" "$B4" BOX_INSTALL_SOURCE="$SRC9"
|
||||||
|
check "migrate+upgrade: both versions present" 0 "" \
|
||||||
|
bash -c "[ -d '$H4/versions/$VER' ] && [ -d '$H4/versions/9.9.9-drill' ]"
|
||||||
|
check "migrate+upgrade: no boxes → the new version is the default" 0 "box 9.9.9-drill" \
|
||||||
|
ibox "$B4/box" --version
|
||||||
|
|
||||||
|
# A broken current must halt the single-version path BEFORE any decision: the
|
||||||
|
# CURRENT guard keys off what current resolves to, and a dangling link makes
|
||||||
|
# that answer a lie. Drive the version tree's own binary — the current chain
|
||||||
|
# is exactly what is broken. H4 has two versions; heal current afterwards.
|
||||||
|
ln -sfn "versions/gone" "$H4/current"
|
||||||
|
check "uninstall: refuses while current is dangling (heal before delete)" 1 "dangling" \
|
||||||
|
ibox "$H4/versions/$VER/bin/box" uninstall 9.9.9-drill --force
|
||||||
|
check "uninstall: ...and both version trees survived the refusal" 0 "" \
|
||||||
|
bash -c "[ -d '$H4/versions/$VER' ] && [ -d '$H4/versions/9.9.9-drill' ]"
|
||||||
|
ln -sfn "versions/9.9.9-drill" "$H4/current"
|
||||||
|
|
||||||
|
# The migration reads VERSION off the old tree — disk data, not installer
|
||||||
|
# data. A hostile value must refuse BEFORE the tree moves anywhere.
|
||||||
|
H9="$WORK/h9"; B9="$WORK/b9"; mkdir -p "$H9/bin" "$B9"
|
||||||
|
cp "$ROOT/bin/box" "$H9/bin/box"; chmod +x "$H9/bin/box"
|
||||||
|
printf '%s\n' '../pwn' > "$H9/VERSION"
|
||||||
|
check "migrate: a hostile flat VERSION refuses to migrate" 1 "not a sane directory name" \
|
||||||
|
inst "$H9" "$B9"
|
||||||
|
check "migrate: ...with the flat tree untouched where it was" 0 "" test -x "$H9/bin/box"
|
||||||
|
|
||||||
|
# --- healing: a wedged \$BINDIR/box must never block an install -------------
|
||||||
|
H5="$WORK/h5"; B5="$WORK/b5"; mkdir -p "$B5"
|
||||||
|
ln -s "$WORK/nowhere/box" "$B5/box" # dangling
|
||||||
|
check "heal: a DANGLING \$BINDIR/box does not wedge the install" 0 "done" inst "$H5" "$B5"
|
||||||
|
check "heal: ...and got repointed" 0 "box $VER" ibox "$B5/box" --version
|
||||||
|
H6="$WORK/h6"; B6="$WORK/b6"; mkdir -p "$B6"
|
||||||
|
ln -s /bin/true "$B6/box" # stale, but resolvable
|
||||||
|
check "heal: a STALE \$BINDIR/box with no tree does not fake 'installed'" 0 "installing $VER" \
|
||||||
|
inst "$H6" "$B6"
|
||||||
|
check "heal: ...the install is real and answers" 0 "box $VER" ibox "$B6/box" --version
|
||||||
|
|
||||||
|
# --- box uninstall: one version ---------------------------------------------
|
||||||
|
check "uninstall: refuses to remove the CURRENT version" 1 "CURRENT" \
|
||||||
|
ibox "$B1/box" uninstall "$VER" --force
|
||||||
|
check "uninstall: an unknown version is refused by name" 1 "no such version" \
|
||||||
|
ibox "$B1/box" uninstall 5.5.5 --force
|
||||||
|
check "uninstall: a path-traversal version dies at the gate (never an rm -rf)" 1 "not a sane version name" \
|
||||||
|
ibox "$B1/box" uninstall '../../../../etc' --force
|
||||||
|
check "uninstall: a version plus --all is ambiguous (usage error)" 2 "" \
|
||||||
|
ibox "$B1/box" uninstall 9.9.9-drill --all --force
|
||||||
|
check "uninstall: removes a non-current version" 0 "removed version" \
|
||||||
|
ibox "$B1/box" uninstall 9.9.9-drill --force
|
||||||
|
check "uninstall: that version dir is gone" 1 "" test -e "$H1/versions/9.9.9-drill"
|
||||||
|
check "uninstall: the current version still answers" 0 "box $VER" ibox "$B1/box" --version
|
||||||
|
|
||||||
|
# --- box uninstall: everything, in the safe order ---------------------------
|
||||||
|
check "uninstall: refuses while boxes exist, naming them" 1 "wedged" \
|
||||||
|
ibox FAKE_BOXES=wedged "$B1/box" uninstall --all --force
|
||||||
|
check "uninstall: the refusal offers --purge-host" 1 "purge-host" \
|
||||||
|
ibox FAKE_BOXES=wedged "$B1/box" uninstall --all --force
|
||||||
|
check "uninstall: refuses without --force when no terminal" 2 "refusing" \
|
||||||
|
ibox bash -c "'$B1/box' uninstall --all </dev/null"
|
||||||
|
# Plant legacy crumbs: a real uninstall leaves neither name generation behind.
|
||||||
|
mkdir -p "$FAKEHOME/.local/share/claudebox"
|
||||||
|
ln -s "$WORK/gone" "$B1/claudebox"
|
||||||
|
check "uninstall --all: removes the whole install" 0 "uninstalled" \
|
||||||
|
ibox "$B1/box" uninstall --all --force
|
||||||
|
check "uninstall --all: ZERO residue — root, symlinks, legacy names" 0 "" bash -c "
|
||||||
|
[ ! -e '$H1' ] && [ ! -L '$H1' ] &&
|
||||||
|
[ ! -e '$B1/box' ] && [ ! -L '$B1/box' ] &&
|
||||||
|
[ ! -e '$B1/claudebox' ] && [ ! -L '$B1/claudebox' ] &&
|
||||||
|
[ ! -e '$FAKEHOME/.local/share/claudebox' ]"
|
||||||
|
# The last word is a re-check: a survivor must turn into a loud INCOMPLETE,
|
||||||
|
# never a cheerful "uninstalled". (Root ignores file modes, so this drill is
|
||||||
|
# meaningful — and runnable — for a non-root runner only.)
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
H7="$WORK/h7"; B7="$WORK/b7"
|
||||||
|
inst "$H7" "$B7" >/dev/null 2>&1
|
||||||
|
mkdir -p "$H7/versions/$VER/stuck"; touch "$H7/versions/$VER/stuck/pin"
|
||||||
|
chmod 555 "$H7/versions/$VER/stuck"
|
||||||
|
check "uninstall: a survivor makes it scream INCOMPLETE (exit 1)" 1 "INCOMPLETE" \
|
||||||
|
ibox "$B7/box" uninstall --all --force
|
||||||
|
chmod -R u+w "$H7" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- the versioned verbs from a working tree: refuse, don't guess -----------
|
||||||
|
check "uninstall: refuses from a working tree" 1 "not a versioned install" "$BOX" uninstall --all --force
|
||||||
|
check "versions: refuses from a working tree" 1 "not a versioned install" "$BOX" versions
|
||||||
|
check "use: refuses from a working tree" 1 "not a versioned install" "$BOX" use 1.0.0
|
||||||
|
|
||||||
|
# The existing-boxes gate must be ONE decision: install.sh and bin/box carry
|
||||||
|
# byte-identical copies (the installer runs before any tree exists), and a
|
||||||
|
# drifted copy is two #66 stances pretending to be one.
|
||||||
|
EBBIN="$(mktemp)"; EBINST="$(mktemp)"
|
||||||
|
awk '/^existing_boxes\(\) \{/,/^\}/' "$ROOT/bin/box" > "$EBBIN"
|
||||||
|
awk '/^existing_boxes\(\) \{/,/^\}/' "$ROOT/install.sh" > "$EBINST"
|
||||||
|
check "existing_boxes: extracted from bin/box (guards the awk)" 0 "user.box=1" cat "$EBBIN"
|
||||||
|
check "existing_boxes: bin/box and install.sh copies are byte-identical" 0 "" diff "$EBBIN" "$EBINST"
|
||||||
|
rm -f "$EBBIN" "$EBINST"
|
||||||
|
|
||||||
|
# Same discipline for the version-name gate: one policy, two copies, no drift
|
||||||
|
# — a version that install.sh would refuse must not be one 'box use' accepts.
|
||||||
|
VVBIN="$(mktemp)"; VVINST="$(mktemp)"
|
||||||
|
awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/bin/box" > "$VVBIN"
|
||||||
|
awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/install.sh" > "$VVINST"
|
||||||
|
check "valid_version: extracted from bin/box (guards the awk)" 0 "A-Za-z0-9" cat "$VVBIN"
|
||||||
|
check "valid_version: bin/box and install.sh copies are byte-identical" 0 "" diff "$VVBIN" "$VVINST"
|
||||||
|
rm -f "$VVBIN" "$VVINST"
|
||||||
|
|
||||||
|
# --purge-host must FORWARD installer-family consent: under --force/BOX_YES
|
||||||
|
# the teardown call carries --yes, or a non-interactive combined uninstall
|
||||||
|
# dies at teardown's own prompt with the flag's promise broken.
|
||||||
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||||
|
check "uninstall: --purge-host forwards consent to teardown-host (--yes)" 0 "" \
|
||||||
|
grep -qF -- 'bash "$root/host/teardown-host.sh" --yes' "$ROOT/bin/box"
|
||||||
|
|
||||||
|
# --- the help keeps its promises --------------------------------------------
|
||||||
|
check "help: the table lists 'versions'" 0 "versions" "$BOX" help
|
||||||
|
check "help use: names the #66 stance" 0 "boxes" "$BOX" help use
|
||||||
|
check "help uninstall: names --purge-host" 0 "purge-host" "$BOX" help uninstall
|
||||||
|
check "help uninstall: promises the absence re-check" 0 "absence" "$BOX" help uninstall
|
||||||
|
|
||||||
|
# --- automation hooks the CI uninstall drill rides ---------------------------
|
||||||
|
check "teardown-host: honors --yes/BOX_YES (CI runs it unattended)" 0 "" \
|
||||||
|
grep -qF 'BOX_YES' "$ROOT/host/teardown-host.sh"
|
||||||
|
check "teardown-host: points at box uninstall when done" 0 "" \
|
||||||
|
grep -qF "box uninstall" "$ROOT/host/teardown-host.sh"
|
||||||
|
check "drill: reads the installed tree through current/" 0 "" \
|
||||||
|
grep -qF '.local/share/box/current/VERSION' "$ROOT/drill/drill.sh"
|
||||||
|
|
||||||
echo "---"
|
echo "---"
|
||||||
echo "$PASS passed, $FAIL failed"
|
echo "$PASS passed, $FAIL failed"
|
||||||
rm -rf "$SHIMDIR"
|
rm -rf "$SHIMDIR" "$WORK"
|
||||||
[ "$FAIL" -eq 0 ]
|
[ "$FAIL" -eq 0 ]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue