diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 14cf9d4..43f06ef 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -751,15 +751,31 @@ if [ "$HOST" = "yes" ]; then # with English prose or shell metacharacters — "curl A | bash; if that # 404s: curl B | bash" is not valid shell (`bash -n` exits 2) and is the # same class of operator-facing failure #111 exists to remove (#125 / - # codex REQUEST_CHANGES on !114). Display uses separate prefixed lines - # (try: / or:); after a live probe succeeds the install loop rewrites - # BOX_MANUAL to the single URL that worked. + # codex REQUEST_CHANGES on !114). Multi-candidate display uses separate + # prefixed lines (try: / or:); a single candidate (GitHub default) is a + # bare pasteable command — a try: prefix turns paste into a silent no-op + # (`try:` is not a command; the pipe's bash still exits 0). After a live + # probe succeeds the install loop rewrites BOX_MANUAL to the single URL + # that worked. Consumers MUST emit via box_manual_emit — never interpolate + # ${BOX_MANUAL} into a single log/warn string (multi-line orphans the or: + # line; claude REQUEST_CHANGES on 1c9a245). box_manual_cmd() { # box_manual_cmd — one pasteable install line printf 'curl -fsSL %s | BOX_YES=1 BOX_REF=%s bash\n' "$1" "$BOX_REF" } box_manual_text() { - local _n=0 _url _cmd + local _n=0 _url _cmd _urls=() while IFS= read -r _url; do + [ -n "$_url" ] && _urls+=("$_url") + done < <(box_install_urls) + # Single candidate: bare command (no try:). Multi: try:/or: lines. + if [ "${#_urls[@]}" -le 1 ]; then + if [ "${#_urls[@]}" -eq 1 ]; then + _cmd="$(box_manual_cmd "${_urls[0]}")" + printf '%s' "$_cmd" + fi + return 0 + fi + for _url in "${_urls[@]}"; do _cmd="$(box_manual_cmd "$_url")" _cmd="${_cmd%$'\n'}" _n=$((_n + 1)) @@ -768,22 +784,28 @@ if [ "$HOST" = "yes" ]; then else printf 'or: %s\n' "$_cmd" fi - done < <(box_install_urls) + done + } + # Emit BOX_MANUAL one line at a time through log or warn. Never splice the + # multi-line value into a prose sentence. + box_manual_emit() { # box_manual_emit log|warn + local _fn="$1" _line + while IFS= read -r _line; do + [ -n "$_line" ] && "$_fn" " ${_line}" + done </dev/null 2>&1; then warn "curl not found — skipping box install; once curl is present, prepare Incus with:" - while IFS= read -r _line; do [ -n "$_line" ] && warn " ${_line}"; done </dev/null 2>&1; then log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'" else - warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand: ${BOX_MANUAL})" + warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand:)" + box_manual_emit warn fi else - warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand: ${BOX_MANUAL}" + warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand:" + box_manual_emit warn fi else - warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand: ${BOX_MANUAL}" + warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand:" + box_manual_emit warn fi rm -f "$BOX_SCRIPT" fi diff --git a/test/cli.sh b/test/cli.sh index f1b19a0..0f68bf3 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -303,23 +303,25 @@ check "bootstrap: box_install_urls Forgejo branch second" 0 \ check "bootstrap: box_install_urls Forgejo tag-first even for BOX_REF=main" 0 \ "https://forgejo.example/heavy-duty/box/raw/tag/main/install.sh" \ biu_line https://forgejo.example main 1 -# BOX_MANUAL recovery text: separate try:/or: lines, each command pasteable -# (codex REQUEST_CHANGES / #125 — prose-joined strings fail `bash -n`). -# Extract box_manual_cmd + box_manual_text with the same nested-fn idiom. +# BOX_MANUAL recovery text: multi-candidate → separate try:/or: lines; +# single-candidate (GitHub) → bare pasteable command (no try: prefix — +# `try: curl…` is a silent no-op under bash -c; claude RC on 1c9a245). +# Extract helpers with the same nested-fn idiom. BIM="$BIU_DIR/box-manual.sh" { awk '/^ box_install_urls\(\) \{/,/^ \}/' "$ROOT/commands/bootstrap.sh" awk '/^ box_manual_cmd\(\) \{/,/^ \}/' "$ROOT/commands/bootstrap.sh" awk '/^ box_manual_text\(\) \{/,/^ \}/' "$ROOT/commands/bootstrap.sh" + awk '/^ box_manual_emit\(\) \{/,/^ \}/' "$ROOT/commands/bootstrap.sh" } | sed 's/^ //' > "$BIM" -check "bootstrap: box_manual helpers extracted" 0 "try:" cat "$BIM" +check "bootstrap: box_manual helpers extracted" 0 "box_manual_emit" cat "$BIM" # shellcheck disable=SC2016 bmanual() { # bmanual HOST REF — render BOX_MANUAL text env BOX_HOST="$1" BOX_REPO=heavy-duty/box BOX_REF="$2" \ bash -c 'set -euo pipefail; . "$1"; box_manual_text' _ "$BIM" } # shellcheck disable=SC2016 -bmanual_cmds_ok() { # every pasteable command after try:/or: passes bash -n +bmanual_cmds_ok() { # every pasteable command after optional try:/or: passes bash -n env BOX_HOST="$1" BOX_REPO=heavy-duty/box BOX_REF="$2" \ bash -c 'set -euo pipefail . "$1" @@ -332,8 +334,9 @@ bmanual_cmds_ok() { # every pasteable command after try:/or: passes bash -n bash -n <<<"$cmd" done < <(box_manual_text)' _ "$BIM" } -check "bootstrap: BOX_MANUAL GitHub is a single try: raw.githubusercontent.com line" 0 \ - "try: curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/0.9.0/install.sh | BOX_YES=1 BOX_REF=0.9.0 bash" \ +# GitHub: bare command, no try: (single candidate — pasteable as-is). +check "bootstrap: BOX_MANUAL GitHub is a bare raw.githubusercontent.com command" 0 \ + "curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/0.9.0/install.sh | BOX_YES=1 BOX_REF=0.9.0 bash" \ bmanual https://github.com 0.9.0 check "bootstrap: BOX_MANUAL Forgejo lists raw/tag first" 0 \ "try: curl -fsSL https://forgejo.example/heavy-duty/box/raw/tag/main/install.sh | BOX_YES=1 BOX_REF=main bash" \ @@ -348,6 +351,14 @@ check "bootstrap: BOX_MANUAL Forgejo commands pass bash -n" 0 "" \ # Regression: the old prose join must not return. check "bootstrap: BOX_MANUAL does not use prose 'if that 404s'" 1 "" \ grep -qF 'if that 404s' "$ROOT/commands/bootstrap.sh" +# claude REQUEST_CHANGES on 1c9a245: multi-line BOX_MANUAL must never be +# interpolated into a single log/warn string (orphans the or: line; try: +# prefix inside a sentence is not pasteable). Only box_manual_emit may +# consume the value, one line at a time. +check "bootstrap: BOX_MANUAL never interpolated into log/warn string" 1 "" \ + grep -nE '(log|warn) .*\$\{BOX_MANUAL\}' "$ROOT/commands/bootstrap.sh" +check "bootstrap: box_manual_emit is the sole multi-line consumer" 0 "" \ + grep -qF 'box_manual_emit' "$ROOT/commands/bootstrap.sh" rm -rf "$BIU_DIR" # Opt-out for rehearsals / offline / hand-managed hosts. check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \