From 1ca35095e76b15085036dbf6bc2bec9eb4780889 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:48:35 +0800 Subject: [PATCH] refactor(install): drive CLI detection, the install menu and hints from the catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh carried nine search-path arrays, eighteen near-identical check_/get__path functions, and three separately hand-maintained enumerations of all nine CLIs. They had to agree and did not: upstream b6d0f1fa is "wire OMP into install.sh's CLI detection (it had none)", and the section comment above the roll-call named six of the nine. All of it now reads the generated catalogue. `detect_all_clis` resolves every CLI in one memoized pass into CLI_FOUND_PATH/CLI_FOUND_COUNT; `check_cli` and `get_cli_path` replace the eighteen pairs; the roll-call, the "no AI CLI found" gate and the closing reminder become loops. Probe order per CLI is unchanged and `test/install-sh-detection-parity.test.ts` proves it against the literals transcribed from the arrays this deletes. Behaviour changes worth naming: - The install menu is built from the catalogue, so it offers every enabled CLI that is not installed and ships a command — five instead of two. Gemini had a command in the registry and appeared in NO list in this script. - Its labels are now the registry's ("Claude" rather than "Claude Code"), the same trade PR A made for `codeman doctor` rows. A suffix map would just be the hand-maintained list again. - On a wget-only host the menu prints commands instead of running them. The registry's commands call curl, whereas the two literals this replaces went through download_to_stdout; rewriting curl to wget inside a string we are about to execute is the wrong instinct. The trust boundary is mechanical, not a promise: CLI_INSTALL_CMD_TRUSTED is written only from the generated per-platform arrays and is the only thing ever executed; CLI_INSTALL_CMD_DISPLAY is what the optional, opt-in refresh may rewrite. The refresh warns on all three failure shapes — empty body, unparseable content, failed fetch — which is the silent-degradation bug from the review, and it parses with node into tab-separated records read by `read`, never eval. Bash 3.2 throughout (macOS ships it): parallel indexed arrays, offset/length windows instead of delimiters, no associative arrays, namerefs, mapfile or here-strings. Verified by executing the script under a real bash 3.2 container, which is also now a CI step alongside `bash -n` and a catalogue `--check` — the empty-window case (`shell` has no binaries) is a runtime `set -u` abort that `bash -n` cannot see. Running it that way caught `detect_os` being called inside the platform loop: ten forks, and ten copies of one error, since a `die` inside `$( )` can only exit the subshell. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015EMxQreQUZX5ZyybxAGh12 --- .github/workflows/ci.yml | 29 + install.sh | 790 +++++++++++------------ test/install-sh-detection-parity.test.ts | 38 +- test/install-sh-invariants.test.ts | 163 +++++ 4 files changed, 580 insertions(+), 440 deletions(-) create mode 100644 test/install-sh-invariants.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5cac857..cdc63783 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,35 @@ jobs: - name: Format check run: npm run format:check + # install.sh reaches users through `curl | bash` with nothing between it and + # them, and until now nothing in this repo checked it at all: no shellcheck, + # no bats, and the vitest gate is Node-only. + - name: install.sh syntax + run: bash -n install.sh + + # macOS ships bash 3.2 and this runner has bash 5, so the constructs that + # actually break a Mac install are invisible here without a container. This + # step is what catches them — in particular expanding an EMPTY array under + # `set -u`, which bash 3.2 treats as an unbound variable and `bash -n` + # cannot see because it is a runtime error, not a syntax one. + - name: install.sh runs on bash 3.2 (macOS's version) + run: | + set -euo pipefail + docker run --rm -v "$PWD":/w -w /w bash:3.2 bash -n /w/install.sh + docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 bash:3.2 bash -c ' + set -euo pipefail + . /w/install.sh + detect_all_clis + # `shell` declares no binaries, so its offset/length window is length 0. + # Iterating it is the empty-array case; reaching here means it did not abort. + echo "bash $BASH_VERSION: ${#CLI_IDS[@]} CLIs, $CLI_FOUND_COUNT found" + cli_catalog_names >/dev/null + cli_catalog_print_install_hints >/dev/null + ' + + - name: CLI catalogue artifacts are in sync with stock.ts + run: npm run generate:cli-catalog -- --check + - name: Server boot smoke test run: | set -u diff --git a/install.sh b/install.sh index aa3621c9..f71a4a6c 100755 --- a/install.sh +++ b/install.sh @@ -76,89 +76,6 @@ TS_NEED_ROOT="0" # explicit caller override so contributors can still fetch the browser if needed. export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}" -# Claude CLI search paths (from src/utils/claude-cli-resolver.ts) -CLAUDE_SEARCH_PATHS=( - "$HOME/.local/bin/claude" - "$HOME/.claude/local/claude" - "/usr/local/bin/claude" - "$HOME/.npm-global/bin/claude" - "$HOME/bin/claude" -) - -# OpenCode CLI search paths (from src/utils/opencode-cli-resolver.ts) -OPENCODE_SEARCH_PATHS=( - "$HOME/.opencode/bin/opencode" - "$HOME/.local/bin/opencode" - "/usr/local/bin/opencode" - "$HOME/go/bin/opencode" - "$HOME/.bun/bin/opencode" - "$HOME/.npm-global/bin/opencode" - "$HOME/bin/opencode" -) - -# Codex CLI search paths (from src/utils/codex-cli-resolver.ts) -CODEX_SEARCH_PATHS=( - "$HOME/.codex/bin/codex" - "$HOME/.local/bin/codex" - "/usr/local/bin/codex" - "$HOME/.bun/bin/codex" - "$HOME/.npm-global/bin/codex" - "$HOME/bin/codex" -) - -# Gemini CLI search paths (from src/utils/gemini-cli-resolver.ts) -GEMINI_SEARCH_PATHS=( - "$HOME/.gemini/bin/gemini" - "$HOME/.local/bin/gemini" - "/usr/local/bin/gemini" - "$HOME/.bun/bin/gemini" - "$HOME/.npm-global/bin/gemini" - "$HOME/bin/gemini" -) - -# Pi CLI search paths (from src/utils/pi-cli-resolver.ts) -PI_SEARCH_PATHS=( - "$HOME/.local/bin/pi" - "/usr/local/bin/pi" - "$HOME/.bun/bin/pi" - "$HOME/.npm-global/bin/pi" - "$HOME/bin/pi" -) - -# DeepSeek Harness search paths (from src/utils/deepseek-cli-resolver.ts) -DSH_SEARCH_PATHS=( - "$HOME/.local/bin/dsh" - "/usr/local/bin/dsh" - "$HOME/.npm-global/bin/dsh" - "$HOME/bin/dsh" -) - -# Grok CLI search paths (from src/utils/grok-cli-resolver.ts) -GROK_SEARCH_PATHS=( - "$HOME/.grok/bin/grok" - "$HOME/.local/bin/grok" - "/usr/local/bin/grok" - "$HOME/bin/grok" -) - -# Antigravity CLI search paths (from src/utils/antigravity-cli-resolver.ts) -ANTIGRAVITY_SEARCH_PATHS=( - "$HOME/.local/bin/agy" - "$HOME/.antigravity/bin/agy" - "/usr/local/bin/agy" - "$HOME/bin/agy" -) - -# OMP CLI search paths (from src/utils/omp-cli-resolver.ts's OMP_SEARCH_DIRS — -# ~/.local/bin leads, omp.sh's installer target; ~/.omp/bin is a fallback only) -OMP_SEARCH_PATHS=( - "$HOME/.local/bin/omp" - "$HOME/.omp/bin/omp" - "/usr/local/bin/omp" - "$HOME/.bun/bin/omp" - "$HOME/.npm-global/bin/omp" - "$HOME/bin/omp" -) # >>> BEGIN GENERATED CLI CATALOGUE # Generated from src/config/cli-registry/stock.ts by scripts/generate-cli-catalog.mts. @@ -477,193 +394,35 @@ check_build_tools() { [[ -z "$(missing_build_tools)" ]] } -check_claude() { - # Check PATH first - if command -v claude &>/dev/null; then - return 0 - fi +# ============================================================================ +# CLI Detection (generic, driven by the generated catalogue above) +# ============================================================================ +# +# One implementation for every CLI, replacing nine near-identical +# check_/get__path pairs plus their nine search-path arrays. Those had +# to be extended by hand for each new CLI, and once were not: upstream b6d0f1fa +# is "wire OMP into install.sh's CLI detection (it had none)", where a user with +# only omp installed was told no AI CLI was found and offered Claude Code. +# Adding an entry to stock.ts now wires detection, the install menu and the +# closing reminder in one step. +# +# Probe order per CLI is UNCHANGED and pinned by +# test/install-sh-detection-parity.test.ts: the process PATH first (each declared +# binary name in turn), then each known install path, dir-major. - # Check known install locations - for path in "${CLAUDE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then +# Index of "$1" in CLI_IDS -> CLI_IDX, returning 1 with CLI_IDX=-1 when unknown. +# A global rather than an echo because this runs inside loops, and a subshell per +# lookup is a fork per CLI per call site. +CLI_IDX=-1 +_cli_index() { + local want="$1" i + CLI_IDX=-1 + for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do + if [[ "${CLI_IDS[$i]}" == "$want" ]]; then + CLI_IDX=$i return 0 fi done - - return 1 -} - -get_claude_path() { - if command -v claude &>/dev/null; then - command -v claude - return - fi - - for path in "${CLAUDE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -check_opencode() { - if command -v opencode &>/dev/null; then - return 0 - fi - - for path in "${OPENCODE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - - return 1 -} - -get_opencode_path() { - if command -v opencode &>/dev/null; then - command -v opencode - return - fi - - for path in "${OPENCODE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -check_codex() { - if command -v codex &>/dev/null; then - return 0 - fi - - for path in "${CODEX_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - - return 1 -} - -get_codex_path() { - if command -v codex &>/dev/null; then - command -v codex - return - fi - - for path in "${CODEX_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -check_gemini() { - if command -v gemini &>/dev/null; then - return 0 - fi - - for path in "${GEMINI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - - return 1 -} - -get_gemini_path() { - if command -v gemini &>/dev/null; then - command -v gemini - return - fi - - for path in "${GEMINI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -check_antigravity() { - if command -v agy &>/dev/null; then - return 0 - fi - - for path in "${ANTIGRAVITY_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - - return 1 -} - -get_antigravity_path() { - if command -v agy &>/dev/null; then - command -v agy - return - fi - - for path in "${ANTIGRAVITY_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -# `pi` is a short, generic name (Raspberry Pi tooling, personal scripts), so the -# server-side resolver additionally probes `pi --version`. Detection here only feeds -# the "you have no AI CLI" hint, so a plain executable test is enough. -check_pi() { - if command -v pi &>/dev/null; then - return 0 - fi - - for path in "${PI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - - return 1 -} - -get_pi_path() { - if command -v pi &>/dev/null; then - command -v pi - return - fi - - for path in "${PI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -# `grok` has known squatters too (the unrelated @vibe-kit/grok-cli), so the -# server-side resolver additionally probes `grok --version`. Detection here only -# feeds the "you have no AI CLI" hint, so a plain executable test is enough. -check_grok() { - if command -v grok &>/dev/null; then - return 0 - fi - - for path in "${GROK_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - return 1 } @@ -682,87 +441,255 @@ dsh_banner_probe() { "${runner[@]}" "$1" --help /dev/null | grep -qi "DeepSeek Harness" } -# Resolved ONCE and memoized: the probe executes a possibly-foreign binary, and -# the check/get/reminder call sites together used to re-run the whole scan many -# times per install. -DSH_RESOLVE_DONE="" -DSH_RESOLVED_PATH="" -resolve_dsh() { - [[ -n "$DSH_RESOLVE_DONE" ]] && return 0 - DSH_RESOLVE_DONE=1 - local candidate path - if command -v dsh &>/dev/null; then - candidate="$(command -v dsh)" - if dsh_banner_probe "$candidate"; then - DSH_RESOLVED_PATH="$candidate" - return 0 - fi - fi +# Is "$2" really the CLI "$1" claims to be? +# +# Every CLI but DeepSeek is accepted on being executable, exactly as before. +# DeepSeek stays a hand-written special case ON PURPOSE: the registry expresses +# its identity check as `discovery.identity.regex`, a JavaScript regex, and +# translating that into a `grep` pattern at install time is a transformation +# nobody should be performing on a security-adjacent check. The parity test pins +# that the registry still demands "DeepSeek Harness", so an upstream banner +# change fails a test instead of silently mis-detecting here. +_cli_candidate_ok() { + case "$1" in + deepseek) dsh_banner_probe "$2" ;; + *) return 0 ;; + esac +} - for path in "${DSH_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]] && dsh_banner_probe "$path"; then - DSH_RESOLVED_PATH="$path" - return 0 +# Resolve every CLI in ONE pass, memoized. +# +# CLI_FOUND_PATH is parallel to CLI_IDS ('' when not found). CLI_FOUND_COUNT +# counts only ENABLED entries that have a binary to look for, which is what the +# "no AI CLI found" gate asks about — `shell` has no binary and must never make +# that gate think an agent is installed. +# +# Memoizing the whole scan generalises the old resolve_dsh memo: the three call +# sites together used to re-run every probe, and for dsh that meant executing a +# possibly-foreign binary repeatedly. +CLI_DETECT_DONE="" +CLI_FOUND_PATH=() +CLI_FOUND_COUNT=0 +detect_all_clis() { + [[ -n "$CLI_DETECT_DONE" ]] && return 0 + CLI_DETECT_DONE=1 + + local i j found bin path bin_end path_end + CLI_FOUND_COUNT=0 + for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do + found="" + + # 1. The process PATH, each declared binary name in turn. + bin_end=$((${CLI_BIN_OFF[$i]} + ${CLI_BIN_LEN[$i]})) + for ((j = ${CLI_BIN_OFF[$i]}; j < bin_end; j++)); do + bin="${CLI_ALL_BINS[$j]}" + if command -v "$bin" &>/dev/null; then + path="$(command -v "$bin")" + if _cli_candidate_ok "${CLI_IDS[$i]}" "$path"; then + found="$path" + break + fi + fi + done + + # 2. The known install locations, dir-major. Note this still runs when a + # PATH hit was REJECTED above — that is how a Debian `dsh` on PATH + # does not hide a real harness in ~/.local/bin. + if [[ -z "$found" ]]; then + path_end=$((${CLI_PATH_OFF[$i]} + ${CLI_PATH_LEN[$i]})) + for ((j = ${CLI_PATH_OFF[$i]}; j < path_end; j++)); do + path="${CLI_ALL_PATHS[$j]}" + if [[ -x "$path" ]] && _cli_candidate_ok "${CLI_IDS[$i]}" "$path"; then + found="$path" + break + fi + done + fi + + CLI_FOUND_PATH[$i]="$found" + if [[ -n "$found" ]] && [[ "${CLI_ENABLED[$i]}" == "1" ]] && [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]]; then + CLI_FOUND_COUNT=$((CLI_FOUND_COUNT + 1)) fi done return 0 } -check_dsh() { - resolve_dsh - [[ -n "$DSH_RESOLVED_PATH" ]] +# Is this CLI installed? Unknown id is "no", never an error. +check_cli() { + detect_all_clis + _cli_index "$1" || return 1 + [[ -n "${CLI_FOUND_PATH[$CLI_IDX]}" ]] } -get_dsh_path() { - resolve_dsh - echo "$DSH_RESOLVED_PATH" +# Where it was found, or nothing. +get_cli_path() { + detect_all_clis + _cli_index "$1" || return 1 + printf '%s\n' "${CLI_FOUND_PATH[$CLI_IDX]}" } -get_grok_path() { - if command -v grok &>/dev/null; then - command -v grok - return - fi +# ---------------------------------------------------------------------------- +# Catalogue helpers +# ---------------------------------------------------------------------------- - for path in "${GROK_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return +# Pick this platform's install commands out of the generated per-platform arrays. +# +# ⚠️ THE TRUST BOUNDARY LIVES HERE, and it is mechanical rather than a promise: +# +# CLI_INSTALL_CMD_TRUSTED — written ONLY from CLI_CMD_LINUX/CLI_CMD_DARWIN, +# i.e. only from the block generated into this file. +# This is the sole array the installer ever executes. +# CLI_INSTALL_CMD_DISPLAY — the copy shown on screen. The optional catalogue +# refresh may rewrite it; it can never write TRUSTED. +# +# So a command that runs arrived in the same file, over the same TLS fetch, in +# the same commit as the `curl | bash` line that fetched this script. That is +# identical trust to the hardcoded vendor one-liners this replaces, and it is +# why nothing fetched at install time is ever executed. The server keeps its own, +# stricter rule unchanged: it never executes an entry's install command at all +# (see CliDiscovery.install.command in src/config/cli-registry/types.ts). +CLI_INSTALL_CMD_TRUSTED=() +CLI_INSTALL_CMD_DISPLAY=() +CLI_PLATFORM_DONE="" +cli_catalog_select_platform() { + [[ -n "$CLI_PLATFORM_DONE" ]] && return 0 + CLI_PLATFORM_DONE=1 + # detect_os ONCE, not per entry: it forks a subshell, and on an unsupported + # platform it also prints. Inside the loop that was ten forks and ten copies of + # the same error, because a `die` inside $( ) can only exit the subshell. + local i platform + platform="$(detect_os)" + for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do + if [[ "$platform" == "macos" ]]; then + CLI_INSTALL_CMD_TRUSTED[$i]="${CLI_CMD_DARWIN[$i]}" + else + CLI_INSTALL_CMD_TRUSTED[$i]="${CLI_CMD_LINUX[$i]}" + fi + CLI_INSTALL_CMD_DISPLAY[$i]="${CLI_INSTALL_CMD_TRUSTED[$i]}" + done +} + +# "Claude, OpenCode, Codex, ..." — the enabled, detectable CLIs, for prose. +cli_catalog_names() { + local i out="" + for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do + [[ "${CLI_ENABLED[$i]}" == "1" ]] || continue + [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue + out="${out:+$out, }${CLI_LABELS[$i]}" + done + printf '%s' "$out" +} + +# The "install one yourself" hints: every enabled CLI that is not installed, +# showing the DISPLAY command. An entry with no install command (DeepSeek ships +# no vendor one-liner) gets its docs URL instead of being silently omitted, +# which is what used to happen to Gemini — it had a command in the registry and +# appeared in no list in this script. +cli_catalog_print_install_hints() { + detect_all_clis + local i + for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do + [[ "${CLI_ENABLED[$i]}" == "1" ]] || continue + [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue + [[ -z "${CLI_FOUND_PATH[$i]}" ]] || continue + if [[ -n "${CLI_INSTALL_CMD_DISPLAY[$i]}" ]]; then + echo -e " ${CYAN}${CLI_INSTALL_CMD_DISPLAY[$i]}${NC} # ${CLI_LABELS[$i]}" + elif [[ -n "${CLI_DOCS[$i]}" ]]; then + echo -e " ${CLI_LABELS[$i]}: see ${CYAN}${CLI_DOCS[$i]}${NC}" fi done } -# `omp` is a short name too, so like grok/pi the server-side resolver -# additionally probes `omp --version`. Detection here only feeds the -# "you have no AI CLI" hint, so a plain executable test is enough. -check_omp() { - if command -v omp &>/dev/null; then +# Optional catalogue refresh — OPT-IN, and deliberately so. +# +# When you `curl | bash` from master the embedded catalogue is already exactly as +# fresh as the script that carries it, so a default-on refresh would buy nothing +# and add a network dependency plus a warning surface to every install. It exists +# for the case the embedded copy really can be stale: re-running an old local +# copy, or a fork. +# +# ⚠️ Only DISPLAY and detection-shaped fields are ever overwritten. TRUSTED is +# untouchable from here — see cli_catalog_select_platform. +# ⚠️ Called from main() only, AFTER check_curl_or_wget has set DOWNLOADER; that +# variable is otherwise unset under `set -u`. The guard below keeps a future +# caller that relocates this from dying instead of simply not refreshing. +cli_catalog_refresh() { + local url="${CODEMAN_CLI_CATALOGUE_URL:-}" + if [[ -z "$url" ]] && [[ "${CODEMAN_REFRESH_CLI_CATALOGUE:-0}" == "1" ]]; then + url="$(cli_catalog_default_url)" + fi + [[ -n "$url" ]] || return 0 + [[ -n "${DOWNLOADER:-}" ]] || return 0 + + local tmp + tmp="$(mktemp)" || return 0 + + if ! download "$url" "$tmp" 2>/dev/null; then + warn "CLI catalogue refresh could not fetch $url; using the catalogue built into this installer." + rm -f "$tmp" + return 0 + fi + if [[ ! -s "$tmp" ]]; then + # The failure shape that used to be SILENT: a plain network failure + # returning an empty body, where the old design fell back to a hardcoded + # two-CLI list and said nothing. There is no degraded list to fall back + # to now, and the fallback is announced either way. + warn "CLI catalogue refresh returned nothing (network failure?); using the catalogue built into this installer." + rm -f "$tmp" return 0 fi - for path in "${OMP_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 + # Parsed with node into tab-separated records and read with `read`, never + # eval'd: this content came off the network. + local parsed count=0 id label + parsed="$(node -e ' + const fs = require("fs"); + let data; + try { data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch { process.exit(2); } + if (!Array.isArray(data)) process.exit(2); + for (const e of data) { + if (!e || typeof e.id !== "string" || typeof e.label !== "string") continue; + if (/[\t\n]/.test(e.id) || /[\t\n]/.test(e.label)) continue; + console.log([e.id, e.label].join("\t")); + } + ' "$tmp" /dev/null)" || { + warn "CLI catalogue refresh returned unparseable content; using the catalogue built into this installer." + rm -f "$tmp" + return 0 + } + rm -f "$tmp" + + while IFS=$'\t' read -r id label; do + [[ -n "$id" ]] || continue + if _cli_index "$id"; then + CLI_LABELS[$CLI_IDX]="$label" + count=$((count + 1)) fi - done + done </dev/null; then - command -v omp - return + if [[ "$count" -eq 0 ]]; then + warn "CLI catalogue refresh matched no known CLI; using the catalogue built into this installer." + else + info "Refreshed CLI catalogue metadata for $count entries." fi - - for path in "${OMP_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done } +# Where a refresh looks when only CODEMAN_REFRESH_CLI_CATALOGUE=1 is set: +# the same repo and branch this installer came from. +cli_catalog_default_url() { + local repo="${REPO_URL%.git}" + repo="${repo#https://github.com/}" + printf 'https://raw.githubusercontent.com/%s/%s/config/clis.stock.json' "$repo" "$BRANCH" +} + +# Resolved at load, not lazily: every element of CLI_INSTALL_CMD_DISPLAY has to +# exist before anything indexes it, or `set -u` aborts on an unset array element +# the first time a hint is printed. +cli_catalog_select_platform + + check_cloudflared() { # Check ~/.local/bin first (matches tunnel-manager.ts resolution order) if [[ -x "$HOME/.local/bin/cloudflared" ]]; then @@ -2301,6 +2228,11 @@ main() { die "curl or wget is required but neither is installed. Please install one first." fi + # Optional, opt-in catalogue refresh. Deliberately here and nowhere else: + # DOWNLOADER is assigned by check_curl_or_wget above and is unset under + # `set -u` on every path that skips main() (the tailscale subcommand is one). + cli_catalog_refresh + # Detect system local os arch distro="" os=$(detect_os) @@ -2397,118 +2329,120 @@ main() { fi fi - # AI CLI (Codeman drives one of: Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi) - local has_claude=false - local has_opencode=false - local has_codex=false - local has_gemini=false - local has_antigravity=false - local has_pi=false - local has_grok=false - local has_dsh=false - local has_omp=false - + # AI CLI. Codeman drives one of the CLIs in the generated catalogue above; + # this used to be a hand-written list here, in the gate below, and in the + # closing reminder — three places that had to agree and did not (the comment + # itself named six of the nine). info "Checking AI CLI tools..." - if check_claude; then - has_claude=true - success "Claude Code found at $(get_claude_path)" - fi - if check_opencode; then - has_opencode=true - success "OpenCode found at $(get_opencode_path)" - fi - if check_codex; then - has_codex=true - success "Codex found at $(get_codex_path)" - fi - if check_gemini; then - has_gemini=true - success "Gemini CLI found at $(get_gemini_path)" - fi - if check_antigravity; then - has_antigravity=true - success "Antigravity CLI found at $(get_antigravity_path)" - fi - if check_pi; then - has_pi=true - success "Pi CLI found at $(get_pi_path)" - fi - if check_grok; then - has_grok=true - success "Grok CLI found at $(get_grok_path)" - fi - if check_dsh; then - has_dsh=true - success "DeepSeek Harness found at $(get_dsh_path)" - fi - if check_omp; then - has_omp=true - success "OMP CLI found at $(get_omp_path)" - fi + detect_all_clis + local i + for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do + [[ "${CLI_ENABLED[$i]}" == "1" ]] || continue + [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue + if [[ -n "${CLI_FOUND_PATH[$i]}" ]]; then + success "${CLI_LABELS[$i]} found at ${CLI_FOUND_PATH[$i]}" + fi + done - if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" && "$has_omp" == "false" ]]; then + if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then echo "" - warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP." + warn "No AI CLI found. Codeman needs at least one: $(cli_catalog_names)." headless_guard "install an AI CLI (curl | bash from its vendor)" echo "" - echo -e " ${BOLD}Which AI CLI would you like to install?${NC}" - echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)" - echo -e " ${CYAN}2)${NC} OpenCode (open-source)" - echo -e " ${CYAN}3)${NC} Both" - echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness or OMP)" - echo "" - local cli_choice="" - if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then - # Explicit automation opt-in: default to Claude Code - cli_choice="1" - info "CODEMAN_NONINTERACTIVE=1: defaulting to Claude Code" + # The menu is built from the catalogue: every enabled CLI that is not + # installed and ships an install command we can run. It used to be a + # fixed four-option prompt offering Claude Code and OpenCode only, so the + # other seven were unreachable even though the registry knows how to + # install five of them. + # + # ⚠️ TRUST BOUNDARY: the command executed comes from CLI_INSTALL_CMD_TRUSTED, + # which only the generated block above writes. The refresh may rewrite the + # _DISPLAY copy shown on screen but can never reach this array. See + # cli_catalog_select_platform. + local -a offer_idx=() + for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do + [[ "${CLI_ENABLED[$i]}" == "1" ]] || continue + [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue + [[ -z "${CLI_FOUND_PATH[$i]}" ]] || continue + [[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || continue + offer_idx[${#offer_idx[@]}]=$i + done + + # ⚠️ The registry's install commands are vendor one-liners that call + # `curl`, whereas the two literals this replaces went through + # download_to_stdout and so honoured `wget`. On a wget-only host we print + # the commands instead of offering to run them: rewriting curl to wget + # inside a string we are about to execute is exactly the wrong instinct. + if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ ${#offer_idx[@]} -gt 0 ]]; then + warn "curl is not available, so the installer cannot run a vendor install script for you." + cli_catalog_print_install_hints + offer_idx=() + fi + + if [[ ${#offer_idx[@]} -eq 0 ]]; then + warn "No AI CLI can be installed automatically here. Codeman will run, but sessions need a CLI to drive." + cli_catalog_print_install_hints else - while true; do - echo -en "${CYAN}Choose [1/2/3/4]:${NC} " >&2 - read_reply cli_choice || { cli_choice="1"; break; } - case "$cli_choice" in - 1|2|3|4) break ;; - *) echo "Please enter 1, 2, 3, or 4." >&2 ;; - esac + echo -e " ${BOLD}Which AI CLI would you like to install?${NC}" + local n=0 idx + for idx in "${offer_idx[@]}"; do + n=$((n + 1)) + echo -e " ${CYAN}${n})${NC} ${CLI_LABELS[$idx]}" done - fi + echo -e " ${CYAN}s)${NC} Skip (I'll install one myself)" + echo "" - if [[ "$cli_choice" == "1" ]] || [[ "$cli_choice" == "3" ]]; then - info "Installing Claude Code CLI..." - download_to_stdout https://claude.ai/install.sh | bash - hash -r 2>/dev/null || true - if check_claude; then - has_claude=true - success "Claude Code installed at $(get_claude_path)" + local cli_choice="" + if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then + # Explicit automation opt-in: default to the first offered entry, + # which is registry order, which is Claude Code (order 0) — the + # same default this prompt has always taken non-interactively. + cli_choice="1" + info "CODEMAN_NONINTERACTIVE=1: defaulting to ${CLI_LABELS[${offer_idx[0]}]}" else - warn "Claude Code installation failed." + while true; do + echo -en "${CYAN}Choose [1-${n}, or s to skip]:${NC} " >&2 + read_reply cli_choice || { cli_choice="1"; break; } + case "$cli_choice" in + s|S) break ;; + ''|*[!0-9]*) echo "Please enter a number between 1 and ${n}, or s." >&2 ;; + *) + if [[ "$cli_choice" -ge 1 ]] && [[ "$cli_choice" -le "$n" ]]; then + break + fi + echo "Please enter a number between 1 and ${n}, or s." >&2 + ;; + esac + done fi - fi - if [[ "$cli_choice" == "2" ]] || [[ "$cli_choice" == "3" ]]; then - info "Installing OpenCode CLI..." - download_to_stdout https://opencode.ai/install | bash - hash -r 2>/dev/null || true - if check_opencode; then - has_opencode=true - success "OpenCode installed at $(get_opencode_path)" + if [[ "$cli_choice" == "s" ]] || [[ "$cli_choice" == "S" ]]; then + warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive." + cli_catalog_print_install_hints else - warn "OpenCode installation failed." + idx="${offer_idx[$((cli_choice - 1))]}" + info "Installing ${CLI_LABELS[$idx]}..." + # /dev/null || true + CLI_DETECT_DONE="" + detect_all_clis + if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then + success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}" + else + warn "${CLI_LABELS[$idx]} installation failed." + fi fi - fi - if [[ "$cli_choice" == "4" ]]; then - warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive." - info "Install one later, e.g.: npm install -g @openai/codex (Codex)" - info " or: curl -fsSL https://antigravity.google/cli/install.sh | bash (Antigravity)" - info " or: npm install -g --ignore-scripts @earendil-works/pi-coding-agent (Pi)" - info " or: curl -fsSL https://x.ai/cli/install.sh | bash (Grok)" - elif [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then - die "The selected AI CLI failed to install. Install one manually and re-run the installer." + if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then + die "The selected AI CLI failed to install. Install one manually and re-run the installer." + fi fi fi + # cloudflared (optional — for remote/mobile access via Cloudflare Tunnel) info "Checking cloudflared (optional, for remote access)..." if check_cloudflared; then @@ -2804,19 +2738,10 @@ main() { echo -e " https://github.com/Ark0N/Codeman" echo "" - if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh && ! check_omp; then + detect_all_clis + if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:" - echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code" - echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode" - echo -e " ${CYAN}npm install -g @openai/codex${NC} # Codex" - echo -e " ${CYAN}curl -fsSL https://antigravity.google/cli/install.sh | bash${NC} # Antigravity" - echo -e " ${CYAN}npm install -g --ignore-scripts @earendil-works/pi-coding-agent${NC} # Pi" - echo -e " ${CYAN}curl -fsSL https://x.ai/cli/install.sh | bash${NC} # Grok" - echo -e " ${CYAN}curl -fsSL https://omp.sh/install | sh${NC} # OMP" - echo "" - echo -e " DeepSeek Harness has no vendor one-liner — install it from within Codeman" - echo -e " once the server is up (Run dropdown → Install DeepSeek Profile, or see" - echo -e " docs/deepseek-integration.md)." + cli_catalog_print_install_hints fi # Security notice — last informational block so it stays visible (when not @@ -3009,6 +2934,11 @@ uninstall() { echo "" } +# Sourcing guard: let the test harness load this file for its pure helpers +# without running an install. bash 3.2 cannot be exercised any other way from +# CI — see .github/workflows/ci.yml and test/install-sh-invariants.test.ts. +if [[ -n "${CODEMAN_INSTALL_SH_LIB:-}" ]]; then return 0 2>/dev/null || exit 0; fi + # Wrap in main to prevent partial execution on curl | bash case "${1:-}" in update) update ;; diff --git a/test/install-sh-detection-parity.test.ts b/test/install-sh-detection-parity.test.ts index 5b81c1d5..5d393ce8 100644 --- a/test/install-sh-detection-parity.test.ts +++ b/test/install-sh-detection-parity.test.ts @@ -101,14 +101,32 @@ const ARRAY_PREFIX_TO_CLI_ID: Record = { OMP: 'omp', }; -/** Every `NAME_SEARCH_PATHS=( "a" "b" )` block in install.sh, in declaration order. */ +/** + * The per-CLI search paths install.sh will actually probe, read back out of the GENERATED + * block: `CLI_ALL_PATHS` sliced by each id's `CLI_PATH_OFF`/`CLI_PATH_LEN` window. + * + * This parser replaced one that read the nine hand-written `*_SEARCH_PATHS` arrays, which + * this change deletes. The literals below did NOT move: they are still the same strings + * transcribed from those arrays, so the pin still measures the generated block against what + * shipped before it existed, which is the only comparison worth making. + */ function parseInstallShSearchPaths(source: string): Record { + const readArray = (name: string): string[] => { + const m = new RegExp(`^${name}=\\((.*)\\)$`, 'm').exec(source); + if (!m) throw new Error(`install.sh has no ${name}= array`); + // Tokens are double-quoted (paths, which carry $HOME), single-quoted (ids, labels) or + // bare (the numeric offset/length windows). + return [...m[1].matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((t) => t[1] ?? t[2] ?? t[3]); + }; + const ids = readArray('CLI_IDS'); + const paths = readArray('CLI_ALL_PATHS'); + const offs = readArray('CLI_PATH_OFF').map(Number); + const lens = readArray('CLI_PATH_LEN').map(Number); const out: Record = {}; - const block = /^([A-Z0-9_]+)_SEARCH_PATHS=\(\s*\n([\s\S]*?)^\)/gm; - for (const match of source.matchAll(block)) { - const entries = [...match[2].matchAll(/^\s*"([^"]+)"\s*$/gm)].map((m) => m[1]); - out[match[1]] = entries; - } + ids.forEach((id, i) => { + const prefix = Object.entries(ARRAY_PREFIX_TO_CLI_ID).find(([, cliId]) => cliId === id)?.[0]; + if (prefix) out[prefix] = paths.slice(offs[i], offs[i] + lens[i]); + }); return out; } @@ -131,20 +149,20 @@ function registrySearchPaths(cliId: string): string[] { describe('install.sh CLI detection parity', () => { const parsed = parseInstallShSearchPaths(INSTALL_SH); - it('finds every declared search-path array (anti-vacuity)', () => { + it('finds every generated search-path window (anti-vacuity)', () => { // If the parse returns nothing, every it.each below passes by comparing [] to []. expect(Object.keys(parsed).sort()).toEqual(Object.keys(LITERAL_SEARCH_PATHS).sort()); for (const [name, paths] of Object.entries(parsed)) { - expect(paths.length, `${name}_SEARCH_PATHS parsed empty`).toBeGreaterThan(0); + expect(paths.length, `${name} window parsed empty`).toBeGreaterThan(0); } }); - it.each(Object.keys(LITERAL_SEARCH_PATHS))('%s_SEARCH_PATHS matches the pinned literals', (prefix) => { + it.each(Object.keys(LITERAL_SEARCH_PATHS))('%s search paths match the pinned literals', (prefix) => { expect(parsed[prefix]).toEqual(LITERAL_SEARCH_PATHS[prefix]); }); it.each(Object.entries(ARRAY_PREFIX_TO_CLI_ID))( - '%s_SEARCH_PATHS is reproduced by registry entry "%s"', + '%s search paths are reproduced by registry entry "%s"', (prefix, cliId) => { // The claim the generator rests on: the registry already knows every path the // installer probes, in the same order. A failure here means the generated block would diff --git a/test/install-sh-invariants.test.ts b/test/install-sh-invariants.test.ts new file mode 100644 index 00000000..8a4c2c8b --- /dev/null +++ b/test/install-sh-invariants.test.ts @@ -0,0 +1,163 @@ +/** + * @fileoverview Static guards over `install.sh`, the one file in this repo nothing else checks. + * + * There is no shellcheck, no bats, and CI is Node-only, so a bash mistake here reaches users + * through `curl | bash` with nothing in between. The CI workflow now runs `bash -n` and a real + * `bash:3.2` container (see `.github/workflows/ci.yml`), which catches syntax and the + * `set -u` classes; this file catches the things that are perfectly valid bash and still wrong + * for THIS script. + * + * Port: none (pure, over one source file). + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const SOURCE = readFileSync(fileURLToPath(new URL('../install.sh', import.meta.url)), 'utf-8'); + +/** Lines with the leading `#` comments removed, so prose quoting a banned form is not a hit. */ +const CODE_LINES = SOURCE.split('\n').filter((line) => !/^\s*#/.test(line)); +const CODE = CODE_LINES.join('\n'); + +describe('install.sh stays bash 3.2 compatible', () => { + // macOS ships bash 3.2 (the last GPLv2 release) and the documented install is + // `curl -fsSL | bash`, so a bash-4 construct is not a warning on a Mac, it is a + // syntax error that kills the install mid-run. + it.each([ + ['associative arrays (`declare -A`)', /\b(?:declare|local|typeset)\s+-[A-Za-z]*A/], + ['case-conversion expansion (`${x,,}` / `${x^^}`)', /\$\{[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?[,^]{1,2}\}/], + ['`mapfile` / `readarray`', /\b(?:mapfile|readarray)\b/], + ['namerefs (`declare -n`)', /\b(?:declare|local|typeset)\s+-[A-Za-z]*n\b/], + ['here-strings (`<<<`)', /<< { + const offenders = CODE_LINES.filter((line) => pattern.test(line)); + expect(offenders, `bash 4+ construct found:\n ${offenders.join('\n ')}`).toEqual([]); + }); +}); + +describe('install.sh generated-catalogue block', () => { + it('has exactly one matched marker pair', () => { + expect(SOURCE.split('# >>> BEGIN GENERATED CLI CATALOGUE').length - 1).toBe(1); + expect(SOURCE.split('# <<< END GENERATED CLI CATALOGUE').length - 1).toBe(1); + expect(SOURCE.indexOf('# >>> BEGIN GENERATED CLI CATALOGUE')).toBeLessThan( + SOURCE.indexOf('# <<< END GENERATED CLI CATALOGUE') + ); + }); + + it('declares every array the detection code indexes', () => { + for (const name of [ + 'CLI_IDS', + 'CLI_LABELS', + 'CLI_ENABLED', + 'CLI_KIND', + 'CLI_NPM', + 'CLI_DOCS', + 'CLI_CMD_LINUX', + 'CLI_CMD_DARWIN', + 'CLI_ALL_BINS', + 'CLI_BIN_OFF', + 'CLI_BIN_LEN', + 'CLI_ALL_PATHS', + 'CLI_PATH_OFF', + 'CLI_PATH_LEN', + ]) { + expect(new RegExp(`^${name}=\\(`, 'm').test(SOURCE), `${name} is not declared`).toBe(true); + } + }); + + it('keeps no hand-written per-CLI detection behind', () => { + // The nine `*_SEARCH_PATHS` arrays and eighteen `check_`/`get__path` pairs are + // what this change removes. One left behind would be a second source of truth that the + // generator does not update — the exact shape of upstream b6d0f1fa. + expect(CODE.match(/_SEARCH_PATHS=\(/g) ?? []).toEqual([]); + + // Keyed on the catalogue's OWN ids and binaries rather than an allowlist of the helpers + // that may exist. `check_tmux` and `check_cloudflared` are legitimate and unrelated; a + // `check_claude` or `get_omp_path` is the thing being removed. Deriving the ban from the + // catalogue means a CLI added later is covered with no edit here. + const names = new Set(); + for (const arrayName of ['CLI_IDS', 'CLI_ALL_BINS']) { + const m = new RegExp(`^${arrayName}=\\((.*)\\)$`, 'm').exec(SOURCE); + for (const token of m?.[1].match(/'([^']*)'/g) ?? []) names.add(token.replace(/'/g, '')); + } + expect(names.size, 'could not read the catalogue ids/binaries').toBeGreaterThan(5); + + const perCliFunctions = [...names] + .flatMap((name) => [`check_${name}()`, `get_${name}_path()`]) + .filter((fn) => new RegExp(`^${fn.replace(/[()]/g, '\\$&')}`, 'm').test(CODE)); + expect(perCliFunctions, `hand-written per-CLI detection still present:\n ${perCliFunctions.join('\n ')}`).toEqual( + [] + ); + }); +}); + +describe('install.sh trust boundary', () => { + // The whole point of splitting TRUSTED from DISPLAY: a command the installer EXECUTES must + // have arrived embedded in this file, over the same TLS fetch and in the same commit as the + // script itself. Anything pulled from the network at install time is display-only. + it('writes CLI_INSTALL_CMD_TRUSTED only from the generated per-platform arrays', () => { + const writes = CODE_LINES.filter((line) => /CLI_INSTALL_CMD_TRUSTED\s*\[[^\]]*\]\s*=/.test(line)); + expect(writes.length, 'expected exactly the two platform assignments').toBe(2); + for (const line of writes) { + expect(line, `TRUSTED written from something other than the generated block:\n ${line}`).toMatch( + /=\s*"\$\{CLI_CMD_(?:LINUX|DARWIN)\[\$i\]\}"/ + ); + } + }); + + it('never lets the refresh touch a *_TRUSTED array', () => { + const refresh = CODE.slice(CODE.indexOf('cli_catalog_refresh() {')); + const body = refresh.slice(0, refresh.indexOf('\n}\n')); + expect(body.length, 'could not isolate cli_catalog_refresh').toBeGreaterThan(0); + expect(/_TRUSTED\s*\[[^\]]*\]\s*=/.test(body), 'the refresh assigns into a TRUSTED array').toBe(false); + }); + + it('never eval()s network-derived catalogue data', () => { + // Scoped to the refresh deliberately. install.sh has two long-standing, legitimate evals + // elsewhere (`eval "$(brew shellenv)"`, Homebrew's documented idiom, and one inside a + // node -e that reads `tailscale serve status`), and banning the word outright would flag + // those while saying nothing about the line that matters: `eval` on a fetched file would + // hand the shell to whatever answered the request. + const refresh = CODE.slice(CODE.indexOf('cli_catalog_refresh() {')); + const body = refresh.slice(0, refresh.indexOf('\n}\n')); + expect(body.length, 'could not isolate cli_catalog_refresh').toBeGreaterThan(0); + expect(/\beval\b/.test(body), 'the catalogue refresh eval()s something').toBe(false); + }); + + it("redirects stdin for every command it executes on the user's behalf", () => { + // Under `curl | bash` the script IS stdin, so a child that reads stdin eats the rest of + // it. Every spawn of an untrusted-length vendor command must carry ` /\bbash -c "\$\{CLI_INSTALL_CMD_TRUSTED/.test(line)); + expect(spawns.length, 'expected the single install-menu spawn').toBe(1); + for (const line of spawns) { + expect(line, `install spawn without { + it('guards the catalogue refresh on DOWNLOADER being set', () => { + // DOWNLOADER is assigned only by check_curl_or_wget, which only main() calls. Any path + // that reaches the refresh without it (the `tailscale` subcommand is one) would abort on + // an unbound variable under `set -u` rather than simply skipping the refresh. + const refresh = CODE.slice(CODE.indexOf('cli_catalog_refresh() {')); + const body = refresh.slice(0, refresh.indexOf('\n}\n')); + expect(body).toMatch(/\[\[\s*-n\s*"\$\{DOWNLOADER:-\}"\s*\]\]\s*\|\|\s*return 0/); + }); + + it('can be sourced without installing anything', () => { + // The bash 3.2 CI step sources this file to exercise detect_all_clis. Without the guard + // the dispatch `case` at the tail would run a real install inside the container. + expect(SOURCE).toMatch( + /if \[\[ -n "\$\{CODEMAN_INSTALL_SH_LIB:-\}" \]\]; then return 0 2>\/dev\/null \|\| exit 0; fi/ + ); + const guardAt = SOURCE.indexOf('CODEMAN_INSTALL_SH_LIB'); + const dispatchAt = SOURCE.indexOf('case "${1:-}" in'); + expect(guardAt, 'the sourcing guard must precede the dispatch case').toBeLessThan(dispatchAt); + }); + + it('still sets the strict flags it has always run under', () => { + expect(SOURCE).toMatch(/^set -euo pipefail$/m); + }); +});