@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 +21 -0
- package/README.md +29 -0
- package/dist/index.mjs +354 -0
- package/package.json +43 -0
- package/src/commands/bundle.ts +37 -0
- package/src/commands/clean.ts +14 -0
- package/src/commands/generate-docs.ts +15 -0
- package/src/commands/generate-sdk.ts +107 -0
- package/src/commands/lint.ts +41 -0
- package/src/commands/publish-registries.ts +70 -0
- package/src/commands/publish-sdk.ts +148 -0
- package/src/commands/serve-docs.ts +12 -0
- package/src/index.ts +13 -0
- package/src/program.ts +154 -0
- package/tsconfig.json +34 -0
- package/tsdown.config.ts +21 -0
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
|
+
}
|
package/tsdown.config.ts
ADDED
|
@@ -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
|
+
});
|