@octalmesh/seagull-cli 0.0.2 → 0.1.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/CHANGELOG.md +49 -0
- package/README.md +157 -20
- package/dist/index.mjs +18 -13
- package/package.json +5 -5
- package/src/commands/bundle.test.ts +192 -0
- package/src/commands/bundle.ts +18 -8
- package/src/commands/clean.test.ts +67 -0
- package/src/commands/generate-docs.test.ts +30 -0
- package/src/commands/generate-sdk.test.ts +382 -0
- package/src/commands/generate-sdk.ts +13 -5
- package/src/commands/lint.test.ts +125 -0
- package/src/commands/publish-registries.test.ts +160 -0
- package/src/commands/publish-sdk.test.ts +383 -0
- package/src/commands/publish-sdk.ts +1 -2
- package/src/commands/serve-docs.test.ts +30 -0
- package/src/program.test.ts +282 -0
- package/src/test-support/fixtures.ts +93 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { MockInstance } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { makeConfig } from "./test-support/fixtures";
|
|
7
|
+
|
|
8
|
+
const loadConfigMock = vi.fn<(...args: unknown[]) => unknown>();
|
|
9
|
+
const resolveConfigPathMock = vi.fn(
|
|
10
|
+
(..._args: unknown[]) => "/repo/seagull.yaml",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const lintCommandMock = vi.fn((..._args: unknown[]) => Promise.resolve());
|
|
14
|
+
const bundleCommandMock = vi.fn((..._args: unknown[]) => Promise.resolve());
|
|
15
|
+
const generateSdkCommandMock = vi.fn((..._args: unknown[]) =>
|
|
16
|
+
Promise.resolve(),
|
|
17
|
+
);
|
|
18
|
+
const cleanCommandMock = vi.fn((..._args: unknown[]) => Promise.resolve());
|
|
19
|
+
const generateDocsCommandMock = vi.fn((..._args: unknown[]) =>
|
|
20
|
+
Promise.resolve(),
|
|
21
|
+
);
|
|
22
|
+
const serveDocsCommandMock = vi.fn((..._args: unknown[]) => Promise.resolve());
|
|
23
|
+
const publishSdkCommandMock = vi.fn((..._args: unknown[]) => Promise.resolve());
|
|
24
|
+
const publishRegistriesCommandMock = vi.fn((..._args: unknown[]) =>
|
|
25
|
+
Promise.resolve(),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
vi.mock("@octalmesh/seagull-core", async (importOriginal) => {
|
|
29
|
+
const actual =
|
|
30
|
+
await importOriginal<typeof import("@octalmesh/seagull-core")>();
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
...actual,
|
|
34
|
+
loadConfig: (...a: unknown[]) => loadConfigMock(...a),
|
|
35
|
+
resolveConfigPath: (...a: unknown[]) => resolveConfigPathMock(...a),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
vi.mock("./commands/lint", () => ({
|
|
40
|
+
lintCommand: (...a: unknown[]) => lintCommandMock(...a),
|
|
41
|
+
}));
|
|
42
|
+
vi.mock("./commands/bundle", () => ({
|
|
43
|
+
bundleCommand: (...a: unknown[]) => bundleCommandMock(...a),
|
|
44
|
+
}));
|
|
45
|
+
vi.mock("./commands/generate-sdk", () => ({
|
|
46
|
+
generateSdkCommand: (...a: unknown[]) => generateSdkCommandMock(...a),
|
|
47
|
+
}));
|
|
48
|
+
vi.mock("./commands/clean", () => ({
|
|
49
|
+
cleanCommand: (...a: unknown[]) => cleanCommandMock(...a),
|
|
50
|
+
}));
|
|
51
|
+
vi.mock("./commands/generate-docs", () => ({
|
|
52
|
+
generateDocsCommand: (...a: unknown[]) => generateDocsCommandMock(...a),
|
|
53
|
+
}));
|
|
54
|
+
vi.mock("./commands/serve-docs", () => ({
|
|
55
|
+
serveDocsCommand: (...a: unknown[]) => serveDocsCommandMock(...a),
|
|
56
|
+
}));
|
|
57
|
+
vi.mock("./commands/publish-sdk", () => ({
|
|
58
|
+
publishSdkCommand: (...a: unknown[]) => publishSdkCommandMock(...a),
|
|
59
|
+
}));
|
|
60
|
+
vi.mock("./commands/publish-registries", () => ({
|
|
61
|
+
publishRegistriesCommand: (...a: unknown[]) =>
|
|
62
|
+
publishRegistriesCommandMock(...a),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const { createProgram } = await import("./program");
|
|
66
|
+
|
|
67
|
+
const metadata = {
|
|
68
|
+
name: "seagull",
|
|
69
|
+
version: "0.0.2",
|
|
70
|
+
description: "test program",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe("createProgram", () => {
|
|
74
|
+
let logSpy: MockInstance;
|
|
75
|
+
let errorSpy: MockInstance;
|
|
76
|
+
const originalExitCode = process.exitCode;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
80
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
81
|
+
process.exitCode = undefined;
|
|
82
|
+
|
|
83
|
+
for (const mock of [
|
|
84
|
+
loadConfigMock,
|
|
85
|
+
resolveConfigPathMock,
|
|
86
|
+
lintCommandMock,
|
|
87
|
+
bundleCommandMock,
|
|
88
|
+
generateSdkCommandMock,
|
|
89
|
+
cleanCommandMock,
|
|
90
|
+
generateDocsCommandMock,
|
|
91
|
+
serveDocsCommandMock,
|
|
92
|
+
publishSdkCommandMock,
|
|
93
|
+
publishRegistriesCommandMock,
|
|
94
|
+
]) {
|
|
95
|
+
mock.mockClear();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
resolveConfigPathMock.mockReturnValue("/repo/seagull.yaml");
|
|
99
|
+
loadConfigMock.mockReturnValue(makeConfig("/repo"));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
logSpy.mockRestore();
|
|
104
|
+
errorSpy.mockRestore();
|
|
105
|
+
process.exitCode = originalExitCode;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("sets name/description/version from the provided metadata", () => {
|
|
109
|
+
const program = createProgram(metadata);
|
|
110
|
+
|
|
111
|
+
expect(program.name()).toBe("seagull");
|
|
112
|
+
expect(program.description()).toBe("test program");
|
|
113
|
+
expect(program.version()).toBe("0.0.2");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("registers all eight subcommands (including docs/publish sub-groups)", () => {
|
|
117
|
+
const program = createProgram(metadata);
|
|
118
|
+
const names = program.commands.map((c) => c.name());
|
|
119
|
+
|
|
120
|
+
expect(names.sort()).toEqual(
|
|
121
|
+
["bundle", "clean", "docs", "generate", "lint", "publish"].sort(),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const docs = program.commands.find((c) => c.name() === "docs")!;
|
|
125
|
+
expect(docs.commands.map((c) => c.name()).sort()).toEqual([
|
|
126
|
+
"generate",
|
|
127
|
+
"serve",
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const publish = program.commands.find((c) => c.name() === "publish")!;
|
|
131
|
+
expect(publish.commands.map((c) => c.name()).sort()).toEqual([
|
|
132
|
+
"registries",
|
|
133
|
+
"sdk",
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("auto-discovers the config file when --config isn't given", async () => {
|
|
138
|
+
const program = createProgram(metadata);
|
|
139
|
+
|
|
140
|
+
await program.parseAsync(["node", "seagull", "lint"]);
|
|
141
|
+
|
|
142
|
+
expect(resolveConfigPathMock).toHaveBeenCalledWith(process.cwd());
|
|
143
|
+
expect(loadConfigMock).toHaveBeenCalledWith("/repo/seagull.yaml");
|
|
144
|
+
expect(lintCommandMock).toHaveBeenCalledOnce();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("uses the --config path (resolved against cwd) instead of auto-discovery when given", async () => {
|
|
148
|
+
const program = createProgram(metadata);
|
|
149
|
+
|
|
150
|
+
await program.parseAsync([
|
|
151
|
+
"node",
|
|
152
|
+
"seagull",
|
|
153
|
+
"--config",
|
|
154
|
+
"custom.yaml",
|
|
155
|
+
"lint",
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
expect(resolveConfigPathMock).not.toHaveBeenCalled();
|
|
159
|
+
expect(loadConfigMock).toHaveBeenCalledWith(
|
|
160
|
+
path.resolve(process.cwd(), "custom.yaml"),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("routes 'bundle' to bundleCommand with the resolved config", async () => {
|
|
165
|
+
const config = makeConfig("/repo");
|
|
166
|
+
|
|
167
|
+
loadConfigMock.mockReturnValue(config);
|
|
168
|
+
|
|
169
|
+
const program = createProgram(metadata);
|
|
170
|
+
|
|
171
|
+
await program.parseAsync(["node", "seagull", "bundle"]);
|
|
172
|
+
|
|
173
|
+
expect(bundleCommandMock).toHaveBeenCalledWith(config);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("routes 'generate' to generateSdkCommand", async () => {
|
|
177
|
+
const program = createProgram(metadata);
|
|
178
|
+
|
|
179
|
+
await program.parseAsync(["node", "seagull", "generate"]);
|
|
180
|
+
|
|
181
|
+
expect(generateSdkCommandMock).toHaveBeenCalledOnce();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("routes 'clean' to cleanCommand", async () => {
|
|
185
|
+
const program = createProgram(metadata);
|
|
186
|
+
|
|
187
|
+
await program.parseAsync(["node", "seagull", "clean"]);
|
|
188
|
+
|
|
189
|
+
expect(cleanCommandMock).toHaveBeenCalledOnce();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("routes 'docs generate' and 'docs serve' to their respective commands", async () => {
|
|
193
|
+
const program = createProgram(metadata);
|
|
194
|
+
|
|
195
|
+
await program.parseAsync(["node", "seagull", "docs", "generate"]);
|
|
196
|
+
await program.parseAsync(["node", "seagull", "docs", "serve"]);
|
|
197
|
+
|
|
198
|
+
expect(generateDocsCommandMock).toHaveBeenCalledOnce();
|
|
199
|
+
expect(serveDocsCommandMock).toHaveBeenCalledOnce();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("routes 'publish sdk' with dryRun:false by default, and forwards --dry-run", async () => {
|
|
203
|
+
const program = createProgram(metadata);
|
|
204
|
+
|
|
205
|
+
await program.parseAsync(["node", "seagull", "publish", "sdk"]);
|
|
206
|
+
expect(publishSdkCommandMock).toHaveBeenLastCalledWith(expect.anything(), {
|
|
207
|
+
dryRun: undefined,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await program.parseAsync([
|
|
211
|
+
"node",
|
|
212
|
+
"seagull",
|
|
213
|
+
"publish",
|
|
214
|
+
"sdk",
|
|
215
|
+
"--dry-run",
|
|
216
|
+
]);
|
|
217
|
+
expect(publishSdkCommandMock).toHaveBeenLastCalledWith(expect.anything(), {
|
|
218
|
+
dryRun: true,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("routes 'publish registries' with --dry-run forwarded", async () => {
|
|
223
|
+
const program = createProgram(metadata);
|
|
224
|
+
|
|
225
|
+
await program.parseAsync([
|
|
226
|
+
"node",
|
|
227
|
+
"seagull",
|
|
228
|
+
"publish",
|
|
229
|
+
"registries",
|
|
230
|
+
"--dry-run",
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
expect(publishRegistriesCommandMock).toHaveBeenCalledWith(
|
|
234
|
+
expect.anything(),
|
|
235
|
+
{ dryRun: true },
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("prints 'seagull: <message>' and sets exitCode=1 when a command throws", async () => {
|
|
240
|
+
lintCommandMock.mockRejectedValueOnce(new Error("spec is invalid"));
|
|
241
|
+
|
|
242
|
+
const program = createProgram(metadata);
|
|
243
|
+
|
|
244
|
+
await program.parseAsync(["node", "seagull", "lint"]);
|
|
245
|
+
|
|
246
|
+
expect(errorSpy).toHaveBeenCalledWith("seagull: spec is invalid");
|
|
247
|
+
expect(process.exitCode).toBe(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("prints a stringified error and sets exitCode=1 when a non-Error is thrown", async () => {
|
|
251
|
+
lintCommandMock.mockRejectedValueOnce("just a string");
|
|
252
|
+
|
|
253
|
+
const program = createProgram(metadata);
|
|
254
|
+
|
|
255
|
+
await program.parseAsync(["node", "seagull", "lint"]);
|
|
256
|
+
|
|
257
|
+
expect(errorSpy).toHaveBeenCalledWith("seagull: just a string");
|
|
258
|
+
expect(process.exitCode).toBe(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("does not touch exitCode when a command succeeds", async () => {
|
|
262
|
+
const program = createProgram(metadata);
|
|
263
|
+
|
|
264
|
+
await program.parseAsync(["node", "seagull", "lint"]);
|
|
265
|
+
|
|
266
|
+
expect(process.exitCode).toBeUndefined();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("propagates a config-loading error (e.g. invalid config) through the same error handler", async () => {
|
|
270
|
+
loadConfigMock.mockImplementationOnce(() => {
|
|
271
|
+
throw new Error("Invalid seagull.yaml");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const program = createProgram(metadata);
|
|
275
|
+
|
|
276
|
+
await program.parseAsync(["node", "seagull", "bundle"]);
|
|
277
|
+
|
|
278
|
+
expect(errorSpy).toHaveBeenCalledWith("seagull: Invalid seagull.yaml");
|
|
279
|
+
expect(process.exitCode).toBe(1);
|
|
280
|
+
expect(bundleCommandMock).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ResolvedArtifact,
|
|
5
|
+
ResolvedArtifactEntry,
|
|
6
|
+
ResolvedConfig,
|
|
7
|
+
ResolvedContract,
|
|
8
|
+
ResolvedPublishing,
|
|
9
|
+
} from "@octalmesh/seagull-core";
|
|
10
|
+
|
|
11
|
+
export function makePublishing(
|
|
12
|
+
overrides: Partial<ResolvedPublishing> = {},
|
|
13
|
+
): ResolvedPublishing {
|
|
14
|
+
return {
|
|
15
|
+
branch: "sdk/svc-auth/ts-client",
|
|
16
|
+
tagTemplate: "svc-{service}-{id}-v{version}",
|
|
17
|
+
repositoryUrl: "https://github.com/OctalMesh/ows-contracts",
|
|
18
|
+
npmRegistry: "https://npm.pkg.github.com",
|
|
19
|
+
npmAccess: "public",
|
|
20
|
+
mavenRepositoryId: "github",
|
|
21
|
+
mavenRepositoryUrl: "https://maven.pkg.github.com/OctalMesh/ows-contracts",
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function makeArtifact(
|
|
27
|
+
overrides: Partial<ResolvedArtifact> = {},
|
|
28
|
+
): ResolvedArtifact {
|
|
29
|
+
return {
|
|
30
|
+
id: "ts-client",
|
|
31
|
+
tool: "openapi-generator",
|
|
32
|
+
lang: "typescript",
|
|
33
|
+
kind: "client",
|
|
34
|
+
generator: "typescript-fetch",
|
|
35
|
+
outputDir: "/tmp/dist/sdk/auth/ts-client",
|
|
36
|
+
branch: "sdk/svc-auth/ts-client",
|
|
37
|
+
publishing: makePublishing(),
|
|
38
|
+
additionalProperties: {},
|
|
39
|
+
package: "@octalmesh/auth-client",
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function makeContract(
|
|
45
|
+
overrides: Partial<ResolvedContract> = {},
|
|
46
|
+
): ResolvedContract {
|
|
47
|
+
return {
|
|
48
|
+
name: "auth",
|
|
49
|
+
title: "Auth Service API",
|
|
50
|
+
entrypoint: "/repo/specs/auth/openapi.yaml",
|
|
51
|
+
entrypointRelative: path.join("specs", "auth", "openapi.yaml"),
|
|
52
|
+
artifacts: [makeArtifact()],
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function makeConfig(
|
|
58
|
+
rootDir: string,
|
|
59
|
+
overrides: Partial<ResolvedConfig> = {},
|
|
60
|
+
): ResolvedConfig {
|
|
61
|
+
const dist = path.join(rootDir, "dist");
|
|
62
|
+
const contracts = overrides.contracts ?? [makeContract()];
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
configVersion: 1,
|
|
66
|
+
rootDir,
|
|
67
|
+
paths: {
|
|
68
|
+
dist,
|
|
69
|
+
specs: path.join(dist, "specs"),
|
|
70
|
+
docs: path.join(dist, "docs"),
|
|
71
|
+
sdk: path.join(dist, "sdk"),
|
|
72
|
+
specFormat: ["json"],
|
|
73
|
+
},
|
|
74
|
+
vars: {},
|
|
75
|
+
docs: {
|
|
76
|
+
server: { host: "localhost", port: 8080 },
|
|
77
|
+
metadata: {
|
|
78
|
+
title: "t",
|
|
79
|
+
description: "d",
|
|
80
|
+
favicon: "f",
|
|
81
|
+
baseServerUrl: "https://example.com",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
contracts,
|
|
85
|
+
allArtifacts: contracts.flatMap((contract) =>
|
|
86
|
+
contract.artifacts.map((artifact): ResolvedArtifactEntry => ({
|
|
87
|
+
contract,
|
|
88
|
+
artifact,
|
|
89
|
+
})),
|
|
90
|
+
),
|
|
91
|
+
...overrides,
|
|
92
|
+
};
|
|
93
|
+
}
|