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:
Devvyn
2026-09-13 17:43:13 +08:00
co-authored by Claude Opus 5
parent 7d6f612ef5
commit 1ca35095e7
4 changed files with 580 additions and 440 deletions
+28 -10
View File
@@ -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
+163
View File
@@ -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);
});
});