2026-07-10 15:00:36 +00:00
#!/usr/bin/env bash
set -euo pipefail
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
The 0.4.0 rename left surface leftovers the user hit through the
CLAUDEBOX_* env vars. Sweep them, drawing a clean line:
box = everything the user touches — env vars (BOX_REPO/REF/HOME/
BIN), installer messages (box-install:), the install tree
(~/.local/share/box, with the installer sweeping the old
~/.local/share/claudebox on upgrade), tool prose, and the
docs (docs/box-{design,recipe}.md).
claudebox = the GitHub repo name (URLs, the claudebox-<ref> tarball
dir, issue refs), the legacy user.claudebox=1 tag, the
old-stack cleanup code (claudenet/claude-dev/claude-isolate/
claudebox-firewall), and the .claudebox/ runbook convention
— a deliberate v1 hold, since renaming it breaks consuming
repos.
Renamed the two doc files and their links; updated drill.sh/doctor.sh
paths and BOX_REPO/BOX_REF; fixed the claude template's in-box briefing
to say 'box'. RUNS.md left as-is (append-only history). No behavior
change beyond the install-dir move, which the installer migrates.
2026-07-14 17:44:24 +00:00
# box installer — intended for: curl -fsSL .../install.sh | bash
2026-07-10 15:00:36 +00:00
#
2026-07-18 16:01:57 +00:00
# Downloads the box source tarball from its GitHub repo (heavy-duty/box) and
# installs it into the VERSIONED layout under $DEST:
#
# $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.
2026-07-10 15:00:36 +00:00
2026-07-14 23:59:34 +00:00
REPO = " ${ BOX_REPO :- heavy -duty/box } "
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
The 0.4.0 rename left surface leftovers the user hit through the
CLAUDEBOX_* env vars. Sweep them, drawing a clean line:
box = everything the user touches — env vars (BOX_REPO/REF/HOME/
BIN), installer messages (box-install:), the install tree
(~/.local/share/box, with the installer sweeping the old
~/.local/share/claudebox on upgrade), tool prose, and the
docs (docs/box-{design,recipe}.md).
claudebox = the GitHub repo name (URLs, the claudebox-<ref> tarball
dir, issue refs), the legacy user.claudebox=1 tag, the
old-stack cleanup code (claudenet/claude-dev/claude-isolate/
claudebox-firewall), and the .claudebox/ runbook convention
— a deliberate v1 hold, since renaming it breaks consuming
repos.
Renamed the two doc files and their links; updated drill.sh/doctor.sh
paths and BOX_REPO/BOX_REF; fixed the claude template's in-box briefing
to say 'box'. RUNS.md left as-is (append-only history). No behavior
change beyond the install-dir move, which the installer migrates.
2026-07-14 17:44:24 +00:00
REF = " ${ BOX_REF :- main } "
2026-07-18 00:01:15 +00:00
# Root installs GLOBALLY, non-root installs per-user. box's install tree is
# EXECUTED by other users (the multi-user host path: rig installs box once, every
# incus-group operator runs it) — unlike rig, which is root-only and can hide in
# /root. So a root install must land in a system location, not $HOME: /root is
# 0700, so a $HOME/.local tree there is unreadable to everyone else and the whole
# fleet gets 'command not found' (#71). /opt/box is the world-readable system
# tree; /usr/local/bin is already on every login PATH. BOX_HOME/BOX_BIN still win.
if [ " $( id -u) " -eq 0 ] ; then
DEST = " ${ BOX_HOME :- /opt/box } "
BINDIR = " ${ BOX_BIN :- /usr/local/bin } "
else
DEST = " ${ BOX_HOME :- $HOME /.local/share/box } "
BINDIR = " ${ BOX_BIN :- $HOME /.local/bin } "
fi
2026-07-10 15:00:36 +00:00
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
The 0.4.0 rename left surface leftovers the user hit through the
CLAUDEBOX_* env vars. Sweep them, drawing a clean line:
box = everything the user touches — env vars (BOX_REPO/REF/HOME/
BIN), installer messages (box-install:), the install tree
(~/.local/share/box, with the installer sweeping the old
~/.local/share/claudebox on upgrade), tool prose, and the
docs (docs/box-{design,recipe}.md).
claudebox = the GitHub repo name (URLs, the claudebox-<ref> tarball
dir, issue refs), the legacy user.claudebox=1 tag, the
old-stack cleanup code (claudenet/claude-dev/claude-isolate/
claudebox-firewall), and the .claudebox/ runbook convention
— a deliberate v1 hold, since renaming it breaks consuming
repos.
Renamed the two doc files and their links; updated drill.sh/doctor.sh
paths and BOX_REPO/BOX_REF; fixed the claude template's in-box briefing
to say 'box'. RUNS.md left as-is (append-only history). No behavior
change beyond the install-dir move, which the installer migrates.
2026-07-14 17:44:24 +00:00
log( ) { printf 'box-install: %s\n' " $* " ; }
warn( ) { printf 'box-install: WARNING: %s\n' " $* " >& 2; }
die( ) { printf 'box-install: ERROR: %s\n' " $* " >& 2; exit 1; }
2026-07-10 15:00:36 +00:00
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
# Ask a yes/no question and echo the answer. The wrinkle: under the intended
# 'curl … | bash', THIS SCRIPT is stdin — so a plain 'read' would consume the
# installer's own remaining lines, not the user's keystroke. Prompts therefore
# read the terminal directly via /dev/tty. When there is no terminal at all (CI,
# a pipe with no tty), there is nobody to ask: BOX_YES=1 means "assume yes to
# every prompt" and is how automation and the drill drive this unattended;
# without it we refuse rather than silently assume consent.
confirm( ) { # $1 = question
[ -n " ${ BOX_YES :- } " ] && return 0
if ! { true >/dev/tty; } 2>/dev/null; then
die "no terminal to confirm on. Re-run with BOX_YES=1 to proceed non-interactively (assumes yes to all prompts)."
fi
local reply
printf 'box-install: %s [y/N] ' " $1 " >/dev/tty
read -r reply </dev/tty || reply = ""
case " $reply " in y| Y| yes| YES) return 0 ; ; *) return 1 ; ; esac
}
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
# 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
}
2026-07-18 16:01:57 +00:00
# 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 .
}
2026-07-10 15:00:36 +00:00
# --- prerequisites ---------------------------------------------------------
2026-07-18 16:01:57 +00:00
# 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
2026-07-10 15:00:36 +00:00
command -v tar >/dev/null 2>& 1 || die "tar is required but was not found. Please install tar and re-run."
2026-07-18 16:01:57 +00:00
if [ -n " ${ BOX_INSTALL_SOURCE :- } " ] ; then
SRCDESC = " local source $BOX_INSTALL_SOURCE "
else
SRCDESC = " $REPO @ $REF "
fi
# --- confirm first ---------------------------------------------------------
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is
2026-07-18 16:01:57 +00:00
# ask whether you meant to. Everything after this converges: re-running with a
# version that is already installed changes nothing and says so, which
# dissolves the whole "curl clobbered my working install / rebuilt the stack
# under my boxes" class of failures (#66).
confirm " Install box from $SRCDESC ? " || die "cancelled — nothing was changed."
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
# Flip $DEST/current to versions/<v> atomically: build the new link beside it,
# rename over. Plain ln -sfn is unlink+create — a window where current names
# 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 "
}
2026-07-18 16:01:57 +00:00
# --- 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) "
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
# 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 "
2026-07-18 16:01:57 +00:00
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 "
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
flip_current " $flat_ver "
2026-07-18 16:01:57 +00:00
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
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
fi
2026-07-10 15:00:36 +00:00
# --- temp workspace --------------------------------------------------------
TMPDIR = " $( mktemp -d) "
cleanup( ) { rm -rf " $TMPDIR " ; }
trap cleanup EXIT
2026-07-18 16:01:57 +00:00
# --- acquire the tree ------------------------------------------------------
if [ -n " ${ BOX_INSTALL_SOURCE :- } " ] ; then
SRC = " $BOX_INSTALL_SOURCE "
INSTALLED_FROM = " local: $SRC "
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 "
2026-07-10 15:00:36 +00:00
2026-07-18 16:01:57 +00:00
log "extracting archive"
tar -xzf " $TMPDIR /box.tar.gz " -C " $TMPDIR " \
|| die "failed to extract archive"
2026-07-10 15:00:36 +00:00
2026-07-18 16:01:57 +00:00
# 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
# claudebox → box rename, when this glob kept looking for claudebox-* and the
# 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
# check below judge whether it is the right tree.
EXTRACTED = " $( find " $TMPDIR " -mindepth 1 -maxdepth 1 -type d | head -n1) "
fi
[ -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? "
2026-07-10 15:00:36 +00:00
2026-07-18 16:01:57 +00:00
# The tree's own VERSION file names the directory it lands in — the version IS
# the identity of what is being installed, and 'box versions' lists these names.
new_ver = " $( cat " $EXTRACTED /VERSION " 2>/dev/null || true ) "
[ -n " $new_ver " ] || die "source has no VERSION file — cannot install it as a version"
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
valid_version " $new_ver " || die " the source's VERSION is not a sane directory name: ' $new_ver ' "
2026-07-10 15:00:36 +00:00
2026-07-18 16:01:57 +00:00
# --- 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 "
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
stage = " $VDIR .new. $$ " ; old = " $VDIR .old. $$ "
rm -rf " $stage " " $old "
2026-07-18 16:01:57 +00:00
chmod +x " $EXTRACTED /bin/box "
mv " $EXTRACTED " " $stage "
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
# 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 "
2026-07-18 16:01:57 +00:00
mv " $stage " " $VDIR "
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
rm -rf " $old "
2026-07-18 16:01:57 +00:00
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
2026-07-10 15:00:36 +00:00
2026-07-18 16:01:57 +00:00
# --- which version is the default? -----------------------------------------
# 'current' is the tracked default; flipping it is the ONLY step that changes
# what an operator's `box` runs. #66's stance, kept exactly here: never change
# versions under existing boxes. A fresh host (or a dangling current) is
# claimed outright; an upgrade flips only when no box exists — otherwise the
# new version sits installed side-by-side and 'box use' is the deliberate act.
cur = " $( readlink -f " $DEST /current " 2>/dev/null || true ) "
want = " $( readlink -f " $VDIR " ) "
if [ -z " $cur " ] || [ ! -d " $cur " ] ; then
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
flip_current " $new_ver "
2026-07-18 16:01:57 +00:00
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
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
flip_current " $new_ver "
2026-07-18 16:01:57 +00:00
log " default version switched: $old_ver -> $new_ver ('box use $old_ver ' switches back) "
fi
2026-07-18 00:01:15 +00:00
fi
feat!: claudebox becomes box — the Claude box is one template among several
The tool underneath was already generic: a thin, honest wrapper over
Incus. What was Claude-specific was welded on — one image, one profile,
one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a
template.
The mechanic: 'box new' stamps the template's identity onto the
instance (user.box=1, user.box.template, user.box.user); shell/exec/
tmux read the user back off the instance, and 'incus copy' carries
user.* keys (audit B2), so a clone knows what it is without consulting
the template. Templates are box.env (parsed against a strict allowlist,
never sourced — no key for a network exists, on purpose) plus a
verbatim cloud-init. Every template launches with the shared box-net
profile: the isolated NIC and root disk, nothing template-controlled —
resources land per-instance from box.env, overridable via BOX_CPU/
BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a
small host now that profile edits can't).
The three open calls, taken as recommended: clean cut at 0.4.0 (no
claudebox shim; the installer retires the old symlink); default
template = claude (muscle memory survives); repo stays heavy-duty/
claudebox, binary is box.
Compat is the tag, not the name: resolve_box and list honor the legacy
user.claudebox=1 forever, and the legacy tag maps to the claude user —
a pre-rename box lists, shells, clones, unchanged.
Deliberate divergence from #17's table: the host-stack resource names
(claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT
renamed — they are host-internal, invisible to users, and renaming
them breaks every provisioned host for zero user-visible gain.
claude-dev is no longer created; setup-host creates box-net, teardown
removes both.
Closes #17
2026-07-14 14:22:50 +00:00
# --- put box on PATH -------------------------------------------------------
2026-07-18 16:01:57 +00:00
# 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.
2026-07-10 15:00:36 +00:00
mkdir -p " $BINDIR "
2026-07-18 16:01:57 +00:00
ln -sfn " $DEST /current/bin/box " " $BINDIR /box "
log " linked $BINDIR /box -> $DEST /current/bin/box "
feat!: claudebox becomes box — the Claude box is one template among several
The tool underneath was already generic: a thin, honest wrapper over
Incus. What was Claude-specific was welded on — one image, one profile,
one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a
template.
The mechanic: 'box new' stamps the template's identity onto the
instance (user.box=1, user.box.template, user.box.user); shell/exec/
tmux read the user back off the instance, and 'incus copy' carries
user.* keys (audit B2), so a clone knows what it is without consulting
the template. Templates are box.env (parsed against a strict allowlist,
never sourced — no key for a network exists, on purpose) plus a
verbatim cloud-init. Every template launches with the shared box-net
profile: the isolated NIC and root disk, nothing template-controlled —
resources land per-instance from box.env, overridable via BOX_CPU/
BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a
small host now that profile edits can't).
The three open calls, taken as recommended: clean cut at 0.4.0 (no
claudebox shim; the installer retires the old symlink); default
template = claude (muscle memory survives); repo stays heavy-duty/
claudebox, binary is box.
Compat is the tag, not the name: resolve_box and list honor the legacy
user.claudebox=1 forever, and the legacy tag maps to the claude user —
a pre-rename box lists, shells, clones, unchanged.
Deliberate divergence from #17's table: the host-stack resource names
(claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT
renamed — they are host-internal, invisible to users, and renaming
them breaks every provisioned host for zero user-visible gain.
claude-dev is no longer created; setup-host creates box-net, teardown
removes both.
Closes #17
2026-07-14 14:22:50 +00:00
# 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
# honors their legacy tag — it is only the old command name that retires.
if [ -L " $BINDIR /claudebox " ] ; then
rm -f " $BINDIR /claudebox "
log "removed the old claudebox symlink — the command is 'box' now (your existing boxes keep working)"
fi
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
The 0.4.0 rename left surface leftovers the user hit through the
CLAUDEBOX_* env vars. Sweep them, drawing a clean line:
box = everything the user touches — env vars (BOX_REPO/REF/HOME/
BIN), installer messages (box-install:), the install tree
(~/.local/share/box, with the installer sweeping the old
~/.local/share/claudebox on upgrade), tool prose, and the
docs (docs/box-{design,recipe}.md).
claudebox = the GitHub repo name (URLs, the claudebox-<ref> tarball
dir, issue refs), the legacy user.claudebox=1 tag, the
old-stack cleanup code (claudenet/claude-dev/claude-isolate/
claudebox-firewall), and the .claudebox/ runbook convention
— a deliberate v1 hold, since renaming it breaks consuming
repos.
Renamed the two doc files and their links; updated drill.sh/doctor.sh
paths and BOX_REPO/BOX_REF; fixed the claude template's in-box briefing
to say 'box'. RUNS.md left as-is (append-only history). No behavior
change beyond the install-dir move, which the installer migrates.
2026-07-14 17:44:24 +00:00
# 0.5.0 moved the install tree from ~/.local/share/claudebox to ~/.local/share/box.
# Sweep the old tree so an upgrade does not leave a stale copy behind.
OLD_DEST = " $HOME /.local/share/claudebox "
if [ -d " $OLD_DEST " ] && [ " $OLD_DEST " != " $DEST " ] ; then
rm -rf " $OLD_DEST "
log " removed the old install tree at $OLD_DEST (it now lives at $DEST ) "
fi
2026-07-10 15:00:36 +00:00
2026-07-18 16:01:57 +00:00
# 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
2026-07-10 15:00:36 +00:00
# --- PATH check ------------------------------------------------------------
case " : $PATH : " in
*" : $BINDIR : " *) : ; ;
*)
log " note: $BINDIR is not on your PATH. "
log " add this to your shell rc (e.g. ~/.bashrc or ~/.zshrc):"
log " export PATH=\" $BINDIR :\$PATH\" "
; ;
esac
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
# --- host setup (second prompt) --------------------------------------------
# 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
# 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.
2026-07-18 16:01:57 +00:00
# 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.
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
# up by hand); BOX_YES answers "yes".
Make host setup complete in one run, and let the installer run it
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.
The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.
With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.
Fixes #63
Fixes #64
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:52:50 +00:00
setup_ok = ""
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
setup_declined = ""
2026-07-18 16:01:57 +00:00
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
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
log "skipping host setup (BOX_SKIP_SETUP_HOST is set)."
setup_declined = 1
Make host setup complete in one run, and let the installer run it
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.
The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.
With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.
Fixes #63
Fixes #64
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:52:50 +00:00
elif [ " $( id -u) " -ne 0 ] && ! command -v sudo >/dev/null 2>& 1; then
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
warn "cannot set up the host: it needs root and sudo was not found."
2026-07-18 16:01:57 +00:00
warn " run this as root to finish: $DEST /current/host/setup-host.sh "
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
setup_declined = 1
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)" ; then
Make host setup complete in one run, and let the installer run it
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.
The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.
With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.
Fixes #63
Fixes #64
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:52:50 +00:00
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
# reads stdin eats the installer's own remaining lines. sudo is unaffected —
# it prompts on /dev/tty, so an interactive host can still authenticate.
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
# 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.
2026-07-18 16:01:57 +00:00
if bash " $DEST /current/host/setup-host.sh " </dev/null; then
Make host setup complete in one run, and let the installer run it
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.
The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.
With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.
Fixes #63
Fixes #64
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:52:50 +00:00
setup_ok = 1
else
warn "host setup did not complete — box is installed, the host is not ready."
warn " fix the error above and re-run: box setup-host"
fi
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
else
log "skipped host setup."
setup_declined = 1
Make host setup complete in one run, and let the installer run it
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.
The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.
With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.
Fixes #63
Fixes #64
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:52:50 +00:00
fi
if [ -n " $setup_ok " ] ; then
2026-07-18 16:01:57 +00:00
log " done ( $SRCDESC , version $new_ver ) — try: box new --name test "
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
elif [ -n " $setup_declined " ] ; then
2026-07-18 16:01:57 +00:00
log " done ( $SRCDESC , version $new_ver ) — when you want this machine to host boxes: box setup-host "
Make host setup complete in one run, and let the installer run it
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.
The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.
With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.
Fixes #63
Fixes #64
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:52:50 +00:00
else
2026-07-18 16:01:57 +00:00
log " done ( $SRCDESC , version $new_ver ) — finish with 'box setup-host', then: box new --name test "
Make host setup complete in one run, and let the installer run it
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.
The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.
With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.
Fixes #63
Fixes #64
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:52:50 +00:00
fi