feat(cli): versions, use, uninstall — the install managed from the CLI, absence-asserted

'box versions' lists what is installed (current + running marked); 'box use'
flips the default, refuses under existing boxes (#66), and asserts the
EFFECTIVE result — current resolves to the version asked for and the chain's
own --version answers it. 'box uninstall' is the real uninstall the old
two-rm-rf-lines of prose never were: one version, or everything in the safe
order (boxes first — refuse or --purge-host — then trees and symlinks), and
it ends by re-checking every promised path is gone, exiting 1 naming
leftovers (the revoke --purge discipline). teardown-host grows --yes/BOX_YES
for the unattended paths and points at 'box uninstall'; the drill reads the
installed tree through current/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-18 16:01:57 +00:00
parent 6e92853925
commit 9d3ca92ffa
3 changed files with 306 additions and 19 deletions

276
bin/box
View file

@ -77,6 +77,9 @@ CMDS=(
"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^"
"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^"
"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 --retire-legacy # remove the old stack (once no legacy box remains)
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'
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
# expose's own flags (--list, --remove) are positional to it, not box's
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
# 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
# the host verbs delegate their flags to the scripts they wrap;
# 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
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
fi
@ -1045,6 +1099,224 @@ cmd_migrate_host() { host_script migrate-host.sh; }
cmd_grant() { host_script grant-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"
}
# 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"
[ -d "$ir/versions/$v" ] || die "no such version: $v (see 'box versions')"
die_under_boxes "switch the default box version" "box use $v"
ln -sfn "versions/$v" "$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"
[ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'box versions')"
cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
[ "$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
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)"
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]:-}"; }
# The escape hatch. The box is resolved and tag-checked; everything else is

View file

@ -202,10 +202,11 @@ EOF
export BOX_SKIP_SETUP_HOST=1
fi
# The installer is a no-op when box is already installed — upgrading is
# uninstall-then-install, by design. The drill re-proves a tree from scratch
# every run, so it does the uninstall itself: clear any prior tree and symlink
# before installing, or install.sh would correctly refuse to touch them.
# The installer converges when a version is already installed (0.7.0's
# versioned layout: re-running the same version is a no-op, and an upgrade
# lands side-by-side without flipping under boxes). The drill re-proves a
# 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"
# 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
# the wrong tree while reporting success. A drill that silently drills the
# 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
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
@ -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 " is supposed to converge in one run (#63). One of those did not happen." >&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
exit 1
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
# restores the stack rather than a fresh build.
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 " 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
@ -469,7 +470,7 @@ phase "B. The box surface"
# ===========================================================================
# Compare against the installed tree's VERSION file, not a hardcoded number —
# 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)"
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
# bad template in the installed tree (the drill owns this host), expect the
# 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"
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" \
@ -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
# migrate-host.sh moves it onto the new stack with its identity intact, and
# 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
no "migrate-host.sh not installed — cannot drill the transition"
else
@ -985,5 +986,5 @@ fi
echo
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 "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 ]

View file

@ -3,20 +3,34 @@
# 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
# 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
# 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
purge=false
[ "${1:-}" = "--purge-incus" ] && purge=true
purge=false; yes=0
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 "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
echo "(both current and pre-0.4.0 names)."
$purge && echo "Incus itself will also be uninstalled (--purge-incus)."
read -rp "Continue? [y/N] " a
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
if [ "$yes" -eq 1 ]; then
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
# 'incus delete' aborts at the first missing name).
@ -72,4 +86,4 @@ if $purge; then
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.)"