@xemahq/repo-build-tooling 0.14.0 → 0.15.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.
package/README.md CHANGED
@@ -29,11 +29,11 @@ pnpm exec xema-scrub-swagger-paths dist
29
29
  | `xema-readiness` | Runs the ordered checks between a source change and a push |
30
30
  | `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]` | Generates `src/generated/*.generated.ts` service and worker descriptors from the repository's `xema-biome.json` manifests; `--check` fails on drift |
31
31
  | `xema-scrub-swagger-paths <dist-dir>...` | Rewrites `@nestjs/swagger` plugin output that names a private install path, then fails the build if any leak remains (`--self-test` available) |
32
- | `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `stage`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
32
+ | `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `stage`, `pnpm-context`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
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 `--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.
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. `pnpm-context <dir> (--subject <svc>… | --image-dir <composed>)` runs on the CI runner after `pnpm install --frozen-lockfile` and builds the offline `xema-pnpm` build context (`staged.json`, `corepack/pnpm.tgz`, `cache/`); it prints `XEMA_PNPM_CONTEXT=<dir>` and `XEMA_PNPM_STORE=<store root>` on stdout (everything else on stderr) and refuses a non-empty `<dir>`. 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.14.0",
3
+ "version": "0.15.0",
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-pnpm-context.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:d3f54741da7e081ed21ee59ca2b27600880be97f359fde67028b756528c66153"
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';
@@ -0,0 +1,119 @@
1
+ // `xema-composed-image pnpm-context <dir> (--subject <svc>... | --image-dir <composed>)`:
2
+ // builds, on the CI runner and AFTER its `pnpm install --frozen-lockfile`, the pnpm context an
3
+ // offline prebuilt image build consumes as the named BuildKit context `xema-pnpm`. The runner's
4
+ // own pnpm store is the second context (`xema-pnpm-store`); it is never copied.
5
+ //
6
+ // <dir>/staged.json { tool, pnpm, storeDir, subjects[], imageDir? } — the sentinel the image checks
7
+ // <dir>/corepack/pnpm.tgz `corepack pack pnpm@<root packageManager>`
8
+ // <dir>/cache/ pnpm `cache-dir` (registry metadata), seeded from the runner's and warmed
9
+ //
10
+ // stdout is exactly two lines for `$GITHUB_ENV`: `XEMA_PNPM_CONTEXT=<dir>` and
11
+ // `XEMA_PNPM_STORE=<store root>`. Everything else goes to stderr. pnpm's own request-level
12
+ // fetch retries apply; nothing here retries a command.
13
+ import { execFileSync } from 'node:child_process';
14
+ import { cpSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { dirname, join, resolve } from 'node:path';
17
+
18
+ import { memberClosure, memberFilters } from './composed-image-members.mjs';
19
+ import { lockfileOnlyCommand } from './composed-image-prebuilt-dockerfile.mjs';
20
+ import { toolVersion } from './composed-image-stage.mjs';
21
+
22
+ /** Runs a command, returns stdout; stderr is the caller's. Injectable for tests. */
23
+ const defaultExec = (file, args, { cwd } = {}) =>
24
+ execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], maxBuffer: 64 * 1024 * 1024 });
25
+
26
+ /** `pnpm@10.33.0+sha512.…` → `10.33.0`. */
27
+ function pnpmVersion(root) {
28
+ const manager = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).packageManager;
29
+ const found = typeof manager === 'string' ? /^pnpm@(\d+\.\d+\.\d+)(?:\+.*)?$/.exec(manager) : null;
30
+ if (found === null) throw new Error(`package.json packageManager must be pnpm@<x.y.z>, got ${JSON.stringify(manager)}`);
31
+ return found[1];
32
+ }
33
+
34
+ function seedCache(exec, source, target, log) {
35
+ mkdirSync(dirname(target), { recursive: true });
36
+ if (!existsSync(source)) {
37
+ mkdirSync(target);
38
+ return 'empty';
39
+ }
40
+ try {
41
+ exec('cp', ['-al', source, target], {});
42
+ return 'hardlinked';
43
+ } catch (error) {
44
+ log(`pnpm-context: cp -al failed (${String(error?.message ?? error).split('\n')[0]}); copying the cache instead`);
45
+ rmSync(target, { recursive: true, force: true });
46
+ cpSync(source, target, { recursive: true });
47
+ return 'copied';
48
+ }
49
+ }
50
+
51
+ /** A scratch tree holding exactly the deps stage's inputs, so `--lockfile-only` never touches the repository. */
52
+ function scratchLockfileInputs({ root, imageDir, exec, copy }) {
53
+ const scratch = mkdtempSync(join(tmpdir(), 'xema-pnpm-lock-'));
54
+ const put = (rel) => {
55
+ if (!existsSync(join(root, rel))) return;
56
+ mkdirSync(dirname(join(scratch, rel)), { recursive: true });
57
+ copyFileSync(join(root, rel), join(scratch, rel));
58
+ };
59
+ for (const rel of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc']) put(rel);
60
+ for (const directory of memberClosure({ root, imageDir, exec })) put(`${directory}/package.json`);
61
+ for (const directory of copy) cpSync(join(root, directory), join(scratch, directory), { recursive: true });
62
+ return scratch;
63
+ }
64
+
65
+ export function buildPnpmContext({
66
+ root = process.cwd(),
67
+ dir,
68
+ subjects = [],
69
+ imageDir,
70
+ copy = [],
71
+ exec = defaultExec,
72
+ log = () => {},
73
+ }) {
74
+ if (typeof dir !== 'string' || dir.length === 0) throw new Error('pnpm-context needs the output directory: pnpm-context <dir>');
75
+ if (subjects.length === 0 && imageDir === undefined) throw new Error('pnpm-context needs --subject <svc>… or --image-dir <composed>');
76
+ if (subjects.length > 0 && imageDir !== undefined) throw new Error('pnpm-context takes --subject or --image-dir, not both');
77
+ const target = resolve(dir);
78
+ if (existsSync(target) && readdirSync(target).length > 0) throw new Error(`${dir} already exists and is not empty`);
79
+
80
+ const pnpm = pnpmVersion(root);
81
+ const storePath = exec('pnpm', ['store', 'path'], { cwd: root }).trim();
82
+ const storeRoot = dirname(storePath);
83
+ if (storePath === '' || !existsSync(storePath)) {
84
+ throw new Error(`the runner's pnpm store ${storePath || '(unknown)'} does not exist — run pnpm install --frozen-lockfile before pnpm-context`);
85
+ }
86
+ const cacheSource = exec('pnpm', ['config', 'get', 'cache-dir'], { cwd: root }).trim();
87
+
88
+ mkdirSync(join(target, 'corepack'), { recursive: true });
89
+ exec('corepack', ['pack', `pnpm@${pnpm}`, '-o', join(target, 'corepack', 'pnpm.tgz')], { cwd: root });
90
+ const cache = join(target, 'cache');
91
+ log(`pnpm-context: cache ${seedCache(exec, cacheSource, cache, log)} from ${cacheSource}`);
92
+
93
+ // Warm: the exact metadata requests the offline image build will make, answered from the registry once, here.
94
+ const base = [`--config.cache-dir=${cache}`];
95
+ if (subjects.length > 0) {
96
+ const tmp = mkdtempSync(join(tmpdir(), 'xema-pnpm-deploy-'));
97
+ try {
98
+ for (const subject of subjects) {
99
+ exec('pnpm', ['deploy', '--prod', '--ignore-scripts', '--prefer-offline', ...base, '--filter', subject, join(tmp, subject.replace(/[^A-Za-z0-9._-]/g, '_'))], { cwd: root });
100
+ log(`pnpm-context: warmed ${subject}`);
101
+ }
102
+ } finally {
103
+ rmSync(tmp, { recursive: true, force: true });
104
+ }
105
+ } else {
106
+ const scratch = scratchLockfileInputs({ root, imageDir, exec, copy });
107
+ try {
108
+ const [file, ...args] = lockfileOnlyCommand(memberFilters({ root, imageDir }).flatMap((f) => f.split(' ')));
109
+ exec(file, [...args, ...base], { cwd: scratch });
110
+ log(`pnpm-context: warmed the ${imageDir} lockfile-only resolve`);
111
+ } finally {
112
+ rmSync(scratch, { recursive: true, force: true });
113
+ }
114
+ }
115
+
116
+ const sentinel = { tool: toolVersion(), pnpm, storeDir: storeRoot, subjects: [...subjects], ...(imageDir === undefined ? {} : { imageDir }) };
117
+ writeFileSync(join(target, 'staged.json'), `${JSON.stringify(sentinel, null, 2)}\n`);
118
+ return { context: target, store: storeRoot };
119
+ }
@@ -0,0 +1,104 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { buildPnpmContext } from './composed-image-pnpm-context.mjs';
8
+ import { lockfileOnlyCommand, renderPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
9
+ import { CLOSURE, fakePnpm, workspace, write } from './composed-image-fixture.test.mjs';
10
+
11
+ function setup({ store = true, cache = true } = {}) {
12
+ const root = workspace();
13
+ write(root, 'package.json', { name: 'r', packageManager: 'pnpm@10.33.0+sha512.abc' });
14
+ write(root, 'pnpm-lock.yaml', 'lockfileVersion: 9\n');
15
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-pnpm-home-'));
16
+ const storePath = path.join(home, '.pnpm-store', 'v10');
17
+ const cacheDir = path.join(home, 'cache');
18
+ if (store) fs.mkdirSync(storePath, { recursive: true });
19
+ if (cache) write(home, 'cache/metadata-v1.3/registry.npmjs.org/x.json', '{}');
20
+ const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'xema-pnpm-out-')), 'ctx');
21
+ const pnpmLs = fakePnpm(root, CLOSURE);
22
+ const calls = [];
23
+ const exec = (file, args, options = {}) => {
24
+ calls.push({ file, args, cwd: options.cwd, scratch: options.cwd !== root && options.cwd !== undefined ? fs.readdirSync(options.cwd) : undefined });
25
+ if (file === 'pnpm' && args[0] === 'ls') return pnpmLs(file, args, options);
26
+ if (file === 'pnpm' && args[0] === 'store') return `${storePath}\n`;
27
+ if (file === 'pnpm' && args[0] === 'config') return `${cacheDir}\n`;
28
+ if (file === 'corepack') {
29
+ fs.writeFileSync(args[args.indexOf('-o') + 1], 'tgz');
30
+ return '';
31
+ }
32
+ if (file === 'cp') {
33
+ fs.cpSync(args[1], args[2], { recursive: true });
34
+ return '';
35
+ }
36
+ return '';
37
+ };
38
+ return { root, out, exec, calls, storePath, home };
39
+ }
40
+
41
+ test('--subject: writes corepack tgz, a seeded and warmed cache dir, and the sentinel; returns the two env values', () => {
42
+ const { root, out, exec, calls, home } = setup();
43
+ const result = buildPnpmContext({ root, dir: out, subjects: ['svc-a', 'svc-b'], exec });
44
+ assert.deepEqual(result, { context: out, store: path.join(home, '.pnpm-store') });
45
+ assert.equal(fs.readFileSync(path.join(out, 'corepack/pnpm.tgz'), 'utf8'), 'tgz');
46
+ assert.ok(fs.existsSync(path.join(out, 'cache/metadata-v1.3/registry.npmjs.org/x.json')));
47
+ const staged = JSON.parse(fs.readFileSync(path.join(out, 'staged.json'), 'utf8'));
48
+ assert.deepEqual(Object.keys(staged), ['tool', 'pnpm', 'storeDir', 'subjects']);
49
+ assert.equal(staged.pnpm, '10.33.0');
50
+ assert.deepEqual(staged.subjects, ['svc-a', 'svc-b']);
51
+ assert.deepEqual(calls.find((c) => c.file === 'corepack').args.slice(0, 2), ['pack', 'pnpm@10.33.0']);
52
+ const deploys = calls.filter((c) => c.args[0] === 'deploy');
53
+ assert.equal(deploys.length, 2);
54
+ for (const d of deploys) {
55
+ for (const flag of ['--prod', '--ignore-scripts', '--prefer-offline', `--config.cache-dir=${path.join(out, 'cache')}`]) assert.ok(d.args.includes(flag), flag);
56
+ assert.ok(!fs.existsSync(d.args.at(-1)), 'the deploy target is deleted');
57
+ }
58
+ });
59
+
60
+ test('refuses a non-empty directory, a missing store, and a bad invocation, writing nothing', () => {
61
+ const full = setup();
62
+ write(full.out, 'old', 'x');
63
+ assert.throws(() => buildPnpmContext({ root: full.root, dir: full.out, subjects: ['s'], exec: full.exec }), /not empty/);
64
+ const none = setup({ store: false });
65
+ assert.throws(() => buildPnpmContext({ root: none.root, dir: none.out, subjects: ['s'], exec: none.exec }), /pnpm store .* does not exist/);
66
+ assert.equal(fs.existsSync(none.out), false);
67
+ assert.throws(() => buildPnpmContext({ root: none.root, dir: none.out, exec: none.exec }), /--subject/);
68
+ assert.throws(() => buildPnpmContext({ root: none.root, dir: none.out, subjects: ['s'], imageDir: 'x', exec: none.exec }), /not both/);
69
+ });
70
+
71
+ test('--image-dir: runs the generator\'s lockfile-only command on a scratch copy and leaves the repository lockfile alone', () => {
72
+ const { root, out, exec, calls } = setup({ cache: false });
73
+ buildPnpmContext({ root, dir: out, imageDir: 'infra/composed-process', exec });
74
+ const resolve = calls.find((c) => c.args.includes('--lockfile-only'));
75
+ assert.notEqual(resolve.cwd, root);
76
+ assert.ok(resolve.scratch.includes('pnpm-lock.yaml') && resolve.scratch.includes('package.json'));
77
+ assert.ok(resolve.args.includes(`--config.cache-dir=${path.join(out, 'cache')}`));
78
+ assert.equal(fs.existsSync(resolve.cwd), false, 'the scratch tree is deleted');
79
+ assert.equal(fs.readFileSync(path.join(root, 'pnpm-lock.yaml'), 'utf8'), 'lockfileVersion: 9\n');
80
+ assert.equal(JSON.parse(fs.readFileSync(path.join(out, 'staged.json'), 'utf8')).imageDir, 'infra/composed-process');
81
+ });
82
+
83
+ test('the lockfile-only command is the one the generated Dockerfile runs (no drift)', () => {
84
+ const root = workspace();
85
+ const words = lockfileOnlyCommand(['--filter', '@t/alpha-api...']);
86
+ assert.deepEqual(words.slice(0, 4), ['pnpm', 'install', '--lockfile-only', '--prefer-offline']);
87
+ assert.ok(words.includes('--config.inject-workspace-packages=true') && words.includes('--config.dedupe-injected-deps=false'));
88
+ const { dockerfile: text } = renderPrebuiltDockerfile({ root, cacheId: 'x', buildSource: 'repo', usesToken: false, exec: fakePnpm(root) });
89
+ assert.ok(text.includes('pnpm install --lockfile-only --prefer-offline ${INJECT} --filter @t/alpha-api... --filter @t/alpha-db-api... &&'));
90
+ assert.ok(text.includes('INJECT="--config.inject-workspace-packages=true --config.dedupe-injected-deps=false"'));
91
+ });
92
+
93
+ test('a failed cp -al falls back to a copy and logs why', () => {
94
+ const { root, out, exec, home } = setup();
95
+ const logs = [];
96
+ const failing = (file, args, options) => {
97
+ if (file === 'cp') throw new Error('cross-device link');
98
+ return exec(file, args, options);
99
+ };
100
+ buildPnpmContext({ root, dir: out, subjects: ['s'], exec: failing, log: (m) => logs.push(m) });
101
+ assert.ok(logs.some((m) => /cp -al failed \(cross-device link\)/.test(m)));
102
+ assert.ok(fs.existsSync(path.join(out, 'cache/metadata-v1.3/registry.npmjs.org/x.json')));
103
+ assert.ok(home);
104
+ });
@@ -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';
@@ -62,6 +62,19 @@ export function onlyBuiltDependencies(workspaceYaml) {
62
62
  return names;
63
63
  }
