@xemahq/api-client-generator 0.2.0 → 0.3.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.
Files changed (46) hide show
  1. package/dist/bin/xema-openapi.js +2 -2
  2. package/dist/bin/xema-openapi.js.map +1 -1
  3. package/dist/lib/client-package.d.ts.map +1 -1
  4. package/dist/lib/client-package.js +9 -4
  5. package/dist/lib/client-package.js.map +1 -1
  6. package/dist/lib/default-stubs.d.ts.map +1 -1
  7. package/dist/lib/default-stubs.js +35 -10
  8. package/dist/lib/default-stubs.js.map +1 -1
  9. package/dist/lib/extract-openapi.d.ts +2 -2
  10. package/dist/lib/extract-openapi.d.ts.map +1 -1
  11. package/dist/lib/extract-openapi.js +16 -7
  12. package/dist/lib/extract-openapi.js.map +1 -1
  13. package/dist/lib/generate-clients.d.ts +1 -1
  14. package/dist/lib/generate-clients.d.ts.map +1 -1
  15. package/dist/lib/generate-clients.js +1 -1
  16. package/dist/lib/generate-clients.js.map +1 -1
  17. package/dist/lib/overrides.d.ts.map +1 -1
  18. package/dist/lib/overrides.js +10 -2
  19. package/dist/lib/overrides.js.map +1 -1
  20. package/dist/lib/repo-post-process.d.ts.map +1 -1
  21. package/dist/lib/repo-post-process.js +2 -1
  22. package/dist/lib/repo-post-process.js.map +1 -1
  23. package/dist/lib/seed-openapi-env.d.ts.map +1 -1
  24. package/dist/lib/seed-openapi-env.js +0 -20
  25. package/dist/lib/seed-openapi-env.js.map +1 -1
  26. package/dist/lib/service-config.d.ts +1 -1
  27. package/dist/lib/service-config.d.ts.map +1 -1
  28. package/dist/lib/service-config.js +2 -2
  29. package/dist/lib/service-config.js.map +1 -1
  30. package/dist/lib/service-nest.d.ts.map +1 -1
  31. package/dist/lib/service-nest.js.map +1 -1
  32. package/package.json +5 -3
  33. package/src/bin/xema-client-generate.ts +11 -0
  34. package/src/bin/xema-openapi.ts +51 -0
  35. package/src/index.ts +9 -0
  36. package/src/lib/client-package.ts +215 -0
  37. package/src/lib/client-readme.ts +124 -0
  38. package/src/lib/custom-fetch-template.ts +200 -0
  39. package/src/lib/default-stubs.ts +422 -0
  40. package/src/lib/extract-openapi.ts +240 -0
  41. package/src/lib/generate-clients.ts +240 -0
  42. package/src/lib/overrides.ts +81 -0
  43. package/src/lib/repo-post-process.ts +106 -0
  44. package/src/lib/seed-openapi-env.ts +137 -0
  45. package/src/lib/service-config.ts +170 -0
  46. package/src/lib/service-nest.ts +58 -0
