@xemahq/repo-build-tooling 0.9.2 → 0.10.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 +19 -0
- package/package.json +7 -5
- package/src/composed-image.mjs +4 -1
- package/src/ghcr-manifest.mjs +4 -2
- package/src/registry-fetch.mjs +82 -0
- package/src/registry-fetch.test.mjs +151 -0
- package/src/service-bootstrap-concurrency.mjs +49 -0
- package/src/service-bootstrap-protocols.mjs +108 -0
- package/src/service-bootstrap-protocols.test.mjs +70 -0
- package/src/service-bootstrap.mjs +491 -0
- package/src/service-bootstrap.test.mjs +304 -0
- package/src/worker-descriptor-generation.mjs +77 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate the per-service `BiomeServiceDescriptor` bootstrap constant that
|
|
4
|
+
* `XemaServiceModule.forBiome(...)` consumes — from the AUTHORITATIVE source,
|
|
5
|
+
* each biome's `xema-biome.json`.
|
|
6
|
+
*
|
|
7
|
+
* Why: today a service's identity (name / version / dependencies) is declared
|
|
8
|
+
* in `xema-biome.json` AND re-typed as string literals inside `app.module.ts`
|
|
9
|
+
* (`ServiceRegistryModule` descriptor + `IdentityBootstrapModule`). That
|
|
10
|
+
* duplication drifts. This codegen makes the manifest the single source of
|
|
11
|
+
* truth: it emits `src/generated/<service>.bootstrap.generated.ts` next to each
|
|
12
|
+
* shipped API, and `app.module.ts` imports the constant instead of repeating
|
|
13
|
+
* the strings. A developer changes name/version/deps in ONE place — the manifest.
|
|
14
|
+
*
|
|
15
|
+
* Manifest fields read — the v5 COMPONENT contract (`xema.components[]`). The
|
|
16
|
+
* v4 `xema.ships.apis[]` spelling this used to read is RETIRED and matches
|
|
17
|
+
* nothing in the fleet.
|
|
18
|
+
*
|
|
19
|
+
* - top-level `version` → descriptor.semver / biomeVersion (required)
|
|
20
|
+
* - `components[].kind === 'service'` → the components this emits for
|
|
21
|
+
* - `components[].protocol.serviceName` → descriptor.name (falls back to `key`)
|
|
22
|
+
* - `components[].artifact.path` → where the file is written (required)
|
|
23
|
+
* - `components[].protocol.displayName` → descriptor.displayName
|
|
24
|
+
* (optional; falls back to `<xema.displayName> API`)
|
|
25
|
+
* - `components[].protocol.description` → descriptor.description (optional)
|
|
26
|
+
* — the ONE authored copy `main.ts` reads for
|
|
27
|
+
* the OpenAPI `info.description`
|
|
28
|
+
* - `components[].protocol.requiresServices` → descriptor.requiresServices
|
|
29
|
+
* (optional; default ['identity-api'])
|
|
30
|
+
* - `components[].protocol.capabilities` → descriptor.protocols (optional)
|
|
31
|
+
* - `components[].protocol.exposesCapabilities` → descriptor.exposesCapabilities
|
|
32
|
+
* (optional; falls back to `xema.exposesCapabilities`)
|
|
33
|
+
* - `components[].requirements.scaling.concurrency.maximumPerInstance`
|
|
34
|
+
* → descriptor.maximumConcurrentRequests (required positive
|
|
35
|
+
* integer; a service without one is refused)
|
|
36
|
+
* - `components[].kind === 'worker'` → a `XemaWorkerDescriptor` constant in
|
|
37
|
+
* `<package>/src/generated/<key>.worker-descriptor.generated.ts`
|
|
38
|
+
* (name = key, requiresServices = protocol.requiresServices,
|
|
39
|
+
* maximumConcurrentActivities = maximumPerInstance, and for a
|
|
40
|
+
* Nexus provider maximumConcurrentNexusOperations =
|
|
41
|
+
* maximumNexusOperationsPerInstance, required, no default). The package is
|
|
42
|
+
* the worker's `artifact.path`, or — for a `shared-oci-image`
|
|
43
|
+
* worker — the path of the component whose image it shares.
|
|
44
|
+
* Like services, only emitted into a package that depends on
|
|
45
|
+
* `@xemahq/xema-service-nest`.
|
|
46
|
+
* - `xema.id` → descriptor.biomeId (required)
|
|
47
|
+
* - `xema.capabilityDomain` → descriptor.capabilityDomain (optional)
|
|
48
|
+
*
|
|
49
|
+
* v4 also carried a per-API `serviceKind`; v5 carries none and needs none —
|
|
50
|
+
* every generated descriptor in the fleet is `ServiceKind.BiomeApi`, the
|
|
51
|
+
* default. `displayName` is the opposite case and is why this port needed a
|
|
52
|
+
* kernel change first: it is REGISTERED WITH identity-api, and 20 of the 50
|
|
53
|
+
* services declare one that differs from `<biome displayName> API`. Deriving it
|
|
54
|
+
* would have renamed them in the identity plane.
|
|
55
|
+
*
|
|
56
|
+
* `biomeId` + `biomeVersion` are NOT decoration: `isHttpCapabilityProjectionEnabled`
|
|
57
|
+
* in `@xemahq/xema-service-nest` gates HTTP capability projection on BOTH being
|
|
58
|
+
* present (a projected capability is stamped with `biome.id`/`biome.version`, and
|
|
59
|
+
* provenance cannot be guessed). A descriptor emitted without them silently
|
|
60
|
+
* projects nothing — so they are emitted unconditionally, and a manifest with no
|
|
61
|
+
* `xema.id` is a hard error rather than a descriptor that boots into a no-op.
|
|
62
|
+
* `capabilityDomain` is the `<domain>` segment of every projected ref; it is
|
|
63
|
+
* emitted only when the manifest declares it (the runtime defaults it to the
|
|
64
|
+
* biome id, so emitting the default would be redundant duplication).
|
|
65
|
+
*
|
|
66
|
+
* It runs in ONE repository: it walks every `xema-biome.json` under the
|
|
67
|
+
* repository root (the working directory, or `--root <dir>`) and writes next to
|
|
68
|
+
* the packages those manifests name. Every repository runs the same published
|
|
69
|
+
* copy, so no repository hand-writes a generated value.
|
|
70
|
+
*
|
|
71
|
+
* xema-service-bootstrap # write
|
|
72
|
+
* xema-service-bootstrap --check # CI: fail on drift
|
|
73
|
+
* xema-service-bootstrap --filter mailops-api # one component
|
|
74
|
+
* xema-service-bootstrap --root <repo-dir> # a repository other than cwd
|
|
75
|
+
*/
|
|
76
|
+
import {
|
|
77
|
+
readdirSync,
|
|
78
|
+
readFileSync,
|
|
79
|
+
writeFileSync,
|
|
80
|
+
mkdirSync,
|
|
81
|
+
existsSync,
|
|
82
|
+
} from 'node:fs';
|
|
83
|
+
import { join, relative, dirname, resolve } from 'node:path';
|
|
84
|
+
import { renderServiceProtocols } from './service-bootstrap-protocols.mjs';
|
|
85
|
+
import { readMaximumPerInstance } from './service-bootstrap-concurrency.mjs';
|
|
86
|
+
import {
|
|
87
|
+
WORKER_DESCRIPTOR_FILE_SUFFIX,
|
|
88
|
+
renderWorkerDescriptorFile,
|
|
89
|
+
workerPackagePath,
|
|
90
|
+
workerRecord,
|
|
91
|
+
} from './worker-descriptor-generation.mjs';
|
|
92
|
+
|
|
93
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', 'coverage']);
|
|
94
|
+
|
|
95
|
+
const argv = process.argv.slice(2);
|
|
96
|
+
const CHECK = argv.includes('--check');
|
|
97
|
+
const optionValue = (flag) => {
|
|
98
|
+
const index = argv.indexOf(flag);
|
|
99
|
+
if (index < 0) return null;
|
|
100
|
+
const value = argv[index + 1];
|
|
101
|
+
if (value === undefined || value.startsWith('--')) {
|
|
102
|
+
console.error(`[xema-service-bootstrap] ${flag} needs a value.`);
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
};
|
|
107
|
+
const FILTER = optionValue('--filter');
|
|
108
|
+
const ROOT = resolve(optionValue('--root') ?? process.cwd());
|
|
109
|
+
|
|
110
|
+
/** Recursively collect files named `target` under `dir`. */
|
|
111
|
+
function walk(dir, target, out) {
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
115
|
+
} catch {
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
for (const e of entries) {
|
|
119
|
+
if (e.isDirectory()) {
|
|
120
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
121
|
+
walk(join(dir, e.name), target, out);
|
|
122
|
+
} else if (e.name === target) {
|
|
123
|
+
out.push(join(dir, e.name));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const CONST_KEY = (name) => name.toUpperCase().replace(/-/g, '_') + '_BOOTSTRAP';
|
|
130
|
+
|
|
131
|
+
const titleCase = (slug) =>
|
|
132
|
+
slug
|
|
133
|
+
.replace(/-/g, ' ')
|
|
134
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
135
|
+
|
|
136
|
+
/** Map a manifest into 0..n descriptor records (one per shipped API). */
|
|
137
|
+
function descriptorsFromManifest(manifestPath) {
|
|
138
|
+
const manifestDir = dirname(manifestPath);
|
|
139
|
+
let m;
|
|
140
|
+
try {
|
|
141
|
+
m = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`[xema-service-bootstrap] cannot parse ${relative(ROOT, manifestPath)}: ${error.message}`,
|
|
145
|
+
{ cause: error },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const xema = m?.xema ?? m;
|
|
149
|
+
const version = m?.version ?? xema?.version;
|
|
150
|
+
// v5: a service is a COMPONENT whose kind says so. `protocol.serviceName` is
|
|
151
|
+
// the wire name; `key` is its fallback, and the two agree fleet-wide today.
|
|
152
|
+
const apis = (xema?.components ?? [])
|
|
153
|
+
.filter((component) => component?.kind === 'service')
|
|
154
|
+
.map((component) => ({
|
|
155
|
+
name: component?.protocol?.serviceName ?? component?.key,
|
|
156
|
+
path: component?.artifact?.path,
|
|
157
|
+
displayName: component?.protocol?.displayName,
|
|
158
|
+
description: component?.protocol?.description,
|
|
159
|
+
requiresServices: component?.protocol?.requiresServices,
|
|
160
|
+
exposesCapabilities: component?.protocol?.exposesCapabilities,
|
|
161
|
+
protocols: component?.protocol?.capabilities,
|
|
162
|
+
component,
|
|
163
|
+
}));
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const api of apis) {
|
|
166
|
+
if (!api?.name || !api?.path) continue;
|
|
167
|
+
if (FILTER && api.name !== FILTER) continue;
|
|
168
|
+
// Only emit for services that OPT IN by declaring the dependency. A biome
|
|
169
|
+
// may ship a worker / non-migrated API in the same manifest; generating a
|
|
170
|
+
// descriptor that imports `@xemahq/xema-service-nest` into a service that
|
|
171
|
+
// doesn't depend on it would break that service's build. The dep is the
|
|
172
|
+
// single signal that a service consumes `XemaServiceModule.forBiome`.
|
|
173
|
+
if (!packageOptsIn(join(manifestDir, api.path, 'package.json'))) continue;
|
|
174
|
+
if (!version) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`[xema-service-bootstrap] ${relative(ROOT, manifestPath)} ships api "${api.name}" but has no top-level "version".`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const displayName =
|
|
180
|
+
api.displayName ??
|
|
181
|
+
(xema?.displayName ? `${xema.displayName} API` : `${titleCase(api.name)}`);
|
|
182
|
+
// v5 carries no per-component serviceKind, and needs none: every generated
|
|
183
|
+
// descriptor in the fleet is a biome API.
|
|
184
|
+
const serviceKind = 'biome-api';
|
|
185
|
+
const requiresServices =
|
|
186
|
+
Array.isArray(api.requiresServices) && api.requiresServices.length > 0
|
|
187
|
+
? api.requiresServices
|
|
188
|
+
: ['identity-api'];
|
|
189
|
+
// SORTED, so the generated file is a function of the declaration SET rather
|
|
190
|
+
// than of the order someone happened to type it in. Without this, moving a
|
|
191
|
+
// ref inside the manifest churns a generated file for no semantic reason —
|
|
192
|
+
// and `--check` would fail on a diff that means nothing.
|
|
193
|
+
const exposesCapabilities = [
|
|
194
|
+
...(Array.isArray(api.exposesCapabilities)
|
|
195
|
+
? api.exposesCapabilities
|
|
196
|
+
: Array.isArray(xema?.exposesCapabilities)
|
|
197
|
+
? xema.exposesCapabilities
|
|
198
|
+
: []),
|
|
199
|
+
].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
200
|
+
const protocols = api.protocols;
|
|
201
|
+
const biomeId = xema?.id;
|
|
202
|
+
if (typeof biomeId !== 'string' || biomeId.trim() === '') {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`[xema-service-bootstrap] ${relative(ROOT, manifestPath)} ships api "${api.name}" but has no "xema.id". ` +
|
|
205
|
+
'The biome id is the provenance stamped on every projected capability — ' +
|
|
206
|
+
'without it `XemaServiceModule.forBiome` disables HTTP capability projection entirely.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const capabilityDomain =
|
|
210
|
+
typeof xema?.capabilityDomain === 'string' &&
|
|
211
|
+
xema.capabilityDomain.trim() !== ''
|
|
212
|
+
? xema.capabilityDomain
|
|
213
|
+
: null;
|
|
214
|
+
const maximumConcurrentRequests = readMaximumPerInstance(
|
|
215
|
+
api.component,
|
|
216
|
+
relative(ROOT, manifestPath),
|
|
217
|
+
);
|
|
218
|
+
const apiSrcDir = join(manifestDir, api.path, 'src');
|
|
219
|
+
out.push({
|
|
220
|
+
name: api.name,
|
|
221
|
+
maximumConcurrentRequests,
|
|
222
|
+
semver: String(version),
|
|
223
|
+
serviceKind,
|
|
224
|
+
displayName,
|
|
225
|
+
description: api.description,
|
|
226
|
+
requiresServices,
|
|
227
|
+
exposesCapabilities,
|
|
228
|
+
protocols,
|
|
229
|
+
biomeId,
|
|
230
|
+
biomeVersion: String(version),
|
|
231
|
+
capabilityDomain,
|
|
232
|
+
outFile: join(
|
|
233
|
+
apiSrcDir,
|
|
234
|
+
'generated',
|
|
235
|
+
`${api.name}.bootstrap.generated.ts`,
|
|
236
|
+
),
|
|
237
|
+
manifestRel: relative(ROOT, manifestPath),
|
|
238
|
+
apiSrcExists: existsSync(apiSrcDir),
|
|
239
|
+
packageJsonPath: join(manifestDir, api.path, 'package.json'),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The package.json dependency that opts a package in to generation. */
|
|
246
|
+
function packageOptsIn(packageJsonPath) {
|
|
247
|
+
try {
|
|
248
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
249
|
+
return Boolean((pkg.dependencies ?? {})[SERVICE_NEST_PACKAGE]);
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Map a manifest into 0..n worker descriptor records (one per worker component). */
|
|
256
|
+
function workerDescriptorsFromManifest(manifestPath) {
|
|
257
|
+
const manifestDir = dirname(manifestPath);
|
|
258
|
+
let m;
|
|
259
|
+
try {
|
|
260
|
+
m = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
261
|
+
} catch (error) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`[xema-service-bootstrap] cannot parse ${relative(ROOT, manifestPath)}: ${error.message}`,
|
|
264
|
+
{ cause: error },
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const components = (m?.xema ?? m)?.components ?? [];
|
|
268
|
+
const manifestRel = relative(ROOT, manifestPath);
|
|
269
|
+
const out = [];
|
|
270
|
+
for (const component of components) {
|
|
271
|
+
if (component?.kind !== 'worker' || !component?.key) continue;
|
|
272
|
+
if (FILTER && component.key !== FILTER) continue;
|
|
273
|
+
const packageDir = join(manifestDir, workerPackagePath(component, components, manifestRel));
|
|
274
|
+
// Same opt-in as services: no dependency on the SDK, no generated import. Checked
|
|
275
|
+
// BEFORE the record, so a worker that is not opted in never has to declare a limit.
|
|
276
|
+
if (!packageOptsIn(join(packageDir, 'package.json'))) continue;
|
|
277
|
+
const record = workerRecord(component, components, manifestRel);
|
|
278
|
+
const srcDir = join(packageDir, 'src');
|
|
279
|
+
out.push({
|
|
280
|
+
...record,
|
|
281
|
+
manifestRel,
|
|
282
|
+
srcExists: existsSync(srcDir),
|
|
283
|
+
outFile: join(srcDir, 'generated', `${record.name}.${WORKER_DESCRIPTOR_FILE_SUFFIX}`),
|
|
284
|
+
render: () =>
|
|
285
|
+
renderWorkerDescriptorFile(record, {
|
|
286
|
+
manifestRel,
|
|
287
|
+
serviceNestPackage: SERVICE_NEST_PACKAGE,
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// THE TWO PACKAGES THIS GENERATOR EMITS AN IMPORT FOR, named once.
|
|
295
|
+
//
|
|
296
|
+
// The protocol import was hardcoded as `@xemahq/kernel-contracts/service-registry`
|
|
297
|
+
// and went stale when the kernel package was renamed to `@xemahq/contracts`.
|
|
298
|
+
// It stayed invisible because that import is emitted ONLY for a service that
|
|
299
|
+
// declares protocols, and for a long time none did.
|
|
300
|
+
//
|
|
301
|
+
// What it did when it finally fired is worse than emitting a dead import: the
|
|
302
|
+
// generator OVERWROTE resource-governance-api's committed, CORRECT file with
|
|
303
|
+
// the retired name, and `--check` therefore reported that correct file as
|
|
304
|
+
// "out of date" and told the reader to regenerate it. The gate was demanding a
|
|
305
|
+
// regression, and obeying it broke xema-base's build.
|
|
306
|
+
const SERVICE_NEST_PACKAGE = '@xemahq/xema-service-nest';
|
|
307
|
+
const KERNEL_CONTRACTS_PACKAGE = '@xemahq/contracts';
|
|
308
|
+
const SERVICE_REGISTRY_SUBPATH = `${KERNEL_CONTRACTS_PACKAGE}/service-registry`;
|
|
309
|
+
|
|
310
|
+
const SERVICE_KIND_ENUM = {
|
|
311
|
+
'platform-service': 'ServiceKind.PlatformService',
|
|
312
|
+
'biome-api': 'ServiceKind.BiomeApi',
|
|
313
|
+
cli: 'ServiceKind.Cli',
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// A generator that emits an import nobody declares produces a file that does not
|
|
317
|
+
// compile, in a repository the generator does not build. This makes that a
|
|
318
|
+
// GENERATION-time failure instead, naming the service and the package.
|
|
319
|
+
//
|
|
320
|
+
// It grades ONLY the kernel-contracts import, and deliberately not the
|
|
321
|
+
// `@xemahq/xema-service-nest` one: a service reaches this function only by
|
|
322
|
+
// DECLARING that exact package — that is the opt-in signal above — so asserting
|
|
323
|
+
// it here could never fail, and a check that cannot fail is worse than no check
|
|
324
|
+
// because it reads as protection in review.
|
|
325
|
+
//
|
|
326
|
+
// It grades DECLARATIONS, not resolution: the constitution already requires that
|
|
327
|
+
// every package list every `@xemahq/*` it imports, so a declared dependency is
|
|
328
|
+
// the right thing to check and it needs no install to check it. Measured when
|
|
329
|
+
// this landed: all 53 generated services declare `@xemahq/xema-service-nest`,
|
|
330
|
+
// and the one service with a protocol import declares the kernel package — so
|
|
331
|
+
// this reddens nothing that was already correct.
|
|
332
|
+
function assertKernelContractsDeclared(d) {
|
|
333
|
+
if (!existsSync(d.packageJsonPath)) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`[xema-service-bootstrap] ${d.name}: no package.json at ${relative(ROOT, d.packageJsonPath)}, so the protocol import this generator emits cannot be checked against what the service declares.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const pkg = JSON.parse(readFileSync(d.packageJsonPath, 'utf8'));
|
|
339
|
+
const declared = new Set([
|
|
340
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
341
|
+
...Object.keys(pkg.peerDependencies ?? {}),
|
|
342
|
+
]);
|
|
343
|
+
if (!declared.has(KERNEL_CONTRACTS_PACKAGE)) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`[xema-service-bootstrap] ${d.name} (${relative(ROOT, d.packageJsonPath)}) declares service protocols, so this generator emits an import from ` +
|
|
346
|
+
`'${SERVICE_REGISTRY_SUBPATH}' — but the service does not declare ${KERNEL_CONTRACTS_PACKAGE}. ` +
|
|
347
|
+
'Either the kernel package was renamed and the constants at the top of this file are stale, or the service is missing a dependency. ' +
|
|
348
|
+
'Both produce the same symptom — a generated file that will not compile — and both are fixed here, never in the generated file.',
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function renderFile(d) {
|
|
354
|
+
const kindExpr = SERVICE_KIND_ENUM[d.serviceKind];
|
|
355
|
+
if (!kindExpr) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`[xema-service-bootstrap] ${d.manifestRel} api "${d.name}" has unknown serviceKind "${d.serviceKind}" (expected platform-service | biome-api | cli).`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
const arr = (xs) =>
|
|
361
|
+
xs.length === 0 ? '[]' : `[${xs.map((x) => `'${x}'`).join(', ')}]`;
|
|
362
|
+
const renderedProtocols = renderServiceProtocols(d.protocols);
|
|
363
|
+
if (renderedProtocols.imports.length > 0) {
|
|
364
|
+
assertKernelContractsDeclared(d);
|
|
365
|
+
}
|
|
366
|
+
const protocolImports =
|
|
367
|
+
renderedProtocols.imports.length === 0
|
|
368
|
+
? ''
|
|
369
|
+
: `import {\n${renderedProtocols.imports.map((name) => ` ${name},`).join('\n')}\n} from '${SERVICE_REGISTRY_SUBPATH}';\n`;
|
|
370
|
+
return `// AUTO-GENERATED by xema-service-bootstrap (@xemahq/repo-build-tooling) — DO NOT EDIT.
|
|
371
|
+
// Source of truth: ${d.manifestRel} (xema.components[] + top-level version).
|
|
372
|
+
// Regenerate: \`pnpm exec xema-service-bootstrap\`.
|
|
373
|
+
${protocolImports}import {
|
|
374
|
+
ServiceKind,
|
|
375
|
+
type BiomeServiceDescriptor,
|
|
376
|
+
} from '${SERVICE_NEST_PACKAGE}';
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Bootstrap identity for ${d.name}, consumed by
|
|
380
|
+
* \`XemaServiceModule.forBiome(${CONST_KEY(d.name)})\`. Edit the biome manifest,
|
|
381
|
+
* not this file.
|
|
382
|
+
*/
|
|
383
|
+
export const ${CONST_KEY(d.name)}: BiomeServiceDescriptor = {
|
|
384
|
+
name: '${d.name}',
|
|
385
|
+
semver: '${d.semver}',
|
|
386
|
+
serviceKind: ${kindExpr},
|
|
387
|
+
displayName: '${d.displayName.replace(/'/g, "\\'")}',
|
|
388
|
+
${d.description ? ` // JSON.stringify, not a quoted literal: a lifted description carries real\n // newlines, and single quotes produced an unterminated string.\n description: ${JSON.stringify(d.description)},\n` : ''}\
|
|
389
|
+
maximumConcurrentRequests: ${d.maximumConcurrentRequests},
|
|
390
|
+
requiresServices: ${arr(d.requiresServices)},
|
|
391
|
+
exposesCapabilities: ${arr(d.exposesCapabilities)},${renderedProtocols.field}
|
|
392
|
+
biomeId: '${d.biomeId}',
|
|
393
|
+
biomeVersion: '${d.biomeVersion}',${
|
|
394
|
+
d.capabilityDomain === null
|
|
395
|
+
? ''
|
|
396
|
+
: `\n capabilityDomain: '${d.capabilityDomain}',`
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const manifests = walk(ROOT, 'xema-biome.json', []);
|
|
403
|
+
const records = manifests.flatMap(descriptorsFromManifest);
|
|
404
|
+
const workerRecords = manifests.flatMap(workerDescriptorsFromManifest);
|
|
405
|
+
|
|
406
|
+
// A SCAN THAT FINDS NOTHING IS A FAILURE, NEVER AN "OK".
|
|
407
|
+
//
|
|
408
|
+
// This generator once read a retired manifest spelling, matched nothing in a
|
|
409
|
+
// fleet it walked, and reported "OK — 0 file(s) up to date": `--check` was a
|
|
410
|
+
// gate that could not fail. So an empty result exits non-zero: no manifest
|
|
411
|
+
// under the root means it ran in the wrong directory, and manifests with no
|
|
412
|
+
// opted-in service or worker mean the reader and the manifests disagree (or the
|
|
413
|
+
// repository has nothing to generate and must not run this).
|
|
414
|
+
if (manifests.length === 0) {
|
|
415
|
+
console.error(
|
|
416
|
+
`[xema-service-bootstrap] found no xema-biome.json under ${ROOT}. ` +
|
|
417
|
+
'Run it from the repository root, or pass --root <repo-dir>.',
|
|
418
|
+
);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
if (records.length === 0 && workerRecords.length === 0 && FILTER === null) {
|
|
422
|
+
console.error(
|
|
423
|
+
`[xema-service-bootstrap] walked ${manifests.length} xema-biome.json manifest(s) under ${ROOT} ` +
|
|
424
|
+
'and produced ZERO service or worker descriptors. A descriptor is emitted for each ' +
|
|
425
|
+
'`xema.components[]` entry of kind `service` or `worker` whose package declares ' +
|
|
426
|
+
`${SERVICE_NEST_PACKAGE}; none here does, so this run would verify nothing.`,
|
|
427
|
+
);
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
if (FILTER !== null && records.length === 0 && workerRecords.length === 0) {
|
|
431
|
+
console.error(`[xema-service-bootstrap] --filter ${FILTER} matched no opted-in service or worker component.`);
|
|
432
|
+
process.exit(1);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let drift = 0;
|
|
436
|
+
let written = 0;
|
|
437
|
+
let skipped = 0;
|
|
438
|
+
let compared = 0;
|
|
439
|
+
const outputs = [
|
|
440
|
+
...records.map((d) => ({
|
|
441
|
+
outFile: d.outFile,
|
|
442
|
+
srcExists: d.apiSrcExists,
|
|
443
|
+
render: () => renderFile(d),
|
|
444
|
+
})),
|
|
445
|
+
...workerRecords,
|
|
446
|
+
];
|
|
447
|
+
for (const d of outputs) {
|
|
448
|
+
if (!d.srcExists) {
|
|
449
|
+
// The manifest declares an api whose source isn't present in this
|
|
450
|
+
// checkout (carved-repo skew). Skip rather than emit into a phantom dir.
|
|
451
|
+
skipped++;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
compared++;
|
|
455
|
+
const next = d.render();
|
|
456
|
+
const prev = existsSync(d.outFile) ? readFileSync(d.outFile, 'utf8') : null;
|
|
457
|
+
if (prev === next) continue;
|
|
458
|
+
if (CHECK) {
|
|
459
|
+
console.error(
|
|
460
|
+
`DRIFT: ${relative(ROOT, d.outFile)} is out of date (regenerate with \`pnpm exec xema-service-bootstrap\`).`,
|
|
461
|
+
);
|
|
462
|
+
drift++;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
mkdirSync(dirname(d.outFile), { recursive: true });
|
|
466
|
+
writeFileSync(d.outFile, next, 'utf8');
|
|
467
|
+
console.log(`wrote ${relative(ROOT, d.outFile)}`);
|
|
468
|
+
written++;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// A RUN THAT COMPARES OR WRITES NOTHING IS A FAILURE: every record skipped because
|
|
472
|
+
// its source is missing would otherwise report OK (check) or "Done" (generate).
|
|
473
|
+
if (compared === 0) {
|
|
474
|
+
console.error(
|
|
475
|
+
`[xema-service-bootstrap] ${outputs.length} descriptor(s) found, but every one was skipped because its package has no src/ in this checkout; ` +
|
|
476
|
+
`${CHECK ? 'nothing was compared' : 'nothing was written'}. Run it in a checkout that contains the sources.`,
|
|
477
|
+
);
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (CHECK) {
|
|
482
|
+
if (drift > 0) {
|
|
483
|
+
console.error(`\n${drift} generated file(s) out of date.`);
|
|
484
|
+
process.exit(1);
|
|
485
|
+
}
|
|
486
|
+
console.log(`OK — ${records.length} service bootstrap and ${workerRecords.length} worker descriptor file(s) up to date.`);
|
|
487
|
+
} else {
|
|
488
|
+
console.log(
|
|
489
|
+
`Done — ${written} written, ${skipped} skipped (source not in checkout), ${outputs.length} total.`,
|
|
490
|
+
);
|
|
491
|
+
}
|