64
64
 
65
+ /** The install-flag word the rendered RUN keeps as a shell variable; `injectFlags()` is what it expands to. */
66
+ export const INJECT_TOKEN = '${INJECT}';
67
+ export const injectFlags = () => ['--config.inject-workspace-packages=true', '--config.dedupe-injected-deps=false'];
68
+
69
+ /**
70
+ * The deps stage's `--lockfile-only` resolve, as words (`INJECT_TOKEN` stands for `injectFlags()`).
71
+ * The Dockerfile renderer and `xema-composed-image pnpm-context` both read THIS function, so the
72
+ * metadata the context warms is exactly what the image resolves.
73
+ */
74
+ export const lockfileOnlyWords = (filters) => ['pnpm', 'install', '--lockfile-only', '--prefer-offline', INJECT_TOKEN, ...filters];
75
+ /** The same command with the inject words expanded: `[file, ...args]`. */
76
+ export const lockfileOnlyCommand = (filters) => lockfileOnlyWords(filters).flatMap((w) => (w === INJECT_TOKEN ? injectFlags() : [w]));
77
+
65
78
  const parts = (sources) => sources.map((source) => ` ${source} \\`).join('\n');
66
79
 
67
80
  /** The prebuilt Dockerfile text. The closure and the members' literal filters are derived from `root` (`memberClosure`); `exec` is injectable for tests. */
