Files
Codeman/test/install-sh-detection-parity.test.ts
T
DevvynandClaude Opus 5 1ca35095e7 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
2026-09-13 17:43:13 +08:00

182 lines
7.9 KiB
TypeScript

/**
* @fileoverview Pins `install.sh`'s CLI detection paths BEFORE they are generated.
*
* PR B replaces nine hand-written `*_SEARCH_PATHS` arrays in `install.sh` with one block
* generated from `STOCK_CLIS`. The arrays are NOT uniform — claude alone has
* `~/.claude/local`, opencode alone has `~/go/bin`, opencode/codex/gemini/pi/omp have
* `~/.bun/bin` while dsh/grok/agy do not, and omp's `~/.omp/bin` sits SECOND rather than
* first — so "generate them from the registry" is a claim that has to be proved, not
* assumed. If the generated list silently narrows, a user with that CLI installed stops
* being detected and is told no AI CLI was found: exactly the bug upstream `b6d0f1fa` fixed
* for omp by hand.
*
* This file is deliberately written FIRST, against the hand-written arrays, and kept
* afterwards as a regression pin. It asserts a three-way identity:
*
* 1. the literals below === what `install.sh` actually contains today
* 2. the literals below === `searchDirs x binaries` from the registry
*
* Together those mean the generator can only produce what is already shipping. (1) fails if
* `install.sh` drifts from the pin; (2) fails if a registry entry's `searchDirs` drifts from
* the installer — which, once the block is generated, is the same statement.
*
* ⚠️ The literals are the SOURCE OF TRUTH here and were transcribed from `install.sh` at
* `72fd231d`. Do not "fix" a failure by re-copying the current file into them; that turns
* the pin into a mirror and it stops guarding anything. Work out which side moved.
*
* Port: none (pure, over one source file and the registry).
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const INSTALL_SH = readFileSync(fileURLToPath(new URL('../install.sh', import.meta.url)), 'utf-8');
/**
* The nine arrays exactly as `install.sh` declares them, in declaration order, with the
* shell-variable form (`$HOME/...`) they carry there rather than the registry's `~/...`.
*
* Keyed by the array's own prefix, which is NOT always the registry id: DeepSeek's entry is
* `deepseek` but its binary and array are `DSH`, and antigravity's binary is `agy`.
*/
const LITERAL_SEARCH_PATHS: Record<string, string[]> = {
CLAUDE: [
'$HOME/.local/bin/claude',
'$HOME/.claude/local/claude',
'/usr/local/bin/claude',
'$HOME/.npm-global/bin/claude',
'$HOME/bin/claude',
],
OPENCODE: [
'$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: [
'$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: [
'$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: ['$HOME/.local/bin/pi', '/usr/local/bin/pi', '$HOME/.bun/bin/pi', '$HOME/.npm-global/bin/pi', '$HOME/bin/pi'],
DSH: ['$HOME/.local/bin/dsh', '/usr/local/bin/dsh', '$HOME/.npm-global/bin/dsh', '$HOME/bin/dsh'],
GROK: ['$HOME/.grok/bin/grok', '$HOME/.local/bin/grok', '/usr/local/bin/grok', '$HOME/bin/grok'],
ANTIGRAVITY: ['$HOME/.local/bin/agy', '$HOME/.antigravity/bin/agy', '/usr/local/bin/agy', '$HOME/bin/agy'],
OMP: [
'$HOME/.local/bin/omp',
'$HOME/.omp/bin/omp',
'/usr/local/bin/omp',
'$HOME/.bun/bin/omp',
'$HOME/.npm-global/bin/omp',
'$HOME/bin/omp',
],
};
/** Array prefix in `install.sh` -> registry id, for the two that differ. */
const ARRAY_PREFIX_TO_CLI_ID: Record<string, string> = {
CLAUDE: 'claude',
OPENCODE: 'opencode',
CODEX: 'codex',
GEMINI: 'gemini',
PI: 'pi',
DSH: 'deepseek',
GROK: 'grok',
ANTIGRAVITY: 'antigravity',
OMP: 'omp',
};
/**
* 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[]> = {};
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;
}
/**
* What the generated block must contain for one entry: `searchDirs x binaries`, in that
* nesting order, with `~` rewritten to `$HOME` the way the generator will emit it.
*
* The dir-major order matters and is not arbitrary — it is the order the resolvers probe in,
* so a binary-major flattening would still contain every path while checking them in the
* wrong sequence, and the first hit would change on a machine with two installs.
*/
function registrySearchPaths(cliId: string): string[] {
const entry = STOCK_CLIS.find((e) => (e.id as string) === cliId);
if (!entry) throw new Error(`no stock entry ${cliId}`);
return entry.discovery.searchDirs.flatMap((dir) =>
entry.discovery.binaries.map((bin) => `${dir.startsWith('~/') ? `$HOME/${dir.slice(2)}` : dir}/${bin}`)
);
}
describe('install.sh CLI detection parity', () => {
const parsed = parseInstallShSearchPaths(INSTALL_SH);
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} window parsed empty`).toBeGreaterThan(0);
}
});
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 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
// detect a different set than the hand-written one it replaces.
expect(registrySearchPaths(cliId)).toEqual(LITERAL_SEARCH_PATHS[prefix]);
}
);
it('covers every stock CLI that has a binary to find', () => {
// `shell` declares no binaries, so it has nothing to detect and no array. Everything
// else must be pinned above, or a new CLI could land with no installer coverage — which
// is the omp bug (upstream b6d0f1fa) restated as a test.
const detectable = STOCK_CLIS.filter((e) => e.discovery.binaries.length > 0).map((e) => e.id as string);
expect(detectable.sort()).toEqual(Object.values(ARRAY_PREFIX_TO_CLI_ID).sort());
});
});