@octalmesh/seagull 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1567 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createRequire } from "node:module";
4
+ import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
5
+ import path, { dirname, extname, join, normalize } from "node:path";
6
+ import { parse, stringify } from "yaml";
7
+ import { z } from "zod";
8
+ import { copyFile, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { spawn, spawnSync } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { tmpdir } from "node:os";
12
+ import { createServer } from "node:http";
13
+ //#region src/config/schema.ts
14
+ const varsTreeSchema = z.lazy(() => z.record(z.string(), z.union([
15
+ z.string(),
16
+ z.number(),
17
+ z.boolean(),
18
+ varsTreeSchema
19
+ ])));
20
+ const githubSchema = z.object({
21
+ owner: z.string().min(1),
22
+ repo: z.string().min(1)
23
+ });
24
+ const pathsSchema = z.object({
25
+ dist: z.string().min(1).default("dist"),
26
+ specs: z.string().min(1).optional(),
27
+ docs: z.string().min(1).optional(),
28
+ sdk: z.string().min(1).optional()
29
+ });
30
+ const docsSchema = z.object({
31
+ server: z.object({
32
+ host: z.string().min(1),
33
+ port: z.number().int().positive()
34
+ }),
35
+ metadata: z.object({
36
+ title: z.string().min(1),
37
+ description: z.string().min(1),
38
+ favicon: z.string().min(1),
39
+ baseServerUrl: z.string().min(1)
40
+ })
41
+ });
42
+ const sdkToolSchema = z.enum(["openapi-generator", "openapi-typescript"]);
43
+ const sdkLangSchema = z.enum([
44
+ "typescript",
45
+ "go",
46
+ "java"
47
+ ]);
48
+ const sdkKindSchema = z.enum(["client", "server"]);
49
+ /**
50
+ * `-g`/`--additional-properties` values: openapi-generator accepts strings,
51
+ * numbers and booleans, all rendered as `key=value` on the CLI.
52
+ */
53
+ const additionalPropertiesSchema = z.record(z.string(), z.union([
54
+ z.string(),
55
+ z.number(),
56
+ z.boolean()
57
+ ])).default({});
58
+ const mavenCoordsSchema = z.object({
59
+ groupId: z.string().min(1),
60
+ artifactId: z.string().min(1)
61
+ });
62
+ /**
63
+ * The shape of a generator "recipe", without the cross-field checks below -
64
+ * kept separate so {@link artifactRefSchema}'s `overrides:` can `.partial()`
65
+ * it (zod rejects `.partial()` on a schema with `.check()` refinements
66
+ * attached).
67
+ */
68
+ const generatorDefBaseSchema = z.object({
69
+ tool: sdkToolSchema,
70
+ lang: sdkLangSchema,
71
+ kind: sdkKindSchema,
72
+ /**
73
+ * `openapi-generator -g <generator>` value. Required when `tool` is
74
+ * `openapi-generator`.
75
+ */
76
+ generator: z.string().min(1).optional(),
77
+ /** npm package name template, e.g. `"@{vars.org}/{service}-client"`. */
78
+ package: z.string().min(1).optional(),
79
+ goModule: z.string().min(1).optional(),
80
+ goPackageName: z.string().min(1).optional(),
81
+ maven: mavenCoordsSchema.optional(),
82
+ additionalProperties: additionalPropertiesSchema,
83
+ /**
84
+ * Optional path (relative to the config file's directory) to a custom README
85
+ * template for this artifact - supports the same `{...}` placeholders as
86
+ * naming templates, plus `{version}`, `{title}`, and `{artifact.*}`. If
87
+ * omitted, a built-in default template for the artifact's language/kind is
88
+ * used instead.
89
+ */
90
+ readme: z.string().min(1).optional()
91
+ });
92
+ /**
93
+ * A single generator "recipe": which tool to invoke and how. Referenced by id
94
+ * from one or more contracts' `artifacts:` list.
95
+ */
96
+ const generatorDefSchema = generatorDefBaseSchema.check((ctx) => {
97
+ const value = ctx.value;
98
+ if (value.tool === "openapi-generator" && !value.generator) ctx.issues.push({
99
+ code: "custom",
100
+ message: "\"generator\" is required when tool is \"openapi-generator\"",
101
+ input: value
102
+ });
103
+ if (value.lang === "go" && !value.goModule) ctx.issues.push({
104
+ code: "custom",
105
+ message: "\"goModule\" is required for lang \"go\"",
106
+ input: value
107
+ });
108
+ if (value.lang === "java" && !value.maven) ctx.issues.push({
109
+ code: "custom",
110
+ message: "\"maven\" ({ groupId, artifactId }) is required for lang \"java\"",
111
+ input: value
112
+ });
113
+ if (value.lang === "typescript" && !value.package) ctx.issues.push({
114
+ code: "custom",
115
+ message: "\"package\" is required for lang \"typescript\"",
116
+ input: value
117
+ });
118
+ });
119
+ const generatorsSchema = z.record(z.string(), generatorDefSchema);
120
+ /**
121
+ * A contract's reference to a generator by id. The plain-string form just runs
122
+ * that generator as-is; the object form lets one contract tweak a shared
123
+ * generator (extra/overridden `additionalProperties`, a different
124
+ * `maven`/`package`/`readme`/... value) without duplicating the whole recipe
125
+ * under a new id, and `as` renames the artifact's own id (output
126
+ * folder / branch / tag segment) if a contract needs two variants of the same
127
+ * base generator.
128
+ */
129
+ const artifactRefSchema = z.union([z.string().min(1), z.object({
130
+ generator: z.string().min(1),
131
+ as: z.string().min(1).optional(),
132
+ overrides: generatorDefBaseSchema.partial().optional()
133
+ })]);
134
+ const contractSchema = z.object({
135
+ name: z.string().min(1),
136
+ title: z.string().min(1),
137
+ entrypoint: z.string().min(1),
138
+ artifacts: z.array(artifactRefSchema).min(1)
139
+ });
140
+ const rootConfigSchema = z.object({
141
+ github: githubSchema,
142
+ vars: varsTreeSchema.default({}),
143
+ paths: pathsSchema.default({ dist: "dist" }),
144
+ docs: docsSchema,
145
+ generators: generatorsSchema,
146
+ contracts: z.array(contractSchema).min(1)
147
+ });
148
+ //#endregion
149
+ //#region src/config/template.ts
150
+ const PLACEHOLDER = /\{([a-zA-Z0-9_.]+)}/g;
151
+ /**
152
+ * Replaces every `{dot.path}` placeholder in `template` with its value from
153
+ * `context`, throwing a descriptive error if a placeholder has no match -
154
+ * a typo'd `{vars.org}` should fail loudly at config-load time, not silently
155
+ * produce a package named literally `@{vars.org}/...`.
156
+ *
157
+ * @param template - The template string, e.g. `"@{vars.org}/{service}-client"`.
158
+ * @param context - The flattened context to resolve placeholders against.
159
+ * @returns The interpolated string.
160
+ */
161
+ function interpolate(template, context) {
162
+ return template.replace(PLACEHOLDER, (_match, key) => {
163
+ const value = context[key];
164
+ if (value === void 0) {
165
+ const available = Object.keys(context).sort().join(", ");
166
+ throw new Error(`Unknown template placeholder "{${key}}" in "${template}" (available: ${available})`);
167
+ }
168
+ return value;
169
+ });
170
+ }
171
+ /**
172
+ * Recursively interpolates every string value in `value` (walking through plain
173
+ * objects), leaving non-string leaves untouched. Used to resolve
174
+ * `additionalProperties` maps, which may mix templated strings with plain
175
+ * booleans/numbers.
176
+ *
177
+ * @param value - The value (string, object, or primitive) to interpolate.
178
+ * @param context - The flattened context to resolve placeholders against.
179
+ * @returns A deep copy of `value` with every string interpolated.
180
+ */
181
+ function interpolateDeep(value, context) {
182
+ if (typeof value === "string") return interpolate(value, context);
183
+ if (Array.isArray(value)) return value.map((item) => interpolateDeep(item, context));
184
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, interpolateDeep(child, context)]));
185
+ return value;
186
+ }
187
+ /**
188
+ * Flattens a nested scope object (e.g. `{ service: "auth", vars: { org: "x" } }`)
189
+ * into dot-path lookup keys (`{ service: "auth", "vars.org": "x" }`) for use
190
+ * with {@link interpolate}.
191
+ *
192
+ * @param scope - The nested scope to flatten.
193
+ * @returns A flat dot-path -> string map.
194
+ */
195
+ function buildTemplateContext(scope) {
196
+ const out = {};
197
+ flatten(scope, "", out);
198
+ return out;
199
+ }
200
+ function flatten(value, prefix, out) {
201
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
202
+ for (const [key, child] of Object.entries(value)) flatten(child, prefix ? `${prefix}.${key}` : key, out);
203
+ return;
204
+ }
205
+ if (value === void 0) return;
206
+ out[prefix] = String(value);
207
+ }
208
+ //#endregion
209
+ //#region src/config/loader.ts
210
+ /**
211
+ * Reads and validates a CLI config file, throwing a readable, multi-issue error
212
+ * message if it doesn't match the schema.
213
+ *
214
+ * @param configPath - Absolute path to the YAML config file.
215
+ * @returns The validated (but not yet resolved) raw config.
216
+ */
217
+ function readRawConfig(configPath) {
218
+ const raw = parse(readFileSync(configPath, "utf8"));
219
+ const result = rootConfigSchema.safeParse(raw);
220
+ if (!result.success) {
221
+ const issues = z.prettifyError(result.error);
222
+ throw new Error(`Invalid ${path.basename(configPath)}:\n${issues}`);
223
+ }
224
+ return result.data;
225
+ }
226
+ /**
227
+ * Deep-merges a generator override (from an artifact's `overrides:` block)
228
+ * onto its base generator def. `additionalProperties` and `maven` are merged
229
+ * key-by-key; every other field is a plain override.
230
+ *
231
+ * @param base - The base generator def, looked up by id from `generators:`.
232
+ * @param overrides - The partial override from the artifact reference, if any.
233
+ * @returns The merged generator def.
234
+ */
235
+ function mergeGeneratorOverride(base, overrides) {
236
+ if (!overrides) return base;
237
+ return {
238
+ ...base,
239
+ ...overrides,
240
+ maven: overrides.maven ? {
241
+ ...base.maven,
242
+ ...overrides.maven
243
+ } : base.maven,
244
+ additionalProperties: {
245
+ ...base.additionalProperties,
246
+ ...overrides.additionalProperties
247
+ }
248
+ };
249
+ }
250
+ /**
251
+ * Resolves a single artifact reference (string id, or `{ generator, overrides,
252
+ * as }`) into its `{ id, def }` pair, looking up the base generator by id and
253
+ * applying any overrides.
254
+ *
255
+ * @param ref - The artifact reference from a contract's `artifacts:`
256
+ * list.
257
+ * @param generators - The full `generators:` map from the raw config.
258
+ * @param contractName - The owning contract's name.
259
+ * @returns The artifact's resolved id and generator def (templates not yet
260
+ * interpolated).
261
+ */
262
+ function resolveArtifactRef(ref, generators, contractName) {
263
+ const generatorId = typeof ref === "string" ? ref : ref.generator;
264
+ const base = generators[generatorId];
265
+ if (!base) {
266
+ const available = Object.keys(generators).sort().join(", ");
267
+ throw new Error(`Contract "${contractName}" references unknown generator "${generatorId}" (available: ${available})`);
268
+ }
269
+ return {
270
+ id: typeof ref === "string" ? ref : ref.as ?? ref.generator,
271
+ def: mergeGeneratorOverride(base, typeof ref === "string" ? void 0 : ref.overrides)
272
+ };
273
+ }
274
+ /**
275
+ * Interpolates templates and resolves absolute paths for a single artifact.
276
+ *
277
+ * @param id - The artifact's resolved id (output folder / branch /
278
+ * tag segment).
279
+ * @param def - The (override-merged, not-yet-interpolated) generator
280
+ * def.
281
+ * @param rootDir - Absolute repo root, `readme` template paths are
282
+ * resolved relative to this.
283
+ * @param sdkDir - Absolute path to the SDK output root (`<dist>/sdk`).
284
+ * @param contractName - The owning contract's name.
285
+ * @param context - The flattened template context for this contract.
286
+ * @returns The fully resolved artifact.
287
+ */
288
+ function resolveArtifact(id, def, rootDir, sdkDir, contractName, context) {
289
+ const resolved = interpolateDeep(def, context);
290
+ return {
291
+ id,
292
+ tool: resolved.tool,
293
+ lang: resolved.lang,
294
+ kind: resolved.kind,
295
+ generator: resolved.generator,
296
+ outputDir: path.join(sdkDir, contractName, id),
297
+ branch: `sdk/svc-${contractName}/${id}`,
298
+ tagPrefix: `svc-${contractName}-${id}`,
299
+ additionalProperties: resolved.additionalProperties,
300
+ package: resolved.package,
301
+ goModule: resolved.goModule,
302
+ goPackageName: resolved.goPackageName,
303
+ maven: resolved.maven,
304
+ readmeTemplate: resolved.readme ? path.resolve(rootDir, resolved.readme) : void 0
305
+ };
306
+ }
307
+ /**
308
+ * Resolves a single contract: its entrypoint path and every artifact in its
309
+ * `artifacts:` list.
310
+ *
311
+ * @param input - The raw contract config.
312
+ * @param rootDir - Absolute repo root, entrypoints/`readme` paths are
313
+ * resolved relative to this.
314
+ * @param sdkDir - Absolute path to the SDK output root (`<dist>/sdk`).
315
+ * @param generators - The full `generators:` map from the raw config.
316
+ * @param githubCtx - `{ owner, repo }`, exposed to templates as
317
+ * `{github.owner}`/`{github.repo}`.
318
+ * @param vars - The `vars:` tree from the raw config, exposed as
319
+ * `{vars.*}`.
320
+ * @returns The fully resolved contract.
321
+ */
322
+ function resolveContract(input, rootDir, sdkDir, generators, githubCtx, vars) {
323
+ const context = buildTemplateContext({
324
+ service: input.name,
325
+ github: githubCtx,
326
+ vars
327
+ });
328
+ const artifacts = input.artifacts.map((ref) => {
329
+ const { id, def } = resolveArtifactRef(ref, generators, input.name);
330
+ return resolveArtifact(id, def, rootDir, sdkDir, input.name, context);
331
+ });
332
+ const entrypoint = path.join(rootDir, input.entrypoint);
333
+ return {
334
+ name: input.name,
335
+ title: input.title,
336
+ entrypoint,
337
+ entrypointRelative: path.relative(rootDir, entrypoint),
338
+ artifacts
339
+ };
340
+ }
341
+ /**
342
+ * Loads, validates, and fully resolves a CLI config file - the single entry
343
+ * point every command uses to get its configuration.
344
+ *
345
+ * Unlike a build tool bundled into the consumer's own repo, seagull is
346
+ * installed as a dependency, so it has no way to guess where the
347
+ * consumer's config lives on its own - `configPath` must be supplied by the
348
+ * caller (the CLI resolves it via `resolveConfigPath()` in
349
+ * `config/resolve-config-file.ts`, or `--config`).
350
+ *
351
+ * @param configPath - Absolute path to the CLI config file.
352
+ * @returns The fully resolved config.
353
+ */
354
+ function loadConfig(configPath) {
355
+ const rootDir = path.dirname(configPath);
356
+ const raw = readRawConfig(configPath);
357
+ const distDir = path.resolve(rootDir, raw.paths.dist);
358
+ const specsDir = raw.paths.specs ? path.resolve(rootDir, raw.paths.specs) : path.join(distDir, "specs");
359
+ const docsDir = raw.paths.docs ? path.resolve(rootDir, raw.paths.docs) : path.join(distDir, "docs");
360
+ const sdkDir = raw.paths.sdk ? path.resolve(rootDir, raw.paths.sdk) : path.join(distDir, "sdk");
361
+ const contracts = raw.contracts.map((contract) => resolveContract(contract, rootDir, sdkDir, raw.generators, raw.github, raw.vars));
362
+ return {
363
+ rootDir,
364
+ paths: {
365
+ dist: distDir,
366
+ specs: specsDir,
367
+ docs: docsDir,
368
+ sdk: sdkDir
369
+ },
370
+ github: raw.github,
371
+ vars: raw.vars,
372
+ docs: raw.docs,
373
+ contracts,
374
+ allArtifacts: contracts.flatMap((contract) => contract.artifacts.map((artifact) => ({
375
+ contract,
376
+ artifact
377
+ })))
378
+ };
379
+ }
380
+ //#endregion
381
+ //#region src/config/resolve-config-file.ts
382
+ /**
383
+ * Config filenames CLI recognizes, checked in this order.
384
+ */
385
+ const CONFIG_FILENAMES = [
386
+ ".seagull",
387
+ ".seagull.yaml",
388
+ ".seagull.yml",
389
+ "seagull.yaml",
390
+ "seagull.yml"
391
+ ];
392
+ /**
393
+ * Finds the CLI config file in a directory, trying each of
394
+ * {@link CONFIG_FILENAMES} in order.
395
+ *
396
+ * @param cwd - The directory to look in (typically `process.cwd()`).
397
+ * @returns The absolute path to the first matching config file.
398
+ * @throws Error if none of the candidate filenames exist in `cwd`.
399
+ *
400
+ * @see {@link CONFIG_FILENAMES} - the list of filenames checked, in order.
401
+ */
402
+ function resolveConfigPath(cwd) {
403
+ for (const filename of CONFIG_FILENAMES) {
404
+ const candidate = path.join(cwd, filename);
405
+ if (existsSync(candidate)) return candidate;
406
+ }
407
+ throw new Error(`No CLI config found in ${cwd} - looked for: ${CONFIG_FILENAMES.join(", ")}. Create one of these, or pass --config <path>.`);
408
+ }
409
+ //#endregion
410
+ //#region src/core/process/exec.ts
411
+ /**
412
+ * Runs a command to completion, streaming its stdio straight through
413
+ * (`inherit`), and rejects if it exits non-zero.
414
+ *
415
+ * This is the async counterpart used for the "one long-running tool" commands
416
+ * (`redocly`, `openapi-generator-cli`, `openapi-typescript`); for short
417
+ * synchronous calls (git plumbing, `npm publish`/`mvn deploy`), see
418
+ * {@link runSync}.
419
+ *
420
+ * @param command - The executable to run.
421
+ * @param args - Arguments to pass to it.
422
+ * @param cwd - The working directory to run it in.
423
+ * @returns A promise that resolves on exit code 0, and rejects otherwise.
424
+ */
425
+ function run(command, args, cwd) {
426
+ return new Promise((resolvePromise, reject) => {
427
+ spawn(command, args, {
428
+ cwd,
429
+ stdio: "inherit",
430
+ shell: true
431
+ }).on("close", (code) => {
432
+ if (code === 0) {
433
+ resolvePromise();
434
+ return;
435
+ }
436
+ reject(/* @__PURE__ */ new Error(`${command} ${args.join(" ")} exited with ${code}`));
437
+ });
438
+ });
439
+ }
440
+ /**
441
+ * Runs a command to completion synchronously, streaming its stdio straight
442
+ * through (`inherit`).
443
+ *
444
+ * @param command - The executable to run.
445
+ * @param args - Arguments to pass to it.
446
+ * @param cwd - The working directory to run it in.
447
+ * @returns The exit status (0 on success).
448
+ */
449
+ function runSync(command, args, cwd) {
450
+ return spawnSync(command, args, {
451
+ cwd,
452
+ stdio: "inherit",
453
+ shell: true
454
+ }).status ?? 1;
455
+ }
456
+ //#endregion
457
+ //#region src/core/process/resolve-bin.ts
458
+ const require = createRequire(import.meta.url);
459
+ /**
460
+ * Resolves the absolute path to an installed npm package's own CLI entrypoint
461
+ * script, using Node's standard module resolution algorithm - so it works the
462
+ * same way regardless of which package manager (npm/pnpm/yarn) installed CLI
463
+ * and its dependencies, or how deeply they get hoisted. Shelling out to
464
+ * `pnpm exec`/`npx` instead would assume a specific package manager and a
465
+ * particular install layout, which doesn't hold once CLI is just another
466
+ * dependency in someone else's project.
467
+ *
468
+ * @param pkgName - The npm package name, e.g. `"@org/cli"`.
469
+ * @param binName - Which entry to resolve from that package's `bin` field.
470
+ * Defaults to the package's own unscoped name.
471
+ * @returns The absolute path to the resolved bin script.
472
+ * @throws Error if the package or the requested bin entry can't be found.
473
+ */
474
+ function resolveBinPath(pkgName, binName) {
475
+ const pkgJsonPath = require.resolve(`${pkgName}/package.json`);
476
+ const pkgDir = path.dirname(pkgJsonPath);
477
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
478
+ const key = binName ?? pkg.name.split("/").pop();
479
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[key];
480
+ if (!bin) throw new Error(`Could not resolve a "${key}" bin entry for package "${pkgName}" - is it installed, and does it expose that bin?`);
481
+ return path.join(pkgDir, bin);
482
+ }
483
+ //#endregion
484
+ //#region src/core/redocly/redocly-sync.ts
485
+ const GENERATED_HEADER = "# AUTO-GENERATED by Seagull from its config and 'redocly.base.yaml'.\n# Do not edit directly, edit those two files instead (this file is\n# regenerated on every lint/bundle/generate run).\n\n";
486
+ /**
487
+ * Regenerates `redocly.yaml`'s `apis:` section from the resolved CLI config,
488
+ * merging it with the hand-authored `extends`/`rules` in `redocly.base.yaml`.
489
+ *
490
+ * The CLI config stays the single source of truth for which APIs exist and
491
+ * where their TypeScript server types land, instead of that being duplicated
492
+ * by hand into `redocly.yaml`.
493
+ *
494
+ * Called at the start of every command that shells out to `redocly` or
495
+ * `openapi-typescript` (both read `redocly.yaml` directly), so it's always
496
+ * up to date before those tools run.
497
+ *
498
+ * @param config - The resolved CLI config.
499
+ */
500
+ async function syncRedoclyConfig(config) {
501
+ const basePath = path.join(config.rootDir, "redocly.base.yaml");
502
+ const base = parse(await readFile(basePath, "utf8"));
503
+ const apis = Object.fromEntries(config.contracts.map((contract) => {
504
+ const typesArtifact = contract.artifacts.find((artifact) => artifact.tool === "openapi-typescript");
505
+ return [`${contract.name}@v1`, {
506
+ root: contract.entrypointRelative,
507
+ ...typesArtifact ? { "x-openapi-ts": { output: path.join(path.relative(config.rootDir, typesArtifact.outputDir), "index.d.ts") } } : {}
508
+ }];
509
+ }));
510
+ const final = {
511
+ extends: base.extends,
512
+ apis,
513
+ rules: base.rules
514
+ };
515
+ await writeFile(path.join(config.rootDir, "redocly.yaml"), GENERATED_HEADER + stringify(final));
516
+ }
517
+ //#endregion
518
+ //#region src/commands/bundle.ts
519
+ /**
520
+ * Bundles every contract's OpenAPI spec into `dist/specs/<contract>.json`.
521
+ *
522
+ * @param config - The resolved CLI config.
523
+ */
524
+ async function bundleCommand(config) {
525
+ await syncRedoclyConfig(config);
526
+ await rm(config.paths.specs, {
527
+ recursive: true,
528
+ force: true
529
+ });
530
+ await mkdir(config.paths.specs, { recursive: true });
531
+ const redoclyBin = resolveBinPath("@redocly/cli", "redocly");
532
+ for (const contract of config.contracts) {
533
+ const output = path.join(config.paths.specs, `${contract.name}.json`);
534
+ await run("node", [
535
+ redoclyBin,
536
+ "bundle",
537
+ contract.entrypoint,
538
+ "-o",
539
+ output
540
+ ], config.rootDir);
541
+ }
542
+ console.log(`Bundled ${config.contracts.length} specifications into ${config.paths.specs}`);
543
+ }
544
+ //#endregion
545
+ //#region src/commands/clean.ts
546
+ /**
547
+ * Removes the entire `dist` output directory.
548
+ *
549
+ * @param config - The resolved CLI config.
550
+ */
551
+ async function cleanCommand(config) {
552
+ await rm(config.paths.dist, {
553
+ recursive: true,
554
+ force: true
555
+ });
556
+ console.log(`Cleaned ${config.paths.dist}`);
557
+ }
558
+ //#endregion
559
+ //#region src/commands/generate-docs.ts
560
+ /**
561
+ * Generates the documentation website for every contract into `dist/docs`.
562
+ *
563
+ * @param config - The resolved CLI config.
564
+ */
565
+ async function generateDocsCommand(config) {
566
+ const output = config.paths.docs;
567
+ const specsOutput = join(output, "specs");
568
+ await rm(output, {
569
+ recursive: true,
570
+ force: true
571
+ });
572
+ await mkdir(specsOutput, { recursive: true });
573
+ for (const contract of config.contracts) {
574
+ const source = join(config.paths.specs, `${contract.name}.json`);
575
+ const target = join(specsOutput, `${contract.name}.json`);
576
+ const contents = await readFile(source, "utf8");
577
+ await writeFile(target, contents);
578
+ }
579
+ const mainEntryPoint = createRequire(import.meta.url).resolve("@scalar/api-reference");
580
+ let packageDir = dirname(mainEntryPoint);
581
+ while (!packageDir.endsWith("@scalar/api-reference") && !packageDir.endsWith("@scalar\\api-reference")) {
582
+ const parent = dirname(packageDir);
583
+ if (parent === packageDir) break;
584
+ packageDir = parent;
585
+ }
586
+ const scalarScriptPath = join(packageDir, "dist", "browser", "standalone.js");
587
+ const scalarTarget = join(output, "scalar.js");
588
+ await copyFile(scalarScriptPath, scalarTarget);
589
+ console.log(`Copied Scalar script to ${scalarTarget}`);
590
+ const sources = config.contracts.map((contract, index) => `{
591
+ title: ${JSON.stringify(contract.title)},
592
+ slug: ${JSON.stringify(contract.name)},
593
+ url: "./specs/${contract.name}.json"${index === 0 ? ",\n default: true" : ""}
594
+ }`).join(",\n");
595
+ const { metadata } = config.docs;
596
+ const html = `<!doctype html>
597
+ <html lang="en">
598
+ <head>
599
+ <meta charset="UTF-8" />
600
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
601
+ </head>
602
+ <body>
603
+ <div id="app"></div>
604
+
605
+ <!-- Load the Script -->
606
+ <script src="./scalar.js"><\/script>
607
+
608
+ <!-- Initialize the API Reference -->
609
+ <script>
610
+ Scalar.createApiReference("#app", {
611
+ baseServerURL: "${metadata.baseServerUrl}",
612
+ favicon: "${metadata.favicon}",
613
+ metaData: {
614
+ title: "${metadata.title}",
615
+ description: "${metadata.description}",
616
+ ogTitle: "${metadata.title}",
617
+ ogDescription: "${metadata.description}",
618
+ },
619
+ layout: "classic",
620
+ darkMode: true,
621
+ telemetry: false,
622
+ showDeveloperTools: "never",
623
+ showOperationId: true,
624
+ hideTestRequestButton: false,
625
+ sources: [
626
+ ${sources}
627
+ ]
628
+ });
629
+ <\/script>
630
+ </body>
631
+ </html>
632
+ `;
633
+ await writeFile(join(output, "index.html"), html);
634
+ console.log(`Generated documentation website at ${output}`);
635
+ }
636
+ //#endregion
637
+ //#region src/core/generator/registry.ts
638
+ /**
639
+ * Looks up the concrete {@link Generator} implementation for a given tool name.
640
+ */
641
+ var GeneratorRegistry = class {
642
+ generators = /* @__PURE__ */ new Map();
643
+ /**
644
+ * Registers a generator implementation under its own {@link Generator.tool}.
645
+ *
646
+ * @param generator - The generator instance to register.
647
+ * @returns `this`, for chaining.
648
+ */
649
+ register(generator) {
650
+ this.generators.set(generator.tool, generator);
651
+ return this;
652
+ }
653
+ /**
654
+ * Resolves the generator implementation for a given tool name.
655
+ *
656
+ * @param tool - The tool name, e.g. `"openapi-generator"`.
657
+ * @returns The registered generator.
658
+ * @throws Error if no generator is registered for that tool.
659
+ */
660
+ resolve(tool) {
661
+ const generator = this.generators.get(tool);
662
+ if (!generator) {
663
+ const available = [...this.generators.keys()].join(", ");
664
+ throw new Error(`No generator implementation registered for tool "${tool}" (available: ${available})`);
665
+ }
666
+ return generator;
667
+ }
668
+ /**
669
+ * All distinct tools currently registered.
670
+ *
671
+ * @returns The registered tool names.
672
+ */
673
+ tools() {
674
+ return [...this.generators.keys()];
675
+ }
676
+ };
677
+ //#endregion
678
+ //#region src/core/readme/default-templates.ts
679
+ /**
680
+ * Renders a sensible default `README.md` for a generated SDK package, used
681
+ * whenever the artifact has no custom `readme:` template configured. Covers
682
+ * every built-in `lang`/`kind` combination; anything more specific (an internal
683
+ * registry URL, a real base API URL, house install instructions) belongs in a
684
+ * custom template instead - see `readme-renderer.ts`.
685
+ *
686
+ * @param args - The contract, artifact, version, and github coordinates to
687
+ * render for.
688
+ * @returns The rendered README content.
689
+ */
690
+ function renderDefaultReadme({ contract, artifact, version, github }) {
691
+ return `${`# ${contract.title} - ${label(artifact)}
692
+
693
+ > Generated from \`${contract.entrypointRelative}\` in [${github.owner}/${github.repo}](https://github.com/${github.owner}/${github.repo}).
694
+ > Do not edit by hand - this package is regenerated and republished on every release.
695
+
696
+ Version: \`${version}\`
697
+ Source branch: \`${artifact.branch}\`
698
+ `}\n${body(contract, artifact, version, github)}\n`;
699
+ }
700
+ function label(artifact) {
701
+ return `${{
702
+ typescript: "TypeScript",
703
+ go: "Go",
704
+ java: "Java"
705
+ }[artifact.lang]} ${artifact.kind === "client" ? "Client SDK" : artifact.lang === "typescript" ? "Server Types" : "Server Stubs"}`;
706
+ }
707
+ function body(contract, artifact, version, github) {
708
+ switch (`${artifact.lang}-${artifact.kind}`) {
709
+ case "typescript-client": return tsClient(artifact, version);
710
+ case "typescript-server": return tsServer(artifact, version);
711
+ case "go-client": return goClient(contract, artifact);
712
+ case "go-server": return goServer(contract, artifact);
713
+ case "java-client": return javaClient(artifact, version, github);
714
+ case "java-server": return javaServer(artifact, version, github);
715
+ default: return "";
716
+ }
717
+ }
718
+ function tsClient(artifact, version) {
719
+ return `## Install
720
+
721
+ \`\`\`bash
722
+ npm config set <scope>:registry https://npm.pkg.github.com
723
+ npm install ${artifact.package}@${version}
724
+ \`\`\`
725
+
726
+ (GitHub Packages requires an authenticated \`.npmrc\` with a token that has
727
+ \`read:packages\`.)
728
+
729
+ ## Usage
730
+
731
+ \`\`\`ts
732
+ import { Configuration, DefaultApi } from "${artifact.package}";
733
+
734
+ const api = new DefaultApi(
735
+ new Configuration({ basePath: "https://api.your-domain.com" }),
736
+ );
737
+
738
+ const result = await api.someOperation();
739
+ \`\`\`
740
+ `;
741
+ }
742
+ function tsServer(artifact, version) {
743
+ return `## Install
744
+
745
+ \`\`\`bash
746
+ npm config set <scope>:registry https://npm.pkg.github.com
747
+ npm install --save-dev ${artifact.package}@${version}
748
+ \`\`\`
749
+
750
+ (GitHub Packages requires an authenticated \`.npmrc\` with a token that has
751
+ \`read:packages\`.)
752
+
753
+ ## Usage
754
+
755
+ \`\`\`ts
756
+ import type { components, operations } from "${artifact.package}";
757
+
758
+ type LoginResponses = operations["login"]["responses"];
759
+
760
+ // Example: an Express handler typed against the contract
761
+ app.post("/login", (req, res) => {
762
+ const body = req.body as components["schemas"]["LoginRequest"];
763
+ const response: LoginResponses[200]["content"]["application/json"] = {
764
+ // ...
765
+ };
766
+ res.json(response);
767
+ });
768
+ \`\`\`
769
+ `;
770
+ }
771
+ function goClient(contract, artifact) {
772
+ return `## Install
773
+
774
+ Go has no package registry, so this module is pulled directly from its
775
+ publishing branch:
776
+
777
+ \`\`\`bash
778
+ go get ${artifact.goModule}@sdk/svc-${contract.name}/${artifact.id}
779
+ \`\`\`
780
+
781
+ To pin an exact release instead of the branch head, use the matching tag:
782
+
783
+ \`\`\`bash
784
+ go get ${artifact.goModule}@${artifact.tagPrefix}-v<version>
785
+ \`\`\`
786
+
787
+ ## Usage
788
+
789
+ \`\`\`go
790
+ import (
791
+ "context"
792
+
793
+ ${artifact.goPackageName} "${artifact.goModule}"
794
+ )
795
+
796
+ func main() {
797
+ cfg := ${artifact.goPackageName}.NewConfiguration()
798
+ client := ${artifact.goPackageName}.NewAPIClient(cfg)
799
+
800
+ resp, _, err := client.DefaultAPI.SomeOperation(context.Background()).Execute()
801
+ _ = resp
802
+ _ = err
803
+ }
804
+ \`\`\`
805
+ `;
806
+ }
807
+ function goServer(contract, artifact) {
808
+ return `## Install
809
+
810
+ \`\`\`bash
811
+ go get ${artifact.goModule}@sdk/svc-${contract.name}/${artifact.id}
812
+ \`\`\`
813
+
814
+ ## Usage
815
+
816
+ Implement the generated \`${artifact.goPackageName}.*ApiServicer\` interfaces and
817
+ wire them into the generated router:
818
+
819
+ \`\`\`go
820
+ router := ${artifact.goPackageName}.NewRouter(
821
+ ${artifact.goPackageName}.NewSomeApiController(yourServiceImpl),
822
+ )
823
+ \`\`\`
824
+ `;
825
+ }
826
+ function javaClient(artifact, version, github) {
827
+ return `## Install (Maven, GitHub Packages)
828
+
829
+ \`\`\`xml
830
+ <dependency>
831
+ <groupId>${artifact.maven?.groupId}</groupId>
832
+ <artifactId>${artifact.maven?.artifactId}</artifactId>
833
+ <version>${version}</version>
834
+ </dependency>
835
+ \`\`\`
836
+
837
+ Add the repository to your \`settings.xml\` (or \`pom.xml\`) with a token that
838
+ has \`read:packages\`:
839
+
840
+ \`\`\`xml
841
+ <repository>
842
+ <id>github</id>
843
+ <url>https://maven.pkg.github.com/${github.owner}/${github.repo}</url>
844
+ </repository>
845
+ \`\`\`
846
+
847
+ ## Usage
848
+
849
+ Generated with \`library=restclient\` - Spring's \`RestClient\`, the current
850
+ recommended synchronous HTTP client for Spring apps:
851
+
852
+ \`\`\`java
853
+ @Configuration
854
+ public class SomeServiceClientConfig {
855
+
856
+ @Bean
857
+ public ApiClient someServiceApiClient(RestClient.Builder builder) {
858
+ ApiClient client = new ApiClient(builder.build());
859
+ client.setBasePath("https://api.your-domain.com");
860
+ return client;
861
+ }
862
+
863
+ @Bean
864
+ public DefaultApi someServiceApi(ApiClient someServiceApiClient) {
865
+ return new DefaultApi(someServiceApiClient);
866
+ }
867
+ }
868
+ \`\`\`
869
+ `;
870
+ }
871
+ function javaServer(artifact, version, github) {
872
+ return `## Install (Maven, GitHub Packages)
873
+
874
+ \`\`\`xml
875
+ <dependency>
876
+ <groupId>${artifact.maven?.groupId}</groupId>
877
+ <artifactId>${artifact.maven?.artifactId}</artifactId>
878
+ <version>${version}</version>
879
+ </dependency>
880
+ \`\`\`
881
+
882
+ Add the repository to your \`settings.xml\` (or \`pom.xml\`) with a token that
883
+ has \`read:packages\`:
884
+
885
+ \`\`\`xml
886
+ <repository>
887
+ <id>github</id>
888
+ <url>https://maven.pkg.github.com/${github.owner}/${github.repo}</url>
889
+ </repository>
890
+ \`\`\`
891
+
892
+ ## Usage
893
+
894
+ This artifact only contains the generated Spring \`@RestController\` interfaces
895
+ (\`interfaceOnly=true\`) - implement them in your service:
896
+
897
+ \`\`\`java
898
+ @RestController
899
+ public class SomeController implements SomeApi {
900
+ // interface methods generated from the OpenAPI contract
901
+ }
902
+ \`\`\`
903
+ `;
904
+ }
905
+ //#endregion
906
+ //#region src/core/readme/readme-renderer.ts
907
+ /**
908
+ * Renders the root-level `README.md` for a generated SDK package.
909
+ *
910
+ * If the artifact has a `readme:` path configured (resolved at config-load time
911
+ * to `artifact.readmeTemplate`), that file is read and interpolated with the
912
+ * same `{...}` placeholder engine naming templates use - `{service}`,
913
+ * `{title}`, `{version}`, `{vars.*}`, `{github.owner}`, `{github.repo}`, plus
914
+ * `{artifact.*}` (id/lang/kind/package/goModule/goPackageName/maven.groupId/
915
+ * maven.artifactId/branch/tagPrefix). Otherwise, falls back to a built-in
916
+ * default template for the artifact's language/kind.
917
+ *
918
+ * @param args - The contract, artifact, version, and github/vars context to
919
+ * render for.
920
+ * @returns The rendered README content.
921
+ */
922
+ async function renderReadme(args) {
923
+ if (!args.artifact.readmeTemplate) return renderDefaultReadme(args);
924
+ return interpolate(await readFile(args.artifact.readmeTemplate, "utf8"), buildTemplateContext({
925
+ service: args.contract.name,
926
+ title: args.contract.title,
927
+ version: args.version,
928
+ github: args.github,
929
+ vars: args.vars,
930
+ artifact: {
931
+ id: args.artifact.id,
932
+ lang: args.artifact.lang,
933
+ kind: args.artifact.kind,
934
+ package: args.artifact.package,
935
+ goModule: args.artifact.goModule,
936
+ goPackageName: args.artifact.goPackageName,
937
+ maven: args.artifact.maven,
938
+ branch: args.artifact.branch,
939
+ tagPrefix: args.artifact.tagPrefix
940
+ }
941
+ }));
942
+ }
943
+ //#endregion
944
+ //#region src/core/version/version.ts
945
+ /**
946
+ * Resolves the version to stamp onto a single contract's generated SDK
947
+ * artifacts (npm/Maven packages, Go module tags, git branches, etc.).
948
+ *
949
+ * The single source of truth is that contract's own `info.version` field in its
950
+ * `openapi.yaml` - bump it there and every artifact, package, and git tag for
951
+ * that contract picks up the new version on the next release. Versions are
952
+ * resolved independently per contract: two services can be at different
953
+ * versions at the same time.
954
+ *
955
+ * `SDK_VERSION_OVERRIDE` (wired up from the release workflow's manual `version`
956
+ * input) bypasses the spec entirely and stamps every contract with the same
957
+ * given value. It exists for one-off emergency republishes, not routine
958
+ * releases. Routine releases should always go through `info.version`.
959
+ *
960
+ * @param spec - The parsed, bundled OpenAPI document for the contract.
961
+ * @param contractName - The contract name, used only for the error message.
962
+ * @returns The resolved version string (no leading `v`).
963
+ * @throws Error if no override is set and the spec has no `info.version`.
964
+ */
965
+ function resolveVersion(spec, contractName) {
966
+ const override = process.env.SDK_VERSION_OVERRIDE?.trim();
967
+ if (override) return override.replace(/^v/, "");
968
+ const specVersion = spec.info?.version?.trim();
969
+ if (!specVersion) throw new Error(`specs/${contractName}/openapi.yaml is missing "info.version" - set it to a semver value. This field is the single source of truth for the ${contractName} contract's SDK version.`);
970
+ return specVersion.replace(/^v/, "");
971
+ }
972
+ /**
973
+ * Computes a stable content hash of a bundled OpenAPI document's raw JSON text.
974
+ * Stamped alongside `VERSION` into every generated SDK package so
975
+ * `publish-sdk.ts` can tell a genuine no-op republish (same spec, same version)
976
+ * apart from a spec that changed without its `info.version` being bumped.
977
+ *
978
+ * @param raw - The raw bundled spec file contents (JSON text).
979
+ * @returns A `sha256` hex digest of the raw contents.
980
+ */
981
+ function hashSpec(raw) {
982
+ return createHash("sha256").update(raw).digest("hex");
983
+ }
984
+ //#endregion
985
+ //#region src/core/generator/generator.ts
986
+ /**
987
+ * The root primitive every concrete SDK generator implements.
988
+ *
989
+ * One instance per underlying tool (`openapi-generator-cli`,
990
+ * `openapi-typescript`, ...) - not one per language, since a single tool
991
+ * invocation (e.g. `openapi-generator-cli -g java`/`-g go`) already covers
992
+ * every language it supports. Language-specific behaviour (patching `go.mod`,
993
+ * `package.json`, `pom.xml`, ...) is composed in via patchers rather than
994
+ * living in per-language subclasses.
995
+ */
996
+ var Generator = class {};
997
+ //#endregion
998
+ //#region src/generators/openapi-generator-cli/patchers/go-module.patcher.ts
999
+ /**
1000
+ * Patches the `go.mod` file in a generated Go SDK package to set the correct
1001
+ * module path (`openapi-generator-cli` has no way to be told this up front for
1002
+ * every template).
1003
+ */
1004
+ var GoModulePatcher = class {
1005
+ async patch({ artifact }) {
1006
+ if (!artifact.goModule) return;
1007
+ const moduleFile = path.join(artifact.outputDir, "go.mod");
1008
+ try {
1009
+ const contents = await readFile(moduleFile, "utf8");
1010
+ await writeFile(moduleFile, contents.replace(/^module .*$/m, `module ${artifact.goModule}`));
1011
+ } catch {}
1012
+ }
1013
+ };
1014
+ //#endregion
1015
+ //#region src/generators/openapi-generator-cli/patchers/maven.patcher.ts
1016
+ /**
1017
+ * Patches the `pom.xml` file in a generated Java SDK package to set the correct
1018
+ * version and distribution management information for publishing.
1019
+ */
1020
+ var MavenPomPatcher = class {
1021
+ async patch({ artifact, version, github }) {
1022
+ const pomFile = path.join(artifact.outputDir, "pom.xml");
1023
+ try {
1024
+ let pom = await readFile(pomFile, "utf8");
1025
+ pom = pom.replace(/<version>[^<]*<\/version>/, `<version>${version}</version>`);
1026
+ if (!pom.includes("<distributionManagement>")) pom = pom.replace("</project>", [
1027
+ " <distributionManagement>",
1028
+ " <repository>",
1029
+ " <id>github</id>",
1030
+ ` <n>${github.owner} ${github.repo} Packages</n>`,
1031
+ ` <url>https://maven.pkg.github.com/${github.owner}/${github.repo}</url>`,
1032
+ " </repository>",
1033
+ " </distributionManagement>",
1034
+ "</project>"
1035
+ ].join("\n"));
1036
+ await writeFile(pomFile, pom);
1037
+ } catch {}
1038
+ }
1039
+ };
1040
+ //#endregion
1041
+ //#region src/generators/openapi-generator-cli/patchers/npm.patcher.ts
1042
+ /**
1043
+ * Patches the `package.json` file in a generated TypeScript SDK package (the
1044
+ * client target - `openapi-generator-cli` produces its own `package.json`, this
1045
+ * just fills in the version and publishing metadata) to set the correct version
1046
+ * and repository information.
1047
+ */
1048
+ var NpmPackagePatcher = class {
1049
+ async patch({ contract, artifact, version, github }) {
1050
+ const pkgFile = path.join(artifact.outputDir, "package.json");
1051
+ const pkgData = await readFile(pkgFile, "utf8");
1052
+ const pkg = JSON.parse(pkgData);
1053
+ pkg.version = version;
1054
+ pkg.repository = {
1055
+ type: "git",
1056
+ url: `git+https://github.com/${github.owner}/${github.repo}.git`,
1057
+ directory: `sdk/svc-${contract.name}/${artifact.id}`
1058
+ };
1059
+ pkg.publishConfig = {
1060
+ registry: "https://npm.pkg.github.com",
1061
+ access: "restricted"
1062
+ };
1063
+ await writeFile(pkgFile, JSON.stringify(pkg, null, 2));
1064
+ }
1065
+ };
1066
+ //#endregion
1067
+ //#region src/generators/openapi-generator-cli/openapi-generator-cli.generator.ts
1068
+ /**
1069
+ * Additional-properties that are fully derivable from an artifact's own
1070
+ * `package`/`goPackageName`/`maven` fields - conventional openapi-generator
1071
+ * knobs (`npmName`, `groupId`, ...) that would otherwise have to be duplicated
1072
+ * by hand in every generator's `additionalProperties:` block, in lockstep with
1073
+ * those same fields. Tool-specific tuning that isn't derivable this way
1074
+ * (`library=restclient`, `withGoMod`, ...) still lives in
1075
+ * `additionalProperties:` and is layered on top of these.
1076
+ *
1077
+ * @param artifact - The resolved artifact to derive properties for.
1078
+ * @returns The derived additional-properties, before the artifact's own
1079
+ * `additionalProperties` are layered on top.
1080
+ */
1081
+ function deriveAdditionalProperties(artifact) {
1082
+ if (artifact.lang === "typescript" && artifact.package) return { npmName: artifact.package };
1083
+ if (artifact.lang === "go" && artifact.goPackageName) return { packageName: artifact.goPackageName };
1084
+ if (artifact.lang === "java" && artifact.maven) {
1085
+ const invokerPackage = `${artifact.maven.groupId}.${artifact.kind}`;
1086
+ return {
1087
+ groupId: artifact.maven.groupId,
1088
+ artifactId: artifact.maven.artifactId,
1089
+ invokerPackage,
1090
+ apiPackage: `${invokerPackage}.api`,
1091
+ modelPackage: `${invokerPackage}.model`
1092
+ };
1093
+ }
1094
+ return {};
1095
+ }
1096
+ /**
1097
+ * Renders an artifact's additional-properties (derived + explicit, explicit
1098
+ * wins on conflicts) as the `key=value,key=value` string
1099
+ * `openapi-generator-cli --additional-properties` expects.
1100
+ *
1101
+ * @param artifact - The resolved artifact.
1102
+ * @returns The rendered `--additional-properties` value.
1103
+ */
1104
+ function buildAdditionalPropertiesArg(artifact) {
1105
+ const merged = {
1106
+ ...deriveAdditionalProperties(artifact),
1107
+ ...artifact.additionalProperties
1108
+ };
1109
+ return Object.entries(merged).map(([key, value]) => `${key}=${String(value)}`).join(",");
1110
+ }
1111
+ /**
1112
+ * Wraps `openapi-generator-cli` - the single tool implementation behind every
1113
+ * `-g` template (`typescript-fetch`, `go`, `go-server`, `java`, `spring`, ...),
1114
+ * regardless of language. Language-specific output patching is delegated to a
1115
+ * {@link Patcher}, selected by `artifact.lang`.
1116
+ */
1117
+ var OpenApiGeneratorCli = class extends Generator {
1118
+ tool = "openapi-generator";
1119
+ patchers = {
1120
+ go: new GoModulePatcher(),
1121
+ typescript: new NpmPackagePatcher(),
1122
+ java: new MavenPomPatcher()
1123
+ };
1124
+ async generate(ctx) {
1125
+ const { artifact, rootDir, specInputPath } = ctx;
1126
+ if (!artifact.generator) throw new Error(`Artifact "${artifact.id}" uses tool "openapi-generator" but has no "generator" value`);
1127
+ await mkdir(artifact.outputDir, { recursive: true });
1128
+ await run("node", [
1129
+ resolveBinPath("@openapitools/openapi-generator-cli", "openapi-generator-cli"),
1130
+ "generate",
1131
+ "-i",
1132
+ specInputPath,
1133
+ "-g",
1134
+ artifact.generator,
1135
+ "-o",
1136
+ artifact.outputDir,
1137
+ `--additional-properties=${buildAdditionalPropertiesArg(artifact)}`
1138
+ ], rootDir);
1139
+ await this.patchers[artifact.lang]?.patch(ctx);
1140
+ }
1141
+ };
1142
+ //#endregion
1143
+ //#region src/generators/openapi-typescript/openapi-typescript.generator.ts
1144
+ /**
1145
+ * Wraps `openapi-typescript`. Unlike `openapi-generator-cli`, it isn't invoked
1146
+ * once per artifact - it reads `redocly.yaml`'s `apis:` map (kept in sync with
1147
+ * the CLI config by `core/redocly/redocly-sync.ts`) and writes every contract's
1148
+ * `index.d.ts` to its configured `x-openapi-ts.output` path in a single run,
1149
+ * so that single global invocation happens once in {@link prepare}.
1150
+ * {@link generate} then only has to write each artifact's package.json` -
1151
+ * `openapi-typescript` emits `index.d.ts` alone, with no package manifest of
1152
+ * its own to patch.
1153
+ */
1154
+ var OpenApiTypescriptGenerator = class extends Generator {
1155
+ tool = "openapi-typescript";
1156
+ async prepare({ rootDir, entries }) {
1157
+ await Promise.all(entries.map((entry) => mkdir(entry.artifact.outputDir, { recursive: true })));
1158
+ await run("node", [resolveBinPath("openapi-typescript", "openapi-typescript")], rootDir);
1159
+ }
1160
+ async generate({ contract, artifact, version, github }) {
1161
+ const pkg = {
1162
+ name: artifact.package,
1163
+ version,
1164
+ description: `Types-only OpenAPI contract for the ${contract.name} service.`,
1165
+ types: "./index.d.ts",
1166
+ files: ["index.d.ts"],
1167
+ license: "MIT",
1168
+ repository: {
1169
+ type: "git",
1170
+ url: `git+https://github.com/${github.owner}/${github.repo}.git`,
1171
+ directory: `sdk/svc-${contract.name}/${artifact.id}`
1172
+ },
1173
+ publishConfig: {
1174
+ registry: "https://npm.pkg.github.com",
1175
+ access: "restricted"
1176
+ }
1177
+ };
1178
+ await writeFile(path.join(artifact.outputDir, "package.json"), JSON.stringify(pkg, null, 2));
1179
+ }
1180
+ };
1181
+ //#endregion
1182
+ //#region src/commands/generate-sdk.ts
1183
+ /**
1184
+ * Generates SDK packages for every artifact of every contract in the config.
1185
+ *
1186
+ * @param config - The resolved config.
1187
+ */
1188
+ async function generateSdkCommand(config) {
1189
+ await syncRedoclyConfig(config);
1190
+ const registry = new GeneratorRegistry().register(new OpenApiGeneratorCli()).register(new OpenApiTypescriptGenerator());
1191
+ await rm(config.paths.sdk, {
1192
+ recursive: true,
1193
+ force: true
1194
+ });
1195
+ await mkdir(config.paths.sdk, { recursive: true });
1196
+ for (const tool of registry.tools()) {
1197
+ const entries = config.allArtifacts.filter((entry) => entry.artifact.tool === tool);
1198
+ await registry.resolve(tool).prepare?.({
1199
+ rootDir: config.rootDir,
1200
+ entries
1201
+ });
1202
+ }
1203
+ const versionCache = /* @__PURE__ */ new Map();
1204
+ async function getVersionInfo(contractName) {
1205
+ const cached = versionCache.get(contractName);
1206
+ if (cached) return cached;
1207
+ const specPath = path.join(config.paths.specs, `${contractName}.json`);
1208
+ const raw = await readFile(specPath, "utf8");
1209
+ const info = {
1210
+ version: resolveVersion(JSON.parse(raw), contractName),
1211
+ hash: hashSpec(raw)
1212
+ };
1213
+ versionCache.set(contractName, info);
1214
+ return info;
1215
+ }
1216
+ for (const { contract, artifact } of config.allArtifacts) {
1217
+ const { version, hash } = await getVersionInfo(contract.name);
1218
+ await registry.resolve(artifact.tool).generate({
1219
+ rootDir: config.rootDir,
1220
+ contract,
1221
+ artifact,
1222
+ version,
1223
+ github: config.github,
1224
+ specInputPath: path.join(config.paths.specs, `${contract.name}.json`)
1225
+ });
1226
+ await writeFile(path.join(artifact.outputDir, "VERSION"), `${version}\n`);
1227
+ await writeFile(path.join(artifact.outputDir, "SPEC_HASH"), `${hash}\n`);
1228
+ await writeFile(path.join(artifact.outputDir, "README.md"), await renderReadme({
1229
+ contract,
1230
+ artifact,
1231
+ version,
1232
+ github: config.github,
1233
+ vars: config.vars
1234
+ }));
1235
+ }
1236
+ const versionSummary = [...versionCache.entries()].map(([name, { version }]) => `${name}@${version}`).join(", ");
1237
+ console.log(`Generated ${config.allArtifacts.length} SDK packages into ${config.paths.sdk} (${versionSummary})`);
1238
+ }
1239
+ //#endregion
1240
+ //#region src/commands/lint.ts
1241
+ /**
1242
+ * Lints every contract's OpenAPI spec.
1243
+ * Sets `process.exitCode = 1` if any contract fails.
1244
+ *
1245
+ * @param config - The resolved CLI config.
1246
+ */
1247
+ async function lintCommand(config) {
1248
+ await syncRedoclyConfig(config);
1249
+ console.log("Linting OpenAPI contracts...");
1250
+ const redoclyBin = resolveBinPath("@redocly/cli", "redocly");
1251
+ let hasErrors = false;
1252
+ for (const contract of config.contracts) {
1253
+ console.log(`Checking [${contract.name}]...`);
1254
+ if (runSync("node", [
1255
+ redoclyBin,
1256
+ "lint",
1257
+ contract.entrypoint
1258
+ ], config.rootDir) !== 0) hasErrors = true;
1259
+ }
1260
+ if (hasErrors) process.exitCode = 1;
1261
+ }
1262
+ //#endregion
1263
+ //#region src/commands/publish-registries.ts
1264
+ /**
1265
+ * Publishes registry-backed packages:
1266
+ * - TypeScript (client and server-types) -> npm (needs a configured registry
1267
+ * or auth token on the machine running this).
1268
+ * - Java (client and server) -> Maven (needs `~/.m2/settings.xml` credentials
1269
+ * for whichever repository `mvn deploy` resolves to).
1270
+ *
1271
+ * Go packages are intentionally skipped - they're consumed straight from
1272
+ * their git branch/tag (see `publish-sdk.ts`), Go has no registry step.
1273
+ *
1274
+ * @param config - The resolved CLI config.
1275
+ * @param options - `{ dryRun }` - print what would run without running it.
1276
+ */
1277
+ async function publishRegistriesCommand(config, options = {}) {
1278
+ const dryRun = options.dryRun ?? false;
1279
+ function run(cmd, args, cwd) {
1280
+ console.log(`$ ${cmd} ${args.join(" ")} (in ${cwd})`);
1281
+ if (dryRun) return;
1282
+ const result = spawnSync(cmd, args, {
1283
+ cwd,
1284
+ stdio: "inherit",
1285
+ shell: true
1286
+ });
1287
+ if (result.status !== 0) throw new Error(`${cmd} ${args.join(" ")} exited with ${result.status}`);
1288
+ }
1289
+ let publishedCount = 0;
1290
+ for (const { contract, artifact } of config.allArtifacts) {
1291
+ if (artifact.lang === "typescript") {
1292
+ console.log(`\n=== npm publish: ${artifact.package} (contract: ${contract.name}, ${artifact.kind}) ===`);
1293
+ run("npm", ["publish"], artifact.outputDir);
1294
+ publishedCount += 1;
1295
+ }
1296
+ if (artifact.lang === "java") {
1297
+ console.log(`\n=== maven deploy: ${artifact.maven?.groupId}:${artifact.maven?.artifactId} (contract: ${contract.name}, ${artifact.kind}) ===`);
1298
+ run("mvn", [
1299
+ "-B",
1300
+ "deploy",
1301
+ "-DskipTests"
1302
+ ], artifact.outputDir);
1303
+ publishedCount += 1;
1304
+ }
1305
+ }
1306
+ console.log(`\nDone - published ${publishedCount} registry package(s).`);
1307
+ }
1308
+ //#endregion
1309
+ //#region src/core/git/git.ts
1310
+ /**
1311
+ * Execute a git command in a given working directory and return the result.
1312
+ *
1313
+ * @param args - The command-line arguments to pass to the git command.
1314
+ * @param cwd - The working directory in which to execute the git command.
1315
+ * @returns The result of the git command.
1316
+ *
1317
+ * @see {@link GitResult} - The result of executing the git command.
1318
+ */
1319
+ function git(args, cwd) {
1320
+ const result = spawnSync("git", args, {
1321
+ cwd,
1322
+ encoding: "utf8"
1323
+ });
1324
+ return {
1325
+ status: result.status ?? 1,
1326
+ stdout: (result.stdout ?? "").trim(),
1327
+ stderr: (result.stderr ?? "").trim()
1328
+ };
1329
+ }
1330
+ /**
1331
+ * Check if a remote branch exists in the given repository.
1332
+ *
1333
+ * @param repoRoot - The root directory of the repository.
1334
+ * @param branch - The name of the branch to check.
1335
+ * @returns Whether the remote branch exists (true) or not (false).
1336
+ */
1337
+ function remoteBranchExists(repoRoot, branch) {
1338
+ return git([
1339
+ "ls-remote",
1340
+ "--exit-code",
1341
+ "--heads",
1342
+ "origin",
1343
+ branch
1344
+ ], repoRoot).status === 0;
1345
+ }
1346
+ /**
1347
+ * Check if a remote tag exists in the given repository.
1348
+ *
1349
+ * @param repoRoot - The root directory of the repository.
1350
+ * @param tag - The name of the tag to check.
1351
+ * @returns Whether the remote tag exists (true) or not (false).
1352
+ */
1353
+ function tagExists(repoRoot, tag) {
1354
+ return git([
1355
+ "ls-remote",
1356
+ "--exit-code",
1357
+ "--tags",
1358
+ "origin",
1359
+ tag
1360
+ ], repoRoot).status === 0;
1361
+ }
1362
+ /**
1363
+ * Fetch a single tag's object from origin into the local repo, without
1364
+ * fetching the rest of history/tags.
1365
+ *
1366
+ * @param repoRoot - The root directory of the repository.
1367
+ * @param tag - The name of the tag to fetch.
1368
+ * @returns The result of the underlying `git fetch` command.
1369
+ */
1370
+ function fetchTag(repoRoot, tag) {
1371
+ return git([
1372
+ "fetch",
1373
+ "origin",
1374
+ `refs/tags/${tag}:refs/tags/${tag}`,
1375
+ "--force"
1376
+ ], repoRoot);
1377
+ }
1378
+ /**
1379
+ * Read a single file's content as it existed at a given git tag, without
1380
+ * checking out a worktree.
1381
+ *
1382
+ * Returns `null` (rather than throwing) both when the tag can't be fetched and
1383
+ * when the tag exists but doesn't contain the requested file - the latter is
1384
+ * expected for tags published before that file was introduced, and callers
1385
+ * should treat "unknown" the same as "no mismatch to report".
1386
+ *
1387
+ * @param repoRoot - The root directory of the repository.
1388
+ * @param tag - The tag to read the file from.
1389
+ * @param filePath - The path of the file within that tag's tree.
1390
+ * @returns The file's content, or `null` if it couldn't be read.
1391
+ */
1392
+ function readFileAtTag(repoRoot, tag, filePath) {
1393
+ if (fetchTag(repoRoot, tag).status !== 0) return null;
1394
+ const show = git(["show", `${tag}:${filePath}`], repoRoot);
1395
+ return show.status === 0 ? show.stdout.trim() : null;
1396
+ }
1397
+ /**
1398
+ * Require that a git command succeeded, throwing an error with the given
1399
+ * message if it did not.
1400
+ *
1401
+ * @param result - The result of the git command to check.
1402
+ * @param message - The error message to throw if the command failed.
1403
+ * @throws Error if the git command failed (non-zero exit code).
1404
+ */
1405
+ function requireOk(result, message) {
1406
+ if (result.status !== 0) throw new Error(`${message}: ${result.stderr || result.stdout}`);
1407
+ }
1408
+ //#endregion
1409
+ //#region src/commands/publish-sdk.ts
1410
+ /**
1411
+ * Redistributes each generated artifact's `dist/sdk/<contract>/<artifact-id>`
1412
+ * into its own orphan branch (`sdk/svc-<contract>/<artifact-id>`) and tags the
1413
+ * publish.
1414
+ *
1415
+ * @param config - The resolved CLI config.
1416
+ * @param options - `{ dryRun }` - skip pushing, just report what would happen.
1417
+ */
1418
+ async function publishSdkCommand(config, options = {}) {
1419
+ const dryRun = options.dryRun ?? false;
1420
+ for (const { contract, artifact } of config.allArtifacts) {
1421
+ const version = (await readFile(path.join(artifact.outputDir, "VERSION"), "utf8")).trim();
1422
+ const localHash = (await readFile(path.join(artifact.outputDir, "SPEC_HASH"), "utf8")).trim();
1423
+ const tag = `${artifact.tagPrefix}-v${version}`;
1424
+ console.log(`\n=== ${contract.name} / ${artifact.id} -> ${artifact.branch} (v${version}) ===`);
1425
+ if (tagExists(config.rootDir, tag)) {
1426
+ const remoteHash = readFileAtTag(config.rootDir, tag, "SPEC_HASH");
1427
+ if (remoteHash !== null && remoteHash !== localHash) throw new Error(`Tag ${tag} already exists, but the ${contract.name} spec content has changed since it was published under that version. Bump "info.version" in ${contract.entrypointRelative} before releasing again.`);
1428
+ console.log(`Tag ${tag} already exists on origin with matching content, skipping (already published).`);
1429
+ continue;
1430
+ }
1431
+ const worktreeDir = await mkdtemp(path.join(tmpdir(), "sdk-publish-"));
1432
+ await rm(worktreeDir, {
1433
+ recursive: true,
1434
+ force: true
1435
+ });
1436
+ git([
1437
+ "fetch",
1438
+ "origin",
1439
+ artifact.branch
1440
+ ], config.rootDir);
1441
+ const hasRemoteBranch = remoteBranchExists(config.rootDir, artifact.branch);
1442
+ requireOk(hasRemoteBranch ? git([
1443
+ "worktree",
1444
+ "add",
1445
+ worktreeDir,
1446
+ `origin/${artifact.branch}`
1447
+ ], config.rootDir) : git([
1448
+ "worktree",
1449
+ "add",
1450
+ "--detach",
1451
+ worktreeDir
1452
+ ], config.rootDir), `Failed to create worktree for ${artifact.branch}`);
1453
+ if (hasRemoteBranch) requireOk(git([
1454
+ "checkout",
1455
+ "-B",
1456
+ artifact.branch,
1457
+ `origin/${artifact.branch}`
1458
+ ], worktreeDir), `Failed to check out ${artifact.branch}`);
1459
+ else requireOk(git([
1460
+ "checkout",
1461
+ "--orphan",
1462
+ artifact.branch
1463
+ ], worktreeDir), `Failed to create orphan branch ${artifact.branch}`);
1464
+ git([
1465
+ "rm",
1466
+ "-rf",
1467
+ "--quiet",
1468
+ "."
1469
+ ], worktreeDir);
1470
+ await cp(artifact.outputDir, worktreeDir, { recursive: true });
1471
+ git(["add", "-A"], worktreeDir);
1472
+ if (!(git([
1473
+ "diff",
1474
+ "--cached",
1475
+ "--quiet"
1476
+ ], worktreeDir).status !== 0)) console.log("No content changes since last publish - committing tag only.");
1477
+ else requireOk(git([
1478
+ "commit",
1479
+ "-m",
1480
+ `chore(sdk): publish ${contract.name} ${artifact.id} v${version}`
1481
+ ], worktreeDir), `Commit failed for ${artifact.branch}`);
1482
+ requireOk(git(["tag", tag], worktreeDir), `Tagging failed for ${tag}`);
1483
+ if (dryRun) console.log(`[dry-run] would push ${artifact.branch} and tag ${tag}`);
1484
+ else {
1485
+ requireOk(git([
1486
+ "push",
1487
+ "origin",
1488
+ `HEAD:refs/heads/${artifact.branch}`
1489
+ ], worktreeDir), `Push failed for ${artifact.branch}`);
1490
+ requireOk(git([
1491
+ "push",
1492
+ "origin",
1493
+ tag
1494
+ ], worktreeDir), `Tag push failed for ${tag}`);
1495
+ console.log(`Published ${artifact.branch} @ ${tag}`);
1496
+ }
1497
+ git([
1498
+ "worktree",
1499
+ "remove",
1500
+ "--force",
1501
+ worktreeDir
1502
+ ], config.rootDir);
1503
+ }
1504
+ console.log(`\nDone - processed ${config.allArtifacts.length} SDK packages.`);
1505
+ }
1506
+ //#endregion
1507
+ //#region src/commands/serve-docs.ts
1508
+ const MIME_TYPES = {
1509
+ ".html": "text/html; charset=utf-8",
1510
+ ".json": "application/json; charset=utf-8",
1511
+ ".css": "text/css; charset=utf-8",
1512
+ ".js": "text/javascript; charset=utf-8",
1513
+ ".svg": "image/svg+xml",
1514
+ ".png": "image/png",
1515
+ ".jpg": "image/jpeg",
1516
+ ".jpeg": "image/jpeg",
1517
+ ".ico": "image/x-icon"
1518
+ };
1519
+ /**
1520
+ * Serves the generated documentation site (`dist/docs`) over plain HTTP for
1521
+ * local previewing.
1522
+ *
1523
+ * @param config - The resolved CLI config.
1524
+ */
1525
+ async function serveDocsCommand(config) {
1526
+ const { host, port } = config.docs.server;
1527
+ const server = createServer((request, response) => {
1528
+ if (!request.url) {
1529
+ response.writeHead(400);
1530
+ response.end();
1531
+ return;
1532
+ }
1533
+ const url = new URL(request.url, `http://${host}:${port}`);
1534
+ const pathname = decodeURIComponent(url.pathname);
1535
+ const relativePath = normalize(pathname).replace(/^(\.\.[/\\])+/, "");
1536
+ let file = join(config.paths.docs, relativePath);
1537
+ try {
1538
+ if (statSync(file).isDirectory()) file = join(file, "index.html");
1539
+ } catch {
1540
+ response.writeHead(404);
1541
+ response.end("Not Found");
1542
+ return;
1543
+ }
1544
+ try {
1545
+ if (!statSync(file).isFile()) {
1546
+ response.writeHead(404);
1547
+ response.end("Not Found");
1548
+ return;
1549
+ }
1550
+ response.writeHead(200, { "Content-Type": MIME_TYPES[extname(file)] ?? "application/octet-stream" });
1551
+ createReadStream(file).pipe(response);
1552
+ } catch {
1553
+ response.writeHead(404);
1554
+ response.end("Not Found");
1555
+ }
1556
+ });
1557
+ await new Promise((resolvePromise) => {
1558
+ server.listen(port, host, () => {
1559
+ console.log(`Scalar documentation: http://${host}:${port}`);
1560
+ resolvePromise();
1561
+ });
1562
+ });
1563
+ }
1564
+ //#endregion
1565
+ export { resolveConfigPath as _, generateSdkCommand as a, Generator as c, cleanCommand as d, bundleCommand as f, CONFIG_FILENAMES as g, runSync as h, lintCommand as i, GeneratorRegistry as l, run as m, publishSdkCommand as n, OpenApiTypescriptGenerator as o, resolveBinPath as p, publishRegistriesCommand as r, OpenApiGeneratorCli as s, serveDocsCommand as t, generateDocsCommand as u, loadConfig as v };
1566
+
1567
+ //# sourceMappingURL=serve-docs-BZaITOD0.mjs.map