mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #380 from opticon454/feature/cli-catalog-consumers
feat(cli-registry): drive install.sh and the Docker agent image from the CLI catalogue
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @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(' '));
|
||||
});
|
||||
|
||||
it('validates an unsafe package name with the SAME regex on both sides', () => {
|
||||
// Equal OUTPUT on today's catalogue (asserted above) does not prove equal VALIDATION — a
|
||||
// looser regex on one side would only show up the day someone ships a hostile package name.
|
||||
// The regex is duplicated rather than shared (the .mjs side cannot import the .ts side, the
|
||||
// whole reason this file exists), so pin the literal PATTERN text is identical between the
|
||||
// two source files rather than trusting the comment that says so.
|
||||
const tsSource = readFileSync(fileURLToPath(new URL('../src/docker-hosts.ts', import.meta.url)), 'utf-8');
|
||||
const mjsSource = readFileSync(fileURLToPath(new URL('../scripts/lib/cli-catalog.mjs', import.meta.url)), 'utf-8');
|
||||
const extract = (source: string, file: string): string => {
|
||||
// Non-greedy to `/;` deliberately: the pattern itself contains a `/` (inside the
|
||||
// character class), so a naive `[^/]+` stops at the wrong slash.
|
||||
const m = /const SAFE_PACKAGE = (\/.+?\/);/.exec(source);
|
||||
expect(m, `could not find the SAFE_PACKAGE regex literal in ${file}`).toBeDefined();
|
||||
return m![1];
|
||||
};
|
||||
expect(extract(tsSource, 'docker-hosts.ts')).toBe(extract(mjsSource, 'cli-catalog.mjs'));
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @fileoverview Every shipped CLI reaches the Docker agent image, and no unshipped one does.
|
||||
*
|
||||
* The image's npm layer is now a build arg fed from the generated catalogue, but four CLIs
|
||||
* still install through hand-written layers because the registry cannot describe what makes
|
||||
* them special — a flag, a companion package, or not being on npm at all. That mix is fine;
|
||||
* what is not fine is a CLI landing in `stock.ts` and reaching NEITHER, which is upstream
|
||||
* `b6d0f1fa` (omp shipped with no installer wiring) in the image instead of the installer.
|
||||
*
|
||||
* So this asserts total coverage rather than checking the arg alone, and requires every
|
||||
* special case to carry a written reason.
|
||||
*
|
||||
* Port: none (pure, over two Dockerfiles, the catalogue and the registry).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { agentImageNpmPackages } from '../scripts/lib/cli-catalog.mjs';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const read = (rel: string): string => readFileSync(fileURLToPath(new URL(`../${rel}`, import.meta.url)), 'utf-8');
|
||||
|
||||
const AGENT_DOCKERFILE = read('docker/agent.Dockerfile');
|
||||
const SERVER_DOCKERFILE = read('docker/server.Dockerfile');
|
||||
const INDEX_HTML = read('src/web/public/index.html');
|
||||
const CATALOG = JSON.parse(read('config/clis.stock.json')) as Array<{
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
discovery: {
|
||||
binaries: string[];
|
||||
install: { npmPackage?: string; agentImageLayer?: { kind: 'dedicated'; reason: string } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const enabledAgents = CATALOG.filter((e) => e.enabled && e.discovery.binaries.length > 0);
|
||||
|
||||
/**
|
||||
* A layer's PROOF it installed the right thing, not merely a substring anywhere in the file.
|
||||
* Every dedicated layer in agent.Dockerfile ends by running `<binary> --version`, so anchoring
|
||||
* on that (rather than `Dockerfile.includes(binary)`) survives a layer being deleted while its
|
||||
* COMMENT — which also names the binary — is left behind. That gap is why this replaced the
|
||||
* looser check.
|
||||
*/
|
||||
const hasVersionProof = (binary: string): boolean => AGENT_DOCKERFILE.includes(`${binary} --version`);
|
||||
|
||||
describe('docker agent image covers the catalogue', () => {
|
||||
it('installs every enabled npm CLI, via the build arg or a documented dedicated layer', () => {
|
||||
const inBuildArg = new Set(agentImageNpmPackages(CATALOG));
|
||||
const missing: string[] = [];
|
||||
for (const entry of enabledAgents) {
|
||||
const pkg = entry.discovery.install.npmPackage;
|
||||
if (!pkg) continue; // standalone installer, checked below
|
||||
if (inBuildArg.has(pkg)) continue;
|
||||
if (entry.discovery.install.agentImageLayer) continue;
|
||||
missing.push(`${entry.id} (${pkg})`);
|
||||
}
|
||||
expect(
|
||||
missing,
|
||||
`npm CLI reaches neither the build arg nor a dedicated layer:\n ${missing.join('\n ')}\n` +
|
||||
'Add it to the arg (it is automatic) or give it a Dockerfile layer AND an agentImageLayer.reason in stock.ts.'
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives every dedicated-layer entry a reason and a real, provable layer', () => {
|
||||
for (const entry of CATALOG) {
|
||||
const layer = entry.discovery.install.agentImageLayer;
|
||||
if (!layer) continue;
|
||||
expect(layer.reason.length, `${entry.id} has an empty agentImageLayer.reason`).toBeGreaterThan(20);
|
||||
const binary = entry.discovery.binaries[0];
|
||||
// Excluded from the shared arg, so it MUST appear in a hand-written layer that actually
|
||||
// ran the binary, or it is simply not installed at all — an exclusion silently becoming
|
||||
// an omission.
|
||||
expect(
|
||||
hasVersionProof(binary),
|
||||
`${entry.id} is excluded from the arg but has no "${binary} --version" proof line in the Dockerfile`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('installs every enabled non-npm CLI in its own layer', () => {
|
||||
for (const entry of enabledAgents) {
|
||||
if (entry.discovery.install.npmPackage) continue;
|
||||
const binary = entry.discovery.binaries[0];
|
||||
expect(
|
||||
hasVersionProof(binary),
|
||||
`${entry.id} ships no npm package and no Dockerfile layer proves it ran "${binary} --version"`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('bakes in nothing from a DISABLED entry', () => {
|
||||
// The maintainer's finding: the earlier export carried no `enabled` field, so a CLI that
|
||||
// ships disabled still had its package installed into every image.
|
||||
for (const entry of CATALOG) {
|
||||
if (entry.enabled) continue;
|
||||
const pkg = entry.discovery.install.npmPackage;
|
||||
if (!pkg) continue;
|
||||
expect(AGENT_DOCKERFILE.includes(pkg), `disabled ${entry.id} is still baked into the image`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes a disabled entry from the build arg (unit, since none ships disabled today)', () => {
|
||||
// Every stock entry is enabled right now, so the assertion above passes vacuously. Feed
|
||||
// the pure helper a fabricated disabled entry so the fix is genuinely covered TODAY
|
||||
// rather than the first time someone ships one.
|
||||
const fabricated = [
|
||||
...CATALOG,
|
||||
{ id: 'ghost', enabled: false, discovery: { binaries: ['ghost'], install: { npmPackage: '@ghost/cli' } } },
|
||||
];
|
||||
expect(agentImageNpmPackages(fabricated)).not.toContain('@ghost/cli');
|
||||
const enabledTwin = fabricated.map((e) => (e.id === 'ghost' ? { ...e, enabled: true } : e));
|
||||
expect(agentImageNpmPackages(enabledTwin)).toContain('@ghost/cli');
|
||||
});
|
||||
|
||||
it('refuses an npm package name that would not survive unquoted expansion', () => {
|
||||
// The Dockerfile expands ${CLI_NPM_PACKAGES} unquoted so word splitting makes the list.
|
||||
// A token with a space or a metacharacter would therefore change what the RUN line means.
|
||||
const hostile = [
|
||||
{ id: 'x', enabled: true, discovery: { binaries: ['x'], install: { npmPackage: 'a && rm -rf /' } } },
|
||||
];
|
||||
expect(() => agentImageNpmPackages(hostile)).toThrow(/unsafe npm package name/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('docker server image divergence is declared, not accidental', () => {
|
||||
// server.Dockerfile deliberately ships a NARROWER list than the agent image, and is left
|
||||
// untouched by this change because two other open PRs already modify it. Asserting the
|
||||
// omissions here makes the divergence reviewable without editing the file: if someone adds
|
||||
// a CLI there, or the intent changes, this fails and the list has to be restated.
|
||||
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp']);
|
||||
|
||||
it('installs exactly the CLIs it declares, and no more', () => {
|
||||
for (const entry of enabledAgents) {
|
||||
const pkg = entry.discovery.install.npmPackage;
|
||||
if (!pkg) continue;
|
||||
const present = SERVER_DOCKERFILE.includes(pkg);
|
||||
if (SERVER_INTENTIONAL_OMISSIONS.has(entry.id)) {
|
||||
expect(present, `${entry.id} is listed as an intentional omission but IS in server.Dockerfile`).toBe(false);
|
||||
} else {
|
||||
expect(present, `${entry.id} is missing from server.Dockerfile and not declared as omitted`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the in-app agent-image hint stays accurate', () => {
|
||||
it('names every enabled CLI binary the image contains', () => {
|
||||
// index.html tells the user what the image holds. It was stale (it omitted omp), which is
|
||||
// the same drift one layer out: prose describing a list nobody re-checks.
|
||||
const hint = INDEX_HTML.split('\n').find((l) => l.includes('build-agent-image.mjs'));
|
||||
expect(hint, 'the agent-image hint disappeared from index.html').toBeDefined();
|
||||
for (const entry of enabledAgents) {
|
||||
expect(hint, `the hint does not mention ${entry.discovery.binaries[0]}`).toContain(entry.discovery.binaries[0]);
|
||||
}
|
||||
});
|
||||
|
||||
it('is checked against the registry, not a copy of itself (anti-vacuity)', () => {
|
||||
expect(STOCK_CLIS.filter((e) => e.enabled && e.discovery.binaries.length > 0).length).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview Static guards over `install.sh`, the one file in this repo nothing else checks.
|
||||
*
|
||||
* There is no shellcheck, no bats, and CI is Node-only, so a bash mistake here reaches users
|
||||
* through `curl | bash` with nothing in between. The CI workflow now runs `bash -n` and a real
|
||||
* `bash:3.2` container (see `.github/workflows/ci.yml`), which catches syntax and the
|
||||
* `set -u` classes; this file catches the things that are perfectly valid bash and still wrong
|
||||
* for THIS script.
|
||||
*
|
||||
* Port: none (pure, over one source file).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE = readFileSync(fileURLToPath(new URL('../install.sh', import.meta.url)), 'utf-8');
|
||||
|
||||
/** Lines with the leading `#` comments removed, so prose quoting a banned form is not a hit. */
|
||||
const CODE_LINES = SOURCE.split('\n').filter((line) => !/^\s*#/.test(line));
|
||||
const CODE = CODE_LINES.join('\n');
|
||||
|
||||
describe('install.sh stays bash 3.2 compatible', () => {
|
||||
// macOS ships bash 3.2 (the last GPLv2 release) and the documented install is
|
||||
// `curl -fsSL <url> | bash`, so a bash-4 construct is not a warning on a Mac, it is a
|
||||
// syntax error that kills the install mid-run.
|
||||
it.each([
|
||||
['associative arrays (`declare -A`)', /\b(?:declare|local|typeset)\s+-[A-Za-z]*A/],
|
||||
['case-conversion expansion (`${x,,}` / `${x^^}`)', /\$\{[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?[,^]{1,2}\}/],
|
||||
['`mapfile` / `readarray`', /\b(?:mapfile|readarray)\b/],
|
||||
['namerefs (`declare -n`)', /\b(?:declare|local|typeset)\s+-[A-Za-z]*n\b/],
|
||||
['here-strings (`<<<`)', /<<</],
|
||||
])('uses no %s', (_label, pattern) => {
|
||||
const offenders = CODE_LINES.filter((line) => pattern.test(line));
|
||||
expect(offenders, `bash 4+ construct found:\n ${offenders.join('\n ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh generated-catalogue block', () => {
|
||||
it('has exactly one matched marker pair', () => {
|
||||
expect(SOURCE.split('# >>> BEGIN GENERATED CLI CATALOGUE').length - 1).toBe(1);
|
||||
expect(SOURCE.split('# <<< END GENERATED CLI CATALOGUE').length - 1).toBe(1);
|
||||
expect(SOURCE.indexOf('# >>> BEGIN GENERATED CLI CATALOGUE')).toBeLessThan(
|
||||
SOURCE.indexOf('# <<< END GENERATED CLI CATALOGUE')
|
||||
);
|
||||
});
|
||||
|
||||
it('declares every array the detection code indexes', () => {
|
||||
for (const name of [
|
||||
'CLI_IDS',
|
||||
'CLI_LABELS',
|
||||
'CLI_ENABLED',
|
||||
'CLI_KIND',
|
||||
'CLI_NPM',
|
||||
'CLI_DOCS',
|
||||
'CLI_CMD_LINUX',
|
||||
'CLI_CMD_DARWIN',
|
||||
'CLI_ALL_BINS',
|
||||
'CLI_BIN_OFF',
|
||||
'CLI_BIN_LEN',
|
||||
'CLI_ALL_PATHS',
|
||||
'CLI_PATH_OFF',
|
||||
'CLI_PATH_LEN',
|
||||
]) {
|
||||
expect(new RegExp(`^${name}=\\(`, 'm').test(SOURCE), `${name} is not declared`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps no hand-written per-CLI detection behind', () => {
|
||||
// The nine `*_SEARCH_PATHS` arrays and eighteen `check_<cli>`/`get_<cli>_path` pairs are
|
||||
// what this change removes. One left behind would be a second source of truth that the
|
||||
// generator does not update — the exact shape of upstream b6d0f1fa.
|
||||
expect(CODE.match(/_SEARCH_PATHS=\(/g) ?? []).toEqual([]);
|
||||
|
||||
// Keyed on the catalogue's OWN ids and binaries rather than an allowlist of the helpers
|
||||
// that may exist. `check_tmux` and `check_cloudflared` are legitimate and unrelated; a
|
||||
// `check_claude` or `get_omp_path` is the thing being removed. Deriving the ban from the
|
||||
// catalogue means a CLI added later is covered with no edit here.
|
||||
const names = new Set<string>();
|
||||
for (const arrayName of ['CLI_IDS', 'CLI_ALL_BINS']) {
|
||||
const m = new RegExp(`^${arrayName}=\\((.*)\\)$`, 'm').exec(SOURCE);
|
||||
for (const token of m?.[1].match(/'([^']*)'/g) ?? []) names.add(token.replace(/'/g, ''));
|
||||
}
|
||||
expect(names.size, 'could not read the catalogue ids/binaries').toBeGreaterThan(5);
|
||||
|
||||
const perCliFunctions = [...names]
|
||||
.flatMap((name) => [`check_${name}()`, `get_${name}_path()`])
|
||||
.filter((fn) => new RegExp(`^${fn.replace(/[()]/g, '\\$&')}`, 'm').test(CODE));
|
||||
expect(perCliFunctions, `hand-written per-CLI detection still present:\n ${perCliFunctions.join('\n ')}`).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh trust boundary', () => {
|
||||
// A command the installer EXECUTES must have arrived embedded in this file, over the same
|
||||
// TLS fetch and in the same commit as the script itself — there is no second, network-derived
|
||||
// copy of these commands anywhere in the script (an earlier draft that added one, and split
|
||||
// a TRUSTED/DISPLAY pair to keep the fetched copy display-only, was dropped before merge:
|
||||
// see docs/cli-registry.md). These three assertions are what is left to guard now that the
|
||||
// fetch path itself does not exist: everything the installer runs or shows still comes only
|
||||
// from the generated block, and nothing in the file eval()s.
|
||||
it('writes CLI_INSTALL_CMD_TRUSTED only from the generated per-platform arrays', () => {
|
||||
const writes = CODE_LINES.filter((line) => /CLI_INSTALL_CMD_TRUSTED\s*\[[^\]]*\]\s*=/.test(line));
|
||||
expect(writes.length, 'expected exactly the two platform assignments').toBe(2);
|
||||
for (const line of writes) {
|
||||
expect(line, `TRUSTED written from something other than the generated block:\n ${line}`).toMatch(
|
||||
/=\s*"\$\{CLI_CMD_(?:LINUX|DARWIN)\[\$i\]\}"/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('fetches no CLI catalogue over the network at install time', () => {
|
||||
// The exact shape of the earlier, dropped design: a URL built from the repo/branch this
|
||||
// script came from, an opt-in env var to enable it, and a `download()` call feeding
|
||||
// straight into the trusted arrays. None of that exists in this file any more; this pins
|
||||
// the absence so it cannot quietly come back without a reviewer noticing.
|
||||
for (const needle of [
|
||||
'cli_catalog_refresh',
|
||||
'cli_catalog_default_url',
|
||||
'CODEMAN_CLI_CATALOGUE_URL',
|
||||
'CODEMAN_REFRESH_CLI_CATALOGUE',
|
||||
'CLI_INSTALL_CMD_DISPLAY',
|
||||
]) {
|
||||
expect(SOURCE.includes(needle), `${needle} should not exist — the catalogue refresh was dropped`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('never eval()s anything', () => {
|
||||
// install.sh has two long-standing, legitimate evals (`eval "$(brew shellenv)"`, Homebrew's
|
||||
// documented idiom, and one inside a node -e that reads `tailscale serve status`), both of
|
||||
// which operate on output this script itself produced, never on fetched content. With no
|
||||
// network-derived catalogue left to eval, the word should not appear at all outside those.
|
||||
const offenders = CODE_LINES.filter(
|
||||
(line) => /\beval\b/.test(line) && !/eval "\$\(.*shellenv\)"/.test(line) && !line.includes('eval(process.argv')
|
||||
);
|
||||
expect(offenders, `unexpected eval:\n ${offenders.join('\n ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it("redirects stdin for every command it executes on the user's behalf", () => {
|
||||
// Under `curl | bash` the script IS stdin, so a child that reads stdin eats the rest of
|
||||
// it. Every spawn of an untrusted-length vendor command must carry `</dev/null`.
|
||||
const spawns = CODE_LINES.filter((line) => /\bbash -c "\$\{CLI_INSTALL_CMD_TRUSTED/.test(line));
|
||||
expect(spawns.length, 'expected the single install-menu spawn').toBe(1);
|
||||
for (const line of spawns) {
|
||||
expect(line, `install spawn without </dev/null:\n ${line}`).toContain('</dev/null');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh runtime safety', () => {
|
||||
it('can be sourced without installing anything', () => {
|
||||
// The bash 3.2 CI step sources this file to exercise detect_all_clis. Without the guard
|
||||
// the dispatch `case` at the tail would run a real install inside the container.
|
||||
expect(SOURCE).toMatch(
|
||||
/if \[\[ -n "\$\{CODEMAN_INSTALL_SH_LIB:-\}" \]\]; then return 0 2>\/dev\/null \|\| exit 0; fi/
|
||||
);
|
||||
const guardAt = SOURCE.indexOf('CODEMAN_INSTALL_SH_LIB');
|
||||
const dispatchAt = SOURCE.indexOf('case "${1:-}" in');
|
||||
expect(guardAt, 'the sourcing guard must precede the dispatch case').toBeLessThan(dispatchAt);
|
||||
});
|
||||
|
||||
it('still sets the strict flags it has always run under', () => {
|
||||
expect(SOURCE).toMatch(/^set -euo pipefail$/m);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user