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>
This commit is contained in:
claude-bot-andresmgsl 2026-07-17 16:10:34 +00:00
parent 043e556027
commit d0a14a18a5
4 changed files with 111 additions and 117 deletions

View file

@ -55,19 +55,22 @@ which records not just what changed but what each drill run proved.
unsets `dns.mode` and so has to be converged back. `DRILL_OWNS_SETUP=1`
hands sequencing back to the drill. Pre-setup tripwires now read *before*
`install.sh`, since that is what triggers setup now.
- **`install.sh` runs the host setup itself** (#64) — it printed a warning and
left you a command to run, so the install reported success and `box new`
failed on a host with no Incus. `BOX_SKIP_SETUP_HOST=1` opts out; if setup
fails, the install still stands and says what to re-run.
- **`install.sh` refuses to change versions under existing boxes** — building
the host stack from the installer means an upgrade reaches under every box
attached to that stack, so it is no longer only a tree swap. Same version and
ref: says so, changes nothing. Version or ref change with boxes on the host
(either tag generation): refuses, loudly, listing them — and refuses *before*
`$DEST` is touched, so the working install survives the refusal. No boxes:
proceeds. `BOX_FORCE_UPGRADE=1` overrides; the drill sets it, since wiping
boxes is its job. The version-aware upgrade that migrates instead of refusing
is #67.
- **`install.sh` asks, sets up the host, and no-ops on re-run** (#64) — it now
prompts *"Install box?"*, then on a fresh host installs the tree and asks a
second question, *"Set up this machine as a box host now?"*, running the whole
isolation stack if you say yes (previously it only printed a warning and left
you a command, so the install reported success and `box new` died on a host
with no Incus). Prompts read `/dev/tty`, since under `curl | bash` the script
itself is stdin; `BOX_YES=1` answers yes unattended (required where there is
no terminal), `BOX_SKIP_SETUP_HOST=1` declines the host-setup step.
- **`install.sh` never overwrites an existing install** — if box is already
installed it says so and changes nothing, so a stray re-run can no longer
clobber a working tree or rebuild the host stack under live boxes. Upgrading
is explicit: uninstall (`rm -rf ~/.local/share/box ~/.local/bin/box`, boxes
preserved first) and install fresh. This replaces the earlier version-diff
refusal with a simpler rule that dissolves the same class of errors. The
version-aware upgrade that migrates boxes instead is #67; a portable
`box export` so a box survives its own deletion is #70.
## 0.5.0 — 2026-07-15

View file

@ -40,20 +40,29 @@ design rationale.
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
```
Installs the tree to `~/.local/share/box` and links `box` onto your
`PATH`, then runs the host setup below for you (it may ask for `sudo`; set
`BOX_SKIP_SETUP_HOST=1` to opt out and run it yourself). Upgrading from a
pre-0.4.0 install also retires the old `claudebox` symlink. (No `git clone`
needed.)
It asks first — **"Install box?"** — then, if box is not already installed,
downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and
asks a second question: **"Set up this machine as a box host now?"** Say yes and
it builds the whole isolation stack for you (it may ask for `sudo`); say no and
you can run `box setup-host` later. (No `git clone` needed.)
**Upgrading, and boxes.** Because the installer builds the host stack, an
upgrade reaches under the boxes attached to it — so it is deliberate about it.
Re-running the same version changes nothing and says so. Changing version on a
host that has boxes **refuses**, lists them, and leaves your install exactly as
it was; deal with the boxes and re-run, or say `BOX_FORCE_UPGRADE=1` to upgrade
over them on purpose (boxes are not deleted, but the stack is rebuilt beneath
them). With no boxes on the host, it just upgrades. Migrating boxes across an
upgrade instead of refusing is [#67](https://github.com/heavy-duty/box/issues/67).
**Re-running is a safe no-op.** If box is already installed, the installer tells
you so and changes nothing — a stray re-run can never clobber your install or
rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall
what you have and install fresh. Preserve any boxes first — `box down <box>`,
copy out anything you need (a portable `box export` is
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
(which deletes the box *and* its snapshots) — then:
```sh
rm -rf ~/.local/share/box ~/.local/bin/box # uninstall
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
```
A version-aware upgrade that migrates boxes instead of asking you to is
[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs
(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1`
declines the host-setup step.
## One-time host setup (Ubuntu 24.04 / Debian 13)

View file

@ -202,11 +202,16 @@ EOF
export BOX_SKIP_SETUP_HOST=1
fi
# The installer refuses to change versions on a host that still has boxes.
# That hatch is for humans with work to lose; the drill's whole job is to
# arrive on a dirty host, wipe every box it recognises (below) and re-prove
# the stack from there — so it opts out, deliberately and in one place.
export BOX_FORCE_UPGRADE=1
# 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.
rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box"
# The installer prompts (install? set up host?) and reads /dev/tty. The drill
# runs unattended with no tty, so it answers yes to everything via BOX_YES.
# OWNS still suppresses the setup prompt via BOX_SKIP_SETUP_HOST above.
export BOX_YES=1
BOX_REPO="$REPO" BOX_REF="$REF" \
bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \

View file

@ -17,10 +17,50 @@ log() { printf 'box-install: %s\n' "$*"; }
warn() { printf 'box-install: WARNING: %s\n' "$*" >&2; }
die() { printf 'box-install: ERROR: %s\n' "$*" >&2; exit 1; }
# 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
}
# --- prerequisites ---------------------------------------------------------
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run."
# --- confirm, then no-op if already installed ------------------------------
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is
# ask whether you meant to. Then, if box is already installed, this run changes
# nothing and says so — a re-run is a safe no-op, which dissolves the whole
# "curl clobbered my working install / rebuilt the stack under my boxes" class
# of failures. Upgrading is deliberately NOT an in-place overwrite: you uninstall
# what you have (dealing with your boxes as you do) and install fresh.
confirm "Install box from $REPO@$REF?" || die "cancelled — nothing was changed."
if [ -e "$BINDIR/box" ] || [ -x "$DEST/bin/box" ]; then
cur="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')"
cur_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo '?')"
log "box is already installed ($cur, version $cur_ver) — nothing to do."
log "To install a different version, remove the current one first:"
log " · preserve any boxes you care about — 'box down <box>', then keep them"
log " (a portable 'box export' is #70; for now copy what you need OUT via"
log " 'box shell'/'box exec'), and 'box rm <box>' when you are done"
log " · uninstall: rm -rf \"$DEST\" \"$BINDIR/box\""
log " · then re-run this installer"
exit 0
fi
# --- temp workspace --------------------------------------------------------
TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; }
@ -47,80 +87,10 @@ EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive"
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
# --- upgrade hatch ---------------------------------------------------------
# This installer builds the host stack itself now, and every box on the host is
# attached to that stack — so a version change here is not just a tree swap, it
# reaches under running boxes. Until the version-aware migration exists (#67),
# refuse rather than guess: if this would change what is installed AND there are
# boxes on the host, stop and let a human decide. Checked BEFORE $DEST is
# touched, so a refusal leaves the working install exactly as it was.
# Same version + same ref = nothing to change: say so and carry on.
new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || echo unknown)"
old_ver="$(cat "$DEST/VERSION" 2>/dev/null || true)"
old_from="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || true)"
if [ -n "$old_ver" ] && [ "$old_ver" = "$new_ver" ] && [ "$old_from" = "$REPO@$REF" ]; then
CHANGING=0
else
CHANGING=1
fi
# How we ask incus about boxes. No incus => no boxes, and nothing to protect.
if [ "$(id -u)" -eq 0 ]; then PRIV=""
elif command -v sudo >/dev/null 2>&1; then PRIV="sudo"
else PRIV=""
fi
# Unprivileged FIRST: anyone who owns boxes is already in incus-admin, so the
# plain query answers it without making the installer demand a sudo password
# just to look. Escalate only if the socket refuses us.
incus_names() { # $1 = tag filter
incus list "$1" --format csv --columns n 2>/dev/null && return 0
[ -n "$PRIV" ] && $PRIV incus list "$1" --format csv --columns n 2>/dev/null
return 0
}
boxes_on_host() {
command -v incus >/dev/null 2>&1 || return 0
# BOTH tags: a pre-rename box carries user.claudebox=1 and is just as much
# someone's work as a current one.
{ incus_names "user.box=1"; incus_names "user.claudebox=1"; } | sed '/^$/d' | sort -u
}
if [ "$CHANGING" = 0 ]; then
log "already at $new_ver ($REPO@$REF) — reinstalling the same tree, nothing to migrate"
elif [ -z "${BOX_FORCE_UPGRADE:-}" ]; then
found="$(boxes_on_host)"
if [ -n "$found" ]; then
printf 'box-install: ERROR: this host has boxes, and this install would change what runs them.\n' >&2
printf '\n installed: %s\n incoming: %s\n\n boxes on this host:\n' \
"${old_from:-<none>} ${old_ver:-<no version file>}" "$REPO@$REF $new_ver" >&2
printf '%s\n' "$found" | sed 's/^/ · /' >&2
cat >&2 <<EOF
Refusing, and nothing has been changed — your current install is intact.
The installer now builds the host stack (network, ACL, profile, firewall)
itself, so upgrading reaches under boxes that are attached to it.
Your options:
· Stay where you are. The boxes keep working. Nothing to do.
· Deal with the boxes, then re-run this installer.
NOTE: 'box rm' deletes a box AND every snapshot it has — it cannot be
undone, and a snapshot does NOT survive its box. Copy anything you need
OUT of a box first ('box shell <box>' / 'box exec <box> -- ...').
· Upgrade anyway, on purpose:
BOX_FORCE_UPGRADE=1 curl -fsSL <this url> | bash
Boxes are not deleted, but the stack is rebuilt underneath them.
A version-aware upgrade that migrates boxes instead of refusing is #67.
EOF
exit 1
fi
fi
# --- atomically replace $DEST ---------------------------------------------
# --- install into $DEST ----------------------------------------------------
# Reached only on a host with no existing install (the no-op check above
# exits otherwise), so this is always a fresh tree, never an overwrite.
log "installing into $DEST"
rm -rf "$DEST"
mkdir -p "$(dirname "$DEST")"
mv "$EXTRACTED" "$DEST"
@ -163,36 +133,43 @@ esac
# and it must not hinge on whether the host stack came up.
printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM"
# --- host setup ------------------------------------------------------------
# The installer finishes the job (#64). Telling the user to go run setup-host
# was a step that read as optional and failed later as mysterious: the install
# reports success, 'box' is on PATH, and 'box new' dies on a host with no
# Incus, no boxnet, no profile. setup-host is idempotent by design, so doing
# this on EVERY install is also how an upgraded host picks up stack changes —
# the isolation fixes that ship as new firewall rules land when the tool that
# claims them lands, instead of waiting on someone to re-run a command.
# BOX_SKIP_SETUP_HOST=1 opts out: CI, image builds, a host set up by hand.
# --- 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.
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
# up by hand); BOX_YES answers "yes".
setup_ok=""
setup_declined=""
if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
log "skipping host setup (BOX_SKIP_SETUP_HOST is set) — run it yourself: box setup-host"
log "skipping host setup (BOX_SKIP_SETUP_HOST is set)."
setup_declined=1
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
warn "host setup needs root and sudo was not found."
warn "cannot set up the host: it needs root and sudo was not found."
warn " run this as root to finish: $DEST/host/setup-host.sh"
else
log "running one-time host setup (installs Incus + the isolation stack; may ask for sudo)"
setup_declined=1
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
# reads stdin eats the installer's own remaining lines. sudo is unaffected —
# it prompts on /dev/tty, so an interactive host can still authenticate.
# setup-host re-execs itself under sg incus-admin if it must add you to the
# group; that re-exec is a child here and completes the whole setup in one go.
if bash "$DEST/host/setup-host.sh" </dev/null; then
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
else
log "skipped host setup."
setup_declined=1
fi
if [ -n "$setup_ok" ]; then
log "done ($REPO@$REF) — try: box new --name test"
elif [ -n "$setup_declined" ]; then
log "done ($REPO@$REF) — when you want this machine to host boxes: box setup-host"
else
log "done ($REPO@$REF) — finish with 'box setup-host', then: box new --name test"
fi