@octalmesh/seagull-cli 0.0.2

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OctalMesh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @octalmesh/seagull-cli
2
+
3
+ **Internal package** - not published to npm on its own. This holds every
4
+ pipeline command (`lint`, `bundle`, `generate`, `docs generate`, `docs serve`,
5
+ `publish sdk`, `publish registries`, `clean`) and the `commander` program
6
+ that wires them up (`createProgram()`), bundled straight into
7
+ [`@octalmesh/seagull`](../..) at build time - that package owns the actual
8
+ `seagull` executable and reads its own `package.json` for `--version`/
9
+ `--help` text, then calls `createProgram()` from here to build the rest.
10
+
11
+ See the [main README](https://github.com/OctalMesh/Seagull#readme) for the
12
+ command reference and full config docs.
13
+
14
+ ## What lives here
15
+
16
+ - `commands/` - one function per pipeline step, each taking a
17
+ `ResolvedConfig` (from [`@octalmesh/seagull-core`](../core)) and returning
18
+ `Promise<void>`.
19
+ - `program.ts` - `createProgram(metadata)`, a pure factory building the
20
+ `commander` `Command` tree. No side effects, no `process.argv` parsing -
21
+ the actual entrypoint (`@octalmesh/seagull`'s `src/cli.ts`) owns that.
22
+
23
+ `commands/generate-docs.ts` and `commands/serve-docs.ts` are thin
24
+ delegations to [`@octalmesh/seagull-docs`](../docs) - the actual docs-site
25
+ implementation lives there.
26
+
27
+ ## License
28
+
29
+ MIT
package/dist/index.mjs ADDED
@@ -0,0 +1,354 @@
1
+ import path from "node:path";
2
+ import { GeneratorRegistry, OpenApiGeneratorCli, OpenApiTypescriptGenerator, git, hashSpec, loadConfig, readFileAtTag, remoteBranchExists, renderArtifactTag, renderReadme, requireOk, resolveBinPath, resolveConfigPath, resolveVersion, run, runSync, syncRedoclyConfig, tagExists } from "@octalmesh/seagull-core";
3
+ import { Command } from "commander";
4
+ import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { generateDocsSite, serveDocsSite } from "@octalmesh/seagull-docs";
6
+ import { tmpdir } from "node:os";
7
+ //#region src/commands/bundle.ts
8
+ /**
9
+ * Bundles every contract's OpenAPI spec into `dist/specs/<contract>.json`.
10
+ *
11
+ * @param config - The resolved seagull config.
12
+ */
13
+ async function bundleCommand(config) {
14
+ await syncRedoclyConfig(config);
15
+ await rm(config.paths.specs, {
16
+ recursive: true,
17
+ force: true
18
+ });
19
+ await mkdir(config.paths.specs, { recursive: true });
20
+ const redoclyBin = resolveBinPath("@redocly/cli", "redocly");
21
+ for (const contract of config.contracts) {
22
+ const output = path.join(config.paths.specs, `${contract.name}.json`);
23
+ await run("node", [
24
+ redoclyBin,
25
+ "bundle",
26
+ contract.entrypoint,
27
+ "-o",
28
+ output
29
+ ], config.rootDir);
30
+ }
31
+ console.log(`Bundled ${config.contracts.length} specifications into ${config.paths.specs}`);
32
+ }
33
+ //#endregion
34
+ //#region src/commands/clean.ts
35
+ /**
36
+ * Removes the entire `dist` output directory.
37
+ *
38
+ * @param config - The resolved seagull config.
39
+ */
40
+ async function cleanCommand(config) {
41
+ await rm(config.paths.dist, {
42
+ recursive: true,
43
+ force: true
44
+ });
45
+ console.log(`Cleaned ${config.paths.dist}`);
46
+ }
47
+ //#endregion
48
+ //#region src/commands/generate-docs.ts
49
+ /**
50
+ * Generates the documentation website for every contract into `dist/docs`.
51
+ * Delegates to `@octalmesh/seagull-docs` - see that package for the actual
52
+ * implementation.
53
+ *
54
+ * @param config - The resolved seagull config.
55
+ */
56
+ async function generateDocsCommand(config) {
57
+ await generateDocsSite(config);
58
+ }
59
+ //#endregion
60
+ //#region src/commands/generate-sdk.ts
61
+ /**
62
+ * Generates SDK packages for every artifact of every contract in the config.
63
+ *
64
+ * @param config - The resolved config.
65
+ */
66
+ async function generateSdkCommand(config) {
67
+ await syncRedoclyConfig(config);
68
+ const registry = new GeneratorRegistry().register(new OpenApiGeneratorCli()).register(new OpenApiTypescriptGenerator());
69
+ await rm(config.paths.sdk, {
70
+ recursive: true,
71
+ force: true
72
+ });
73
+ await mkdir(config.paths.sdk, { recursive: true });
74
+ for (const tool of registry.tools()) {
75
+ const entries = config.allArtifacts.filter((entry) => entry.artifact.tool === tool);
76
+ await registry.resolve(tool).prepare?.({
77
+ rootDir: config.rootDir,
78
+ entries
79
+ });
80
+ }
81
+ const versionCache = /* @__PURE__ */ new Map();
82
+ async function getVersionInfo(contractName) {
83
+ const cached = versionCache.get(contractName);
84
+ if (cached) return cached;
85
+ const specPath = path.join(config.paths.specs, `${contractName}.json`);
86
+ const raw = await readFile(specPath, "utf8");
87
+ const spec = JSON.parse(raw);
88
+ const info = {
89
+ version: resolveVersion(spec, contractName),
90
+ hash: hashSpec(raw)
91
+ };
92
+ versionCache.set(contractName, info);
93
+ return info;
94
+ }
95
+ for (const { contract, artifact } of config.allArtifacts) {
96
+ const { version, hash } = await getVersionInfo(contract.name);
97
+ await registry.resolve(artifact.tool).generate({
98
+ rootDir: config.rootDir,
99
+ contract,
100
+ artifact,
101
+ version,
102
+ github: config.github,
103
+ specInputPath: path.join(config.paths.specs, `${contract.name}.json`)
104
+ });
105
+ await writeFile(path.join(artifact.outputDir, "VERSION"), `${version}\n`);
106
+ await writeFile(path.join(artifact.outputDir, "SPEC_HASH"), `${hash}\n`);
107
+ await writeFile(path.join(artifact.outputDir, "README.md"), await renderReadme({
108
+ contract,
109
+ artifact,
110
+ version,
111
+ github: config.github,
112
+ vars: config.vars
113
+ }));
114
+ }
115
+ const versionSummary = [...versionCache.entries()].map(([name, { version }]) => `${name}@${version}`).join(", ");
116
+ console.log(`Generated ${config.allArtifacts.length} SDK packages into ${config.paths.sdk} (${versionSummary})`);
117
+ }
118
+ //#endregion
119
+ //#region src/commands/lint.ts
120
+ /**
121
+ * Lints every contract's OpenAPI spec.
122
+ * Sets `process.exitCode = 1` if any contract fails.
123
+ *
124
+ * @param config - The resolved seagull config.
125
+ */
126
+ async function lintCommand(config) {
127
+ await syncRedoclyConfig(config);
128
+ console.log("Linting OpenAPI contracts...");
129
+ const redoclyBin = resolveBinPath("@redocly/cli", "redocly");
130
+ let hasErrors = false;
131
+ for (const contract of config.contracts) {
132
+ console.log(`Checking [${contract.name}]...`);
133
+ if (runSync("node", [
134
+ redoclyBin,
135
+ "lint",
136
+ contract.entrypoint
137
+ ], config.rootDir) !== 0) hasErrors = true;
138
+ }
139
+ if (hasErrors) process.exitCode = 1;
140
+ }
141
+ //#endregion
142
+ //#region src/commands/publish-registries.ts
143
+ /**
144
+ * Publishes registry-backed packages:
145
+ * - TypeScript (client and server-types) -> npm (needs a configured registry
146
+ * or auth token on the machine running this).
147
+ * - Java (client and server) -> Maven (needs `~/.m2/settings.xml` credentials
148
+ * for whichever repository `mvn deploy` resolves to).
149
+ *
150
+ * Go packages are intentionally skipped - they're consumed straight from
151
+ * their git branch/tag (see `publish-sdk.ts`), Go has no registry step.
152
+ *
153
+ * @param config - The resolved CLI config.
154
+ * @param options - `{ dryRun }` - print what would run without running it.
155
+ */
156
+ async function publishRegistriesCommand(config, options = {}) {
157
+ const dryRun = options.dryRun ?? false;
158
+ async function runCommand(cmd, args, cwd) {
159
+ console.log(`$ ${cmd} ${args.join(" ")} (in ${cwd})`);
160
+ if (dryRun) return;
161
+ await run(cmd, args, cwd);
162
+ }
163
+ let publishedCount = 0;
164
+ for (const { contract, artifact } of config.allArtifacts) {
165
+ if (artifact.lang === "typescript") {
166
+ console.log(`\n=== npm publish: ${artifact.package} (contract: ${contract.name}, ${artifact.kind}) ===`);
167
+ await runCommand("npm", ["publish"], artifact.outputDir);
168
+ publishedCount += 1;
169
+ }
170
+ if (artifact.lang === "java") {
171
+ console.log(`\n=== maven deploy: ${artifact.maven?.groupId}:${artifact.maven?.artifactId} (contract: ${contract.name}, ${artifact.kind}) ===`);
172
+ await runCommand("mvn", [
173
+ "-B",
174
+ "deploy",
175
+ "-DskipTests"
176
+ ], artifact.outputDir);
177
+ publishedCount += 1;
178
+ }
179
+ }
180
+ console.log(`\nDone - published ${publishedCount} registry package(s).`);
181
+ }
182
+ //#endregion
183
+ //#region src/commands/publish-sdk.ts
184
+ /**
185
+ * Redistributes each generated artifact's `dist/sdk/<contract>/<artifact-id>`
186
+ * into its own orphan branch (per the artifact's resolved `publishing.branch`)
187
+ * and tags the
188
+ * publish.
189
+ *
190
+ * @param config - The resolved CLI config.
191
+ * @param options - `{ dryRun }` - skip pushing, just report what would happen.
192
+ */
193
+ async function publishSdkCommand(config, options = {}) {
194
+ const dryRun = options.dryRun ?? false;
195
+ for (const { contract, artifact } of config.allArtifacts) {
196
+ const version = (await readFile(path.join(artifact.outputDir, "VERSION"), "utf8")).trim();
197
+ const localHash = (await readFile(path.join(artifact.outputDir, "SPEC_HASH"), "utf8")).trim();
198
+ const tag = renderArtifactTag(artifact, contract.name, version, config.github, config.vars);
199
+ console.log(`\n=== ${contract.name} / ${artifact.id} -> ${artifact.branch} (v${version}) ===`);
200
+ if (tagExists(config.rootDir, tag)) {
201
+ const remoteHash = readFileAtTag(config.rootDir, tag, "SPEC_HASH");
202
+ 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.`);
203
+ console.log(`Tag ${tag} already exists on origin with matching content, skipping (already published).`);
204
+ continue;
205
+ }
206
+ const worktreeDir = await mkdtemp(path.join(tmpdir(), "sdk-publish-"));
207
+ await rm(worktreeDir, {
208
+ recursive: true,
209
+ force: true
210
+ });
211
+ git([
212
+ "fetch",
213
+ "origin",
214
+ artifact.branch
215
+ ], config.rootDir);
216
+ const hasRemoteBranch = remoteBranchExists(config.rootDir, artifact.branch);
217
+ const setup = hasRemoteBranch ? git([
218
+ "worktree",
219
+ "add",
220
+ worktreeDir,
221
+ `origin/${artifact.branch}`
222
+ ], config.rootDir) : git([
223
+ "worktree",
224
+ "add",
225
+ "--detach",
226
+ worktreeDir
227
+ ], config.rootDir);
228
+ requireOk(setup, `Failed to create worktree for ${artifact.branch}`);
229
+ if (hasRemoteBranch) requireOk(git([
230
+ "checkout",
231
+ "-B",
232
+ artifact.branch,
233
+ `origin/${artifact.branch}`
234
+ ], worktreeDir), `Failed to check out ${artifact.branch}`);
235
+ else requireOk(git([
236
+ "checkout",
237
+ "--orphan",
238
+ artifact.branch
239
+ ], worktreeDir), `Failed to create orphan branch ${artifact.branch}`);
240
+ git([
241
+ "rm",
242
+ "-rf",
243
+ "--quiet",
244
+ "."
245
+ ], worktreeDir);
246
+ await cp(artifact.outputDir, worktreeDir, { recursive: true });
247
+ git(["add", "-A"], worktreeDir);
248
+ if (!(git([
249
+ "diff",
250
+ "--cached",
251
+ "--quiet"
252
+ ], worktreeDir).status !== 0)) console.log("No content changes since last publish - committing tag only.");
253
+ else requireOk(git([
254
+ "commit",
255
+ "-m",
256
+ `chore(sdk): publish ${contract.name} ${artifact.id} v${version}`
257
+ ], worktreeDir), `Commit failed for ${artifact.branch}`);
258
+ requireOk(git(["tag", tag], worktreeDir), `Tagging failed for ${tag}`);
259
+ if (dryRun) console.log(`[dry-run] would push ${artifact.branch} and tag ${tag}`);
260
+ else {
261
+ requireOk(git([
262
+ "push",
263
+ "origin",
264
+ `HEAD:refs/heads/${artifact.branch}`
265
+ ], worktreeDir), `Push failed for ${artifact.branch}`);
266
+ requireOk(git([
267
+ "push",
268
+ "origin",
269
+ tag
270
+ ], worktreeDir), `Tag push failed for ${tag}`);
271
+ console.log(`Published ${artifact.branch} @ ${tag}`);
272
+ }
273
+ git([
274
+ "worktree",
275
+ "remove",
276
+ "--force",
277
+ worktreeDir
278
+ ], config.rootDir);
279
+ }
280
+ console.log(`\nDone - processed ${config.allArtifacts.length} SDK packages.`);
281
+ }
282
+ //#endregion
283
+ //#region src/commands/serve-docs.ts
284
+ /**
285
+ * Serves the generated documentation site (`dist/docs`) over plain HTTP for
286
+ * local previewing. Delegates to `@octalmesh/seagull-docs`.
287
+ *
288
+ * @param config - The resolved seagull config.
289
+ */
290
+ async function serveDocsCommand(config) {
291
+ await serveDocsSite(config);
292
+ }
293
+ //#endregion
294
+ //#region src/program.ts
295
+ /**
296
+ * Builds the seagull commander program - every subcommand, wired up to the
297
+ * pipeline command functions. Pure and side-effect-free (doesn't parse
298
+ * `process.argv` or read any file itself) so it's usable both by the real
299
+ * CLI entrypoint and by anything that wants to drive it programmatically or
300
+ * test it.
301
+ *
302
+ * @param metadata - `{ name, version, description }` shown in `--help`/`--version`
303
+ * - the caller's own `package.json` fields, since this
304
+ * package doesn't read its own (it's bundled into
305
+ * `@octalmesh/seagull`, whose metadata is what should show).
306
+ * @returns The configured commander `Command`, ready for `.parseAsync()`.
307
+ */
308
+ function createProgram(metadata) {
309
+ const program = new Command();
310
+ program.name(metadata.name).description(metadata.description).version(metadata.version).option("-c, --config <path>", "path to the seagull config file (default: auto-detected in the current directory)");
311
+ /** Resolves and loads the config, using `--config` if given, else
312
+ * auto-discovering it in the current directory. */
313
+ function resolveConfig() {
314
+ const { config: configOption } = program.opts();
315
+ const configPath = configOption ? path.resolve(process.cwd(), configOption) : resolveConfigPath(process.cwd());
316
+ return loadConfig(configPath);
317
+ }
318
+ program.command("lint").description("Lint every contract's OpenAPI spec with Redocly.").action(withErrorHandling(async () => lintCommand(resolveConfig())));
319
+ program.command("bundle").description("Bundle every contract's OpenAPI spec into dist/specs.").action(withErrorHandling(async () => bundleCommand(resolveConfig())));
320
+ program.command("generate").description("Generate every configured SDK artifact into dist/sdk.").action(withErrorHandling(async () => generateSdkCommand(resolveConfig())));
321
+ program.command("clean").description("Remove the dist output directory.").action(withErrorHandling(async () => cleanCommand(resolveConfig())));
322
+ const docs = program.command("docs").description("Documentation site commands.");
323
+ docs.command("generate").description("Generate the documentation site into dist/docs.").action(withErrorHandling(async () => generateDocsCommand(resolveConfig())));
324
+ docs.command("serve").description("Serve the generated documentation site locally.").action(withErrorHandling(async () => serveDocsCommand(resolveConfig())));
325
+ const publish = program.command("publish").description("Publishing commands.");
326
+ publish.command("sdk").description("Publish generated SDKs to their per-artifact git branches/tags.").option("--dry-run", "print what would be pushed without pushing").action(withErrorHandling(async (opts) => {
327
+ await publishSdkCommand(resolveConfig(), { dryRun: opts.dryRun });
328
+ }));
329
+ publish.command("registries").description("Publish registry-backed packages (npm publish / mvn deploy).").option("--dry-run", "print what would be published without publishing").action(withErrorHandling(async (opts) => {
330
+ await publishRegistriesCommand(resolveConfig(), { dryRun: opts.dryRun });
331
+ }));
332
+ return program;
333
+ }
334
+ /**
335
+ * Wraps a commander action so a thrown Error prints as `seagull: <message>`
336
+ * and exits non-zero, instead of an unhandled-rejection stack trace.
337
+ *
338
+ * @param fn - The action function to wrap.
339
+ * @returns A wrapped action function that handles errors.
340
+ */
341
+ function withErrorHandling(fn) {
342
+ return async (...args) => {
343
+ try {
344
+ await fn(...args);
345
+ } catch (error) {
346
+ console.error(`seagull: ${error instanceof Error ? error.message : String(error)}`);
347
+ process.exitCode = 1;
348
+ }
349
+ };
350
+ }
351
+ //#endregion
352
+ export { bundleCommand, cleanCommand, createProgram, generateDocsCommand, generateSdkCommand, lintCommand, publishRegistriesCommand, publishSdkCommand, serveDocsCommand };
353
+
354
+ //# sourceMappingURL=index.mjs.map
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@octalmesh/seagull-cli",
3
+ "version": "0.0.2",
4
+ "description": "Seagull's pipeline commands and commander program - bundled into @octalmesh/seagull.",
5
+ "author": "OctalMesh <contact@octalmesh.com> (https://octalmesh.com)",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/OctalMesh/Seagull/tree/main/packages/cli",
8
+ "type": "module",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/OctalMesh/Seagull.git",
12
+ "directory": "packages/cli"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/OctalMesh/Seagull/issues",
16
+ "email": "security@octalmesh.com"
17
+ },
18
+ "main": "./dist/index.mjs",
19
+ "types": "./dist/index.d.mts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.mts",
23
+ "default": "./dist/index.mjs"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "engines": {
31
+ "node": ">=22.22.0"
32
+ },
33
+ "dependencies": {
34
+ "@octalmesh/seagull-core": "0.0.2",
35
+ "@octalmesh/seagull-docs": "0.0.2",
36
+ "commander": "^15.0.0"
37
+ },
38
+ "scripts": {
39
+ "build": "tsdown",
40
+ "dev": "tsdown --watch",
41
+ "typecheck": "tsc --noEmit"
42
+ }
43
+ }
@@ -0,0 +1,37 @@
1
+ import { mkdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ type ResolvedConfig,
6
+ resolveBinPath,
7
+ run,
8
+ syncRedoclyConfig,
9
+ } from "@octalmesh/seagull-core";
10
+
11
+ /**
12
+ * Bundles every contract's OpenAPI spec into `dist/specs/<contract>.json`.
13
+ *
14
+ * @param config - The resolved seagull config.
15
+ */
16
+ export async function bundleCommand(config: ResolvedConfig): Promise<void> {
17
+ await syncRedoclyConfig(config);
18
+
19
+ await rm(config.paths.specs, { recursive: true, force: true });
20
+ await mkdir(config.paths.specs, { recursive: true });
21
+
22
+ const redoclyBin = resolveBinPath("@redocly/cli", "redocly");
23
+
24
+ for (const contract of config.contracts) {
25
+ const output = path.join(config.paths.specs, `${contract.name}.json`);
26
+
27
+ await run(
28
+ "node",
29
+ [redoclyBin, "bundle", contract.entrypoint, "-o", output],
30
+ config.rootDir,
31
+ );
32
+ }
33
+
34
+ console.log(
35
+ `Bundled ${config.contracts.length} specifications into ${config.paths.specs}`,
36
+ );
37
+ }
@@ -0,0 +1,14 @@
1
+ import { rm } from "node:fs/promises";
2
+
3
+ import type { ResolvedConfig } from "@octalmesh/seagull-core";
4
+
5
+ /**
6
+ * Removes the entire `dist` output directory.
7
+ *
8
+ * @param config - The resolved seagull config.
9
+ */
10
+ export async function cleanCommand(config: ResolvedConfig): Promise<void> {
11
+ await rm(config.paths.dist, { recursive: true, force: true });
12
+
13
+ console.log(`Cleaned ${config.paths.dist}`);
14
+ }
@@ -0,0 +1,15 @@
1
+ import type { ResolvedConfig } from "@octalmesh/seagull-core";
2
+ import { generateDocsSite } from "@octalmesh/seagull-docs";
3
+
4
+ /**
5
+ * Generates the documentation website for every contract into `dist/docs`.
6
+ * Delegates to `@octalmesh/seagull-docs` - see that package for the actual
7
+ * implementation.
8
+ *
9
+ * @param config - The resolved seagull config.
10
+ */
11
+ export async function generateDocsCommand(
12
+ config: ResolvedConfig,
13
+ ): Promise<void> {
14
+ await generateDocsSite(config);
15
+ }
@@ -0,0 +1,107 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ type BundledSpec,
6
+ GeneratorRegistry,
7
+ OpenApiGeneratorCli,
8
+ OpenApiTypescriptGenerator,
9
+ type ResolvedConfig,
10
+ hashSpec,
11
+ renderReadme,
12
+ resolveVersion,
13
+ syncRedoclyConfig,
14
+ } from "@octalmesh/seagull-core";
15
+
16
+ interface VersionInfo {
17
+ version: string;
18
+ hash: string;
19
+ }
20
+
21
+ /**
22
+ * Generates SDK packages for every artifact of every contract in the config.
23
+ *
24
+ * @param config - The resolved config.
25
+ */
26
+ export async function generateSdkCommand(
27
+ config: ResolvedConfig,
28
+ ): Promise<void> {
29
+ // openapi-typescript reads 'redocly.yaml' directly, so it needs to be in sync
30
+ // with the seagull config before that generator's 'prepare()' runs
31
+ await syncRedoclyConfig(config);
32
+
33
+ const registry = new GeneratorRegistry()
34
+ .register(new OpenApiGeneratorCli())
35
+ .register(new OpenApiTypescriptGenerator());
36
+
37
+ await rm(config.paths.sdk, { recursive: true, force: true });
38
+ await mkdir(config.paths.sdk, { recursive: true });
39
+
40
+ for (const tool of registry.tools()) {
41
+ const entries = config.allArtifacts.filter(
42
+ (entry) => entry.artifact.tool === tool,
43
+ );
44
+
45
+ await registry
46
+ .resolve(tool)
47
+ .prepare?.({ rootDir: config.rootDir, entries });
48
+ }
49
+
50
+ const versionCache = new Map<string, VersionInfo>();
51
+
52
+ async function getVersionInfo(contractName: string): Promise<VersionInfo> {
53
+ const cached = versionCache.get(contractName);
54
+
55
+ if (cached) {
56
+ return cached;
57
+ }
58
+
59
+ const specPath = path.join(config.paths.specs, `${contractName}.json`);
60
+ const raw = await readFile(specPath, "utf8");
61
+ const spec = JSON.parse(raw) as BundledSpec;
62
+
63
+ const info: VersionInfo = {
64
+ version: resolveVersion(spec, contractName),
65
+ hash: hashSpec(raw),
66
+ };
67
+
68
+ versionCache.set(contractName, info);
69
+
70
+ return info;
71
+ }
72
+
73
+ for (const { contract, artifact } of config.allArtifacts) {
74
+ const { version, hash } = await getVersionInfo(contract.name);
75
+ const generator = registry.resolve(artifact.tool);
76
+
77
+ await generator.generate({
78
+ rootDir: config.rootDir,
79
+ contract,
80
+ artifact,
81
+ version,
82
+ github: config.github,
83
+ specInputPath: path.join(config.paths.specs, `${contract.name}.json`),
84
+ });
85
+
86
+ await writeFile(path.join(artifact.outputDir, "VERSION"), `${version}\n`);
87
+ await writeFile(path.join(artifact.outputDir, "SPEC_HASH"), `${hash}\n`);
88
+ await writeFile(
89
+ path.join(artifact.outputDir, "README.md"),
90
+ await renderReadme({
91
+ contract,
92
+ artifact,
93
+ version,
94
+ github: config.github,
95
+ vars: config.vars,
96
+ }),
97
+ );
98
+ }
99
+
100
+ const versionSummary = [...versionCache.entries()]
101
+ .map(([name, { version }]) => `${name}@${version}`)
102
+ .join(", ");
103
+
104
+ console.log(
105
+ `Generated ${config.allArtifacts.length} SDK packages into ${config.paths.sdk} (${versionSummary})`,
106
+ );
107
+ }
@@ -0,0 +1,41 @@
1
+ import {
2
+ type ResolvedConfig,
3
+ resolveBinPath,
4
+ runSync,
5
+ syncRedoclyConfig,
6
+ } from "@octalmesh/seagull-core";
7
+
8
+ /**
9
+ * Lints every contract's OpenAPI spec.
10
+ * Sets `process.exitCode = 1` if any contract fails.
11
+ *
12
+ * @param config - The resolved seagull config.
13
+ */
14
+ export async function lintCommand(config: ResolvedConfig): Promise<void> {
15
+ // redocly reads 'redocly.yaml' directly, so it needs to be in sync with the
16
+ // seagull config before linting
17
+ await syncRedoclyConfig(config);
18
+
19
+ console.log("Linting OpenAPI contracts...");
20
+
21
+ const redoclyBin = resolveBinPath("@redocly/cli", "redocly");
22
+ let hasErrors = false;
23
+
24
+ for (const contract of config.contracts) {
25
+ console.log(`Checking [${contract.name}]...`);
26
+
27
+ const status = runSync(
28
+ "node",
29
+ [redoclyBin, "lint", contract.entrypoint],
30
+ config.rootDir,
31
+ );
32
+
33
+ if (status !== 0) {
34
+ hasErrors = true;
35
+ }
36
+ }
37
+
38
+ if (hasErrors) {
39
+ process.exitCode = 1;
40
+ }
41
+ }
@@ -0,0 +1,70 @@
1
+ import {
2
+ type ResolvedConfig,
3
+ run as runProcess,
4
+ } from "@octalmesh/seagull-core";
5
+
6
+ export interface PublishRegistriesOptions {
7
+ dryRun?: boolean;
8
+ }
9
+
10
+ /**
11
+ * Publishes registry-backed packages:
12
+ * - TypeScript (client and server-types) -> npm (needs a configured registry
13
+ * or auth token on the machine running this).
14
+ * - Java (client and server) -> Maven (needs `~/.m2/settings.xml` credentials
15
+ * for whichever repository `mvn deploy` resolves to).
16
+ *
17
+ * Go packages are intentionally skipped - they're consumed straight from
18
+ * their git branch/tag (see `publish-sdk.ts`), Go has no registry step.
19
+ *
20
+ * @param config - The resolved CLI config.
21
+ * @param options - `{ dryRun }` - print what would run without running it.
22
+ */
23
+ export async function publishRegistriesCommand(
24
+ config: ResolvedConfig,
25
+ options: PublishRegistriesOptions = {},
26
+ ): Promise<void> {
27
+ const dryRun = options.dryRun ?? false;
28
+
29
+ async function runCommand(
30
+ cmd: string,
31
+ args: string[],
32
+ cwd: string,
33
+ ): Promise<void> {
34
+ console.log(`$ ${cmd} ${args.join(" ")} (in ${cwd})`);
35
+
36
+ if (dryRun) {
37
+ return;
38
+ }
39
+
40
+ await runProcess(cmd, args, cwd);
41
+ }
42
+
43
+ let publishedCount = 0;
44
+
45
+ for (const { contract, artifact } of config.allArtifacts) {
46
+ if (artifact.lang === "typescript") {
47
+ console.log(
48
+ `\n=== npm publish: ${artifact.package} (contract: ${contract.name}, ${artifact.kind}) ===`,
49
+ );
50
+ await runCommand("npm", ["publish"], artifact.outputDir);
51
+
52
+ publishedCount += 1;
53
+ }
54
+
55
+ if (artifact.lang === "java") {
56
+ console.log(
57
+ `\n=== maven deploy: ${artifact.maven?.groupId}:${artifact.maven?.artifactId} (contract: ${contract.name}, ${artifact.kind}) ===`,
58
+ );
59
+ await runCommand(
60
+ "mvn",
61
+ ["-B", "deploy", "-DskipTests"],
62
+ artifact.outputDir,
63
+ );
64
+
65
+ publishedCount += 1;
66
+ }
67
+ }
68
+
69
+ console.log(`\nDone - published ${publishedCount} registry package(s).`);
70
+ }
@@ -0,0 +1,148 @@
1
+ import { cp, mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ type ResolvedConfig,
7
+ git,
8
+ readFileAtTag,
9
+ remoteBranchExists,
10
+ renderArtifactTag,
11
+ requireOk,
12
+ tagExists,
13
+ } from "@octalmesh/seagull-core";
14
+
15
+ export interface PublishSdkOptions {
16
+ dryRun?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Redistributes each generated artifact's `dist/sdk/<contract>/<artifact-id>`
21
+ * into its own orphan branch (per the artifact's resolved `publishing.branch`)
22
+ * and tags the
23
+ * publish.
24
+ *
25
+ * @param config - The resolved CLI config.
26
+ * @param options - `{ dryRun }` - skip pushing, just report what would happen.
27
+ */
28
+ export async function publishSdkCommand(
29
+ config: ResolvedConfig,
30
+ options: PublishSdkOptions = {},
31
+ ): Promise<void> {
32
+ const dryRun = options.dryRun ?? false;
33
+
34
+ for (const { contract, artifact } of config.allArtifacts) {
35
+ const version = (
36
+ await readFile(path.join(artifact.outputDir, "VERSION"), "utf8")
37
+ ).trim();
38
+ const localHash = (
39
+ await readFile(path.join(artifact.outputDir, "SPEC_HASH"), "utf8")
40
+ ).trim();
41
+ const tag = renderArtifactTag(
42
+ artifact,
43
+ contract.name,
44
+ version,
45
+ config.github,
46
+ config.vars,
47
+ );
48
+
49
+ console.log(
50
+ `\n=== ${contract.name} / ${artifact.id} -> ${artifact.branch} (v${version}) ===`,
51
+ );
52
+
53
+ if (tagExists(config.rootDir, tag)) {
54
+ const remoteHash = readFileAtTag(config.rootDir, tag, "SPEC_HASH");
55
+
56
+ if (remoteHash !== null && remoteHash !== localHash) {
57
+ throw new Error(
58
+ `Tag ${tag} already exists, but the ${contract.name} spec content ` +
59
+ `has changed since it was published under that version. Bump ` +
60
+ `"info.version" in ${contract.entrypointRelative} before ` +
61
+ `releasing again.`,
62
+ );
63
+ }
64
+
65
+ console.log(
66
+ `Tag ${tag} already exists on origin with matching content, skipping (already published).`,
67
+ );
68
+ continue;
69
+ }
70
+
71
+ const worktreeDir = await mkdtemp(path.join(tmpdir(), "sdk-publish-"));
72
+ await rm(worktreeDir, { recursive: true, force: true });
73
+
74
+ git(["fetch", "origin", artifact.branch], config.rootDir);
75
+ const hasRemoteBranch = remoteBranchExists(config.rootDir, artifact.branch);
76
+
77
+ const setup = hasRemoteBranch
78
+ ? git(
79
+ ["worktree", "add", worktreeDir, `origin/${artifact.branch}`],
80
+ config.rootDir,
81
+ )
82
+ : git(["worktree", "add", "--detach", worktreeDir], config.rootDir);
83
+ requireOk(setup, `Failed to create worktree for ${artifact.branch}`);
84
+
85
+ if (hasRemoteBranch) {
86
+ requireOk(
87
+ git(
88
+ ["checkout", "-B", artifact.branch, `origin/${artifact.branch}`],
89
+ worktreeDir,
90
+ ),
91
+ `Failed to check out ${artifact.branch}`,
92
+ );
93
+ } else {
94
+ requireOk(
95
+ git(["checkout", "--orphan", artifact.branch], worktreeDir),
96
+ `Failed to create orphan branch ${artifact.branch}`,
97
+ );
98
+ }
99
+
100
+ git(["rm", "-rf", "--quiet", "."], worktreeDir);
101
+ await cp(artifact.outputDir, worktreeDir, { recursive: true });
102
+
103
+ git(["add", "-A"], worktreeDir);
104
+ const hasChanges =
105
+ git(["diff", "--cached", "--quiet"], worktreeDir).status !== 0;
106
+
107
+ if (!hasChanges) {
108
+ console.log(
109
+ "No content changes since last publish - committing tag only.",
110
+ );
111
+ } else {
112
+ requireOk(
113
+ git(
114
+ [
115
+ "commit",
116
+ "-m",
117
+ `chore(sdk): publish ${contract.name} ${artifact.id} v${version}`,
118
+ ],
119
+ worktreeDir,
120
+ ),
121
+ `Commit failed for ${artifact.branch}`,
122
+ );
123
+ }
124
+
125
+ requireOk(git(["tag", tag], worktreeDir), `Tagging failed for ${tag}`);
126
+
127
+ if (dryRun) {
128
+ console.log(`[dry-run] would push ${artifact.branch} and tag ${tag}`);
129
+ } else {
130
+ requireOk(
131
+ git(
132
+ ["push", "origin", `HEAD:refs/heads/${artifact.branch}`],
133
+ worktreeDir,
134
+ ),
135
+ `Push failed for ${artifact.branch}`,
136
+ );
137
+ requireOk(
138
+ git(["push", "origin", tag], worktreeDir),
139
+ `Tag push failed for ${tag}`,
140
+ );
141
+ console.log(`Published ${artifact.branch} @ ${tag}`);
142
+ }
143
+
144
+ git(["worktree", "remove", "--force", worktreeDir], config.rootDir);
145
+ }
146
+
147
+ console.log(`\nDone - processed ${config.allArtifacts.length} SDK packages.`);
148
+ }
@@ -0,0 +1,12 @@
1
+ import type { ResolvedConfig } from "@octalmesh/seagull-core";
2
+ import { serveDocsSite } from "@octalmesh/seagull-docs";
3
+
4
+ /**
5
+ * Serves the generated documentation site (`dist/docs`) over plain HTTP for
6
+ * local previewing. Delegates to `@octalmesh/seagull-docs`.
7
+ *
8
+ * @param config - The resolved seagull config.
9
+ */
10
+ export async function serveDocsCommand(config: ResolvedConfig): Promise<void> {
11
+ await serveDocsSite(config);
12
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { createProgram } from "./program";
2
+ export type { ProgramMetadata } from "./program";
3
+
4
+ export { bundleCommand } from "./commands/bundle";
5
+ export { cleanCommand } from "./commands/clean";
6
+ export { generateDocsCommand } from "./commands/generate-docs";
7
+ export { generateSdkCommand } from "./commands/generate-sdk";
8
+ export { lintCommand } from "./commands/lint";
9
+ export type { PublishRegistriesOptions } from "./commands/publish-registries";
10
+ export { publishRegistriesCommand } from "./commands/publish-registries";
11
+ export type { PublishSdkOptions } from "./commands/publish-sdk";
12
+ export { publishSdkCommand } from "./commands/publish-sdk";
13
+ export { serveDocsCommand } from "./commands/serve-docs";
package/src/program.ts ADDED
@@ -0,0 +1,154 @@
1
+ import path from "node:path";
2
+
3
+ import {
4
+ type ResolvedConfig,
5
+ loadConfig,
6
+ resolveConfigPath,
7
+ } from "@octalmesh/seagull-core";
8
+ import { Command } from "commander";
9
+
10
+ import { bundleCommand } from "./commands/bundle";
11
+ import { cleanCommand } from "./commands/clean";
12
+ import { generateDocsCommand } from "./commands/generate-docs";
13
+ import { generateSdkCommand } from "./commands/generate-sdk";
14
+ import { lintCommand } from "./commands/lint";
15
+ import { publishRegistriesCommand } from "./commands/publish-registries";
16
+ import { publishSdkCommand } from "./commands/publish-sdk";
17
+ import { serveDocsCommand } from "./commands/serve-docs";
18
+
19
+ export interface ProgramMetadata {
20
+ name: string;
21
+ version: string;
22
+ description: string;
23
+ }
24
+
25
+ interface DryRunOptions {
26
+ dryRun?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Builds the seagull commander program - every subcommand, wired up to the
31
+ * pipeline command functions. Pure and side-effect-free (doesn't parse
32
+ * `process.argv` or read any file itself) so it's usable both by the real
33
+ * CLI entrypoint and by anything that wants to drive it programmatically or
34
+ * test it.
35
+ *
36
+ * @param metadata - `{ name, version, description }` shown in `--help`/`--version`
37
+ * - the caller's own `package.json` fields, since this
38
+ * package doesn't read its own (it's bundled into
39
+ * `@octalmesh/seagull`, whose metadata is what should show).
40
+ * @returns The configured commander `Command`, ready for `.parseAsync()`.
41
+ */
42
+ export function createProgram(metadata: ProgramMetadata): Command {
43
+ const program = new Command();
44
+
45
+ program
46
+ .name(metadata.name)
47
+ .description(metadata.description)
48
+ .version(metadata.version)
49
+ .option(
50
+ "-c, --config <path>",
51
+ "path to the seagull config file (default: auto-detected in the current directory)",
52
+ );
53
+
54
+ /**
55
+ * Resolves and loads the config, using `--config` if given, else
56
+ * auto-discovering it in the current directory.
57
+ */
58
+ function resolveConfig(): ResolvedConfig {
59
+ const { config: configOption } = program.opts<{ config?: string }>();
60
+ const configPath = configOption
61
+ ? path.resolve(process.cwd(), configOption)
62
+ : resolveConfigPath(process.cwd());
63
+
64
+ return loadConfig(configPath);
65
+ }
66
+
67
+ program
68
+ .command("lint")
69
+ .description("Lint every contract's OpenAPI spec with Redocly.")
70
+ .action(withErrorHandling(async () => lintCommand(resolveConfig())));
71
+
72
+ program
73
+ .command("bundle")
74
+ .description("Bundle every contract's OpenAPI spec into dist/specs.")
75
+ .action(withErrorHandling(async () => bundleCommand(resolveConfig())));
76
+
77
+ program
78
+ .command("generate")
79
+ .description("Generate every configured SDK artifact into dist/sdk.")
80
+ .action(withErrorHandling(async () => generateSdkCommand(resolveConfig())));
81
+
82
+ program
83
+ .command("clean")
84
+ .description("Remove the dist output directory.")
85
+ .action(withErrorHandling(async () => cleanCommand(resolveConfig())));
86
+
87
+ const docs = program
88
+ .command("docs")
89
+ .description("Documentation site commands.");
90
+
91
+ docs
92
+ .command("generate")
93
+ .description("Generate the documentation site into dist/docs.")
94
+ .action(
95
+ withErrorHandling(async () => generateDocsCommand(resolveConfig())),
96
+ );
97
+
98
+ docs
99
+ .command("serve")
100
+ .description("Serve the generated documentation site locally.")
101
+ .action(withErrorHandling(async () => serveDocsCommand(resolveConfig())));
102
+
103
+ const publish = program
104
+ .command("publish")
105
+ .description("Publishing commands.");
106
+
107
+ publish
108
+ .command("sdk")
109
+ .description(
110
+ "Publish generated SDKs to their per-artifact git branches/tags.",
111
+ )
112
+ .option("--dry-run", "print what would be pushed without pushing")
113
+ .action(
114
+ withErrorHandling(async (opts: DryRunOptions) => {
115
+ await publishSdkCommand(resolveConfig(), { dryRun: opts.dryRun });
116
+ }),
117
+ );
118
+
119
+ publish
120
+ .command("registries")
121
+ .description("Publish registry-backed packages (npm publish / mvn deploy).")
122
+ .option("--dry-run", "print what would be published without publishing")
123
+ .action(
124
+ withErrorHandling(async (opts: DryRunOptions) => {
125
+ await publishRegistriesCommand(resolveConfig(), {
126
+ dryRun: opts.dryRun,
127
+ });
128
+ }),
129
+ );
130
+
131
+ return program;
132
+ }
133
+
134
+ /**
135
+ * Wraps a commander action so a thrown Error prints as `seagull: <message>`
136
+ * and exits non-zero, instead of an unhandled-rejection stack trace.
137
+ *
138
+ * @param fn - The action function to wrap.
139
+ * @returns A wrapped action function that handles errors.
140
+ */
141
+ function withErrorHandling<Args extends unknown[]>(
142
+ fn: (...args: Args) => Promise<void>,
143
+ ): (...args: Args) => Promise<void> {
144
+ return async (...args: Args) => {
145
+ try {
146
+ await fn(...args);
147
+ } catch (error) {
148
+ console.error(
149
+ `seagull: ${error instanceof Error ? error.message : String(error)}`,
150
+ );
151
+ process.exitCode = 1;
152
+ }
153
+ };
154
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+
4
+ "compilerOptions": {
5
+ /* Environment */
6
+ "target": "es2024",
7
+ "lib": ["esnext"],
8
+ "types": ["node"],
9
+ "allowJs": false,
10
+
11
+ /* Modules */
12
+ "module": "esnext",
13
+ "moduleResolution": "bundler",
14
+ "moduleDetection": "force",
15
+ "allowImportingTsExtensions": false,
16
+ "erasableSyntaxOnly": true,
17
+ "esModuleInterop": true,
18
+ "resolveJsonModule": true,
19
+ "forceConsistentCasingInFileNames": true,
20
+ "isolatedModules": true,
21
+ "noEmit": true,
22
+
23
+ /* Strict Type Checking */
24
+ "strict": true,
25
+ "skipLibCheck": true,
26
+ "noFallthroughCasesInSwitch": true,
27
+ "noImplicitOverride": true,
28
+ "noUnusedLocals": true,
29
+ "noUnusedParameters": true,
30
+ "noUncheckedIndexedAccess": true
31
+ },
32
+
33
+ "include": ["src/**/*.ts", "*.config.ts"]
34
+ }
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ /**
4
+ * Tsdown configuration
5
+ *
6
+ * @see {@link https://tsdown.dev Tsdown documentation}
7
+ */
8
+ // noinspection JSUnusedGlobalSymbols
9
+ export default defineConfig({
10
+ entry: {
11
+ index: "src/index.ts",
12
+ },
13
+ platform: "node",
14
+ format: ["esm"],
15
+ target: "node22",
16
+ dts: {
17
+ entry: "src/index.ts",
18
+ },
19
+ clean: true,
20
+ sourcemap: true,
21
+ });