diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e6c9b..680c81c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8d422de..de9e3d1 100644 --- a/README.md +++ b/README.md @@ -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 `, +copy out anything you need (a portable `box export` is +[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm ` +(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) diff --git a/drill/drill.sh b/drill/drill.sh index 5de6e8a..9bb7dbe 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -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")" \ diff --git a/install.sh b/install.sh index fbc1170..4eed235 100755 --- a/install.sh +++ b/install.sh @@ -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/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 '')" + 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 ', 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 ' 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:-} ${old_ver:-}" "$REPO@$REF $new_ver" >&2 - printf '%s\n' "$found" | sed 's/^/ · /' >&2 - cat >&2 <' / 'box exec -- ...'). - · Upgrade anyway, on purpose: - BOX_FORCE_UPGRADE=1 curl -fsSL | 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 #