@xemahq/repo-build-tooling 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ pnpm exec xema-scrub-swagger-paths dist
33
33
  | `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
34
34
  | `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
35
35
 
36
- `xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--tooling-version`, `--copy`, `--cache-id` and `--build-source`. `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--tooling-version`, `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. `stage` and `filters` accept `--also <workspace package name>` (repeatable): the package's closure is built and staged (built outputs only, no Prisma checks) alongside the members; an unknown name is refused. Run `xema-composed-image` with no arguments for the full usage.
36
+ `xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--copy`, `--cache-id` and `--build-source`; it pins the version of this tool itself, and there is no flag for it (`--tooling-version` is refused as an unknown option). `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. `stage` and `filters` accept `--also <workspace package name>` (repeatable): the package's closure is built and staged (built outputs only, no Prisma checks) alongside the members; an unknown name is refused. Run `xema-composed-image` with no arguments for the full usage.
37
37
 
38
38
  Every step refuses rather than skips. The package has no dependencies.
39
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
@@ -41,9 +41,9 @@
41
41
  "@xemahq/release-policy": "^14.0.0"
42
42
  },
43
43
  "scripts": {
44
- "test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-migrate.test.mjs"
44
+ "test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-migrate.test.mjs"
45
45
  },
46
46
  "xemaRelease": {
47
- "sourceDigest": "sha256:a3994566dfeac8ede246a07315647fa7f10828b510cf5835a0640a8a97650f5c"
47
+ "sourceDigest": "sha256:c1fafb67e8735c78e8eb13a40fe3a3e9745ad5873b906d7c782e33734f86cf88"
48
48
  }
49
49
  }
@@ -5,7 +5,7 @@
5
5
  // build context carries (`--copy`, repeatable), its pnpm cache id (`--cache-id`),
6
6
  // the repository its member subjects name in an edition's image lock
7
7
  // (`--build-source`) and the EXACT version of this tool the builder installs
8
- // (`--tooling-version`). The tool is pinned exactly so the image directory, which
8
+ // (its own package version, never a flag). The tool is pinned exactly so the image directory, which
9
9
  // the content tag hashes, changes whenever the tool does.
10
10
  //
11
11
  // NOTHING OF THE TOOL SHIPS: it is installed globally in the builder stage only,
@@ -17,19 +17,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { dirname, join, posix } from 'node:path';
18
18
 
19
19
  import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
20
+ import { toolVersion } from './composed-image-stage.mjs';
20
21
 
21
22
  const TOKEN_REFERENCE = /_authToken\b|\$\{(?:NPM_TOKEN|GH_PACKAGES_TOKEN)\}/;
22
- const VERSION = /^\d+\.\d+\.\d+$/;
23
23
  const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
24
24
  const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
25
25
  const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
26
26
  export const NODE_BASE =
27
27
  'mirror.gcr.io/library/node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436';
28
28
 