@@ -89,7 +102,7 @@ export function renderPrebuiltDockerfile({
89
102
  const dirFlag = imageDir === COMPOSED_IMAGE_DIR ? '' : ` --image-dir ${imageDir}`;
90
103
  const toolDir = '/opt/xema-composed-image';
91
104
  const toolMount = `--mount=type=bind,source=${stageDir}/tool,target=${toolDir}`;
92
- const node = `${toolDir}/src/composed-image.mjs`;
105
+ const node = `${toolDir}/${IN_IMAGE_ENTRY}`;
93
106
  const store = `--mount=type=cache,id=pnpm-store-${cacheId},target=/root/.pnpm-store`;
94
107
  const metadata = `--mount=type=cache,id=pnpm-metadata-${cacheId},target=/root/.cache/pnpm`;
95
108
  const secret = usesToken ? ' --mount=type=secret,id=npm_token,required=true \\\n' : '';
@@ -143,9 +156,9 @@ ${parts(manifestSources)}
143
156
  RUN ${store} \\
144
157
  ${metadata} \\
145
158
  ${secret} ${toolMount} \\
146
- ${tokenExport}INJECT="--config.inject-workspace-packages=true --config.dedupe-injected-deps=false" && \\
159
+ ${tokenExport}INJECT="${injectFlags().join(' ')}" && \\
147
160
  cp pnpm-lock.yaml /tmp/pnpm-lock.committed.yaml && \\
148
- pnpm install --lockfile-only --prefer-offline \${INJECT} ${filters} && \\
161
+ ${lockfileOnlyWords(filters.split(' ')).join(' ')} && \\
149
162
  node ${node} pins${dirFlag} /tmp/pnpm-lock.committed.yaml && \\
150
163
  pnpm install --prod --prefer-offline --ignore-scripts \${INJECT} ${filters} --frozen-lockfile && \\
151
164
  pnpm rebuild ${built.join(' ')} ${filters}
@@ -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
 
@@ -5,7 +5,8 @@
5
5
  //
6
6
  // <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist);
7
7
  // `--also <pkg>` adds that package's closure (no member checks)
8
- // <dir>/out/<member>/node_modules/.prisma/ every member's generated Prisma clients
8
+ // <dir>/out/<pkg dir>/node_modules/.prisma/ the generated Prisma clients of every closure root that has
9
+ // `prisma/schema.prisma` (members and `--also` roots alike)
9
10
  // <dir>/tool/ this package (package.json + src, no tests):
10
11
  // bind-mounted into the build, never copied into a layer
11
12
  //
@@ -50,7 +51,7 @@ function renderedDockerfile(root, imageDir) {
50
51
  }
51
52
 
52
53
  /** Copy `package.json` + `src/` (no `*.test.mjs`) of this package to `<dir>/tool`. */
53
- function copyTool(target) {
54
+ export function copyTool(target) {
54
55
  const files = ['package.json', ...listFiles(join(TOOL_PACKAGE, 'src')).filter((f) => !f.endsWith('.test.mjs')).map((f) => `src/${f}`)];
55
56
  copyFiles(TOOL_PACKAGE, target, files);
56
57
  return files.length;
@@ -124,12 +125,25 @@ export async function stageBuildOutputs({
124
125
  const plan = []; // { source, destination, files }
125
126
  for (const directory of closure) {
126
127
  const sources = outputs.map((output) => posix.join(directory, output));
127
- if (memberDirectories.has(directory)) sources.push(posix.join(directory, 'node_modules', '.prisma'));
128
+ // Members always (their checks above name a missing client); any other closure root when it carries a schema.
129
+ if (memberDirectories.has(directory) || existsSync(join(root, directory, 'prisma', 'schema.prisma'))) {
130
+ sources.push(posix.join(directory, 'node_modules', '.prisma'));
131
+ }
128
132
  for (const rel of sources) {
129
133
  const source = join(root, rel);
130
- if (!existsSync(source)) continue;
134
+ if (!existsSync(source)) {
135
+ if (rel.endsWith('/node_modules/.prisma') && !memberDirectories.has(directory)) {
136
+ problems.push(`${directory} has prisma/schema.prisma but no generated client: run prisma generate`);
137
+ }
138
+ continue;
139
+ }
131
140
  const files = listFiles(source);
132
141
  for (const file of files) if (file.endsWith('.node')) problems.push(`${rel}/${file}: a native addon cannot be staged (the image installs native code itself, per arch)`);
142
+ if (rel.endsWith('/node_modules/.prisma')) {
143
+ for (const file of files) {
144
+ if (/^libquery_engine/.test(posix.basename(file))) problems.push(`${rel}/${file}: a query engine cannot be staged (the Prisma client must be pure JS/wasm)`);
145
+ }
146
+ }
133
147
  plan.push({ source, destination: join(target, 'out', rel), files });
134
148
  }
135
149
  }
@@ -151,3 +151,25 @@ test('filters and the argument parser carry --also', async () => {
151
151
  assert.equal(memberFilters({ root, also: ['@t/alpha-api'] }).filter((f) => f === '--filter @t/alpha-api...').length, 1);
152
152
  assert.deepEqual(parseArguments(['stage', 'd', '--also', 'a', '--also', 'b']).options.also, ['a', 'b']);
153
153
  });
154
+
155
+ test('--also stages node_modules/.prisma of an extra closure root that has a schema, and refuses a query engine in it', async () => {
156
+ const root = withExtra();
157
+ write(root, 'packages/sdks/extra/prisma/schema.prisma', 'datasource db {}\n');
158
+ write(root, 'packages/sdks/extra/node_modules/.prisma/client/index.js', '// client\n');
159
+ const result = await stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] });
160
+ assert.ok(result.outputs >= 5);
161
+ assert.ok(files(path.join(root, '.xema-stage/out')).includes('packages/sdks/extra/node_modules/.prisma/client/index.js'));
162
+ fs.rmSync(path.join(root, '.xema-stage'), { recursive: true });
163
+ write(root, 'packages/sdks/extra/node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-3.0.x.so', 'x');
164
+ await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /extra\/node_modules\/\.prisma\/client\/libquery_engine.*pure JS\/wasm/);
165
+ fs.rmSync(path.join(root, 'packages/sdks/extra/node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-3.0.x.so'));
166
+ write(root, 'packages/sdks/extra/node_modules/.prisma/client/query.node', 'x');
167
+ await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /extra\/node_modules\/\.prisma\/client\/query\.node/);
168
+ });
169
+
170
+ test('--also refuses a non-member closure root with prisma/schema.prisma but no generated client', async () => {
171
+ const root = withExtra();
172
+ write(root, 'packages/sdks/extra/prisma/schema.prisma', 'datasource db {}\n');
173
+ await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /packages\/sdks\/extra has prisma\/schema\.prisma but no generated client: run prisma generate/);
174
+ assert.equal(fs.existsSync(path.join(root, '.xema-stage')), false);
175
+ });
@@ -44,6 +44,11 @@
44
44
  // generated Prisma clients into a build-step directory, with this tool,
