@xemahq/repo-build-tooling 0.8.2 → 0.9.0

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.
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env node
2
+ // ═══════════════════════════════════════════════════════════════════════════
3
+ // THE BUILD-TIME HALF OF A REPOSITORY'S COMPOSED-PROCESS IMAGE.
4
+ //
5
+ // A composed-process image is ONE image per build: every platform-scheduled
6
+ // service of one repository at one commit, loaded by `@xemahq/xema-service-nest`'s
7
+ // `xema-composed-process` host. Which members share a process is the
8
+ // DEPLOYMENT's declaration, read at boot; this tool decides only what the image
9
+ // CONTAINS, and proves at build time that it can carry it. The runtime half
10
+ // (the host bin, `readServiceEntry`) stays in xema-service-nest; this half never
11
+ // ships inside an image.
12
+ //
13
+ // Every producing repository runs the same steps, so they live here rather than
14
+ // as a copy per repository. What differs per repository is passed in, never
15
+ // assumed: where its biome manifests are (`--manifests`), which Dockerfile is its
16
+ // canonical service Dockerfile (`--canonical-dockerfile`, optional), its
17
+ // cross-repository input manifest (`--cross-repo-manifest`, optional), and the
18
+ // repository a member's sealed subject names (`--build-source`, the `repo` of
19
+ // its subjects in the edition's image lock).
20
+ //
21
+ // Commands (all relative to `--root`, default the current directory):
22
+ //
23
+ // members --write | --check
24
+ // derive the member set and write / verify
25
+ // `<image-dir>/image-build-inputs.json` (its existence at a commit IS
26
+ // the repository's composed-process declaration).
27
+ // filters print `--filter <package>...` for every member, so install and
28
+ // build cover exactly the members' closures.
29
+ // dirs print every member's directory, one per line.
30
+ // pins <committed-lockfile>
31
+ // refuse a re-resolved lockfile that pins any registry package the
32
+ // committed one does not.
33
+ // index <commit> --build-source <repo>
34
+ // write the MEMBER INDEX (the kernel's `ComposedProcessMemberIndexSchema`)
35
+ // and the `xema-composed-process` link the image runs.
36
+ // load evaluate every member's entry exactly as the host does
37
+ // (`readServiceEntry`) and refuse the image if any graph does not load.
38
+ // coordinate --image-name <name>
39
+ // the image's content tag (computed ONLY by the repository's own
40
+ // `xema-compute-image-tag`) and whether GHCR already serves it
41
+ // (`composed-image-coordinate.mjs`; environment-driven, GITHUB_OUTPUT).
42
+ // smoke-refusal <image> [--member <biomeId>/<componentKey>]x2
43
+ // boot the pushed image with a declaration the host must refuse
44
+ // (`smoke-refusal.mjs`).
45
+ // dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>
46
+ // render `<image-dir>/Dockerfile` from the package's template
47
+ // (`composed-image-dockerfile.mjs`).
48
+ //
49
+ // THE MEMBER SET IS DERIVED, NEVER CHOSEN. A component is a member when the
50
+ // kernel would let it be one — `kind: service`, `scheduler: platform` (a worker,
51
+ // a job or a runtime-scheduled service is never a member) — AND the image can run
52
+ // it as it runs dedicated. Four derived facts keep a service out, each one a
53
+ // property of the repository rather than an opinion:
54
+ //
55
+ // entry-shape its `src/main.ts` is not a top-level, single
56
+ // `void bootstrapXemaService(…)` (the host's
57
+ // `readServiceEntry` refuses any other shape by name).
58
+ // own-dockerfile it builds from its OWN Dockerfile, which carries
59
+ // image-level requirements this image does not provide.
60
+ // vendored-inputs it bakes content from another repository (the
61
+ // `--cross-repo-manifest`'s `inputs[].consumerService`).
62
+ // image-toolchain the `--canonical-dockerfile` installs a verification
63
+ // toolchain for it (`ARG SUPPLY_CHAIN_*_SERVICES="…"`).
64
+ //
65
+ // Every step REFUSES rather than skips: an image that silently lacked a member
66
+ // would be refused by the host only when a deployment first composed it.
67
+ // ═══════════════════════════════════════════════════════════════════════════
68
+
69
+ import { execFileSync } from 'node:child_process';
70
+ import { existsSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs';
71
+ import { createRequire } from 'node:module';
72
+ import { dirname, join, posix, relative, resolve } from 'node:path';
73
+ import { pathToFileURL } from 'node:url';
74
+
75
+ import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
76
+ import { composedCoordinate, emitCoordinate } from './composed-image-coordinate.mjs';
77
+ import { syncComposedDockerfile } from './composed-image-dockerfile.mjs';
78
+ import { runRefusalSmoke } from './smoke-refusal.mjs';
79
+
80
+ export { COMPOSED_IMAGE_DIR };
81
+ export const IMAGE_BUILD_INPUTS_FILE = 'image-build-inputs.json';
82
+ export const MEMBER_INDEX_FILE = 'xema-composed-member-index.json';
83
+ export const HOST_LINK = 'xema-composed-process';
84
+ export const DEFAULT_MANIFEST_GLOBS = Object.freeze(['**/xema-biome.json']);
85
+
86
+ const SDK = '@xemahq/xema-service-nest';
87
+ const IDENTITY_PACKAGES = Object.freeze(['@nestjs/common', '@nestjs/core', '@nestjs/config', 'reflect-metadata', SDK]);
88
+ const SUPPLY_CHAIN_ARG = /^ARG\s+(SUPPLY_CHAIN_[A-Z_]+_SERVICES)="([^"]*)"\s*$/gm;
89
+ const COMMIT = /^[a-f0-9]{40}$/;
90
+
91
+ export const ExclusionReason = Object.freeze({
92
+ EntryShape: 'entry-shape',
93
+ OwnDockerfile: 'own-dockerfile',
94
+ VendoredInputs: 'vendored-inputs',
95
+ ImageToolchain: 'image-toolchain',
96
+ });
97
+
98
+ // ── entry shape ─────────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * JS/TS source with `//` and `/* *\/` comment bodies blanked, string and
102
+ * template literal bodies kept. Newlines survive, so `^`-anchored line tests
103
+ * still see each line at its own column.
104
+ */
105
+ export function codeWithoutComments(source) {
106
+ if (typeof source !== 'string') throw new TypeError(`expected a string, got ${typeof source}`);
107
+ let out = '';
108
+ let state = 'code';
109
+ for (let i = 0; i < source.length; i += 1) {
110
+ const c = source[i];
111
+ const next = source[i + 1];
112
+ if (state === 'line') {
113
+ if (c === '\n') {
114
+ state = 'code';
115
+ out += c;
116
+ } else out += ' ';
117
+ continue;
118
+ }
119
+ if (state === 'block') {
120
+ if (c === '*' && next === '/') {
121
+ state = 'code';
122
+ out += ' ';
123
+ i += 1;
124
+ } else out += c === '\n' ? '\n' : ' ';
125
+ continue;
126
+ }
127
+ if (state === 'code') {
128
+ if (c === '/' && next === '/') {
129
+ state = 'line';
130
+ out += ' ';
131
+ i += 1;
132
+ continue;
133
+ }
134
+ if (c === '/' && next === '*') {
135
+ state = 'block';
136
+ out += ' ';
137
+ i += 1;
138
+ continue;
139
+ }
140
+ if (c === "'" || c === '"' || c === '`') state = c;
141
+ out += c;
142
+ continue;
143
+ }
144
+ // Inside a string or template literal.
145
+ out += c;
146
+ if (c === '\\') {
147
+ if (next !== undefined) out += next;
148
+ i += 1;
149
+ } else if (c === state) state = 'code';
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** Exactly one `bootstrapXemaService(` call, as a top-level `void bootstrapXemaService(` statement. */
155
+ export function isComposableEntry(mainTs) {
156
+ const code = codeWithoutComments(mainTs);
157
+ const calls = code.match(/\bbootstrapXemaService\s*\(/g) ?? [];
158
+ return calls.length === 1 && /^void bootstrapXemaService\(/m.test(code);
159
+ }
160
+
161
+ // ── the member set ──────────────────────────────────────────────────────────
162
+
163
+ function trackedManifests(root, manifestGlobs) {
164
+ if (!Array.isArray(manifestGlobs) || manifestGlobs.length === 0) {
165
+ throw new Error('manifestGlobs must name at least one glob');
166
+ }
167
+ const files = execFileSync('git', ['ls-files', '-z', '--', ...manifestGlobs.map((glob) => `:(glob)${glob}`)], {
168
+ cwd: root,
169
+ encoding: 'utf8',
170
+ })
171
+ .split('\0')
172
+ .filter(Boolean)
173
+ .sort();
174
+ if (files.length === 0) {
175
+ throw new Error(`no tracked biome manifest matches ${manifestGlobs.join(', ')} under ${root}`);
176
+ }
177
+ return files;
178
+ }
179
+
180
+ function supplyChainServices(root, canonicalDockerfile) {
181
+ if (canonicalDockerfile === undefined) return new Set();
182
+ const dockerfile = readFileSync(join(root, canonicalDockerfile), 'utf8');
183
+ const names = new Set();
184
+ let found = 0;
185
+ for (const match of dockerfile.matchAll(SUPPLY_CHAIN_ARG)) {
186
+ found += 1;
187
+ for (const name of match[2].split(/\s+/).filter(Boolean)) names.add(name);
188
+ }
189
+ if (found === 0) {
190
+ throw new Error(
191
+ `${canonicalDockerfile} declares no ARG SUPPLY_CHAIN_*_SERVICES="…" — the image-toolchain ` +
192
+ 'exclusion cannot be derived from it; omit --canonical-dockerfile if this repository has none',
193
+ );
194
+ }
195
+ return names;
196
+ }
197
+
198
+ function vendoringServices(root, crossRepoManifest) {
199
+ if (crossRepoManifest === undefined) return new Set();
200
+ const path = join(root, crossRepoManifest);
201
+ if (!existsSync(path)) return new Set();
202
+ const manifest = JSON.parse(readFileSync(path, 'utf8'));
203
+ return new Set((manifest.inputs ?? []).map((input) => input.consumerService));
204
+ }
205
+
206
+ function sortKeys(record) {
207
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
208
+ }
209
+
210
+ /** Derive `{ members: { key: directory }, excluded: { key: reason } }` from the tracked biome manifests. */
211
+ export function deriveComposedMembers({
212
+ root = process.cwd(),
213
+ manifestGlobs = DEFAULT_MANIFEST_GLOBS,
214
+ canonicalDockerfile,
215
+ crossRepoManifest,
216
+ } = {}) {
217
+ const toolchain = supplyChainServices(root, canonicalDockerfile);
218
+ const vendoring = vendoringServices(root, crossRepoManifest);
219
+ const members = {};
220
+ const excluded = {};
221
+ for (const manifestPath of trackedManifests(root, manifestGlobs)) {
222
+ const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8'));
223
+ const biomeId = manifest.xema?.id;
224
+ if (typeof biomeId !== 'string' || biomeId.length === 0) throw new Error(`${manifestPath}: no biome id`);
225
+ for (const component of manifest.xema?.components ?? []) {
226
+ if (component.kind !== 'service' || component.scheduler !== 'platform') continue;
227
+ const artifactPath = component.artifact?.path;
228
+ if (typeof artifactPath !== 'string') {
229
+ throw new Error(`${manifestPath}: service "${component.key}" has no artifact.path`);
230
+ }
231
+ const directory = posix.normalize(posix.join(posix.dirname(manifestPath), artifactPath));
232
+ const key = `${biomeId}/${component.key}`;
233
+ const mainTs = join(root, directory, 'src', 'main.ts');
234
+ if (!existsSync(mainTs)) throw new Error(`${key}: ${directory}/src/main.ts does not exist`);
235
+ let reason;
236
+ if (existsSync(join(root, directory, 'Dockerfile'))) reason = ExclusionReason.OwnDockerfile;
237
+ else if (vendoring.has(component.key)) reason = ExclusionReason.VendoredInputs;
238
+ else if (toolchain.has(component.key)) reason = ExclusionReason.ImageToolchain;
239
+ else if (!isComposableEntry(readFileSync(mainTs, 'utf8'))) reason = ExclusionReason.EntryShape;
240
+ if (reason === undefined) members[key] = directory;
241
+ else excluded[key] = reason;
242
+ }
243
+ }
244
+ if (Object.keys(members).length === 0) {
245
+ throw new Error('no composable platform service found — an empty composed image is never correct');
246
+ }
247
+ return { members: sortKeys(members), excluded: sortKeys(excluded) };
248
+ }
249
+
250
+ /** The image's `image-build-inputs.json`: exactly `{ "workspacePackages": [<member directory>, …] }`. */
251
+ export function renderImageBuildInputs({ members }) {
252
+ return `${JSON.stringify({ workspacePackages: [...new Set(Object.values(members))].sort() }, null, 2)}\n`;
253
+ }
254
+
255
+ /** `--write` writes the derived declaration; `--check` throws when the committed one differs. */
256
+ export function syncImageBuildInputs({ mode, root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, ...deriveOptions }) {
257
+ if (mode !== 'write' && mode !== 'check') throw new Error(`mode must be write or check, got "${mode}"`);
258
+ const derived = deriveComposedMembers({ root, ...deriveOptions });
259
+ const rendered = renderImageBuildInputs(derived);
260
+ const relativeTarget = posix.join(imageDir, IMAGE_BUILD_INPUTS_FILE);
261
+ const target = join(root, relativeTarget);
262
+ if (mode === 'write') writeFileSync(target, rendered);
263
+ else if ((existsSync(target) ? readFileSync(target, 'utf8') : '') !== rendered) {
264
+ throw new Error(
265
+ `${relativeTarget} is stale — the composed image would not carry exactly the composable services. ` +
266
+ 'Run: xema-composed-image members --write (with the same options)',
267
+ );
268
+ }
269
+ return { ...derived, target: relativeTarget };
270
+ }
271
+
272
+ // ── the image build ─────────────────────────────────────────────────────────
273
+
274
+ /** The `<biomeId>/<componentKey>` of the one service whose artifact is `directory`. */
275
+ function memberKey(root, directory) {
276
+ for (let dir = dirname(directory); dir !== '.' && dir !== dirname(dir); dir = dirname(dir)) {
277
+ const manifestPath = join(root, dir, 'xema-biome.json');
278
+ if (!existsSync(manifestPath)) continue;
279
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
280
+ const matches = (manifest.xema?.components ?? []).filter(
281
+ (component) =>
282
+ component.kind === 'service' &&
283
+ typeof component.artifact?.path === 'string' &&
284
+ posix.normalize(posix.join(dir, component.artifact.path)) === directory,
285
+ );
286
+ if (matches.length !== 1) {
287
+ throw new Error(`${dir}/xema-biome.json declares ${matches.length} service(s) at ${directory}, not exactly one`);
288
+ }
289
+ return `${manifest.xema.id}/${matches[0].key}`;
290
+ }
291
+ throw new Error(`no biome manifest above ${directory}`);
292
+ }
293
+
294
+ /** `[key, directory]` for every member the committed declaration names. */
295
+ export function readMembers({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR } = {}) {
296
+ const inputs = join(root, imageDir, IMAGE_BUILD_INPUTS_FILE);
297
+ const directories = JSON.parse(readFileSync(inputs, 'utf8')).workspacePackages ?? [];
298
+ if (directories.length === 0) throw new Error(`${inputs} declares no workspacePackages`);
299
+ return directories.map((directory) => [memberKey(root, directory), directory]);
300
+ }
301
+
302
+ function packageName(root, directory) {
303
+ const manifest = JSON.parse(readFileSync(join(root, directory, 'package.json'), 'utf8'));
304
+ if (typeof manifest.name !== 'string') throw new Error(`${directory}/package.json has no name`);
305
+ return manifest.name;
306
+ }
307
+
308
+ /** The pnpm `--filter <package>...` arguments covering every member's closure. */
309
+ export function memberFilters(options = {}) {
310
+ const root = options.root ?? process.cwd();
311
+ return readMembers(options).map(([, directory]) => `--filter ${packageName(root, directory)}...`);
312
+ }
313
+
314
+ /** The registry packages (`name@version`) a v9 lockfile's `packages:` section pins. */
315
+ export function registryPins(lockfilePath) {
316
+ const lines = readFileSync(lockfilePath, 'utf8').split('\n');
317
+ if (lines[0] !== "lockfileVersion: '9.0'") {
318
+ throw new Error(`${lockfilePath} is not a v9 lockfile (first line "${lines[0]}")`);
319
+ }
320
+ const start = lines.indexOf('packages:');
321
+ if (start === -1) throw new Error(`${lockfilePath} has no packages: section`);
322
+ const pins = new Set();
323
+ for (const line of lines.slice(start + 1)) {
324
+ if (/^\S/.test(line)) break;
325
+ const key = /^ {2}(\S.*):$/.exec(line)?.[1];
326
+ if (key === undefined) continue;
327
+ const unquoted = key.replace(/^'(.*)'$/, '$1');
328
+ // Workspace packages an injected install adds are `file:` entries, not registry pins.
329
+ if (!/@file:/.test(unquoted)) pins.add(unquoted);
330
+ }
331
+ return pins;
332
+ }
333
+
334
+ /** Refuse a re-resolved `<root>/pnpm-lock.yaml` that pins a registry package the committed lockfile does not. */
335
+ export function assertPinsUnchanged({ root = process.cwd(), committedLockfile }) {
336
+ if (committedLockfile === undefined) throw new Error('pins needs the committed lockfile path');
337
+ const committed = registryPins(committedLockfile);
338
+ const added = [...registryPins(join(root, 'pnpm-lock.yaml'))].filter((pin) => !committed.has(pin));
339
+ if (added.length > 0) {
340
+ throw new Error(
341
+ `the injected re-resolution pinned registry package(s) the committed lockfile does not:\n${added
342
+ .map((pin) => ` - ${pin}`)
343
+ .join('\n')}`,
344
+ );
345
+ }
346
+ return committed.size;
347
+ }
348
+
349
+ function resolveFrom(root, directory, specifier) {
350
+ try {
351
+ return realpathSync(createRequire(join(root, directory, 'package.json')).resolve(specifier));
352
+ } catch (error) {
353
+ if (error?.code === 'MODULE_NOT_FOUND') return undefined;
354
+ throw error;
355
+ }
356
+ }
357
+
358
+ function packageRoot(file, name) {
359
+ for (let dir = dirname(file); dir !== dirname(dir); dir = dirname(dir)) {
360
+ const manifest = join(dir, 'package.json');
361
+ if (existsSync(manifest) && JSON.parse(readFileSync(manifest, 'utf8')).name === name) return dir;
362
+ }
363
+ throw new Error(`no ${name} package root above ${file}`);
364
+ }
365
+
366
+ /** Write the member index and the host link; returns the host bin path. */
367
+ export function writeMemberIndex({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, commit, buildSourceRepository }) {
368
+ if (!COMMIT.test(commit ?? '')) throw new Error(`index needs the exact 40-hex build commit, got "${commit}"`);
369
+ if (typeof buildSourceRepository !== 'string' || buildSourceRepository.length === 0) {
370
+ throw new Error("index needs --build-source: the repository its subjects name in the image lock (their 'repo')");
371
+ }
372
+ const members = readMembers({ root, imageDir });
373
+ const problems = [];
374
+ const entries = {};
375
+ const identity = new Map();
376
+ for (const [key, directory] of members) {
377
+ const entry = `${directory}/dist/main.js`;
378
+ if (!existsSync(join(root, entry))) problems.push(`${key}: built entry ${entry} does not exist`);
379
+ entries[key] = { entry };
380
+ for (const specifier of IDENTITY_PACKAGES) {
381
+ const resolved = resolveFrom(root, directory, specifier);
382
+ if (resolved === undefined) {
383
+ // A member that does not depend on a package shares nothing that could
384
+ // be a second copy — except the host itself, which every member needs.
385
+ if (specifier === SDK) problems.push(`${key}: does not resolve ${SDK}`);
386
+ continue;
387
+ }
388
+ const seen = identity.get(specifier);
389
+ if (seen === undefined) identity.set(specifier, { resolved, key });
390
+ else if (seen.resolved !== resolved) {
391
+ problems.push(`${key} resolves ${specifier} at ${resolved}, ${seen.key} at ${seen.resolved}`);
392
+ }
393
+ }
394
+ }
395
+ if (problems.length > 0) {
396
+ throw new Error(`the composed image cannot carry its members:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
397
+ }
398
+ const document = { schemaVersion: 1, buildSource: { repository: buildSourceRepository, commit }, members: entries };
399
+ // Graded by the kernel's own schema, resolved through the members' module graph.
400
+ const { ComposedProcessMemberIndexSchema } = createRequire(join(root, members[0][1], 'package.json'))(
401
+ '@xemahq/contracts/process-topology',
402
+ );
403
+ ComposedProcessMemberIndexSchema.parse(document);
404
+ writeFileSync(join(root, MEMBER_INDEX_FILE), `${JSON.stringify(document, null, 2)}\n`);
405
+
406
+ const sdkRoot = packageRoot(identity.get(SDK).resolved, SDK);
407
+ const bin = JSON.parse(readFileSync(join(sdkRoot, 'package.json'), 'utf8')).bin?.[HOST_LINK];
408
+ if (typeof bin !== 'string') throw new Error(`${SDK} at ${sdkRoot} ships no "${HOST_LINK}" bin`);
409
+ // Relative to the REAL root: the SDK path is a realpath, and a root reached through a
410
+ // symlink (macOS /var, a bind mount) would otherwise yield a link that escapes it.
411
+ symlinkSync(relative(realpathSync(root), join(sdkRoot, bin)), join(root, HOST_LINK));
412
+ return { members: members.length, host: join(sdkRoot, bin) };
413
+ }
414
+
415
+ /** Every member's module graph loads, through the host's own entry reader. */
416
+ export function loadMembers({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR } = {}) {
417
+ const members = readMembers({ root, imageDir });
418
+ const problems = [];
419
+ for (const [key, directory] of members) {
420
+ try {
421
+ createRequire(join(root, directory, 'package.json'))(SDK).readServiceEntry(join(root, directory, 'dist', 'main.js'), {});
422
+ } catch (error) {
423
+ problems.push(`${key}: ${error?.message ?? String(error)}`);
424
+ }
425
+ }
426
+ if (problems.length > 0) {
427
+ throw new Error(
428
+ `${problems.length} of ${members.length} member(s) do not load:\n${problems.map((p) => ` - ${p}`).join('\n')}`,
429
+ );
430
+ }
431
+ return members.length;
432
+ }
433
+
434
+ // ── the bin ─────────────────────────────────────────────────────────────────
435
+
436
+ const USAGE =
437
+ 'usage: xema-composed-image <command> [--root <dir>] [--image-dir <dir>]\n' +
438
+ ' members --write|--check [--manifests <glob>]… [--canonical-dockerfile <path>] [--cross-repo-manifest <path>]\n' +
439
+ ' filters | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load\n' +
440
+ ' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG REGISTRY_USER REGISTRY_TOKEN [TAG_ARGS_FILE])\n' +
441
+ ' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
442
+ ' dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>';
443
+
444
+ const REPEATABLE = new Set(['manifests', 'copy', 'member']);
445
+ const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
446
+ 'image-name', 'tooling-version', 'cache-id', 'copy', 'member',
447
+ ]);
448
+
449
+ export function parseArguments(argv) {
450
+ const positional = [];
451
+ const options = {};
452
+ for (let i = 0; i < argv.length; i += 1) {
453
+ const arg = argv[i];
454
+ if (arg === '--write' || arg === '--check') {
455
+ options.mode = arg.slice(2);
456
+ continue;
457
+ }
458
+ if (!arg.startsWith('--')) {
459
+ positional.push(arg);
460
+ continue;
461
+ }
462
+ const name = arg.slice(2);
463
+ const value = argv[i + 1];
464
+ if (!VALUED.has(name)) throw new Error(`unknown option ${arg}\n${USAGE}`);
465
+ if (value === undefined || value.startsWith('--')) throw new Error(`${arg} needs a value`);
466
+ i += 1;
467
+ if (REPEATABLE.has(name)) (options[name] ??= []).push(value);
468
+ else options[name] = value;
469
+ }
470
+ return { command: positional[0], argument: positional[1], options };
471
+ }
472
+
473
+ export function main(argv, { log = console.log, write = (text) => process.stdout.write(text) } = {}) {
474
+ const { command, argument, options } = parseArguments(argv);
475
+ const root = resolve(options.root ?? process.cwd());
476
+ const imageDir = options['image-dir'] ?? COMPOSED_IMAGE_DIR;
477
+ switch (command) {
478
+ case 'members': {
479
+ const result = syncImageBuildInputs({
480
+ mode: options.mode,
481
+ root,
482
+ imageDir,
483
+ manifestGlobs: options.manifests ?? DEFAULT_MANIFEST_GLOBS,
484
+ canonicalDockerfile: options['canonical-dockerfile'],
485
+ crossRepoManifest: options['cross-repo-manifest'],
486
+ });
487
+ for (const [key, reason] of Object.entries(result.excluded)) log(` excluded ${key}: ${reason}`);
488
+ log(
489
+ `composed-process members: ${Object.keys(result.members).length} carried, ` +
490
+ `${Object.keys(result.excluded).length} excluded (${result.target} ${options.mode === 'write' ? 'written' : 'current'})`,
491
+ );
492
+ return;
493
+ }
494
+ case 'filters':
495
+ write(`${memberFilters({ root, imageDir }).join(' ')}\n`);
496
+ return;
497
+ case 'dirs':
498
+ write(`${readMembers({ root, imageDir }).map(([, directory]) => directory).join('\n')}\n`);
499
+ return;
500
+ case 'pins': {
501
+ const count = assertPinsUnchanged({ root, committedLockfile: argument });
502
+ log(`composed install: every registry pin is the committed lockfile's (${count})`);
503
+ return;
504
+ }
505
+ case 'index': {
506
+ const result = writeMemberIndex({ root, imageDir, commit: argument, buildSourceRepository: options['build-source'] });
507
+ log(`composed member index: ${result.members} member(s) at ${argument}; host ${result.host}`);
508
+ return;
509
+ }
510
+ case 'load':
511
+ log(`composed members: all ${loadMembers({ root, imageDir })} module graph(s) load`);
512
+ return;
513
+ case 'coordinate':
514
+ return composedCoordinate({ root, imageDir, imageName: options['image-name'] }).then((outputs) =>
515
+ emitCoordinate(outputs, { log }),
516
+ );
517
+ case 'smoke-refusal':
518
+ runRefusalSmoke({ image: argument, members: options.member, log, write });
519
+ return;
520
+ case 'dockerfile': {
521
+ const target = syncComposedDockerfile({
522
+ mode: options.mode,
523
+ root,
524
+ imageDir,
525
+ toolingVersion: options['tooling-version'],
526
+ copy: options.copy,
527
+ cacheId: options['cache-id'],
528
+ buildSource: options['build-source'],
529
+ });
530
+ log(`composed Dockerfile ${target} ${options.mode === 'write' ? 'written' : 'current'}`);
531
+ return;
532
+ }
533
+ default:
534
+ throw new Error(USAGE);
535
+ }
536
+ }
537
+
538
+ const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : undefined;
539
+ if (invokedPath !== undefined && import.meta.url === pathToFileURL(invokedPath).href) {
540
+ try {
541
+ await main(process.argv.slice(2));
542
+ // A loaded member graph may hold handles its modules opened at evaluation;
543
+ // the verdict is in, so the step ends here rather than on their timers.
544
+ process.exit(0);
545
+ } catch (error) {
546
+ console.error(`xema-composed-image: ${error.message}`);
547
+ process.exit(1);
548
+ }
549
+ }