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` 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* hands sequencing back to the drill. Pre-setup tripwires now read *before*
`install.sh`, since that is what triggers setup now. `install.sh`, since that is what triggers setup now.
- **`install.sh` runs the host setup itself** (#64) — it printed a warning and - **`install.sh` asks, sets up the host, and no-ops on re-run** (#64) — it now
left you a command to run, so the install reported success and `box new` prompts *"Install box?"*, then on a fresh host installs the tree and asks a
failed on a host with no Incus. `BOX_SKIP_SETUP_HOST=1` opts out; if setup second question, *"Set up this machine as a box host now?"*, running the whole
fails, the install still stands and says what to re-run. isolation stack if you say yes (previously it only printed a warning and left
- **`install.sh` refuses to change versions under existing boxes** — building you a command, so the install reported success and `box new` died on a host
the host stack from the installer means an upgrade reaches under every box with no Incus). Prompts read `/dev/tty`, since under `curl | bash` the script
attached to that stack, so it is no longer only a tree swap. Same version and itself is stdin; `BOX_YES=1` answers yes unattended (required where there is
ref: says so, changes nothing. Version or ref change with boxes on the host no terminal), `BOX_SKIP_SETUP_HOST=1` declines the host-setup step.
(either tag generation): refuses, loudly, listing them — and refuses *before* - **`install.sh` never overwrites an existing install** — if box is already
`$DEST` is touched, so the working install survives the refusal. No boxes: installed it says so and changes nothing, so a stray re-run can no longer
proceeds. `BOX_FORCE_UPGRADE=1` overrides; the drill sets it, since wiping clobber a working tree or rebuild the host stack under live boxes. Upgrading
boxes is its job. The version-aware upgrade that migrates instead of refusing is explicit: uninstall (`rm -rf ~/.local/share/box ~/.local/bin/box`, boxes
is #67. 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 ## 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 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 It asks first — **"Install box?"** — then, if box is not already installed,
`PATH`, then runs the host setup below for you (it may ask for `sudo`; set downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and
`BOX_SKIP_SETUP_HOST=1` to opt out and run it yourself). Upgrading from a asks a second question: **"Set up this machine as a box host now?"** Say yes and
pre-0.4.0 install also retires the old `claudebox` symlink. (No `git clone` it builds the whole isolation stack for you (it may ask for `sudo`); say no and
needed.) you can run `box setup-host` later. (No `git clone` needed.)
**Upgrading, and boxes.** Because the installer builds the host stack, an **Re-running is a safe no-op.** If box is already installed, the installer tells
upgrade reaches under the boxes attached to it — so it is deliberate about it. you so and changes nothing — a stray re-run can never clobber your install or
Re-running the same version changes nothing and says so. Changing version on a rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall
host that has boxes **refuses**, lists them, and leaves your install exactly as what you have and install fresh. Preserve any boxes first — `box down <box>`,
it was; deal with the boxes and re-run, or say `BOX_FORCE_UPGRADE=1` to upgrade copy out anything you need (a portable `box export` is
over them on purpose (boxes are not deleted, but the stack is rebuilt beneath [#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
them). With no boxes on the host, it just upgrades. Migrating boxes across an (which deletes the box *and* its snapshots) — then:
upgrade instead of refusing is [#67](https://github.com/heavy-duty/box/issues/67).
```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) ## One-time host setup (Ubuntu 24.04 / Debian 13)

View file