45
45
  // for the `--prebuilt` Dockerfile (`composed-image-stage.mjs`). Each `--also`
46
46
  // workspace package adds its closure's built outputs (no Prisma checks).
47
+ // pnpm-context <dir> (--subject <svc>… | --image-dir <composed>) [--copy <dir>]…
48
+ // CI-runner only, after `pnpm install --frozen-lockfile`: build the offline `xema-pnpm`
49
+ // build context (corepack pnpm.tgz, warmed metadata cache, staged.json) for the
50
+ // prebuilt Dockerfiles (`composed-image-pnpm-context.mjs`). stdout is
51
+ // `XEMA_PNPM_CONTEXT=<dir>` and `XEMA_PNPM_STORE=<store root>` for `$GITHUB_ENV`.
47
52
  // sync-injected | prisma-check
48
53
  // IN-IMAGE proofs of the prebuilt build: the injected workspace copies hold
49
54
  // their packages' `files`; every Prisma client runs on the target arch.
@@ -88,19 +93,20 @@
88
93
  // would be refused by the host only when a deployment first composed it.
89
94
  // ═══════════════════════════════════════════════════════════════════════════
90
95
 
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';
96
+ import { execFileSync } from 'node:child_process';
97
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
98
+ import { dirname, join, posix, resolve } from 'node:path';
95
99
  import { pathToFileURL } from 'node:url';