@@ -0,0 +1,240 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ import { ALL_SURFACES, type ApiSurface } from '@xemahq/xema-decorators';
7
+
8
+ import { ensureClientPackage } from './client-package';
9
+ import { ensureClientReadme } from './client-readme';
10
+ import { postProcessClient } from './repo-post-process';
11
+ import {
12
+ resolveServiceContext,
13
+ surfaceClientDir,
14
+ surfaceClientPackageName,
15
+ surfaceSpecPath,
16
+ type ResolvedServiceContext,
17
+ } from './service-config';
18
+
19
+ /**
20
+ * Native dynamic import of a module resolved from the SERVICE's node_modules
21
+ * (so we use the service's orval, not the generator's). `new Function` keeps
22
+ * the `import()` from being downleveled to `require()` by the CJS transpile,
23
+ * so an ESM-only orval still loads.
24
+ */
25
+ async function importFromService(
26
+ serviceDir: string,
27
+ request: string,
28
+ ): Promise<Record<string, unknown>> {
29
+ const resolved = require.resolve(request, { paths: [serviceDir] });
30
+ const dynamicImport = new Function('u', 'return import(u)') as (
31
+ u: string,
32
+ ) => Promise<Record<string, unknown>>;
33
+ return dynamicImport(pathToFileURL(resolved).href);
34
+ }
35
+
36
+ type OrvalGenerate = (config: unknown) => Promise<unknown>;
37
+
38
+ async function resolveOrvalGenerate(
39
+ serviceDir: string,
40
+ ): Promise<OrvalGenerate> {
41
+ const mod = await importFromService(serviceDir, 'orval');
42
+ const generate =
43
+ (mod.generate as OrvalGenerate | undefined) ??
44
+ (mod.default as { generate?: OrvalGenerate } | undefined)?.generate;
45
+ if (typeof generate !== 'function') {
46
+ throw new Error(
47
+ 'api-client-generator: could not resolve orval `generate` from the service.',
48
+ );
49
+ }
50
+ return generate;
51
+ }
52
+
53
+ /**
54
+ * In-memory orval config equivalent to the legacy per-service orval.config.ts.
55
+ * orval's programmatic `generate()` expects a SINGLE normalized project config
56
+ * (`{ input, output }`), not the named-project map the CLI reads from a file.
57
+ */
58
+ function orvalConfigFor(specPath: string, clientSrc: string): unknown {
59
+ return {
60
+ input: { target: specPath },
61
+ output: {
62
+ mode: 'tags-split',
63
+ target: `${clientSrc}/endpoints`,
64
+ schemas: `${clientSrc}/models`,
65
+ client: 'fetch',
66
+ override: {
67
+ fetch: { includeHttpResponseReturnType: false },
68
+ mutator: {
69
+ path: `${clientSrc}/custom-fetch.ts`,
70
+ name: 'customFetch',
71
+ },
72
+ },
73
+ },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Orphan backlog, keyed `<serviceId>:<surface>` → why it is not fixable from
79
+ * the generator. **Currently EMPTY, and it must stay that way.**
80
+ *
81
+ * It briefly carried five entries: services that capability auto-projection had
82
+ * reclassified fully-internal without retiring their PUBLIC-surface client
83
+ * package. All five are resolved — `audit-log-api` regained a genuine Public
84
+ * surface (two `platform_admin` browser reads); `biome-fetcher-api`,
85
+ * `policy-distributor-api`, `workspace-git-api` and
86
+ * `workspace-storage-pool-api` are service-to-service only and their public
87
+ * client packages were deleted.
88
+ *
89
+ * An entry here is a tracked backlog item, never a silent pass — the same
90
+ * contract as `tooling/boundaries/stack-confidentiality-baseline.json`. Adding
91
+ * one buys time for a condition the generator genuinely cannot resolve; it is
92
+ * NOT a place to park a newly-orphaned client. Resolve the orphan instead:
93
+ * either the service emits the surface (fix the classification at the route,
94
+ * and give it a real guard) or it does not (delete the package and migrate
95
+ * consumers to the internal client).
96
+ */
97
+ const KNOWN_ORPHANED_SURFACE_CLIENTS: Readonly<Record<string, string>> =
98
+ Object.freeze({});
99
+
100
+ /**
101
+ * Fail fast when a surface lost its spec (it has no endpoints any more) while a
102
+ * previously generated client package for that surface is still on disk with
103
+ * generated endpoints in it.
104
+ *
105
+ * Skipping such a surface silently — which is what the plain
106
+ * `if (!existsSync(specPath)) continue` did — leaves a package that still
107
+ * builds, still publishes and still exports operations the service no longer
108
+ * classifies into that surface. That is dead code the generator can neither
109
+ * refresh nor prune, so it must be an error the operator resolves (delete or
110
+ * deprecate the package), not a warning.
111
+ *
112
+ * Guarded by `surfacesWithSpec.length > 0`: when the service has NO spec at all
113
+ * the caller simply has not run `xema-openapi` yet, which is a different (and
114
+ * not-an-orphan) situation.
115
+ *
116
+ * {@link KNOWN_ORPHANED_SURFACE_CLIENTS} is the escape hatch for an orphan the
117
+ * generator genuinely cannot resolve. It is EMPTY — the backlog this guard
118
+ * originally uncovered has been worked off — so today every orphan is a hard
119
+ * error. See that constant before adding to it.
120
+ */
121
+ function assertNoOrphanedSurfaceClients(
122
+ ctx: ResolvedServiceContext,
123
+ surfacesWithSpec: readonly ApiSurface[],
124
+ ): void {
125
+ if (surfacesWithSpec.length === 0) {
126
+ return;
127
+ }
128
+ const orphans = ALL_SURFACES.filter(
129
+ (surface) =>
130
+ !surfacesWithSpec.includes(surface) &&
131
+ existsSync(join(surfaceClientDir(ctx, surface), 'src', 'endpoints')),
132
+ );
133
+ if (orphans.length === 0) {
134
+ return;
135
+ }
136
+
137
+ const baselined = orphans.filter((surface) =>
138
+ Object.hasOwn(
139
+ KNOWN_ORPHANED_SURFACE_CLIENTS,
140
+ `${ctx.api.serviceId}:${surface}`,
141
+ ),
142
+ );
143
+ const blocking = orphans.filter((surface) => !baselined.includes(surface));
144
+
145
+ for (const surface of baselined) {
146
+ const key = `${ctx.api.serviceId}:${surface}`;
147
+
148
+ console.warn(
149
+ `[xema-client-generate] KNOWN ORPHAN ${key} — ${surfaceClientDir(ctx, surface)} ` +
150
+ `is stale and will not be regenerated. ${KNOWN_ORPHANED_SURFACE_CLIENTS[key]}`,
151
+ );
152
+ }
153
+
154
+ if (blocking.length === 0) {
155
+ return;
156
+ }
157
+ throw new Error(
158
+ `api-client-generator: ${ctx.api.serviceId} emits no spec for surface(s) ` +
159
+ `${blocking.join(', ')}, but a generated client package still exists at ` +
160
+ `${blocking.map((s) => surfaceClientDir(ctx, s)).join(', ')}. That client ` +
161
+ `is orphaned — it would keep exporting operations the surface no longer ` +
162
+ `has. Delete the package (and its workspace consumers) or restore the ` +
163
+ `surface classification.`,
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Generate (and compile) one ORVAL client package per surface that has an
169
+ * emitted spec. Surfaces without a spec are skipped — no empty packages. All
170
+ * generated clients share the service version (stamped by
171
+ * {@link ensureClientPackage}).
172
+ *
173
+ * @returns the surfaces that were generated.
174
+ */
175
+ export async function generateClientsForService(
176
+ serviceDir: string = process.cwd(),
177
+ ): Promise<ApiSurface[]> {
178
+ const ctx: ResolvedServiceContext = resolveServiceContext(serviceDir);
179
+ const generate = await resolveOrvalGenerate(ctx.serviceDir);
180
+
181
+ const surfacesWithSpec = ALL_SURFACES.filter((s) =>
182
+ existsSync(surfaceSpecPath(ctx, s)),
183
+ );
184
+ assertNoOrphanedSurfaceClients(ctx, surfacesWithSpec);
185
+
186
+ const generated: ApiSurface[] = [];
187
+ for (const surface of surfacesWithSpec) {
188
+ const specPath = surfaceSpecPath(ctx, surface);
189
+ const { clientDir, clientSrc } = ensureClientPackage(ctx, surface);
190
+ ensureClientReadme({
191
+ clientDir,
192
+ packageName: surfaceClientPackageName(ctx.api.serviceId, surface),
193
+ serviceTitle: ctx.api.title,
194
+ surface,
195
+ repoRoot: ctx.repoRoot,
196
+ });
197
+ // Clean prior generated output so endpoints/models removed from this
198
+ // surface's spec (e.g. routes reclassified to another surface) don't linger
199
+ // as stale files — orval does not prune removed operations.
200
+ rmSync(join(clientSrc, 'endpoints'), { recursive: true, force: true });
201
+ rmSync(join(clientSrc, 'models'), { recursive: true, force: true });
202
+ await generate(orvalConfigFor(specPath, clientSrc));
203
+ postProcessClient(ctx.repoRoot, ctx.serviceDir, clientSrc);
204
+ // `tsc` is additive: it emits for the sources it sees and NEVER deletes an
205
+ // output whose source disappeared. Without this, every operation ever
206
+ // generated into this surface survives in `dist/` forever — so a route
207
+ // reclassified to another surface keeps a stale `.js` + `.d.ts` in the old
208
+ // client. Consumers then type-check GREEN against a symbol the surface no
209
+ // longer has, the published tarball (`files: ["dist"]`) ships endpoints the
210
+ // spec does not contain, and the breakage only appears on a clean
211
+ // checkout/CI where `dist` starts empty. Pruning `dist` alongside
212
+ // `src/endpoints` + `src/models` keeps the compiled output a faithful image
213
+ // of the spec.
214
+ rmSync(join(clientDir, 'dist'), { recursive: true, force: true });
215
+ // Compile to dist using the client package's own tsconfig.
216
+ execFileSync('pnpm', ['exec', 'tsc', '-p', 'tsconfig.json'], {
217
+ cwd: clientDir,
218
+ stdio: 'inherit',
219
+ });
220
+ generated.push(surface);
221
+ }
222
+
223
+ // eslint-disable-next-line no-console
224
+ console.log(
225
+ `[xema-client-generate] ${ctx.api.serviceId}: generated ${
226
+ generated.length
227
+ } client(s) → ${generated.join(', ') || '(none)'}`,
228
+ );
229
+ return generated;
230
+ }
231
+
232
+ /** CLI entry: generate clients from cwd; exit non-zero on failure. */
233
+ export function runClientGenerateCli(): void {
234
+ generateClientsForService()
235
+ .then(() => process.exit(0))
236
+ .catch((err: unknown) => {
237
+ console.error('[xema-client-generate] generation failed:', err);
238
+ process.exit(1);
239
+ });
240
+ }
@@ -0,0 +1,81 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import type { TestingModuleBuilder } from '@nestjs/testing';
5
+
6
+ /**
7
+ * Layer 3 of the DI-stub strategy: the optional per-service escape hatch. Most
8
+ * services need none of this — the universal env seed (Layer 1) + convention
9
+ * stubs (Layer 2) cover them. A service with a bespoke provider that boots at
10
+ * import/compile time ships an `openapi.overrides.ts` at its root:
11
+ *
12
+ * ```ts
13
+ * // <service>/openapi.overrides.ts
14
+ * import type { OpenApiOverrides } from '@xemahq/api-client-generator';
15
+ * export default {
16
+ * env: { SOME_CUSTOM_VAR: 'dummy' },
17
+ * applyOverrides: (builder) => builder.overrideProvider(WeirdSvc).useValue({ … }),
18
+ * } satisfies OpenApiOverrides;
19
+ * ```
20
+ */
21
+ export interface OpenApiOverrides {
22
+ /** Extra dummy env vars seeded before `AppModule` is imported. */
23
+ readonly env?: Record<string, string>;
24
+ /**
25
+ * Hook applied to the `Test.createTestingModule(...)` builder AFTER the
26
+ * default convention stubs, so a service can override a default or stub an
27
+ * extra provider. Return the builder (or void — it is mutated in place).
28
+ */
29
+ readonly applyOverrides?: (
30
+ builder: TestingModuleBuilder,
31
+ ) => TestingModuleBuilder | void;
32
+ }
33
+
34
+ const OVERRIDES_BASENAME = 'openapi.overrides';
35
+
36
+ /**
37
+ * Load `<serviceDir>/openapi.overrides.ts` (or `.js`) if present. Returns
38
+ * `undefined` when absent — the common case. Throws if the file exists but
39
+ * exports a malformed shape (fail-fast; no silent ignore).
40
+ */
41
+ export function loadServiceOverrides(
42
+ serviceDir: string,
43
+ ): OpenApiOverrides | undefined {
44
+ const candidate = [`${OVERRIDES_BASENAME}.ts`, `${OVERRIDES_BASENAME}.js`]
45
+ .map((f) => join(serviceDir, f))
46
+ .find((p) => existsSync(p));
47
+ if (!candidate) {
48
+ return undefined;
49
+ }
50
+
51
+ const loaded: unknown = require(candidate);
52
+ if (loaded === null || typeof loaded !== 'object') {
53
+ throw new Error(
54
+ `api-client-generator: ${candidate} must export an overrides object.`,
55
+ );
56
+ }
57
+ const selected =
58
+ 'default' in loaded &&
59
+ loaded.default !== null &&
60
+ typeof loaded.default === 'object'
61
+ ? loaded.default
62
+ : loaded;
63
+ const overrides = Object.fromEntries(Object.entries(selected));
64
+ if (
65
+ overrides.env !== undefined &&
66
+ (typeof overrides.env !== 'object' || overrides.env === null)
67
+ ) {
68
+ throw new Error(
69
+ `api-client-generator: ${candidate} \`env\` must be a Record<string,string>.`,
70
+ );
71
+ }
72
+ if (
73
+ overrides.applyOverrides !== undefined &&
74
+ typeof overrides.applyOverrides !== 'function'
75
+ ) {
76
+ throw new Error(
77
+ `api-client-generator: ${candidate} \`applyOverrides\` must be a function.`,
78
+ );
79
+ }
80
+ return overrides;
81
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Bridge to the repository's existing `tooling/codegen` ORVAL post-processors.
6
+ * The generator reuses them (rather than re-implementing) so the generated
7
+ * client is byte-identical to what `scripts/build-client-index.js` produced
8
+ * today — same barrel, same fixers, same arguments.
9
+ */
10
+
11
+ interface PostProcessors {
12
+ regenerateModelsBarrel: (clientSrc: string) => number;
13
+ fixOrvalResponseTypes: (serviceRoot: string) => number;
14
+ fixOrvalArrayParams: (clientSrc: string) => number;
15
+ }
16
+
17
+ function requireRepoTool(repoRoot: string, file: string): unknown {
18
+ const path = join(repoRoot, 'tooling', 'codegen', file);
19
+ if (!existsSync(path)) {
20
+ throw new Error(
21
+ `api-client-generator: required post-processor ${path} not found. The ` +
22
+ `repo must ship tooling/codegen/${file}.`,
23
+ );
24
+ }
25
+
26
+ return require(path);
27
+ }
28
+
29
+ export function loadRepoPostProcessors(repoRoot: string): PostProcessors {
30
+ const { regenerateModelsBarrel } = requireRepoTool(
31
+ repoRoot,
32
+ 'regenerate-models-barrel.js',
33
+ ) as { regenerateModelsBarrel: (c: string) => number };
34
+ const { fixOrvalResponseTypes } = requireRepoTool(
35
+ repoRoot,
36
+ 'fix-orval-response-types.js',
37
+ ) as { fixOrvalResponseTypes: (r: string) => number };
38
+ const { fixOrvalArrayParams } = requireRepoTool(
39
+ repoRoot,
40
+ 'fix-orval-array-params.js',
41
+ ) as { fixOrvalArrayParams: (c: string) => number };
42
+ return { regenerateModelsBarrel, fixOrvalResponseTypes, fixOrvalArrayParams };
43
+ }
44
+
45
+ /**
46
+ * Rebuild `<clientSrc>/index.ts` — the barrel re-exporting custom-fetch +
47
+ * models + every tags-split endpoint file. Identical to the loop in the legacy
48
+ * per-service `build-client-index.js`.
49
+ */
50
+ function rebuildIndexBarrel(clientSrc: string): number {
51
+ const lines = [
52
+ '// Auto-generated by @xemahq/api-client-generator — do not edit manually.',
53
+ "export { configureClient, getClientConfig, ClientError, customFetch, type ClientConfig } from './custom-fetch';",
54
+ ];
55
+
56
+ const modelsIndex = join(clientSrc, 'models', 'index.ts');
57
+ if (existsSync(modelsIndex)) {
58
+ const content = readFileSync(modelsIndex, 'utf8');
59
+ if (/^export /m.test(content)) {
60
+ lines.push("export * from './models';");
61
+ }
62
+ }
63
+
64
+ const endpointsDir = join(clientSrc, 'endpoints');
65
+ if (existsSync(endpointsDir)) {
66
+ for (const entry of readdirSync(endpointsDir, { withFileTypes: true })) {
67
+ if (!entry.isDirectory()) {
68
+ continue;
69
+ }
70
+ const files = readdirSync(join(endpointsDir, entry.name));
71
+ const tsFile = files.find(
72
+ (f) => f.endsWith('.ts') && !f.endsWith('.d.ts'),
73
+ );
74
+ if (tsFile) {
75
+ lines.push(
76
+ `export * from './endpoints/${entry.name}/${tsFile.replace('.ts', '')}';`,
77
+ );
78
+ }
79
+ }
80
+ }
81
+
82
+ lines.push('');
83
+ writeFileSync(join(clientSrc, 'index.ts'), lines.join('\n'));
84
+ return lines.length - 2;
85
+ }
86
+
87
+ /**
88
+ * Run the full post-ORVAL pipeline for one generated client, mirroring the
89
+ * legacy `build-client-index.js` ordering exactly: models barrel → index
90
+ * barrel → response-type fix → array-param fix.
91
+ *
92
+ * `serviceRoot` is passed to `fixOrvalResponseTypes` unchanged from the legacy
93
+ * call site (it resolves its own models path internally), so the output is
94
+ * identical to today's pipeline.
95
+ */
96
+ export function postProcessClient(
97
+ repoRoot: string,
98
+ serviceRoot: string,
99
+ clientSrc: string,
100
+ ): void {
101
+ const tools = loadRepoPostProcessors(repoRoot);
102
+ tools.regenerateModelsBarrel(clientSrc);
103
+ rebuildIndexBarrel(clientSrc);
104
+ tools.fixOrvalResponseTypes(serviceRoot);
105
+ tools.fixOrvalArrayParams(clientSrc);
106
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Layer 1 of the DI-stub strategy: seed deterministic dummy env vars BEFORE
3
+ * the service's `AppModule` is imported. NestJS modules read `process.env` at
4
+ * import time, so this MUST run first.
5
+ *
6
+ * Every value is a throwaway placeholder. `app.init()` is deliberately skipped
7
+ * during extraction, so nothing here is ever dialed — the values only need to
8
+ * exist so import-time `getOrThrow`/`new URL(...)` calls don't crash.
9
+ *
10
+ * This is the union of the seeds the 41 hand-written `extract-openapi.ts`
11
+ * scripts set. A service that reads an env var not covered here adds it via the
12
+ * optional per-service `openapi.overrides.ts` `env` field (Layer 3) — the
13
+ * generator never silently invents a value it wasn't told about beyond this
14
+ * shared baseline.
15
+ */
16
+
17
+ const DUMMY_ENCRYPTION_KEY_HEX =
18
+ '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
19
+ const DUMMY_BASE64_KEY = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
20
+
21
+ /** Set a default only if the var is unset (`??=` semantics). */
22
+ function seed(vars: Record<string, string>): void {
23
+ for (const [key, value] of Object.entries(vars)) {
24
+ if (process.env[key] === undefined) {
25
+ process.env[key] = value;
26
+ }
27
+ }
28
+ }
29
+
30
+ /** The throwaway database every extraction points at. Never dialed. */
31
+ const SCRATCH_DB_NAME = 'dummy_openapi';
32
+
33
+ /**
34
+ * Per-service scratch Postgres schema.
35
+ *
36
+ * Extraction is run in PARALLEL by turbo across ~40 services. If they all point
37
+ * at one database AND one `public` schema they share a single
38
+ * `_prisma_migrations` table, so any code path that migrates at DI-construction
39
+ * time interleaves foreign migration histories and wedges its neighbours. Giving
40
+ * each service its own schema makes the scratch surfaces disjoint by
41
+ * construction. Postgres identifiers are lower-cased and capped at 63 bytes.
42
+ */
43
+ export function openApiScratchSchema(serviceId: string): string {
44
+ return `extract_${serviceId.replace(/[^a-zA-Z0-9]+/g, '_')}`
45
+ .toLowerCase()
46
+ .slice(0, 63);
47
+ }
48
+
49
+ /**
50
+ * @param serviceId the `xema.api.serviceId` of the service being extracted —
51
+ * used to derive a scratch schema unique to this extraction process.
52
+ */
53
+ export function seedOpenApiEnv(serviceId: string): void {
54
+ const schema = openApiScratchSchema(serviceId);
55
+ seed({
56
+ NODE_ENV: 'development',
57
+
58
+ // Extraction-mode flag. `XemaServiceModule` eagerly resolves peer URLs from
59
+ // the KernelState/etcd registry at DI-construction time; with no live cluster
60
+ // that throws and the module can't even be constructed for scanning. Setting
61
+ // this makes the SDK's construction-time resolvers (`resolveHttpUrlEagerly`)
62
+ // short-circuit to a non-routable `.invalid` stub. The var NAME is the
63
+ // service-registry-nest `OPENAPI_EXTRACTION_ENV_VAR` contract; the SDK is a
64
+ // higher layer, so we set the env by name rather than import it here.
65
+ XEMA_OPENAPI_EXTRACTION: '1',
66
+
67
+ // Database (Prisma + raw pools). The scratch schema is per-service — see
68
+ // `openApiScratchSchema`. `DB_SCHEMA` is NOT redundant with the URL's
69
+ // `?schema=`: `createBiomePrismaAdapterFromEnv` passes it as the adapter's
70
+ // second-arg `{ schema }`, while `?schema=` is what a Prisma CLI/migrate
71
+ // invocation reads.
72
+ DATABASE_URL: `postgresql://dummy:dummy@localhost:5432/${SCRATCH_DB_NAME}?schema=${schema}`,
73
+ DB_HOST: 'localhost',
74
+ DB_PORT: '5432',
75
+ DB_NAME: SCRATCH_DB_NAME,
76
+ DB_SCHEMA: schema,
77
+ DB_USER: 'dummy',
78
+ DB_PASSWORD: 'dummy',
79
+ REDIS_URL: 'redis://localhost:6379',
80
+ OPENSEARCH_URL: 'http://localhost:9200',
81
+
82
+ // Identity / auth.
83
+ IDENTITY_API_URL: 'http://localhost:3053',
84
+ IDENTITY_API_INTERNAL_TOKEN: 'dummy-token',
85
+ JWT_PUBLIC_KEY_URL:
86
+ 'http://localhost:8180/realms/dummy/protocol/openid-connect/certs',
87
+ JWT_ISSUER: 'http://localhost:8180/realms/dummy',
88
+ GATEWAY_JWT_SECRET: 'dummy-gateway-jwt-secret-for-openapi-extraction',
89
+ SHARE_TOKEN_SECRET: 'dummy-share-token-secret',
90
+ OPENCODE_WORKER_PASSWORD: 'dummy-worker-password',
91
+
92
+ // Service registry (etcd-only autodiscovery). `createKernelState` fail-fasts
93
+ // without this; the etcd3 client is lazy (no dial until first call) and
94
+ // extraction skips `app.init()`, so the dummy endpoint is never contacted.
95
+ XEMA_ETCD_ENDPOINTS: 'localhost:2379',
96
+
97
+ // Event hub. `WEBHOOK_URL` is the delivery callback every service that
98
+ // SUBSCRIBES declares to `EventHubModule.forRootAsync` — it is boot-REQUIRED
99
+ // (`getOrThrow`) in ~20 services, so it belongs in the shared baseline
100
+ // rather than being re-declared in ~20 `openapi.overrides.ts` files.
101
+ EVENT_HUB_URL: 'http://localhost:3128',
102
+ WEBHOOK_URL: 'http://localhost:3000/webhooks/events',
103
+
104
+ // Cross-service HTTP URLs (resolved via the registry in prod; dummy here).
105
+ BACKLOG_API_URL: 'http://localhost:3100',
106
+ MEMORY_API_URL: 'http://localhost:3060',
107
+ LLM_REGISTRY_API_URL: 'http://localhost:3112',
108
+ LLM_GATEWAY_API_URL: 'http://localhost:3110',
109
+ ARTIFACT_STORE_API_URL: 'http://localhost:3130',
110
+ KNOWLEDGE_BASE_API_URL: 'http://localhost:3200',
111
+ PROJECT_REGISTRY_API_URL: 'http://localhost:3300',
112
+ CONNECTOR_GATEWAY_API_URL: 'http://localhost:3900',
113
+ DESIGN_SYSTEM_BUILDER_API_URL: 'http://localhost:3210',
114
+ SEARCH_API_URL: 'http://localhost:3220',
115
+ BILLING_API_URL: 'http://localhost:3140',
116
+ CAPABILITY_REGISTRY_API_URL: 'http://localhost:3700',
117
+
118
+ // SCM / git.
119
+ SCM_WEBHOOK_SECRET: 'dummy-webhook-secret',
120
+ SCM_WEBHOOK_BASE_URL: 'http://localhost:3500/webhooks',
121
+ GITEA_URL: 'http://localhost:3000',
122
+ GITEA_ADMIN_USER: 'gitea_admin',
123
+ GITEA_ADMIN_PASS: 'gitea_admin_pass',
124
+
125
+ // Encryption keys (hex 32-byte + base64 variants used across services).
126
+ MASTER_ENCRYPTION_KEY: DUMMY_ENCRYPTION_KEY_HEX,
127
+ ENCRYPTION_MASTER_KEY: DUMMY_BASE64_KEY,
128
+ LLM_REGISTRY_ENCRYPTION_KEY:
129
+ 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
130
+ VARIABLE_ENCRYPTION_KEY:
131
+ 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
132
+
133
+ // Platform observability endpoints.
134
+ PLATFORM_METRICS_ENDPOINT: 'http://localhost:9090/metrics',
135
+ PLATFORM_AUDIT_ENDPOINT: 'http://localhost:3601/audit',
136
+ });
137
+ }