mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
fix(install): let Skip in the AI CLI menu continue instead of aborting the install
Choosing "s" (Skip) in the new catalogue-driven install menu warned, printed the install hints and then fell into the shared "The selected AI CLI failed to install" gate one line below, because CLI_FOUND_COUNT is 0 by construction inside that block and skipping does not change it. The AI CLI check runs before the clone and the build, so a user who picked the documented skip option ended up with nothing installed. The code this replaced guarded the gate with an elif on the skip choice. The menu moves out of main() into offer_ai_cli_install() and the gate moves inside the install branch: skipping continues to the clone, a chosen install that leaves nothing behind is still fatal. Being a function, the interactive path can now be driven with a stubbed read_reply, which is what nothing reached before: two behavioural tests in test/install-sh-invariants.test.ts run the real function in a real bash (skip continues with exit 0, a failed install dies with exit 1), and the bash 3.2 CI step drives the skip path in the container as well. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,18 @@ jobs:
|
||||
echo "bash $BASH_VERSION: ${#CLI_IDS[@]} CLIs, $CLI_FOUND_COUNT found"
|
||||
cli_catalog_names >/dev/null
|
||||
cli_catalog_print_install_hints >/dev/null
|
||||
# The install menu with nothing installed and the user answering "s":
|
||||
# skipping must warn and continue, never trip the "failed to install"
|
||||
# gate (it did once, aborting the install before the clone).
|
||||
has_tty() { return 0; }
|
||||
headless_guard() { return 0; }
|
||||
read_reply() { eval "$1=s"; }
|
||||
NONINTERACTIVE=0
|
||||
k=0; while [[ $k -lt ${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="no-such-cli-$k"; k=$((k + 1)); done
|
||||
k=0; while [[ $k -lt ${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/$k"; k=$((k + 1)); done
|
||||
CLI_DETECT_DONE=""; detect_all_clis
|
||||
offer_ai_cli_install >/dev/null 2>&1
|
||||
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
|
||||
'
|
||||
|
||||
- name: CLI catalogue artifacts are in sync with stock.ts
|
||||
|
||||
+116
-103
@@ -609,6 +609,121 @@ cli_catalog_print_install_hints() {
|
||||
# the first time a hint is printed.
|
||||
cli_catalog_select_platform
|
||||
|
||||
# Offer to install one AI CLI from the catalogue, or let the user skip.
|
||||
#
|
||||
# Split out of main() so the bash 3.2 CI step and test/install-sh-invariants.test.ts
|
||||
# can drive the menu with a stubbed read_reply: the interactive path is the one
|
||||
# part of this script no static check reaches, and it is where choosing "s" (Skip)
|
||||
# once fell into the "failed to install" gate and aborted the whole installer.
|
||||
# That gate therefore lives INSIDE the install branch: skipping is a documented
|
||||
# choice that continues to the clone and build (sessions just need a CLI later),
|
||||
# while a chosen install that leaves nothing behind is still fatal.
|
||||
offer_ai_cli_install() {
|
||||
local i
|
||||
echo ""
|
||||
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 ""
|
||||
|
||||
# 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,
|
||||
# the only array the generated block above writes and the only one the
|
||||
# installer ever runs or displays — see cli_catalog_select_platform.
|
||||
#
|
||||
# ⚠️ The registry's install commands are a MIX: some call `curl` directly
|
||||
# (vendor one-liners), others are `npm install -g …`, which never needed
|
||||
# curl at all. A wget-only host used to lose the WHOLE menu over this,
|
||||
# including every npm entry — the two literals this replaced went through
|
||||
# download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1
|
||||
# silently stopped defaulting to Claude Code as documented. Filter per
|
||||
# entry instead: only a command that actually starts with `curl ` is
|
||||
# curl-dependent, so only THOSE are held back on a wget-only host.
|
||||
# Rewriting curl to wget inside a string about to be executed is the
|
||||
# wrong instinct either way — the ones we can't run, we show as a hint.
|
||||
local -a offer_idx=()
|
||||
local curl_only_skipped=0
|
||||
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
|
||||
if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then
|
||||
curl_only_skipped=$((curl_only_skipped + 1))
|
||||
continue
|
||||
fi
|
||||
offer_idx[${#offer_idx[@]}]=$i
|
||||
done
|
||||
|
||||
if [[ "$curl_only_skipped" -gt 0 ]]; then
|
||||
warn "curl is not available, so $curl_only_skipped install command(s) that need it were left out of the menu below (still shown as hints if you skip)."
|
||||
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
|
||||
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
|
||||
echo -e " ${CYAN}s)${NC} Skip (I'll install one myself)"
|
||||
echo ""
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
check_cloudflared() {
|
||||
# Check ~/.local/bin first (matches tunnel-manager.ts resolution order)
|
||||
@@ -2260,109 +2375,7 @@ main() {
|
||||
done
|
||||
|
||||
if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then
|
||||
echo ""
|
||||
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 ""
|
||||
|
||||
# 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,
|
||||
# the only array the generated block above writes and the only one the
|
||||
# installer ever runs or displays — see cli_catalog_select_platform.
|
||||
#
|
||||
# ⚠️ The registry's install commands are a MIX: some call `curl` directly
|
||||
# (vendor one-liners), others are `npm install -g …`, which never needed
|
||||
# curl at all. A wget-only host used to lose the WHOLE menu over this,
|
||||
# including every npm entry — the two literals this replaced went through
|
||||
# download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1
|
||||
# silently stopped defaulting to Claude Code as documented. Filter per
|
||||
# entry instead: only a command that actually starts with `curl ` is
|
||||
# curl-dependent, so only THOSE are held back on a wget-only host.
|
||||
# Rewriting curl to wget inside a string about to be executed is the
|
||||
# wrong instinct either way — the ones we can't run, we show as a hint.
|
||||
local -a offer_idx=()
|
||||
local curl_only_skipped=0
|
||||
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
|
||||
if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then
|
||||
curl_only_skipped=$((curl_only_skipped + 1))
|
||||
continue
|
||||
fi
|
||||
offer_idx[${#offer_idx[@]}]=$i
|
||||
done
|
||||
|
||||
if [[ "$curl_only_skipped" -gt 0 ]]; then
|
||||
warn "curl is not available, so $curl_only_skipped install command(s) that need it were left out of the menu below (still shown as hints if you skip)."
|
||||
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
|
||||
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
|
||||
echo -e " ${CYAN}s)${NC} Skip (I'll install one myself)"
|
||||
echo ""
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
offer_ai_cli_install
|
||||
fi
|
||||
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE = readFileSync(fileURLToPath(new URL('../install.sh', import.meta.url)), 'utf-8');
|
||||
const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url));
|
||||
const SOURCE = readFileSync(INSTALL_SH, '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));
|
||||
@@ -164,3 +166,64 @@ describe('install.sh runtime safety', () => {
|
||||
expect(SOURCE).toMatch(/^set -euo pipefail$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh AI CLI install menu', () => {
|
||||
// The menu is the one interactive path in the script, which is why it used to be the
|
||||
// only part nothing exercised: choosing "s" (Skip) once fell straight into the shared
|
||||
// "failed to install" gate and aborted the installer before the clone. These drive the
|
||||
// real function (offer_ai_cli_install) in a real bash, with detection pointed at
|
||||
// nothing so the menu appears, and read_reply scripted.
|
||||
const DRIVER = `
|
||||
set -euo pipefail
|
||||
export CODEMAN_INSTALL_SH_LIB=1
|
||||
. "$1"
|
||||
k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done
|
||||
k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done
|
||||
if [[ -n "\${MENU_INSTALL_CMD:-}" ]]; then
|
||||
k=0; while [[ $k -lt \${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do CLI_INSTALL_CMD_TRUSTED[$k]="$MENU_INSTALL_CMD"; k=$((k + 1)); done
|
||||
fi
|
||||
CLI_DETECT_DONE=""
|
||||
detect_all_clis
|
||||
echo "found=$CLI_FOUND_COUNT"
|
||||
NONINTERACTIVE=0
|
||||
DOWNLOADER=curl
|
||||
has_tty() { return 0; }
|
||||
headless_guard() { return 0; }
|
||||
read_reply() { eval "$1=\\"$MENU_ANSWER\\""; }
|
||||
offer_ai_cli_install
|
||||
echo "REACHED THE STEP AFTER THE MENU"
|
||||
`;
|
||||
|
||||
function driveMenu(answer: string, installCommand?: string) {
|
||||
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, MENU_ANSWER: answer, ...(installCommand ? { MENU_INSTALL_CMD: installCommand } : {}) },
|
||||
});
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
return { status: result.status, stdout: strip(result.stdout ?? ''), stderr: strip(result.stderr ?? '') };
|
||||
}
|
||||
|
||||
it('offers the menu only when nothing is installed', () => {
|
||||
const run = driveMenu('s');
|
||||
expect(run.stdout).toContain('found=0');
|
||||
expect(run.stderr).toContain('Choose [1-');
|
||||
});
|
||||
|
||||
it('continues past the menu when the user skips', () => {
|
||||
const run = driveMenu('s');
|
||||
expect(run.stderr).toContain('Skipping AI CLI install');
|
||||
expect(run.stdout, run.stderr).toContain('REACHED THE STEP AFTER THE MENU');
|
||||
expect(run.stderr).not.toContain('failed to install');
|
||||
expect(run.status).toBe(0);
|
||||
});
|
||||
|
||||
it('still dies when the chosen install leaves nothing behind', () => {
|
||||
const run = driveMenu('1', 'false');
|
||||
expect(run.stderr).toContain('installation failed');
|
||||
expect(run.stderr).toContain('The selected AI CLI failed to install');
|
||||
expect(run.stdout).not.toContain('REACHED THE STEP AFTER THE MENU');
|
||||
expect(run.status).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user