96
100
 
97
101
  import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
98
102
  import { composedCoordinate, emitCoordinate } from './composed-image-coordinate.mjs';
99
103
  import { describeErrorChain } from '@xemahq/release-policy/registry';
104
+ import { MEMBER_INDEX_FILE, assertPinsUnchanged, loadMembers, loadMembersEach, writeMemberIndex } from './composed-image-in-image.mjs';
100
105
  import { syncComposedDockerfile } from './composed-image-dockerfile.mjs';
101
106
  import { IMAGE_BUILD_INPUTS_FILE, memberClosure, memberFilters, readMembers } from './composed-image-members.mjs';
102
107
  import { syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
103
108
  import { stageBuildOutputs } from './composed-image-stage.mjs';
109
+ import { buildPnpmContext } from './composed-image-pnpm-context.mjs';
104
110
  import { syncInjected } from './composed-image-sync-injected.mjs';
105
111
  import { prismaCheck } from './composed-image-prisma-check.mjs';
106
112
  import { runMigrate } from './composed-migrate.mjs';
@@ -108,14 +114,19 @@ import { runImageLoad } from './load-image.mjs';
108
114
  import { runRefusalSmoke } from './smoke-refusal.mjs';
109
115
 
110
116
  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';
117
+ export {
118
+ HOST_LINK,
119
+ LOAD_ONE,
120
+ MEMBER_INDEX_FILE,
121
+ assertPinsUnchanged,
122
+ loadMembers,
123
+ loadMembersEach,
124
+ registryPins,
125
+ writeMemberIndex,
126
+ } from './composed-image-in-image.mjs';
113
127
  export const DEFAULT_MANIFEST_GLOBS = Object.freeze(['**/xema-biome.json']);
114
128
 
115
- const SDK = '@xemahq/xema-service-nest';
116
- const IDENTITY_PACKAGES = Object.freeze(['@nestjs/common', '@nestjs/core', '@nestjs/config', 'reflect-metadata', SDK]);
117
129
  const SUPPLY_CHAIN_ARG = /^ARG\s+(SUPPLY_CHAIN_[A-Z_]+_SERVICES)="([^"]*)"\s*$/gm;
