Files
Codeman/test/agent-image-build-args-parity.test.ts
T
DevvynandClaude Opus 5 7af4dbc0f8 feat(docker): derive the agent image's npm CLI list from the catalogue
docker/agent.Dockerfile hardcoded the four npm-published CLIs it installs, one
of the several lists that had to be kept in step with the registry by hand.

It now takes them as `ARG CLI_NPM_PACKAGES`, supplied by
scripts/build-agent-image.mjs from config/clis.stock.json, with the default set
to today's list so a bare `docker build` still produces the same image. The arg
is expanded unquoted because word splitting is what turns the list into several
arguments, which is exactly why every token is validated against
^[@A-Za-z0-9][@A-Za-z0-9/._-]*$ on the producing side; a package name carrying a
space or a metacharacter is refused rather than reaching the RUN line. Verified
by building the layer: four packages in, four arguments out, and the default
still applies with no arg.

The list is filtered on each entry's `enabled` flag — the field whose absence
was the maintainer's §3 finding, where a CLI shipping disabled still got baked
into every image. No stock entry is disabled today, so that assertion would pass
vacuously; a unit test feeds the pure helper a fabricated disabled entry so the
fix is covered now rather than the first time someone ships one.

⚠️ It reads the STOCK catalogue, never the merged registry. A user's
~/.codeman/clis.json must not change what is inside an image tagged
codeman/agent:base, or two machines holding that tag hold different images.

Four CLIs keep hand-written layers because the registry cannot describe what
makes them special: pi's --ignore-scripts, deepseek's pnpm companion and dsh-tui
profile, and the three standalone installers. Rather than extend the schema for
a Docker-only benefit, the coverage test requires each to carry a written reason
AND still be present, so an exclusion cannot quietly become an omission.

There are two producers of this command line and there have to be — the .mjs
cannot import TypeScript, and src/docker-hosts.ts builds the same argv for the
in-app auto-build — so a parity test pins them together, package list, arg pairs
and rendered argv. Their order is pinned too: a different order is a different
RUN string and so a needless cache miss between the two build paths.

docker/server.Dockerfile is deliberately NOT edited (PRs #373 and #377 both
modify it); its narrower list is asserted as a declared omission list instead, so
the divergence is reviewable without touching the file.

Also fixes the in-app hint at index.html, which the new coverage test caught
still omitting omp.

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

81 lines
3.3 KiB
TypeScript

/**
* @fileoverview The two producers of the agent-image `docker build` command line must agree.
*
* There are two, and there have to be: `scripts/build-agent-image.mjs` is what a human runs
* and is a `.mjs`, so it cannot import the TypeScript registry and reads the generated
* `config/clis.stock.json` instead; `src/docker-hosts.ts` builds the same command for the
* in-app auto-build on the first Docker case, from `STOCK_CLIS` directly.
*
* Two independent producers of one command line is exactly the shape that drifts, and the
* failure would be quiet and confusing: an image built by hand and an image built by the app
* would hold different CLIs under the SAME `codeman/agent:base` tag, so which CLIs a container
* has would depend on who built it.
*
* Port: none (pure).
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
agentImageBuildArgPairs as mjsPairs,
agentImageNpmPackages as mjsPackages,
} from '../scripts/lib/cli-catalog.mjs';
import {
agentImageBuildArgPairs as tsPairs,
agentImageBuildArgs,
agentImageNpmPackages as tsPackages,
} from '../src/docker-hosts.js';
const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8'));
describe('agent-image build args: the .mjs and the TS mirror agree', () => {
it('resolve the same npm package list, in the same order', () => {
// Order matters as well as membership: a different order is a different RUN string, hence
// a different layer hash, hence a cache miss between the two build paths.
expect(tsPackages()).toEqual(mjsPackages(CATALOG));
});
it('produce the same --build-arg pairs', () => {
expect(tsPairs()).toEqual(mjsPairs(CATALOG));
});
it('render the same argv', () => {
// What the .mjs assembles by hand around its pairs, spelled out here so a change to
// either side's argv SHAPE (not just its values) fails too.
const pairs = tsPairs();
const expected = [
'build',
'-f',
'/repo/docker/agent.Dockerfile',
'-t',
'codeman/agent:base',
'--no-cache',
...pairs.flatMap(([name, value]) => ['--build-arg', `${name}=${value}`]),
'/repo',
];
expect(agentImageBuildArgs('/repo/docker/agent.Dockerfile', 'codeman/agent:base', '/repo', true, pairs)).toEqual(
expected
);
});
it('keeps --build-arg out of the argv when nothing is passed', () => {
// The parameter defaults to empty, so an existing caller that has not been updated still
// produces exactly the command it produced before.
expect(agentImageBuildArgs('/d', 'i', '/c')).toEqual(['build', '-f', '/d', '-t', 'i', '/c']);
});
it('resolves a non-empty list (anti-vacuity)', () => {
// Two empty lists compare equal very happily.
expect(tsPackages().length).toBeGreaterThan(3);
expect(tsPairs()[0][1].length).toBeGreaterThan(20);
});
it('matches the Dockerfile ARG default, so a bare `docker build` is cache-identical', () => {
const dockerfile = readFileSync(fileURLToPath(new URL('../docker/agent.Dockerfile', import.meta.url)), 'utf-8');
const declared = /^ARG CLI_NPM_PACKAGES="([^"]*)"$/m.exec(dockerfile)?.[1];
expect(declared, 'the Dockerfile no longer declares CLI_NPM_PACKAGES').toBeDefined();
expect(declared).toBe(tsPackages().join(' '));
});
});