From 7d6f612ef5748ee240a96a4f8fbabeb726d39489 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:38:30 +0800 Subject: [PATCH] feat(cli-registry): generate a CLI catalogue for install.sh and the Docker build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consumers of the registry cannot import TypeScript: `install.sh`, which runs via `curl | bash` before any checkout exists, and `scripts/build-agent-image.mjs`. Both currently hand-maintain their own CLI lists, and both have already drifted. `scripts/generate-cli-catalog.mts` (`npm run generate:cli-catalog`, plus a `--check` mode) emits from `STOCK_CLIS`: - `config/clis.stock.json` for the `.mjs` and the tests. It carries `enabled` — the field the earlier attempt omitted, which is how a disabled CLI's npm package still got baked into every agent image. - a marker-delimited block inside `install.sh`, embedded rather than fetched. The embedded copy is the FULL catalogue on purpose: the earlier design fetched it and fell back to a hardcoded two-CLI list, degrading silently on an empty response. There is no degraded mode to fall into now. The block is bash 3.2 safe: parallel indexed arrays, no associative arrays, no namerefs, no mapfile. Variable-length lists use OFFSET/LENGTH windows into one flat array rather than a delimiter, so a $HOME containing a space needs no IFS handling and `shell` (no binaries) gets length 0 and is never iterated. Search paths are emitted dir-major, matching the probe order the hand-written arrays use and `test/install-sh-detection-parity.test.ts` pins. Only fields the two consumers need are exported. `launch`/`env`/`capabilities`/ `overlays` are spawn-time concerns the server alone interprets, and a test asserts they never leak into the artifact. `main()` sits behind an `isMainModule()` guard so the sync test can import the renderers. Without it, importing the module would rewrite the artifacts as a side effect of checking them — passing always, guarding never. This commit adds the block; it does not yet delete the hand-written arrays, so the detection pin keeps measuring both against each other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015EMxQreQUZX5ZyybxAGh12 --- config/clis.stock.json | 273 +++++++++++++++++++++++++++++++ install.sh | 29 ++++ package.json | 1 + scripts/generate-cli-catalog.mts | 247 ++++++++++++++++++++++++++++ test/cli-catalog-sync.test.ts | 80 +++++++++ 5 files changed, 630 insertions(+) create mode 100644 config/clis.stock.json create mode 100644 scripts/generate-cli-catalog.mts create mode 100644 test/cli-catalog-sync.test.ts diff --git a/config/clis.stock.json b/config/clis.stock.json new file mode 100644 index 00000000..382c25a7 --- /dev/null +++ b/config/clis.stock.json @@ -0,0 +1,273 @@ +[ + { + "id": "claude", + "label": "Claude", + "shortBadge": "CC", + "enabled": true, + "order": 0, + "kind": "agent", + "discovery": { + "binaries": [ + "claude" + ], + "searchDirs": [ + "~/.local/bin", + "~/.claude/local", + "/usr/local/bin", + "~/.npm-global/bin", + "~/bin" + ], + "install": { + "command": { + "linux": "curl -fsSL https://claude.ai/install.sh | bash", + "darwin": "curl -fsSL https://claude.ai/install.sh | bash", + "wsl": "curl -fsSL https://claude.ai/install.sh | bash" + }, + "npmPackage": "@anthropic-ai/claude-code", + "docsUrl": "https://docs.claude.com/claude-code" + } + } + }, + { + "id": "shell", + "label": "Shell", + "shortBadge": "SH", + "enabled": true, + "order": 1, + "kind": "shell", + "discovery": { + "binaries": [], + "searchDirs": [], + "install": { + "command": {} + } + } + }, + { + "id": "opencode", + "label": "OpenCode", + "shortBadge": "OC", + "enabled": true, + "order": 10, + "kind": "agent", + "discovery": { + "binaries": [ + "opencode" + ], + "searchDirs": [ + "~/.opencode/bin", + "~/.local/bin", + "/usr/local/bin", + "~/go/bin", + "~/.bun/bin", + "~/.npm-global/bin", + "~/bin" + ], + "install": { + "command": { + "linux": "curl -fsSL https://opencode.ai/install | bash", + "darwin": "curl -fsSL https://opencode.ai/install | bash" + }, + "npmPackage": "opencode-ai", + "docsUrl": "https://opencode.ai/docs" + } + } + }, + { + "id": "codex", + "label": "Codex", + "shortBadge": "CX", + "enabled": true, + "order": 20, + "kind": "agent", + "discovery": { + "binaries": [ + "codex" + ], + "searchDirs": [ + "~/.codex/bin", + "~/.local/bin", + "/usr/local/bin", + "~/.bun/bin", + "~/.npm-global/bin", + "~/bin" + ], + "install": { + "command": { + "linux": "npm install -g @openai/codex", + "darwin": "npm install -g @openai/codex" + }, + "npmPackage": "@openai/codex", + "docsUrl": "https://developers.openai.com/codex/cli" + } + } + }, + { + "id": "gemini", + "label": "Gemini", + "shortBadge": "GM", + "enabled": true, + "order": 30, + "kind": "agent", + "discovery": { + "binaries": [ + "gemini" + ], + "searchDirs": [ + "~/.gemini/bin", + "~/.local/bin", + "/usr/local/bin", + "~/.bun/bin", + "~/.npm-global/bin", + "~/bin" + ], + "install": { + "command": { + "linux": "npm install -g @google/gemini-cli", + "darwin": "npm install -g @google/gemini-cli" + }, + "npmPackage": "@google/gemini-cli", + "docsUrl": "https://github.com/google-gemini/gemini-cli" + } + } + }, + { + "id": "antigravity", + "label": "Antigravity", + "shortBadge": "AG", + "enabled": true, + "order": 40, + "kind": "agent", + "discovery": { + "binaries": [ + "agy" + ], + "searchDirs": [ + "~/.local/bin", + "~/.antigravity/bin", + "/usr/local/bin", + "~/bin" + ], + "install": { + "command": { + "linux": "curl -fsSL https://antigravity.google/cli/install.sh | bash", + "darwin": "curl -fsSL https://antigravity.google/cli/install.sh | bash" + }, + "docsUrl": "https://antigravity.google/cli" + } + } + }, + { + "id": "pi", + "label": "Pi", + "shortBadge": "PI", + "enabled": true, + "order": 50, + "kind": "agent", + "discovery": { + "binaries": [ + "pi" + ], + "searchDirs": [ + "~/.local/bin", + "/usr/local/bin", + "~/.bun/bin", + "~/.npm-global/bin", + "~/bin" + ], + "install": { + "command": { + "linux": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent", + "darwin": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent" + }, + "npmPackage": "@earendil-works/pi-coding-agent", + "docsUrl": "https://pi.dev" + } + } + }, + { + "id": "grok", + "label": "Grok", + "shortBadge": "GK", + "enabled": true, + "order": 70, + "kind": "agent", + "discovery": { + "binaries": [ + "grok" + ], + "searchDirs": [ + "~/.grok/bin", + "~/.local/bin", + "/usr/local/bin", + "~/bin" + ], + "install": { + "command": { + "linux": "curl -fsSL https://x.ai/cli/install.sh | bash", + "darwin": "curl -fsSL https://x.ai/cli/install.sh | bash" + }, + "docsUrl": "https://github.com/xai-org/grok-build" + } + } + }, + { + "id": "deepseek", + "label": "DeepSeek", + "shortBadge": "DS", + "enabled": true, + "order": 80, + "kind": "agent", + "discovery": { + "binaries": [ + "dsh" + ], + "searchDirs": [ + "~/.local/bin", + "/usr/local/bin", + "~/.npm-global/bin", + "~/bin" + ], + "identity": { + "arg": "--help", + "regex": "DeepSeek\\s+Harness" + }, + "install": { + "command": { + "linux": "npm install -g @deepseek-ai/dsh", + "darwin": "npm install -g @deepseek-ai/dsh" + }, + "npmPackage": "@deepseek-ai/dsh", + "docsUrl": "https://github.com/deepseek-ai/deepseek-harness" + } + } + }, + { + "id": "omp", + "label": "OMP", + "shortBadge": "OM", + "enabled": true, + "order": 90, + "kind": "agent", + "discovery": { + "binaries": [ + "omp" + ], + "searchDirs": [ + "~/.local/bin", + "~/.omp/bin", + "/usr/local/bin", + "~/.bun/bin", + "~/.npm-global/bin", + "~/bin" + ], + "install": { + "command": { + "linux": "curl -fsSL https://omp.sh/install | sh", + "darwin": "brew install can1357/tap/omp" + }, + "docsUrl": "https://omp.sh" + } + } + } +] diff --git a/install.sh b/install.sh index 70d3cffd..aa3621c9 100755 --- a/install.sh +++ b/install.sh @@ -160,6 +160,35 @@ OMP_SEARCH_PATHS=( "$HOME/bin/omp" ) +# >>> BEGIN GENERATED CLI CATALOGUE +# Generated from src/config/cli-registry/stock.ts by scripts/generate-cli-catalog.mts. +# Do not edit by hand: run `npm run generate:cli-catalog` and commit the result. +# +# Parallel indexed arrays, bash 3.2 safe (no associative arrays, no nameref, no mapfile). +# The variable-length lists use OFFSET/LENGTH windows into one flat array rather than a +# delimiter, so a $HOME containing a space needs no IFS handling and an entry with nothing +# to contribute (shell has no binaries) gets length 0 and is simply never iterated. +# +# ⚠️ TRUST BOUNDARY: CLI_CMD_LINUX/CLI_CMD_DARWIN are the ONLY source of a command this +# script will ever execute, and they arrive embedded in this file — same TLS fetch, same +# commit as the script itself. Nothing fetched at install time may write them; the +# refresh may only touch the *_DISPLAY copy. See cli_catalog_select_platform below. +CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp') +CLI_LABELS=('Claude' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP') +CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1) +CLI_KIND=('agent' 'shell' 'agent' 'agent' 'agent' 'agent' 'agent' 'agent' 'agent' 'agent') +CLI_NPM=('@anthropic-ai/claude-code' '' 'opencode-ai' '@openai/codex' '@google/gemini-cli' '' '@earendil-works/pi-coding-agent' '' '@deepseek-ai/dsh' '') +CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh') +CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' 'npm install -g @deepseek-ai/dsh' 'curl -fsSL https://omp.sh/install | sh') +CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' 'npm install -g @deepseek-ai/dsh' 'brew install can1357/tap/omp') +CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp') +CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8) +CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1) +CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$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" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp") +CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41) +CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6) +# <<< END GENERATED CLI CATALOGUE + # ============================================================================ # Color Output # ============================================================================ diff --git a/package.json b/package.json index 19704dbd..6ec1aff6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "postinstall": "node scripts/postinstall.js", "build": "node scripts/build.mjs", "build:gesture": "node scripts/build-gesture-bundle.mjs", + "generate:cli-catalog": "tsx scripts/generate-cli-catalog.mts", "start": "NODE_COMPILE_CACHE=${HOME}/.codeman/compile-cache node dist/index.js", "dev": "tsx src/index.ts web", "web": "node dist/index.js web", diff --git a/scripts/generate-cli-catalog.mts b/scripts/generate-cli-catalog.mts new file mode 100644 index 00000000..9ccdadbc --- /dev/null +++ b/scripts/generate-cli-catalog.mts @@ -0,0 +1,247 @@ +/** + * Regenerates the two CLI-catalogue artifacts from `src/config/cli-registry/stock.ts`, + * which stays the single source of truth. + * + * npm run generate:cli-catalog # rewrite both artifacts + * npm run generate:cli-catalog -- --check # exit 1 on drift, write nothing + * + * The artifacts exist because two consumers cannot import TypeScript: + * + * - `config/clis.stock.json` — read by `scripts/lib/cli-catalog.mjs` (a `.mjs` that feeds + * the Docker build args) and by the tests. + * - a generated block inside `install.sh` — the installer runs via `curl | bash` BEFORE any + * checkout exists, so it can read neither the registry nor the JSON. Its copy is embedded. + * + * ⚠️ The embedded copy is the FULL catalogue, deliberately. An earlier design fetched the + * JSON at install time and fell back to a hardcoded two-CLI list, which degraded silently on + * an empty response. There is no degraded mode to fall into now. + * + * ⚠️ Only fields the two consumers actually need are exported. `launch`, `env`, `capabilities` + * and `overlays` are spawn-time concerns the server alone interprets, and exporting them would + * invite a second implementation of the launch model outside the process that owns it. + * + * `test/cli-catalog-sync.test.ts` pins both artifacts against a fresh generation. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import type { CliEntry } from '../src/config/cli-registry/types.js'; + +const JSON_PATH = fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)); +const INSTALL_SH_PATH = fileURLToPath(new URL('../install.sh', import.meta.url)); + +const BEGIN_MARKER = '# >>> BEGIN GENERATED CLI CATALOGUE'; +const END_MARKER = '# <<< END GENERATED CLI CATALOGUE'; + +/** Platforms install.sh can be running on. `wsl`/`win32` resolve through the linux arm. */ +type InstallPlatform = 'linux' | 'darwin'; + +// --------------------------------------------------------------------------- +// config/clis.stock.json +// --------------------------------------------------------------------------- + +interface CatalogEntry { + id: string; + label: string; + shortBadge: string; + enabled: boolean; + order: number; + kind: string; + discovery: { + binaries: string[]; + searchDirs: string[]; + identity?: { arg: string; regex: string }; + install: { command: Record; npmPackage?: string; docsUrl?: string }; + }; +} + +function toCatalogEntry(entry: CliEntry): CatalogEntry { + const { binaries, searchDirs, identity, install } = entry.discovery; + return { + id: entry.id as string, + label: entry.label, + shortBadge: entry.shortBadge, + // ⚠️ The field the previous attempt omitted, which is how a disabled CLI's npm package + // still got baked into every agent image. Every consumer filters on it. + enabled: entry.enabled, + order: entry.order, + kind: entry.kind, + discovery: { + binaries: [...binaries], + searchDirs: [...searchDirs], + ...(identity ? { identity: { arg: identity.arg, regex: identity.regex } } : {}), + install: { + command: { ...install.command } as Record, + ...(install.npmPackage ? { npmPackage: install.npmPackage } : {}), + ...(install.docsUrl ? { docsUrl: install.docsUrl } : {}), + }, + }, + }; +} + +export function renderCatalogJson(entries: CliEntry[] = STOCK_CLIS): string { + return `${JSON.stringify(entries.map(toCatalogEntry), null, 2)}\n`; +} + +// --------------------------------------------------------------------------- +// The install.sh block +// --------------------------------------------------------------------------- + +/** Single-quote a value for bash, escaping any embedded single quote. */ +function shQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * A search dir as install.sh spells it. `~` becomes `$HOME` inside DOUBLE quotes so the shell + * expands it at load time, exactly as the hand-written arrays did; everything else is + * absolute and needs no expansion. + */ +function shPath(dir: string, binary: string): string { + const expanded = dir.startsWith('~/') ? `$HOME/${dir.slice(2)}` : dir; + return `"${expanded}/${binary}"`; +} + +/** + * The install command to run on `platform`, mirroring `resolveInstallCommandForPlatform()`: + * the exact platform, else linux, else whatever is declared. Resolved HERE, at generation + * time, so that fallback logic stays in tested TypeScript instead of being reimplemented in + * bash against an array the script would have to index by platform anyway. + */ +function installCommandFor(entry: CliEntry, platform: InstallPlatform): string { + const { command } = entry.discovery.install; + return command[platform] ?? command.linux ?? Object.values(command)[0] ?? ''; +} + +export function renderInstallShBlock(entries: CliEntry[] = STOCK_CLIS): string { + const ids: string[] = []; + const labels: string[] = []; + const enabled: string[] = []; + const kinds: string[] = []; + const npm: string[] = []; + const docs: string[] = []; + const cmdLinux: string[] = []; + const cmdDarwin: string[] = []; + const allBins: string[] = []; + const binOff: number[] = []; + const binLen: number[] = []; + const allPaths: string[] = []; + const pathOff: number[] = []; + const pathLen: number[] = []; + + for (const entry of entries) { + ids.push(shQuote(entry.id as string)); + labels.push(shQuote(entry.label)); + enabled.push(entry.enabled ? '1' : '0'); + kinds.push(shQuote(entry.kind)); + npm.push(shQuote(entry.discovery.install.npmPackage ?? '')); + docs.push(shQuote(entry.discovery.install.docsUrl ?? '')); + cmdLinux.push(shQuote(installCommandFor(entry, 'linux'))); + cmdDarwin.push(shQuote(installCommandFor(entry, 'darwin'))); + + const { binaries, searchDirs } = entry.discovery; + binOff.push(allBins.length); + binLen.push(binaries.length); + for (const bin of binaries) allBins.push(shQuote(bin)); + + // Dir-major, matching the probe order the hand-written arrays used and + // `test/install-sh-detection-parity.test.ts` pins. + pathOff.push(allPaths.length); + let count = 0; + for (const dir of searchDirs) { + for (const bin of binaries) { + allPaths.push(shPath(dir, bin)); + count++; + } + } + pathLen.push(count); + } + + const arr = (name: string, values: Array): string => + values.length === 0 ? `${name}=()` : `${name}=(${values.join(' ')})`; + + return [ + BEGIN_MARKER, + '# Generated from src/config/cli-registry/stock.ts by scripts/generate-cli-catalog.mts.', + '# Do not edit by hand: run `npm run generate:cli-catalog` and commit the result.', + '#', + '# Parallel indexed arrays, bash 3.2 safe (no associative arrays, no nameref, no mapfile).', + '# The variable-length lists use OFFSET/LENGTH windows into one flat array rather than a', + '# delimiter, so a $HOME containing a space needs no IFS handling and an entry with nothing', + '# to contribute (shell has no binaries) gets length 0 and is simply never iterated.', + '#', + '# ⚠️ TRUST BOUNDARY: CLI_CMD_LINUX/CLI_CMD_DARWIN are the ONLY source of a command this', + '# script will ever execute, and they arrive embedded in this file — same TLS fetch, same', + '# commit as the script itself. Nothing fetched at install time may write them; the', + '# refresh may only touch the *_DISPLAY copy. See cli_catalog_select_platform below.', + arr('CLI_IDS', ids), + arr('CLI_LABELS', labels), + arr('CLI_ENABLED', enabled), + arr('CLI_KIND', kinds), + arr('CLI_NPM', npm), + arr('CLI_DOCS', docs), + arr('CLI_CMD_LINUX', cmdLinux), + arr('CLI_CMD_DARWIN', cmdDarwin), + arr('CLI_ALL_BINS', allBins), + arr('CLI_BIN_OFF', binOff), + arr('CLI_BIN_LEN', binLen), + arr('CLI_ALL_PATHS', allPaths), + arr('CLI_PATH_OFF', pathOff), + arr('CLI_PATH_LEN', pathLen), + END_MARKER, + ].join('\n'); +} + +/** Replace the marked block in `source`, or throw if the markers are missing/malformed. */ +export function spliceInstallShBlock(source: string, block: string): string { + const begin = source.indexOf(BEGIN_MARKER); + const end = source.indexOf(END_MARKER); + if (begin === -1 || end === -1) { + throw new Error( + `install.sh is missing the generated-catalogue markers (${BEGIN_MARKER} / ${END_MARKER}). ` + + 'Add them once by hand; the generator only rewrites between them.' + ); + } + if (end < begin) throw new Error('install.sh has the catalogue markers in the wrong order.'); + return source.slice(0, begin) + block + source.slice(end + END_MARKER.length); +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +/** + * ⚠️ Guarded so the module can be IMPORTED for its pure renderers without running. + * `test/cli-catalog-sync.test.ts` imports them, and an unguarded main would have that test + * rewrite the very artifacts it is supposed to be checking — passing always, guarding never. + */ +function isMainModule(): boolean { + const invoked = process.argv[1]; + if (!invoked) return false; + return fileURLToPath(import.meta.url) === resolve(invoked); +} + +function main(): void { + const check = process.argv.includes('--check'); + const wantJson = renderCatalogJson(); + const wantInstallSh = spliceInstallShBlock(readFileSync(INSTALL_SH_PATH, 'utf-8'), renderInstallShBlock()); + + if (check) { + const drift: string[] = []; + if (readFileSync(JSON_PATH, 'utf-8') !== wantJson) drift.push('config/clis.stock.json'); + if (readFileSync(INSTALL_SH_PATH, 'utf-8') !== wantInstallSh) drift.push('install.sh'); + if (drift.length > 0) { + console.error(`Out of date with stock.ts: ${drift.join(', ')}`); + console.error('Run `npm run generate:cli-catalog` and commit the result.'); + process.exit(1); + } + console.log('CLI catalogue artifacts are in sync with stock.ts.'); + } else { + writeFileSync(JSON_PATH, wantJson, 'utf-8'); + writeFileSync(INSTALL_SH_PATH, wantInstallSh, 'utf-8'); + console.log(`Wrote config/clis.stock.json and install.sh's catalogue block (${STOCK_CLIS.length} entries).`); + } +} + +if (isMainModule()) main(); diff --git a/test/cli-catalog-sync.test.ts b/test/cli-catalog-sync.test.ts new file mode 100644 index 00000000..fcb93297 --- /dev/null +++ b/test/cli-catalog-sync.test.ts @@ -0,0 +1,80 @@ +/** + * @fileoverview Pins the two generated CLI-catalogue artifacts against a fresh generation. + * + * `config/clis.stock.json` and the marked block inside `install.sh` are both derived from + * `src/config/cli-registry/stock.ts`. Generated files that are committed rot the moment + * someone edits the source and forgets the generator, and the failure is silent in the worst + * possible way: the installer keeps detecting the OLD set of CLIs while the server offers the + * new one. Same class as the drift this whole change exists to remove, just moved one level + * out. + * + * ⚠️ The renderers are imported from the generator, which means the generator's `main()` must + * stay behind its `isMainModule()` guard. Without it, importing this module would rewrite the + * artifacts as a side effect of checking them — the test would pass unconditionally and + * guard nothing. + * + * Port: none (pure, over two files and the registry). + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { renderCatalogJson, renderInstallShBlock, spliceInstallShBlock } from '../scripts/generate-cli-catalog.mts'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +const REGENERATE = 'Run `npm run generate:cli-catalog` and commit the result.'; + +const jsonPath = fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)); +const installShPath = fileURLToPath(new URL('../install.sh', import.meta.url)); + +describe('generated CLI catalogue artifacts', () => { + it('config/clis.stock.json matches a fresh generation', () => { + expect(readFileSync(jsonPath, 'utf-8'), `config/clis.stock.json is stale. ${REGENERATE}`).toBe(renderCatalogJson()); + }); + + it("install.sh's generated block matches a fresh generation", () => { + const current = readFileSync(installShPath, 'utf-8'); + expect(current, `install.sh's catalogue block is stale. ${REGENERATE}`).toBe( + spliceInstallShBlock(current, renderInstallShBlock()) + ); + }); + + it('exports every stock CLI, carrying the enabled flag', () => { + const exported = JSON.parse(readFileSync(jsonPath, 'utf-8')) as Array<{ id: string; enabled: boolean }>; + expect(exported.map((e) => e.id)).toEqual(STOCK_CLIS.map((e) => e.id as string)); + // The field the previous attempt omitted, which let a disabled CLI's npm package be baked + // into every agent image. Its PRESENCE is the contract; its value is whatever stock says. + for (const entry of exported) { + expect(typeof entry.enabled, `${entry.id} has no enabled flag`).toBe('boolean'); + } + }); + + it('exports no spawn-time fields', () => { + // launch/env/capabilities/overlays are the server's alone. Exporting them would invite a + // second reading of the launch model in a consumer that cannot be tested against a spawn. + const raw = readFileSync(jsonPath, 'utf-8'); + for (const forbidden of ['"launch"', '"env"', '"capabilities"', '"overlays"']) { + expect(raw.includes(forbidden), `${forbidden} leaked into the exported catalogue`).toBe(false); + } + }); + + it('splices only between the markers (anti-clobber)', () => { + // The generator rewrites a window, not the file. If the splice ever widened, it would eat + // hand-written installer code on the next run and nothing else here would notice. + const current = readFileSync(installShPath, 'utf-8'); + const spliced = spliceInstallShBlock( + current, + '# >>> BEGIN GENERATED CLI CATALOGUE\n# <<< END GENERATED CLI CATALOGUE' + ); + expect(spliced.startsWith(current.slice(0, current.indexOf('# >>> BEGIN GENERATED CLI CATALOGUE')))).toBe(true); + expect( + spliced.endsWith( + current.slice(current.indexOf('# <<< END GENERATED CLI CATALOGUE') + '# <<< END GENERATED CLI CATALOGUE'.length) + ) + ).toBe(true); + }); + + it('refuses a file with no markers rather than appending', () => { + expect(() => spliceInstallShBlock('#!/usr/bin/env bash\necho hi\n', 'block')).toThrow(/markers/); + }); +});