118
- const COMMIT = /^[a-f0-9]{40}$/;
119
130
 
120
131
  export const ExclusionReason = Object.freeze({
121
132
  EntryShape: 'entry-shape',
@@ -302,162 +313,13 @@ export function syncImageBuildInputs({ mode, root = process.cwd(), imageDir = CO
302
313
 
303
314
  // ── the image build ─────────────────────────────────────────────────────────
304
315
 
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
316
  // ── the bin ─────────────────────────────────────────────────────────────────
456
317
 
457
318
  const USAGE =
458
319
  'usage: xema-composed-image <command> [--root <dir>] [--image-dir <dir>]\n' +
459
320
  ' members --write|--check [--manifests <glob>]… [--canonical-dockerfile <path>] [--cross-repo-manifest <path>]\n' +
460
321
  ' filters [--also <pkg>]… | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
322
+ ' pnpm-context <dir> (--subject <svc>… | --image-dir <composed>) [--copy <dir>]…\n' +
461
323
  ' stage <dir> [--output <rel>]… [--also <pkg>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
462
324
  ' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG [TAG_ARGS_FILE])\n' +
463
325
  ' load-image <image>\n' +
@@ -465,10 +327,10 @@ const USAGE =
465
327
  ' dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>\n' +
466
328
  ' dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
467
329
 
468
- const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also']);
330
+ const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also', 'subject']);
469
331
  const BOOLEAN = new Set(['prebuilt', 'each']);
470
332
  const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
471
- 'image-name', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also',
333
+ 'image-name', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also', 'subject',
472
334
  ]);
473
335
 
474
336
  export function parseArguments(argv) {
@@ -548,6 +410,18 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
548
410
  log(`composed stage ${argument}: ${staged.packages} package(s), ${staged.outputs} output dir(s), ${staged.files} file(s), tool ${staged.toolFiles} file(s)`);
549
411
  });
550
412
  }
413
+ case 'pnpm-context': {
414
+ const result = buildPnpmContext({
415
+ root,
416
+ dir: argument,
417
+ subjects: options.subject,
418
+ imageDir: options.subject === undefined ? options['image-dir'] : undefined,
419
+ copy: options.copy,
420
+ log: (message) => process.stderr.write(`${message}\n`),
421
+ });
422
+ write(`XEMA_PNPM_CONTEXT=${result.context}\nXEMA_PNPM_STORE=${result.store}\n`);
423
+ return;
424
+ }
551
425
  case 'sync-injected':
552
426
  syncInjected({ root, imageDir, log });
553
427
  return;