29
- function validate({ toolingVersion, copy, cacheId, buildSource }) {
30
- if (!VERSION.test(toolingVersion ?? '')) {
31
- throw new Error(`--tooling-version must be an exact x.y.z version, got "${toolingVersion}"`);
32
- }
29
+ function validate({ copy, cacheId, buildSource }) {
33
30
  if (!Array.isArray(copy) || copy.length === 0) throw new Error('--copy needs at least one directory');
34
31
  for (const directory of copy) {
35
32
  if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
@@ -46,9 +43,10 @@ export function npmrcUsesToken(npmrcText) {
46
43
  }
47
44
 
48
45
  /** The Dockerfile text. `usesToken` mounts the `npm_token` build secret; derive it with `npmrcUsesToken`. `imageDir` is where the repository keeps the image (and so where this is written). */
49
- export function renderComposedDockerfile({ toolingVersion, copy, cacheId, buildSource, usesToken, imageDir = COMPOSED_IMAGE_DIR }) {
46
+ export function renderComposedDockerfile({ copy, cacheId, buildSource, usesToken, imageDir = COMPOSED_IMAGE_DIR }) {
50
47
  if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
51
- validate({ toolingVersion, copy, cacheId, buildSource });
48
+ validate({ copy, cacheId, buildSource });
49
+ const toolingVersion = toolVersion();
52
50
  const dirFlag = imageDir === COMPOSED_IMAGE_DIR ? '' : ` --image-dir ${imageDir}`;
53
51
  const tool = (command) => `xema-composed-image ${command}${dirFlag}`;
54
52
  const copies = [...new Set([...copy.map((d) => posix.normalize(`${d}/`)), posix.normalize(`${imageDir}/`)])]
@@ -4,10 +4,11 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import test from 'node:test';
6
6
 
7
+ import { toolVersion } from './composed-image-stage.mjs';
8
+
7
9
  import { npmrcUsesToken, renderComposedDockerfile, syncComposedDockerfile } from './composed-image-dockerfile.mjs';
8
10
 
9
11
  const OPTIONS = {
10
- toolingVersion: '0.9.0',
11
12
  copy: ['packages', 'tooling/dev/', 'biomes'],
12
13
  cacheId: 'alpha-composed-process',
13
14
  buildSource: 'alpha',
@@ -16,7 +17,7 @@ const OPTIONS = {
16
17
 
17
18
  test('the template carries every parameter and no per-repository literal', () => {
18
19
  const text = renderComposedDockerfile(OPTIONS);
19
- assert.match(text, /npm install --global --ignore-scripts @xemahq\/repo-build-tooling@0\.9\.0\n/);
20
+ assert.ok(text.includes(`npm install --global --ignore-scripts @xemahq/repo-build-tooling@${toolVersion()}\n`));
20
21
  assert.match(text, /^COPY packages\/ \.\/packages\/\nCOPY tooling\/dev\/ \.\/tooling\/dev\/\nCOPY biomes\/ \.\/biomes\/\nCOPY infra\/composed-process\/ \.\/infra\/composed-process\/$/m);
21
22
  assert.match(text, /id=pnpm-store-alpha-composed-process,/);
22
23
  assert.match(text, /id=pnpm-metadata-alpha-composed-process,/);
@@ -39,9 +40,7 @@ test('nothing of the tool reaches the final stage; load runs in the builder as n
39
40
  assert.match(final, /CMD \["node", "\/monorepo\/xema-composed-process"\]/);
40
41
  });
41
42
 
42
- test('the tool version, the directories and the ids are validated', () => {
43
- assert.throws(() => renderComposedDockerfile({ ...OPTIONS, toolingVersion: '^0.9.0' }), /exact x\.y\.z/);
44
- assert.throws(() => renderComposedDockerfile({ ...OPTIONS, toolingVersion: undefined }), /exact x\.y\.z/);
43
+ test('the directories and the ids are validated', () => {
45
44
  assert.throws(() => renderComposedDockerfile({ ...OPTIONS, copy: [] }), /at least one/);
46
45
  for (const bad of ['../x', '/abs', 'a b', 'a;b']) assert.throws(() => renderComposedDockerfile({ ...OPTIONS, copy: [bad] }), /repository-relative/);
47
46
  assert.throws(() => renderComposedDockerfile({ ...OPTIONS, cacheId: 'Bad_Id' }), /--cache-id/);
@@ -56,7 +55,7 @@ test('--write renders and --check refuses a stale or missing file', () => {
56
55
  assert.throws(() => syncComposedDockerfile({ mode: 'check', root, ...OPTIONS }), /is stale/);
57
56
  assert.equal(syncComposedDockerfile({ mode: 'write', root, ...OPTIONS }), 'infra/composed-process/Dockerfile');
58
57
  syncComposedDockerfile({ mode: 'check', root, ...OPTIONS });
59
- assert.throws(() => syncComposedDockerfile({ mode: 'check', root, ...OPTIONS, toolingVersion: '0.9.1' }), /is stale/);
58
+ assert.throws(() => syncComposedDockerfile({ mode: 'check', root, ...OPTIONS, cacheId: 'beta-composed-process' }), /is stale/);
60
59
  assert.throws(() => syncComposedDockerfile({ mode: 'sideways', root, ...OPTIONS }), /write or check/);
61
60
  });
62
61
 
@@ -0,0 +1,83 @@
1
+ // The prebuilt image bind-mounts `copyTool`'s output (package.json + src/, NO node_modules) and
2
+ // runs a .mjs entry from it. That entry's whole import closure must load without node_modules.
3
+ import assert from 'node:assert/strict';
4
+ import { spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import test, { after } from 'node:test';
9
+
10
+ import { renderPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
11
+ import { copyTool } from './composed-image-stage.mjs';
12
+ import { fakePnpm, workspace } from './composed-image-fixture.test.mjs';
13
+
14
+ const temps = [];
15
+ after(() => {
16
+ for (const dir of temps) fs.rmSync(dir, { recursive: true, force: true });
17
+ });
18
+
19
+ function temp(prefix) {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
21
+ temps.push(dir);
22
+ return dir;
23
+ }
24
+
25
+ function renderedEntries() {
26
+ const root = workspace();
27
+ const { dockerfile } = renderPrebuiltDockerfile({
28
+ root,
29
+ copy: ['tooling/dev'],
30
+ cacheId: 'alpha-composed-process',
31
+ buildSource: 'alpha',
32
+ usesToken: true,
33
+ exec: fakePnpm(root),
34
+ });
35
+ const entries = [...new Set([...dockerfile.matchAll(/node \/opt\/xema-composed-image\/(\S+\.mjs)/g)].map((m) => m[1]))];
36
+ assert.ok(entries.length > 0, 'the rendered Dockerfile runs at least one tool entry');
37
+ return entries;
38
+ }
39
+
40
+ const LOCKFILE = "lockfileVersion: '9.0'\n\npackages:\n\n left-pad@1.3.0:\n resolution: {integrity: sha512-x}\n";
41
+
42
+ test('every in-image entry runs from the copied tool directory with no node_modules', () => {
43
+ const tool = path.join(temp('xema-tool-'), 'tool');
44
+ copyTool(tool);
45
+ const root = temp('xema-root-');
46
+ fs.writeFileSync(path.join(root, 'pnpm-lock.yaml'), LOCKFILE);
47
+ const committed = path.join(root, 'committed.yaml');
48
+ fs.copyFileSync(path.join(root, 'pnpm-lock.yaml'), committed);
49
+ for (const entry of renderedEntries()) {
50
+ const result = spawnSync(process.execPath, [path.join(tool, entry), 'pins', committed, '--root', root], { encoding: 'utf8' });
51
+ assert.doesNotMatch(result.stderr, /ERR_MODULE_NOT_FOUND/, `${entry}: ${result.stderr}`);
52
+ assert.equal(result.status, 0, `${entry} exited ${result.status}: ${result.stderr}`);
53
+ }
54
+ });
55
+
56
+ const IMPORT_SPECIFIER =
57
+ /(?:^|[\s;])(?:import|export)\s+(?:[^'"`;]*?\s+from\s+)?['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)/gm;
58
+
59
+ function closureOffenders(entry) {
60
+ const seen = new Set();
61
+ const offenders = [];
62
+ const visit = (file) => {
63
+ if (seen.has(file)) return;
64
+ seen.add(file);
65
+ const source = fs.readFileSync(file, 'utf8');
66
+ for (const match of source.matchAll(IMPORT_SPECIFIER)) {
67
+ const specifier = match[1] ?? match[2];
68
+ if (specifier.startsWith('node:')) continue;
69
+ if (specifier.startsWith('./') || specifier.startsWith('../')) visit(path.resolve(path.dirname(file), specifier));
70
+ else offenders.push(`${path.basename(file)} imports ${specifier}`);
71
+ }
72
+ };
73
+ visit(entry);
74
+ return offenders;
75
+ }
76
+
77
+ test('every in-image entry imports only node: builtins and relative modules, transitively', () => {
78
+ const tool = path.join(temp('xema-tool-'), 'tool');
79
+ copyTool(tool);
80
+ for (const entry of renderedEntries()) {
81
+ assert.deepEqual(closureOffenders(path.join(tool, entry)), [], `${entry} reaches a bare package`);
82
+ }
83
+ });
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ // ═══════════════════════════════════════════════════════════════════════════
3
+ // THE IN-IMAGE HALF OF THE COMPOSED-PROCESS BUILD.
4
+ //
5
+ // The prebuilt Dockerfile bind-mounts a copy of this package's `package.json` + `src/`
6
+ // (NO node_modules) and runs THIS file for `pins`, `index`, `load`, `sync-injected` and
7
+ // `prisma-check`. So this module and everything it imports load only `node:` builtins and
8
+ // relative modules; a bare package import would die with ERR_MODULE_NOT_FOUND in the image
9
+ // (`composed-image-in-image-closure.test.mjs` proves it). `composed-image.mjs` re-exports
10
+ // these functions and dispatches the same verbs outside Docker.
11
+ // ═══════════════════════════════════════════════════════════════════════════
12
+
13
+ import { spawnSync } from 'node:child_process';
14
+ import { existsSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+ import { dirname, join, relative, resolve } from 'node:path';
17
+ import { pathToFileURL } from 'node:url';
18
+
19
+ import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
20
+ import { readMembers } from './composed-image-members.mjs';
21
+ import { syncInjected } from './composed-image-sync-injected.mjs';
22
+ import { prismaCheck } from './composed-image-prisma-check.mjs';
23
+
24
+ export const MEMBER_INDEX_FILE = 'xema-composed-member-index.json';
25
+ export const HOST_LINK = 'xema-composed-process';
26
+
27
+ const SDK = '@xemahq/xema-service-nest';
28
+ const IDENTITY_PACKAGES = Object.freeze(['@nestjs/common', '@nestjs/core', '@nestjs/config', 'reflect-metadata', SDK]);
29
+ const COMMIT = /^[a-f0-9]{40}$/;
30
+
31
+ /** The registry packages (`name@version`) a v9 lockfile's `packages:` section pins. */
32
+ export function registryPins(lockfilePath) {
33
+ const lines = readFileSync(lockfilePath, 'utf8').split('\n');
34
+ if (lines[0] !== "lockfileVersion: '9.0'") {
35
+ throw new Error(`${lockfilePath} is not a v9 lockfile (first line "${lines[0]}")`);
36
+ }
37
+ const start = lines.indexOf('packages:');
38
+ if (start === -1) throw new Error(`${lockfilePath} has no packages: section`);
39
+ const pins = new Set();
40
+ for (const line of lines.slice(start + 1)) {
41
+ if (/^\S/.test(line)) break;
42
+ const key = /^ {2}(\S.*):$/.exec(line)?.[1];
43
+ if (key === undefined) continue;
44
+ const unquoted = key.replace(/^'(.*)'$/, '$1');
45
+ // Workspace packages an injected install adds are `file:` entries, not registry pins.
46
+ if (!/@file:/.test(unquoted)) pins.add(unquoted);
47
+ }
48
+ return pins;
49
+ }
50
+
51
+ /** Refuse a re-resolved `<root>/pnpm-lock.yaml` that pins a registry package the committed lockfile does not. */
52
+ export function assertPinsUnchanged({ root = process.cwd(), committedLockfile }) {
53
+ if (committedLockfile === undefined) throw new Error('pins needs the committed lockfile path');
54
+ const committed = registryPins(committedLockfile);
55
+ const added = [...registryPins(join(root, 'pnpm-lock.yaml'))].filter((pin) => !committed.has(pin));
56
+ if (added.length > 0) {
57
+ throw new Error(
58
+ `the injected re-resolution pinned registry package(s) the committed lockfile does not:\n${added
59
+ .map((pin) => ` - ${pin}`)
60
+ .join('\n')}`,
61
+ );
62
+ }
63
+ return committed.size;
64
+ }
65
+
66
+ function resolveFrom(root, directory, specifier) {
67
+ try {
68
+ return realpathSync(createRequire(join(root, directory, 'package.json')).resolve(specifier));
69
+ } catch (error) {
70
+ if (error?.code === 'MODULE_NOT_FOUND') return undefined;
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ function packageRoot(file, name) {
76
+ for (let dir = dirname(file); dir !== dirname(dir); dir = dirname(dir)) {
77
+ const manifest = join(dir, 'package.json');
78
+ if (existsSync(manifest) && JSON.parse(readFileSync(manifest, 'utf8')).name === name) return dir;
79
+ }
80
+ throw new Error(`no ${name} package root above ${file}`);
81
+ }
82
+
83
+ /** Write the member index and the host link; returns the host bin path. */
84
+ export function writeMemberIndex({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, commit, buildSourceRepository }) {
85
+ if (!COMMIT.test(commit ?? '')) throw new Error(`index needs the exact 40-hex build commit, got "${commit}"`);
86
+ if (typeof buildSourceRepository !== 'string' || buildSourceRepository.length === 0) {
87
+ throw new Error("index needs --build-source: the repository its subjects name in the image lock (their 'repo')");
88
+ }
89
+ const members = readMembers({ root, imageDir });
90
+ const problems = [];
91
+ const entries = {};
92
+ const identity = new Map();
93
+ for (const [key, directory] of members) {
94
+ const entry = `${directory}/dist/main.js`;
95
+ if (!existsSync(join(root, entry))) problems.push(`${key}: built entry ${entry} does not exist`);
96
+ entries[key] = { entry };
97
+ for (const specifier of IDENTITY_PACKAGES) {
98
+ const resolved = resolveFrom(root, directory, specifier);
99
+ if (resolved === undefined) {
100
+ // A member that does not depend on a package shares nothing that could
101
+ // be a second copy — except the host itself, which every member needs.
102
+ if (specifier === SDK) problems.push(`${key}: does not resolve ${SDK}`);
103
+ continue;
104
+ }
105
+ const seen = identity.get(specifier);
106
+ if (seen === undefined) identity.set(specifier, { resolved, key });
107
+ else if (seen.resolved !== resolved) {
108
+ problems.push(`${key} resolves ${specifier} at ${resolved}, ${seen.key} at ${seen.resolved}`);
109
+ }
110
+ }
111
+ }
112
+ if (problems.length > 0) {
113
+ throw new Error(`the composed image cannot carry its members:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
114
+ }
115
+ const document = { schemaVersion: 1, buildSource: { repository: buildSourceRepository, commit }, members: entries };
116
+ // Graded by the kernel's own schema, resolved through the members' module graph.
117
+ const { ComposedProcessMemberIndexSchema } = createRequire(join(root, members[0][1], 'package.json'))(
118
+ '@xemahq/contracts/process-topology',
119
+ );
120
+ ComposedProcessMemberIndexSchema.parse(document);
121
+ writeFileSync(join(root, MEMBER_INDEX_FILE), `${JSON.stringify(document, null, 2)}\n`);
122
+
123
+ const sdkRoot = packageRoot(identity.get(SDK).resolved, SDK);
124
+ const bin = JSON.parse(readFileSync(join(sdkRoot, 'package.json'), 'utf8')).bin?.[HOST_LINK];
125
+ if (typeof bin !== 'string') throw new Error(`${SDK} at ${sdkRoot} ships no "${HOST_LINK}" bin`);
126
+ // Relative to the REAL root: the SDK path is a realpath, and a root reached through a
127
+ // symlink (macOS /var, a bind mount) would otherwise yield a link that escapes it.
128
+ symlinkSync(relative(realpathSync(root), join(sdkRoot, bin)), join(root, HOST_LINK));
129
+ return { members: members.length, host: join(sdkRoot, bin) };
130
+ }
131
+
132
+ /** Every member's module graph loads, through the host's own entry reader. */
133
+ export function loadMembers({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR } = {}) {
134
+ const members = readMembers({ root, imageDir });
135
+ const problems = [];
136
+ for (const [key, directory] of members) {
137
+ try {
138
+ createRequire(join(root, directory, 'package.json'))(SDK).readServiceEntry(join(root, directory, 'dist', 'main.js'), {});
139
+ } catch (error) {
140
+ problems.push(`${key}: ${error?.message ?? String(error)}`);
141
+ }
142
+ }
143
+ if (problems.length > 0) {
144
+ throw new Error(
145
+ `${problems.length} of ${members.length} member(s) do not load:\n${problems.map((p) => ` - ${p}`).join('\n')}`,
146
+ );
147
+ }
148
+ return members.length;
149
+ }
150
+
151
+ export const LOAD_ONE = `const { createRequire } = require('node:module');
152
+ const { join } = require('node:path');
153
+ const [directory, sdk] = process.argv.slice(1);
154
+ createRequire(join(directory, 'package.json'))(sdk).readServiceEntry(join(directory, 'dist', 'main.js'), {});
155
+ process.exit(0);`;
156
+
157
+ /** Every member loads ALONE: one `node` process per member, same environment and user. `spawn` is injectable. */
158
+ export function loadMembersEach({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, spawn = spawnSync } = {}) {
159
+ const members = readMembers({ root, imageDir });
160
+ const problems = [];
161
+ for (const [key, directory] of members) {
162
+ const result = spawn(process.execPath, ['-e', LOAD_ONE, join(root, directory), SDK], {
163
+ cwd: join(root, directory),
164
+ env: process.env,
165
+ encoding: 'utf8',
166
+ });
167
+ if (result.error) problems.push(`${key}: ${result.error.message}`);
168
+ else if (result.status !== 0) {
169
+ const detail = String(result.stderr ?? '').trim().split('\n').filter(Boolean).slice(0, 8).join(' | ');
170
+ problems.push(`${key}: exited ${result.status ?? result.signal}${detail === '' ? '' : ` — ${detail}`}`);
171
+ }
172
+ }
173
+ if (problems.length > 0) {
174
+ throw new Error(
175
+ `${problems.length} of ${members.length} member(s) do not load on their own:\n${problems.map((p) => ` - ${p}`).join('\n')}`,
176
+ );
177
+ }
178
+ return members.length;
179
+ }
180
+
181
+ // ── the in-image bin ────────────────────────────────────────────────────────
182
+
183
+ const USAGE =
184
+ 'usage: composed-image-in-image <command> [--root <dir>] [--image-dir <dir>]\n' +
185
+ ' pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each] | sync-injected | prisma-check';
186
+
187
+ function parseInImageArguments(argv) {
188
+ const positional = [];
189
+ const options = {};
190
+ for (let i = 0; i < argv.length; i += 1) {
191
+ const arg = argv[i];
192
+ if (!arg.startsWith('--')) {
193
+ positional.push(arg);
194
+ continue;
195
+ }
196
+ const name = arg.slice(2);
197
+ if (name === 'each') {
198
+ options.each = true;
199
+ continue;
200
+ }
201
+ if (name !== 'root' && name !== 'image-dir' && name !== 'build-source') throw new Error(`unknown option ${arg}\n${USAGE}`);
202
+ const value = argv[i + 1];
203
+ if (value === undefined || value.startsWith('--')) throw new Error(`${arg} needs a value`);
204
+ i += 1;
205
+ options[name] = value;
206
+ }
207
+ return { command: positional[0], argument: positional[1], options };
208
+ }
209
+
210
+ export function main(argv, { log = console.log } = {}) {
211
+ const { command, argument, options } = parseInImageArguments(argv);
212
+ const root = resolve(options.root ?? process.cwd());
213
+ const imageDir = options['image-dir'] ?? COMPOSED_IMAGE_DIR;
214
+ switch (command) {
215
+ case 'pins': {
216
+ const count = assertPinsUnchanged({ root, committedLockfile: argument });
217
+ log(`composed install: every registry pin is the committed lockfile's (${count})`);
218
+ return;
219
+ }
220
+ case 'index': {
221
+ const result = writeMemberIndex({ root, imageDir, commit: argument, buildSourceRepository: options['build-source'] });
222
+ log(`composed member index: ${result.members} member(s) at ${argument}; host ${result.host}`);
223
+ return;
224
+ }
225
+ case 'load':
226
+ if (options.each) {
227
+ log(`composed members: all ${loadMembersEach({ root, imageDir })} module graph(s) load on their own`);
228
+ return;
229
+ }
230
+ log(`composed members: all ${loadMembers({ root, imageDir })} module graph(s) load`);
231
+ return;
232
+ case 'sync-injected':
233
+ syncInjected({ root, imageDir, log });
234
+ return;
235
+ case 'prisma-check':
236
+ return prismaCheck({ root, imageDir, log }).then(() => undefined);
237
+ default:
238
+ throw new Error(USAGE);
239
+ }
240
+ }
241
+
242
+ const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : undefined;
243
+ if (invokedPath !== undefined && import.meta.url === pathToFileURL(invokedPath).href) {
244
+ try {
245
+ await main(process.argv.slice(2));
246
+ // A loaded member graph may hold handles its modules opened at evaluation; the verdict is in.
247
+ process.exit(0);
248
+ } catch (error) {
249
+ console.error(`xema-composed-image: ${error?.message ?? String(error)}`);
250
+ let depth = 0;
251
+ for (let cause = error?.cause; cause !== undefined && depth < 5; cause = cause?.cause, depth += 1) {
252
+ console.error(` caused by ${cause?.message ?? String(cause)}`);
253
+ }
254
+ process.exit(1);
255
+ }
256
+ }
@@ -1,2 +1,5 @@
1
1
  // Where a repository keeps its composed-process image: the one place this is spelled.
2
2
  export const COMPOSED_IMAGE_DIR = 'infra/composed-process';
3
+
4
+ // The one entry the prebuilt image runs from the bind-mounted tool directory: loads only `node:` and relative modules.
5
+ export const IN_IMAGE_ENTRY = 'src/composed-image-in-image.mjs';
@@ -6,18 +6,17 @@
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { dirname, join, posix } from 'node:path';
8
8
 
9
- import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
9
+ import { COMPOSED_IMAGE_DIR, IN_IMAGE_ENTRY } from './composed-image-paths.mjs';
10
+ import { toolVersion } from './composed-image-stage.mjs';
10
11
  import { NODE_BASE, npmrcUsesToken } from './composed-image-dockerfile.mjs';
11
12
  import { memberClosure, memberFilters, memberManifests } from './composed-image-members.mjs';
12
13
 
13
14
  export const DEFAULT_STAGE_DIR = '.xema-stage';
14
- const VERSION = /^\d+\.\d+\.\d+$/;
15
15
  const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
16
16
  const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
17
17
  const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
18
18
 
19
- function validate({ toolingVersion, copy, cacheId, buildSource, stageDir }) {
20
- if (!VERSION.test(toolingVersion ?? '')) throw new Error(`--tooling-version must be an exact x.y.z version, got "${toolingVersion}"`);
19
+ function validate({ copy, cacheId, buildSource, stageDir }) {
21
20
  for (const directory of [...(copy ?? []), stageDir]) {
22
21
  if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
23
22
  throw new Error(`"${directory}" is not a repository-relative directory`);
@@ -69,7 +68,6 @@ const parts = (sources) => sources.map((source) => ` ${source} \\`).join('\n'
69
68
  export function renderPrebuiltDockerfile({
70
69
  root = process.cwd(),
71
70
  imageDir = COMPOSED_IMAGE_DIR,
72
- toolingVersion,
73
71
  copy = [],
74
72
  cacheId,
75
73
  buildSource,
@@ -78,7 +76,8 @@ export function renderPrebuiltDockerfile({
78
76
  exec,
79
77
  }) {
80
78
  if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
81
- validate({ toolingVersion, copy, cacheId, buildSource, stageDir });
79
+ validate({ copy, cacheId, buildSource, stageDir });
80
+ const toolingVersion = toolVersion();
82
81
  const workspaceFile = join(root, 'pnpm-workspace.yaml');
83
82
  const built = existsSync(workspaceFile) ? onlyBuiltDependencies(readFileSync(workspaceFile, 'utf8')) : [];
84
83
  if (built.length === 0) {
@@ -90,7 +89,7 @@ export function renderPrebuiltDockerfile({
90
89
  const dirFlag = imageDir === COMPOSED_IMAGE_DIR ? '' : ` --image-dir ${imageDir}`;
91
90
  const toolDir = '/opt/xema-composed-image';
92
91
  const toolMount = `--mount=type=bind,source=${stageDir}/tool,target=${toolDir}`;
93
- const node = `${toolDir}/src/composed-image.mjs`;
92
+ const node = `${toolDir}/${IN_IMAGE_ENTRY}`;
94
93
  const store = `--mount=type=cache,id=pnpm-store-${cacheId},target=/root/.pnpm-store`;
95
94
  const metadata = `--mount=type=cache,id=pnpm-metadata-${cacheId},target=/root/.cache/pnpm`;
96
95
  const secret = usesToken ? ' --mount=type=secret,id=npm_token,required=true \\\n' : '';
@@ -8,7 +8,7 @@ import { parseArguments } from './composed-image.mjs';
8
8
  import { CLOSURE, fakePnpm, workspace } from './composed-image-fixture.test.mjs';
9
9
 
10
10
  function options(root, extra = {}) {
11
- return { root, toolingVersion: '0.11.0', copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', usesToken: true, exec: fakePnpm(root), ...extra };
11
+ return { root, copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', usesToken: true, exec: fakePnpm(root), ...extra };
12
12
  }
13
13
 
14
14
  test('the closure comes from pnpm ls with the members filters, and the filters are literal per member', () => {
@@ -40,7 +40,7 @@ test('the tool is only bind-mounted, and the final stage carries nothing of it',
40
40
  const final = dockerfile.split('\nFROM base\n')[1];
41
41
  assert.doesNotMatch(final, /xema-composed-image|repo-build-tooling|npm install|\/opt\//);
42
42
  assert.doesNotMatch(final, /COPY --from=assemble \/monorepo \/monorepo/);
43
- assert.match(dockerfile, /USER node\nRUN --mount=\S+ \\\n {2}NODE_ENV=production node \/opt\/xema-composed-image\/src\/composed-image\.mjs load --each\n/);
43
+ assert.match(dockerfile, /USER node\nRUN --mount=\S+ \\\n {2}NODE_ENV=production node \/opt\/xema-composed-image\/src\/composed-image-in-image\.mjs load --each\n/);
44
44
  assert.match(dockerfile, /cp \/opt\/xema-composed-image\/src\/composed-migrate\.mjs \/monorepo\/xema-composed-migrate\.mjs/);
45
45
  });
46
46
 
@@ -50,7 +50,7 @@ function renderedDockerfile(root, imageDir) {
50
50
  }
51
51
 
52
52
  /** Copy `package.json` + `src/` (no `*.test.mjs`) of this package to `<dir>/tool`. */
53
- function copyTool(target) {
53
+ export function copyTool(target) {
54
54
  const files = ['package.json', ...listFiles(join(TOOL_PACKAGE, 'src')).filter((f) => !f.endsWith('.test.mjs')).map((f) => `src/${f}`)];
55
55
  copyFiles(TOOL_PACKAGE, target, files);
56
56
  return files.length;
@@ -61,10 +61,10 @@
61
61
  // smoke-refusal <image> [--member <biomeId>/<componentKey>]x2
62
62
  // boot the pushed image with a declaration the host must refuse
63
63
  // (`smoke-refusal.mjs`).
64
- // dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>
64
+ // dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>
65
65
  // render `<image-dir>/Dockerfile` from the package's template
66
66
  // (`composed-image-dockerfile.mjs`).
67
- // dockerfile --prebuilt --write|--check --tooling-version <x.y.z> --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]
67
+ // dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]
68
68
  // render the Dockerfile that ASSEMBLES the image from `stage` output, and its
69
69
  // `Dockerfile.dockerignore` (`composed-image-prebuilt-dockerfile.mjs`).
70
70
  //
@@ -88,15 +88,15 @@
88
88
  // would be refused by the host only when a deployment first composed it.
89
89
  // ═══════════════════════════════════════════════════════════════════════════
90
90
 
91
- import { execFileSync, spawnSync } from 'node:child_process';
92
- import { existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs';
93
- import { createRequire } from 'node:module';
94
- import { dirname, join, posix, relative, resolve } from 'node:path';
91
+ import { execFileSync } from 'node:child_process';
92
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
93
+ import { dirname, join, posix, resolve } from 'node:path';
95
94
  import { pathToFileURL } from 'node:url';
96
95
 
97
96
  import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
98
97
  import { composedCoordinate, emitCoordinate } from './composed-image-coordinate.mjs';
99
98
  import { describeErrorChain } from '@xemahq/release-policy/registry';
99
+ import { MEMBER_INDEX_FILE, assertPinsUnchanged, loadMembers, loadMembersEach, writeMemberIndex } from './composed-image-in-image.mjs';
100
100
  import { syncComposedDockerfile } from './composed-image-dockerfile.mjs';
101
101
  import { IMAGE_BUILD_INPUTS_FILE, memberClosure, memberFilters, readMembers } from './composed-image-members.mjs';
102
102
  import { syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
@@ -108,14 +108,19 @@ import { runImageLoad } from './load-image.mjs';
108
108
  import { runRefusalSmoke } from './smoke-refusal.mjs';
109
109
 
110
110
  export { COMPOSED_IMAGE_DIR, IMAGE_BUILD_INPUTS_FILE, memberClosure, memberFilters, readMembers };
111
- export const MEMBER_INDEX_FILE = 'xema-composed-member-index.json';
112
- export const HOST_LINK = 'xema-composed-process';
111
+ export {
112
+ HOST_LINK,
113
+ LOAD_ONE,
114
+ MEMBER_INDEX_FILE,
115
+ assertPinsUnchanged,
116
+ loadMembers,
117
+ loadMembersEach,
118
+ registryPins,
119
+ writeMemberIndex,
120
+ } from './composed-image-in-image.mjs';
113
121
  export const DEFAULT_MANIFEST_GLOBS = Object.freeze(['**/xema-biome.json']);
114
122
 
115
- const SDK = '@xemahq/xema-service-nest';
116
- const IDENTITY_PACKAGES = Object.freeze(['@nestjs/common', '@nestjs/core', '@nestjs/config', 'reflect-metadata', SDK]);
117
123
  const SUPPLY_CHAIN_ARG = /^ARG\s+(SUPPLY_CHAIN_[A-Z_]+_SERVICES)="([^"]*)"\s*$/gm;
118
- const COMMIT = /^[a-f0-9]{40}$/;
119
124
 
120
125
  export const ExclusionReason = Object.freeze({
121
126
  EntryShape: 'entry-shape',
@@ -302,156 +307,6 @@ export function syncImageBuildInputs({ mode, root = process.cwd(), imageDir = CO
302
307
 
303
308
  // ── the image build ─────────────────────────────────────────────────────────
304
309
 
305
- /** The registry packages (`name@version`) a v9 lockfile's `packages:` section pins. */
306
- export function registryPins(lockfilePath) {
307
- const lines = readFileSync(lockfilePath, 'utf8').split('\n');
308
- if (lines[0] !== "lockfileVersion: '9.0'") {
309
- throw new Error(`${lockfilePath} is not a v9 lockfile (first line "${lines[0]}")`);
310
- }
311
- const start = lines.indexOf('packages:');
312
- if (start === -1) throw new Error(`${lockfilePath} has no packages: section`);
313
- const pins = new Set();
314
- for (const line of lines.slice(start + 1)) {
315
- if (/^\S/.test(line)) break;
316
- const key = /^ {2}(\S.*):$/.exec(line)?.[1];
317
- if (key === undefined) continue;
318
- const unquoted = key.replace(/^'(.*)'$/, '$1');
319
- // Workspace packages an injected install adds are `file:` entries, not registry pins.
320
- if (!/@file:/.test(unquoted)) pins.add(unquoted);
321
- }
322
- return pins;
323
- }
324
-
325
- /** Refuse a re-resolved `<root>/pnpm-lock.yaml` that pins a registry package the committed lockfile does not. */
326
- export function assertPinsUnchanged({ root = process.cwd(), committedLockfile }) {
327
- if (committedLockfile === undefined) throw new Error('pins needs the committed lockfile path');
328
- const committed = registryPins(committedLockfile);
329
- const added = [...registryPins(join(root, 'pnpm-lock.yaml'))].filter((pin) => !committed.has(pin));
330
- if (added.length > 0) {
331
- throw new Error(
332
- `the injected re-resolution pinned registry package(s) the committed lockfile does not:\n${added
333
- .map((pin) => ` - ${pin}`)
334
- .join('\n')}`,
335
- );
336
- }
337
- return committed.size;
338
- }
339
-
340
- function resolveFrom(root, directory, specifier) {
341
- try {
342
- return realpathSync(createRequire(join(root, directory, 'package.json')).resolve(specifier));
343
- } catch (error) {
344
- if (error?.code === 'MODULE_NOT_FOUND') return undefined;
345
- throw error;
346
- }
347
- }
348
-
349
- function packageRoot(file, name) {
350
- for (let dir = dirname(file); dir !== dirname(dir); dir = dirname(dir)) {
351
- const manifest = join(dir, 'package.json');
352
- if (existsSync(manifest) && JSON.parse(readFileSync(manifest, 'utf8')).name === name) return dir;
353
- }
354
- throw new Error(`no ${name} package root above ${file}`);
355
- }
356
-
357
- /** Write the member index and the host link; returns the host bin path. */
358
- export function writeMemberIndex({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, commit, buildSourceRepository }) {
359
- if (!COMMIT.test(commit ?? '')) throw new Error(`index needs the exact 40-hex build commit, got "${commit}"`);
360
- if (typeof buildSourceRepository !== 'string' || buildSourceRepository.length === 0) {
361
- throw new Error("index needs --build-source: the repository its subjects name in the image lock (their 'repo')");
362
- }
363
- const members = readMembers({ root, imageDir });
364
- const problems = [];
365
- const entries = {};
366
- const identity = new Map();
367
- for (const [key, directory] of members) {
368
- const entry = `${directory}/dist/main.js`;
369
- if (!existsSync(join(root, entry))) problems.push(`${key}: built entry ${entry} does not exist`);
370
- entries[key] = { entry };
371
- for (const specifier of IDENTITY_PACKAGES) {
372
- const resolved = resolveFrom(root, directory, specifier);
373
- if (resolved === undefined) {
374
- // A member that does not depend on a package shares nothing that could
375
- // be a second copy — except the host itself, which every member needs.
376
- if (specifier === SDK) problems.push(`${key}: does not resolve ${SDK}`);
377
- continue;
378
- }
379
- const seen = identity.get(specifier);
380
- if (seen === undefined) identity.set(specifier, { resolved, key });
381
- else if (seen.resolved !== resolved) {
382
- problems.push(`${key} resolves ${specifier} at ${resolved}, ${seen.key} at ${seen.resolved}`);
383
- }
384
- }
385
- }
386
- if (problems.length > 0) {
387
- throw new Error(`the composed image cannot carry its members:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
388
- }
389
- const document = { schemaVersion: 1, buildSource: { repository: buildSourceRepository, commit }, members: entries };
390
- // Graded by the kernel's own schema, resolved through the members' module graph.
391
- const { ComposedProcessMemberIndexSchema } = createRequire(join(root, members[0][1], 'package.json'))(
392
- '@xemahq/contracts/process-topology',
393
- );
394
- ComposedProcessMemberIndexSchema.parse(document);
395
- writeFileSync(join(root, MEMBER_INDEX_FILE), `${JSON.stringify(document, null, 2)}\n`);
396
-
397
- const sdkRoot = packageRoot(identity.get(SDK).resolved, SDK);
398
- const bin = JSON.parse(readFileSync(join(sdkRoot, 'package.json'), 'utf8')).bin?.[HOST_LINK];
399
- if (typeof bin !== 'string') throw new Error(`${SDK} at ${sdkRoot} ships no "${HOST_LINK}" bin`);
400
- // Relative to the REAL root: the SDK path is a realpath, and a root reached through a
401
- // symlink (macOS /var, a bind mount) would otherwise yield a link that escapes it.
402
- symlinkSync(relative(realpathSync(root), join(sdkRoot, bin)), join(root, HOST_LINK));
403
- return { members: members.length, host: join(sdkRoot, bin) };
404
- }
405
-
406
- /** Every member's module graph loads, through the host's own entry reader. */
407
- export function loadMembers({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR } = {}) {
408
- const members = readMembers({ root, imageDir });
409
- const problems = [];
410
- for (const [key, directory] of members) {
411
- try {
412
- createRequire(join(root, directory, 'package.json'))(SDK).readServiceEntry(join(root, directory, 'dist', 'main.js'), {});
413
- } catch (error) {
414
- problems.push(`${key}: ${error?.message ?? String(error)}`);
415
- }
416
- }
417
- if (problems.length > 0) {
418
- throw new Error(
419
- `${problems.length} of ${members.length} member(s) do not load:\n${problems.map((p) => ` - ${p}`).join('\n')}`,
420
- );
421
- }
422
- return members.length;
423
- }
424
-
425
- const LOAD_ONE = `const { createRequire } = require('node:module');
426
- const { join } = require('node:path');
427
- const [directory, sdk] = process.argv.slice(1);
428
- createRequire(join(directory, 'package.json'))(sdk).readServiceEntry(join(directory, 'dist', 'main.js'), {});
429
- process.exit(0);`;
430
-
431
- /** Every member loads ALONE: one `node` process per member, same environment and user. `spawn` is injectable. */
432
- export function loadMembersEach({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, spawn = spawnSync } = {}) {
433
- const members = readMembers({ root, imageDir });
434
- const problems = [];
435
- for (const [key, directory] of members) {
436
- const result = spawn(process.execPath, ['-e', LOAD_ONE, join(root, directory), SDK], {
437
- cwd: join(root, directory),
438
- env: process.env,
439
- encoding: 'utf8',
440
- });
441
- if (result.error) problems.push(`${key}: ${result.error.message}`);
442
- else if (result.status !== 0) {
443
- const detail = String(result.stderr ?? '').trim().split('\n').filter(Boolean).slice(0, 8).join(' | ');
444
- problems.push(`${key}: exited ${result.status ?? result.signal}${detail === '' ? '' : ` — ${detail}`}`);
445
- }
446
- }
447
- if (problems.length > 0) {
448
- throw new Error(
449
- `${problems.length} of ${members.length} member(s) do not load on their own:\n${problems.map((p) => ` - ${p}`).join('\n')}`,
450
- );
451
- }
452
- return members.length;
453
- }
454
-
455
310
  // ── the bin ─────────────────────────────────────────────────────────────────
456
311
 
457
312
  const USAGE =
@@ -462,13 +317,13 @@ const USAGE =
462
317
  ' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG [TAG_ARGS_FILE])\n' +
463
318
  ' load-image <image>\n' +
464
319
  ' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
465
- ' dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>\n' +
466
- ' dockerfile --prebuilt --write|--check --tooling-version <x.y.z> --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
320
+ ' dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>\n' +
321
+ ' dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
467
322
 
468
323
  const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also']);
469
324
  const BOOLEAN = new Set(['prebuilt', 'each']);
470
325
  const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
471
- 'image-name', 'tooling-version', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also',
326
+ 'image-name', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also',
472
327
  ]);
473
328
 
474
329
  export function parseArguments(argv) {
@@ -573,7 +428,6 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
573
428
  mode: options.mode,
574
429
  root,
575
430
  imageDir,
576
- toolingVersion: options['tooling-version'],
577
431
  copy: options.copy,
578
432
  cacheId: options['cache-id'],
579
433
  buildSource: options['build-source'],
@@ -586,7 +440,6 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
586
440
  mode: options.mode,
587
441
  root,
588
442
  imageDir,
589
- toolingVersion: options['tooling-version'],
590
443
  copy: options.copy,
591
444
  cacheId: options['cache-id'],
592
445
  buildSource: options['build-source'],
@@ -239,12 +239,13 @@ test('the bin runs through a symlink, as npm installs it, and exits non-zero on
239
239
  });
240
240
 
241
241
  test('the new commands parse their options; coordinate refuses without an image name', async () => {
242
- const parsed = parseArguments(['dockerfile', '--write', '--copy', 'a', '--copy', 'b', '--cache-id', 'c', '--tooling-version', '1.2.3']);
242
+ const parsed = parseArguments(['dockerfile', '--write', '--copy', 'a', '--copy', 'b', '--cache-id', 'c']);
243
243
  assert.deepEqual(parsed.options.copy, ['a', 'b']);
244
244
  assert.equal(parsed.options['cache-id'], 'c');
245
245
  assert.deepEqual(parseArguments(['smoke-refusal', 'img', '--member', 'x/y', '--member', 'x/z']).options.member, ['x/y', 'x/z']);
246
246
  assert.throws(() => main(['coordinate'], { log() {} }), /--image-name/);
247
- assert.throws(() => main(['dockerfile', '--write'], { log() {} }), /exact x\.y\.z/);
247
+ assert.throws(() => main(['dockerfile', '--write'], { log() {} }), /at least one/);
248
+ assert.throws(() => parseArguments(['dockerfile', '--write', '--tooling-version', '1.2.3']), /unknown option --tooling-version/);
248
249
  assert.throws(() => main(['smoke-refusal'], { log() {} }), /usage/);
249
250
  });
250
251