@xemahq/repo-build-tooling 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.14.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:003d770229d6ab378573068cfaf5b02e89b6bbd632dcfce3ccb17125d94322e1"
47
+ "sourceDigest": "sha256:c1fafb67e8735c78e8eb13a40fe3a3e9745ad5873b906d7c782e33734f86cf88"
48
48
  }
49
49
  }
@@ -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,7 +6,7 @@
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
10
  import { toolVersion } from './composed-image-stage.mjs';
11
11
  import { NODE_BASE, npmrcUsesToken } from './composed-image-dockerfile.mjs';
12
12
  import { memberClosure, memberFilters, memberManifests } from './composed-image-members.mjs';
@@ -89,7 +89,7 @@ export function renderPrebuiltDockerfile({
89
89
  const dirFlag = imageDir === COMPOSED_IMAGE_DIR ? '' : ` --image-dir ${imageDir}`;
90
90
  const toolDir = '/opt/xema-composed-image';
91
91
  const toolMount = `--mount=type=bind,source=${stageDir}/tool,target=${toolDir}`;
92
- const node = `${toolDir}/src/composed-image.mjs`;
92
+ const node = `${toolDir}/${IN_IMAGE_ENTRY}`;
93
93
  const store = `--mount=type=cache,id=pnpm-store-${cacheId},target=/root/.pnpm-store`;
94
94
  const metadata = `--mount=type=cache,id=pnpm-metadata-${cacheId},target=/root/.cache/pnpm`;
95
95
  const secret = usesToken ? ' --mount=type=secret,id=npm_token,required=true \\\n' : '';
@@ -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;
@@ -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 =