@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.
- package/LICENSE.md +21 -0
- package/README.md +180 -0
- package/dist/cli.mjs +59 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.mts +391 -0
- package/dist/index.mjs +4 -0
- package/dist/serve-docs-BZaITOD0.mjs +1567 -0
- package/dist/serve-docs-BZaITOD0.mjs.map +1 -0
- package/package.json +95 -0
|
@@ -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
|