/** * @fileoverview Reads the generated CLI catalogue for the Docker build. * * `scripts/build-agent-image.mjs` is a `.mjs` and cannot import the TypeScript registry, so it * reads `config/clis.stock.json` (generated by `scripts/generate-cli-catalog.mts`) instead. * The pure half lives here so `src/docker-hosts.ts`'s programmatic mirror of the same build * command can be pinned against it by a test — those two produce the docker argv independently * and must not drift. */ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const CATALOG_PATH = fileURLToPath(new URL('../../config/clis.stock.json', import.meta.url)); /** * npm package names the AGENT image installs in its shared `npm install -g` layer. * * PURE: takes the parsed catalogue, returns a sorted-by-registry-order list. * * ⚠️ Filters on `enabled`. That is the field the earlier attempt's export omitted, which is * how a CLI that ships disabled still had its package baked into every image. * * ⚠️ SPECIAL_CASES are excluded here and installed by their own hand-written Dockerfile * layers, because the registry cannot express what makes them special — a flag, a companion * package, or not being on npm at all. `test/docker-agent-image-coverage.test.ts` requires * every one of them to carry a reason and to still be present in the Dockerfile, so an * exclusion cannot quietly become an omission. */ export const AGENT_IMAGE_SPECIAL_CASES = { pi: 'installed with --ignore-scripts in its own layer, so the flag cannot leak to the shared block', deepseek: 'needs pnpm alongside it (dsh plugin, issue #352) and a dsh-tui profile install', }; /** Tokens allowed in an npm package name reaching a Dockerfile build arg unquoted. */ const SAFE_PACKAGE = /^[@A-Za-z0-9][@A-Za-z0-9/._-]*$/; export function agentImageNpmPackages(catalog) { const packages = []; for (const entry of catalog) { if (!entry.enabled) continue; if (entry.id in AGENT_IMAGE_SPECIAL_CASES) continue; const pkg = entry.discovery?.install?.npmPackage; if (!pkg) continue; // antigravity/grok/omp ship standalone installers, not npm if (!SAFE_PACKAGE.test(pkg)) { // The value is interpolated into a Dockerfile ARG that is expanded UNQUOTED (word // splitting is how the list becomes several arguments), so a token with whitespace or // shell metacharacters would change what the RUN line means. throw new Error(`Refusing unsafe npm package name for "${entry.id}": ${JSON.stringify(pkg)}`); } packages.push(pkg); } return packages; } /** The `--build-arg` pairs the agent image takes. PURE. */ export function agentImageBuildArgPairs(catalog) { return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')]]; } /** Read the committed catalogue. IO. */ export function readCatalog(path = CATALOG_PATH) { return JSON.parse(readFileSync(path, 'utf-8')); }