/** * @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. * * ⚠️ An entry carrying `discovery.install.agentImageLayer` is excluded here and installed by * its own hand-written Dockerfile layer instead, because the registry cannot express what * makes it special — a flag, a companion package, or not being on npm at all. This used to be * an id-keyed table duplicated between this file and `src/docker-hosts.ts` (exactly the shape * `test/cli-registry-no-id-branching.test.ts` exists to forbid inside `src/`, which is why it * was a blind spot rather than a pass — that test scans `src/` only). It is data now: both * producers filter on the SAME field from the SAME catalogue entry, `reason` is required by * `schema.ts`, and `test/docker-agent-image-coverage.test.ts` requires every one of them to * still be present in the Dockerfile, so an exclusion cannot quietly become an omission. */ /** 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.discovery?.install?.agentImageLayer) 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. // ⚠️ This exact regex is duplicated in `agentImageNpmPackages()` in // `src/docker-hosts.ts` (that file cannot import this one — it is the TypeScript side of // the same two-producers split this whole module exists for). Keep both literal patterns // identical; `test/agent-image-build-args-parity.test.ts` pins that they are. throw new Error(`Refusing unsafe npm package name for "${entry.id}": ${JSON.stringify(pkg)}`); } packages.push(pkg); } return packages; } /** * Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az). * ⚠️ Mirrored by `GIT_HOST_CLI_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them. */ export const GIT_HOST_CLI_BUILD_ARGS = [ ['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'], ['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'], ]; /** Environment variables passed through to the agent image's system Git configuration. */ export const GIT_IDENTITY_BUILD_ARGS = [ ['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'], ['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'], ]; /** * The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable * contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same * as before these existed; anything other than 0/1 is refused rather than guessed at. */ export function gitHostCliBuildArgPairs(env) { const pairs = []; for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) { const value = env[envName]; if (value === undefined || value === '') continue; if (value !== '0' && value !== '1') { throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`); } pairs.push([argName, value]); } return pairs; } /** The `--build-arg` pairs for Git identity, requiring either both values or neither. */ export function gitIdentityBuildArgPairs(env) { const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? '']); const configured = pairs.filter(([, value]) => value !== ''); if (configured.length === 0) return []; if (configured.length !== pairs.length) { throw new Error('Git user name and email must both be set when configuring Git identity'); } return pairs; } /** The `--build-arg` pairs the agent image takes. PURE given `env`. */ export function agentImageBuildArgPairs(catalog, env = process.env) { return [ ['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')], ...gitHostCliBuildArgPairs(env), ...gitIdentityBuildArgPairs(env), ]; } /** Read the committed catalogue. IO. */ export function readCatalog(path = CATALOG_PATH) { return JSON.parse(readFileSync(path, 'utf-8')); }