@ -202,11 +202,16 @@ EOF
export BOX_SKIP_SETUP_HOST=1 export BOX_SKIP_SETUP_HOST=1
fi fi
# The installer refuses to change versions on a host that still has boxes. # The installer is a no-op when box is already installed — upgrading is
# That hatch is for humans with work to lose; the drill's whole job is to # uninstall-then-install, by design. The drill re-proves a tree from scratch
# arrive on a dirty host, wipe every box it recognises (below) and re-prove # every run, so it does the uninstall itself: clear any prior tree and symlink
# the stack from there — so it opts out, deliberately and in one place. # before installing, or install.sh would correctly refuse to touch them.
export BOX_FORCE_UPGRADE=1 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" \ BOX_REPO="$REPO" BOX_REF="$REF" \
bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \ 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; } warn() { printf 'box-install: WARNING: %s\n' "$*" >&2; }
die() { printf 'box-install: ERROR: %s\n' "$*" >&2; exit 1; } 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 --------------------------------------------------------- # --- prerequisites ---------------------------------------------------------
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run." 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." 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 -------------------------------------------------------- # --- temp workspace --------------------------------------------------------
TMPDIR="$(mktemp -d)" TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; } 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" [ -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?" [ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
# --- upgrade hatch --------------------------------------------------------- # --- install into $DEST ----------------------------------------------------
# This installer builds the host stack itself now, and every box on the host is # Reached only on a host with no existing install (the no-op check above
# attached to that stack — so a version change here is not just a tree swap, it # exits otherwise), so this is always a fresh tree, never an overwrite.
# 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 ---------------------------------------------
log "installing into $DEST" log "installing into $DEST"
rm -rf "$DEST"
mkdir -p "$(dirname "$DEST")" mkdir -p "$(dirname "$DEST")"
mv "$EXTRACTED" "$DEST" mv "$EXTRACTED" "$DEST"
@ -163,36 +133,43 @@ esac
# and it must not hinge on whether the host stack came up. # and it must not hinge on whether the host stack came up.
printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM" printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM"
# --- host setup ------------------------------------------------------------ # --- host setup (second prompt) --------------------------------------------
# The installer finishes the job (#64). Telling the user to go run setup-host # The tool is installed; the machine is not yet a box host. Offer to finish the
# was a step that read as optional and failed later as mysterious: the install # job — build Incus and the isolation stack — rather than leave 'box new' to die
# reports success, 'box' is on PATH, and 'box new' dies on a host with no # later on a host with no boxnet and no profile (#64). This is its own decision:
# Incus, no boxnet, no profile. setup-host is idempotent by design, so doing # you might be installing the CLI on a workstation and hosting boxes elsewhere.
# this on EVERY install is also how an upgraded host picks up stack changes — # BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
# the isolation fixes that ship as new firewall rules land when the tool that # up by hand); BOX_YES answers "yes".
# 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.
setup_ok="" setup_ok=""
setup_declined=""
if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then 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 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" warn " run this as root to finish: $DEST/host/setup-host.sh"
else setup_declined=1
log "running one-time host setup (installs Incus + the isolation stack; may ask for sudo)" elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
# </dev/null because under 'curl … | bash' this script IS stdin: a child that # </dev/null because under 'curl … | bash' this script IS stdin: a child that
# reads stdin eats the installer's own remaining lines. sudo is unaffected — # reads stdin eats the installer's own remaining lines. sudo is unaffected —
# it prompts on /dev/tty, so an interactive host can still authenticate. # it prompts on /dev/tty, so an interactive host can still authenticate.
# setup-host re-execs itself under sg incus-admin if it must add you to the
# group; that re-exec is a child here and completes the whole setup in one go.
if bash "$DEST/host/setup-host.sh" </dev/null; then if bash "$DEST/host/setup-host.sh" </dev/null; then
setup_ok=1 setup_ok=1
else else
warn "host setup did not complete — box is installed, the host is not ready." warn "host setup did not complete — box is installed, the host is not ready."
warn " fix the error above and re-run: box setup-host" warn " fix the error above and re-run: box setup-host"
fi fi
else
log "skipped host setup."
setup_declined=1
fi fi
if [ -n "$setup_ok" ]; then if [ -n "$setup_ok" ]; then
log "done ($REPO@$REF) — try: box new --name test" 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 else
log "done ($REPO@$REF) — finish with 'box setup-host', then: box new --name test" log "done ($REPO@$REF) — finish with 'box setup-host', then: box new --name test"
fi fi