mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
refactor(install): drive CLI detection, the install menu and hints from the catalogue
install.sh carried nine search-path arrays, eighteen near-identical
check_<cli>/get_<cli>_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EMxQreQUZX5ZyybxAGh12
This commit is contained in:
@@ -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
|
||||
|
||||
+360
-430
@@ -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_<cli>/get_<cli>_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 2>/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 2>/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 <<EOF
|
||||
$parsed
|
||||
EOF
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
get_omp_path() {
|
||||
if command -v omp &>/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: under `curl | bash` a child that reads stdin would
|
||||
# consume the rest of this script.
|
||||
bash -c "${CLI_INSTALL_CMD_TRUSTED[$idx]}" </dev/null || true
|
||||
hash -r 2>/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 ;;
|
||||
|
||||
@@ -101,14 +101,32 @@ const ARRAY_PREFIX_TO_CLI_ID: Record<string, string> = {
|
||||
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<string, string[]> {
|
||||
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<string, string[]> = {};
|
||||
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
|
||||
|
||||
@@ -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 <url> | 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 (`<<<`)', /<<</],
|
||||
])('uses no %s', (_label, pattern) => {
|
||||
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_<cli>`/`get_<cli>_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<string>();
|
||||
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 `</dev/null`.
|
||||
const spawns = CODE_LINES.filter((line) => /\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 </dev/null:\n ${line}`).toContain('</dev/null');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh runtime safety', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user