@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.
@@ -0,0 +1,160 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { MockInstance } from "vitest";
3
+
4
+ import {
5
+ makeArtifact,
6
+ makeConfig,
7
+ makeContract,
8
+ } from "../test-support/fixtures";
9
+
10
+ const runMock = vi.fn((..._args: unknown[]) => Promise.resolve());
11
+
12
+ vi.mock("@octalmesh/seagull-core", async (importOriginal) => {
13
+ const actual =
14
+ await importOriginal<typeof import("@octalmesh/seagull-core")>();
15
+
16
+ return { ...actual, run: (...a: unknown[]) => runMock(...a) };
17
+ });
18
+
19
+ const { publishRegistriesCommand } = await import("./publish-registries");
20
+
21
+ describe("publishRegistriesCommand", () => {
22
+ let logSpy: MockInstance;
23
+
24
+ beforeEach(() => {
25
+ runMock.mockClear();
26
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
27
+ });
28
+
29
+ afterEach(() => {
30
+ logSpy.mockRestore();
31
+ });
32
+
33
+ it("npm-publishes each typescript artifact from its outputDir", async () => {
34
+ const outputDir = "/tmp/dist/sdk/auth/ts-client";
35
+ const config = makeConfig("/repo", {
36
+ contracts: [
37
+ makeContract({
38
+ artifacts: [
39
+ makeArtifact({ id: "ts-client", lang: "typescript", outputDir }),
40
+ ],
41
+ }),
42
+ ],
43
+ });
44
+
45
+ await publishRegistriesCommand(config);
46
+
47
+ expect(runMock).toHaveBeenCalledWith("npm", ["publish"], outputDir);
48
+ });
49
+
50
+ it("mvn-deploys each java artifact from its outputDir", async () => {
51
+ const outputDir = "/tmp/dist/sdk/auth/java-client";
52
+ const config = makeConfig("/repo", {
53
+ contracts: [
54
+ makeContract({
55
+ artifacts: [
56
+ makeArtifact({
57
+ id: "java-client",
58
+ lang: "java",
59
+ package: undefined,
60
+ maven: {
61
+ groupId: "com.octalmesh.auth",
62
+ artifactId: "auth-client",
63
+ },
64
+ outputDir,
65
+ }),
66
+ ],
67
+ }),
68
+ ],
69
+ });
70
+
71
+ await publishRegistriesCommand(config);
72
+
73
+ expect(runMock).toHaveBeenCalledWith(
74
+ "mvn",
75
+ ["-B", "deploy", "-DskipTests"],
76
+ outputDir,
77
+ );
78
+ });
79
+
80
+ it("skips go artifacts entirely (no registry step)", async () => {
81
+ const config = makeConfig("/repo", {
82
+ contracts: [
83
+ makeContract({
84
+ artifacts: [
85
+ makeArtifact({
86
+ id: "go-client",
87
+ lang: "go",
88
+ package: undefined,
89
+ goModule: "github.com/octalmesh/ows-contracts",
90
+ goPackageName: "authclient",
91
+ }),
92
+ ],
93
+ }),
94
+ ],
95
+ });
96
+
97
+ await publishRegistriesCommand(config);
98
+
99
+ expect(runMock).not.toHaveBeenCalled();
100
+ expect(logSpy).toHaveBeenCalledWith(
101
+ expect.stringContaining("published 0 registry package(s)"),
102
+ );
103
+ });
104
+
105
+ it("in dry-run mode, logs the command it would run but never calls run()", async () => {
106
+ const outputDir = "/tmp/dist/sdk/auth/ts-client";
107
+ const config = makeConfig("/repo", {
108
+ contracts: [
109
+ makeContract({
110
+ artifacts: [
111
+ makeArtifact({ id: "ts-client", lang: "typescript", outputDir }),
112
+ ],
113
+ }),
114
+ ],
115
+ });
116
+
117
+ await publishRegistriesCommand(config, { dryRun: true });
118
+
119
+ expect(runMock).not.toHaveBeenCalled();
120
+ expect(logSpy).toHaveBeenCalledWith(
121
+ expect.stringContaining(`$ npm publish (in ${outputDir})`),
122
+ );
123
+ });
124
+
125
+ it("counts and publishes both a typescript and a java artifact within the same run", async () => {
126
+ const config = makeConfig("/repo", {
127
+ contracts: [
128
+ makeContract({
129
+ name: "auth",
130
+ artifacts: [
131
+ makeArtifact({
132
+ id: "ts-client",
133
+ lang: "typescript",
134
+ outputDir: "/a",
135
+ }),
136
+ ],
137
+ }),
138
+ makeContract({
139
+ name: "catalog",
140
+ artifacts: [
141
+ makeArtifact({
142
+ id: "java-client",
143
+ lang: "java",
144
+ package: undefined,
145
+ maven: { groupId: "g", artifactId: "a" },
146
+ outputDir: "/b",
147
+ }),
148
+ ],
149
+ }),
150
+ ],
151
+ });
152
+
153
+ await publishRegistriesCommand(config);
154
+
155
+ expect(runMock).toHaveBeenCalledTimes(2);
156
+ expect(logSpy).toHaveBeenCalledWith(
157
+ expect.stringContaining("published 2 registry package(s)"),
158
+ );
159
+ });
160
+ });
@@ -0,0 +1,383 @@
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import type { GitResult } from "@octalmesh/seagull-core";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import type { MockInstance } from "vitest";
8
+
9
+ import {
10
+ makeArtifact,
11
+ makeConfig,
12
+ makeContract,
13
+ } from "../test-support/fixtures";
14
+
15
+ const gitMock = vi.fn<(args: string[], cwd: string) => GitResult>();
16
+ const tagExistsMock = vi.fn((..._args: unknown[]) => false);
17
+ const remoteBranchExistsMock = vi.fn((..._args: unknown[]) => false);
18
+ const readFileAtTagMock = vi.fn((..._args: unknown[]): string | null => null);
19
+ const renderArtifactTagMock = vi.fn(
20
+ (..._args: unknown[]) => "svc-auth-ts-client-v1.0.0",
21
+ );
22
+
23
+ vi.mock("@octalmesh/seagull-core", async (importOriginal) => {
24
+ const actual =
25
+ await importOriginal<typeof import("@octalmesh/seagull-core")>();
26
+
27
+ return {
28
+ ...actual,
29
+ git: (...a: [string[], string]) => gitMock(...a),
30
+ tagExists: (...a: unknown[]) => tagExistsMock(...(a as [string, string])),
31
+ remoteBranchExists: (...a: unknown[]) =>
32
+ remoteBranchExistsMock(...(a as [string, string])),
33
+ readFileAtTag: (...a: unknown[]) =>
34
+ readFileAtTagMock(...(a as [string, string, string])),
35
+ renderArtifactTag: (...a: unknown[]) => renderArtifactTagMock(...a),
36
+ };
37
+ });
38
+
39
+ const { publishSdkCommand } = await import("./publish-sdk");
40
+
41
+ function defaultGitImpl(args: string[], _cwd?: string): GitResult {
42
+ if (args[0] === "diff") {
43
+ return { status: 1, stdout: "", stderr: "" };
44
+ }
45
+
46
+ return { status: 0, stdout: "", stderr: "" };
47
+ }
48
+
49
+ describe("publishSdkCommand", () => {
50
+ let dir: string;
51
+ let logSpy: MockInstance;
52
+
53
+ beforeEach(async () => {
54
+ dir = await mkdtemp(path.join(tmpdir(), "seagull-publishsdk-"));
55
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
56
+
57
+ gitMock.mockReset().mockImplementation(defaultGitImpl);
58
+ tagExistsMock.mockReset().mockReturnValue(false);
59
+ remoteBranchExistsMock.mockReset().mockReturnValue(false);
60
+ readFileAtTagMock.mockReset().mockReturnValue(null);
61
+ renderArtifactTagMock
62
+ .mockReset()
63
+ .mockReturnValue("svc-auth-ts-client-v1.0.0");
64
+ });
65
+
66
+ afterEach(async () => {
67
+ await rm(dir, { recursive: true, force: true });
68
+ logSpy.mockRestore();
69
+ });
70
+
71
+ async function prepareArtifactOutput(
72
+ outputDir: string,
73
+ version = "1.0.0",
74
+ hash = "some-hash",
75
+ ): Promise<void> {
76
+ await mkdir(outputDir, { recursive: true });
77
+ await writeFile(path.join(outputDir, "VERSION"), `${version}\n`);
78
+ await writeFile(path.join(outputDir, "SPEC_HASH"), `${hash}\n`);
79
+ await writeFile(path.join(outputDir, "index.js"), "module.exports = {};");
80
+ }
81
+
82
+ it("skips publishing when the tag already exists with matching spec hash", async () => {
83
+ const outputDir = path.join(dir, "auth", "ts-client");
84
+
85
+ await prepareArtifactOutput(outputDir, "1.0.0", "some-hash");
86
+ tagExistsMock.mockReturnValue(true);
87
+ readFileAtTagMock.mockReturnValue("some-hash");
88
+
89
+ const config = makeConfig(dir, {
90
+ contracts: [
91
+ makeContract({
92
+ artifacts: [makeArtifact({ id: "ts-client", outputDir })],
93
+ }),
94
+ ],
95
+ });
96
+
97
+ await publishSdkCommand(config);
98
+
99
+ expect(gitMock).not.toHaveBeenCalled();
100
+ expect(logSpy).toHaveBeenCalledWith(
101
+ expect.stringContaining("already published"),
102
+ );
103
+ });
104
+
105
+ it("throws when the tag exists but the published spec hash has diverged", async () => {
106
+ const outputDir = path.join(dir, "auth", "ts-client");
107
+
108
+ await prepareArtifactOutput(outputDir, "1.0.0", "new-hash");
109
+ tagExistsMock.mockReturnValue(true);
110
+ readFileAtTagMock.mockReturnValue("old-hash");
111
+
112
+ const config = makeConfig(dir, {
113
+ contracts: [
114
+ makeContract({
115
+ artifacts: [makeArtifact({ id: "ts-client", outputDir })],
116
+ }),
117
+ ],
118
+ });
119
+
120
+ await expect(publishSdkCommand(config)).rejects.toThrow(
121
+ /already exists, but the auth spec content has changed/,
122
+ );
123
+ });
124
+
125
+ it("treats a null remote spec hash (unreadable tag) as no conflict and skips", async () => {
126
+ const outputDir = path.join(dir, "auth", "ts-client");
127
+
128
+ await prepareArtifactOutput(outputDir);
129
+ tagExistsMock.mockReturnValue(true);
130
+ readFileAtTagMock.mockReturnValue(null);
131
+
132
+ const config = makeConfig(dir, {
133
+ contracts: [
134
+ makeContract({
135
+ artifacts: [makeArtifact({ id: "ts-client", outputDir })],
136
+ }),
137
+ ],
138
+ });
139
+
140
+ await expect(publishSdkCommand(config)).resolves.toBeUndefined();
141
+ expect(gitMock).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it("for a brand-new artifact (no remote branch), creates an orphan branch and pushes it", async () => {
145
+ const outputDir = path.join(dir, "auth", "ts-client");
146
+
147
+ await prepareArtifactOutput(outputDir);
148
+ remoteBranchExistsMock.mockReturnValue(false);
149
+
150
+ const config = makeConfig(dir, {
151
+ contracts: [
152
+ makeContract({
153
+ artifacts: [
154
+ makeArtifact({
155
+ id: "ts-client",
156
+ outputDir,
157
+ branch: "sdk/svc-auth/ts-client",
158
+ }),
159
+ ],
160
+ }),
161
+ ],
162
+ });
163
+
164
+ await publishSdkCommand(config);
165
+
166
+ const calls = gitMock.mock.calls.map(([args]) => args.join(" "));
167
+
168
+ expect(calls).toContain("fetch origin -- sdk/svc-auth/ts-client");
169
+ expect(calls.some((c) => c.startsWith("worktree add --detach "))).toBe(
170
+ true,
171
+ );
172
+ expect(calls).toContain("checkout --orphan sdk/svc-auth/ts-client");
173
+ expect(calls).toContain("rm -rf --quiet .");
174
+ expect(calls).toContain("add -A");
175
+ expect(
176
+ calls.some((c) =>
177
+ c.startsWith("commit -m chore(sdk): publish auth ts-client v1.0.0"),
178
+ ),
179
+ ).toBe(true);
180
+ expect(calls).toContain("tag svc-auth-ts-client-v1.0.0");
181
+ expect(calls).toContain(
182
+ "push origin HEAD:refs/heads/sdk/svc-auth/ts-client",
183
+ );
184
+ expect(calls).toContain("push origin svc-auth-ts-client-v1.0.0");
185
+ expect(calls.some((c) => c.startsWith("worktree remove --force "))).toBe(
186
+ true,
187
+ );
188
+
189
+ expect(logSpy).toHaveBeenCalledWith(
190
+ expect.stringContaining(
191
+ "Published sdk/svc-auth/ts-client @ svc-auth-ts-client-v1.0.0",
192
+ ),
193
+ );
194
+ });
195
+
196
+ it("for an existing remote branch, fetches then checks out -B against origin/<branch>", async () => {
197
+ const outputDir = path.join(dir, "auth", "ts-client");
198
+
199
+ await prepareArtifactOutput(outputDir);
200
+ remoteBranchExistsMock.mockReturnValue(true);
201
+
202
+ const config = makeConfig(dir, {
203
+ contracts: [
204
+ makeContract({
205
+ artifacts: [
206
+ makeArtifact({
207
+ id: "ts-client",
208
+ outputDir,
209
+ branch: "sdk/svc-auth/ts-client",
210
+ }),
211
+ ],
212
+ }),
213
+ ],
214
+ });
215
+
216
+ await publishSdkCommand(config);
217
+
218
+ const calls = gitMock.mock.calls.map(([args]) => args.join(" "));
219
+
220
+ expect(
221
+ calls.some(
222
+ (c) =>
223
+ c.startsWith("worktree add ") &&
224
+ c.endsWith("origin/sdk/svc-auth/ts-client"),
225
+ ),
226
+ ).toBe(true);
227
+ expect(calls).toContain(
228
+ "checkout -B sdk/svc-auth/ts-client origin/sdk/svc-auth/ts-client",
229
+ );
230
+ expect(calls).not.toContain("checkout --orphan sdk/svc-auth/ts-client");
231
+ });
232
+
233
+ it("in dry-run mode, tags locally but never pushes", async () => {
234
+ const outputDir = path.join(dir, "auth", "ts-client");
235
+
236
+ await prepareArtifactOutput(outputDir);
237
+
238
+ const config = makeConfig(dir, {
239
+ contracts: [
240
+ makeContract({
241
+ artifacts: [makeArtifact({ id: "ts-client", outputDir })],
242
+ }),
243
+ ],
244
+ });
245
+
246
+ await publishSdkCommand(config, { dryRun: true });
247
+
248
+ const calls = gitMock.mock.calls.map(([args]) => args.join(" "));
249
+
250
+ expect(calls.some((c) => c.startsWith("push"))).toBe(false);
251
+ expect(calls).toContain("tag svc-auth-ts-client-v1.0.0");
252
+ expect(logSpy).toHaveBeenCalledWith(
253
+ expect.stringContaining("[dry-run] would push"),
254
+ );
255
+ });
256
+
257
+ it("skips the commit step (but still tags) when the worktree has no content changes", async () => {
258
+ const outputDir = path.join(dir, "auth", "ts-client");
259
+
260
+ await prepareArtifactOutput(outputDir);
261
+ gitMock.mockImplementation((args) => {
262
+ if (args[0] === "diff") {
263
+ return { status: 0, stdout: "", stderr: "" };
264
+ }
265
+
266
+ return { status: 0, stdout: "", stderr: "" };
267
+ });
268
+
269
+ const config = makeConfig(dir, {
270
+ contracts: [
271
+ makeContract({
272
+ artifacts: [makeArtifact({ id: "ts-client", outputDir })],
273
+ }),
274
+ ],
275
+ });
276
+
277
+ await publishSdkCommand(config, { dryRun: true });
278
+
279
+ const calls = gitMock.mock.calls.map(([args]) => args.join(" "));
280
+
281
+ expect(calls.some((c) => c.startsWith("commit"))).toBe(false);
282
+ expect(calls).toContain("tag svc-auth-ts-client-v1.0.0");
283
+ expect(logSpy).toHaveBeenCalledWith(
284
+ expect.stringContaining("No content changes since last publish"),
285
+ );
286
+ });
287
+
288
+ it("throws with the underlying stderr when a required git step fails", async () => {
289
+ const outputDir = path.join(dir, "auth", "ts-client");
290
+
291
+ await prepareArtifactOutput(outputDir);
292
+ gitMock.mockImplementation((args) => {
293
+ if (args[0] === "worktree" && args[1] === "add") {
294
+ return { status: 128, stdout: "", stderr: "fatal: no such ref" };
295
+ }
296
+
297
+ return defaultGitImpl(args);
298
+ });
299
+
300
+ const config = makeConfig(dir, {
301
+ contracts: [
302
+ makeContract({
303
+ artifacts: [
304
+ makeArtifact({
305
+ id: "ts-client",
306
+ outputDir,
307
+ branch: "sdk/svc-auth/ts-client",
308
+ }),
309
+ ],
310
+ }),
311
+ ],
312
+ });
313
+
314
+ await expect(publishSdkCommand(config)).rejects.toThrow(
315
+ /Failed to create worktree for sdk\/svc-auth\/ts-client: fatal: no such ref/,
316
+ );
317
+ });
318
+
319
+ it("copies the artifact's generated output into the worktree before committing", async () => {
320
+ const outputDir = path.join(dir, "auth", "ts-client");
321
+
322
+ await prepareArtifactOutput(outputDir);
323
+
324
+ let worktreeDirSeen: string | undefined;
325
+
326
+ gitMock.mockImplementation((args, cwd) => {
327
+ if (args[0] === "worktree" && args[1] === "add") {
328
+ worktreeDirSeen = args.includes("--detach") ? args[3] : args[2];
329
+ }
330
+
331
+ return defaultGitImpl(args, cwd);
332
+ });
333
+
334
+ const config = makeConfig(dir, {
335
+ contracts: [
336
+ makeContract({
337
+ artifacts: [makeArtifact({ id: "ts-client", outputDir })],
338
+ }),
339
+ ],
340
+ });
341
+
342
+ await publishSdkCommand(config, { dryRun: true });
343
+
344
+ expect(worktreeDirSeen).toBeDefined();
345
+ await expect(
346
+ readFile(path.join(worktreeDirSeen!, "index.js"), "utf8"),
347
+ ).resolves.toContain("module.exports");
348
+ });
349
+
350
+ it("processes multiple artifacts across contracts, logging a final summary count", async () => {
351
+ const authDir = path.join(dir, "auth", "ts-client");
352
+ const catalogDir = path.join(dir, "catalog", "ts-server");
353
+
354
+ await prepareArtifactOutput(authDir);
355
+ await prepareArtifactOutput(catalogDir);
356
+
357
+ const config = makeConfig(dir, {
358
+ contracts: [
359
+ makeContract({
360
+ name: "auth",
361
+ artifacts: [makeArtifact({ id: "ts-client", outputDir: authDir })],
362
+ }),
363
+ makeContract({
364
+ name: "catalog",
365
+ entrypoint: "/repo/specs/catalog/openapi.yaml",
366
+ artifacts: [
367
+ makeArtifact({
368
+ id: "ts-server",
369
+ tool: "openapi-typescript",
370
+ outputDir: catalogDir,
371
+ }),
372
+ ],
373
+ }),
374
+ ],
375
+ });
376
+
377
+ await publishSdkCommand(config, { dryRun: true });
378
+
379
+ expect(logSpy).toHaveBeenCalledWith(
380
+ expect.stringContaining("processed 2 SDK packages"),
381
+ );
382
+ });
383
+ });
@@ -42,7 +42,6 @@ export async function publishSdkCommand(
42
42
  artifact,
43
43
  contract.name,
44
44
  version,
45
- config.github,
46
45
  config.vars,
47
46
  );
48
47
 
@@ -71,7 +70,7 @@ export async function publishSdkCommand(
71
70
  const worktreeDir = await mkdtemp(path.join(tmpdir(), "sdk-publish-"));
72
71
  await rm(worktreeDir, { recursive: true, force: true });
73
72
 
74
- git(["fetch", "origin", artifact.branch], config.rootDir);
73
+ git(["fetch", "origin", "--", artifact.branch], config.rootDir);
75
74
  const hasRemoteBranch = remoteBranchExists(config.rootDir, artifact.branch);
76
75
 
77
76
  const setup = hasRemoteBranch
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { makeConfig } from "../test-support/fixtures";
4
+
5
+ const serveDocsSiteMock = vi.fn((..._args: unknown[]) => Promise.resolve());
6
+
7
+ vi.mock("@octalmesh/seagull-docs", () => ({
8
+ serveDocsSite: (...a: unknown[]) => serveDocsSiteMock(...a),
9
+ }));
10
+
11
+ const { serveDocsCommand } = await import("./serve-docs");
12
+
13
+ describe("serveDocsCommand", () => {
14
+ it("delegates straight to serveDocsSite with the resolved config", async () => {
15
+ const config = makeConfig("/repo");
16
+
17
+ await serveDocsCommand(config);
18
+
19
+ expect(serveDocsSiteMock).toHaveBeenCalledTimes(1);
20
+ expect(serveDocsSiteMock).toHaveBeenCalledWith(config);
21
+ });
22
+
23
+ it("propagates errors from serveDocsSite", async () => {
24
+ serveDocsSiteMock.mockRejectedValueOnce(new Error("port in use"));
25
+
26
+ await expect(serveDocsCommand(makeConfig("/repo"))).rejects.toThrow(
27
+ "port in use",
28
+ );
29
+ });
30
+ });