@octalmesh/seagull-core 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +173 -39
  3. package/dist/index.mjs +218 -152
  4. package/package.json +6 -6
  5. package/src/config/loader.test.ts +505 -0
  6. package/src/config/loader.ts +31 -12
  7. package/src/config/publishing.test.ts +92 -0
  8. package/src/config/publishing.ts +9 -5
  9. package/src/config/resolve-config-file.test.ts +60 -0
  10. package/src/config/schema.test.ts +466 -0
  11. package/src/config/schema.ts +16 -10
  12. package/src/config/spec-format.test.ts +54 -0
  13. package/src/config/spec-format.ts +48 -0
  14. package/src/config/template.test.ts +168 -0
  15. package/src/config/types.ts +3 -3
  16. package/src/generator/generator.test.ts +59 -0
  17. package/src/generator/registry.test.ts +84 -0
  18. package/src/generator/types.ts +9 -14
  19. package/src/generators/openapi-generator-cli/openapi-generator-cli.generator.test.ts +259 -0
  20. package/src/generators/openapi-generator-cli/patchers/go-module.patcher.test.ts +119 -0
  21. package/src/generators/openapi-generator-cli/patchers/maven.patcher.test.ts +141 -0
  22. package/src/generators/openapi-generator-cli/patchers/npm.patcher.test.ts +132 -0
  23. package/src/generators/openapi-typescript/openapi-typescript.generator.test.ts +190 -0
  24. package/src/git/git.test.ts +234 -0
  25. package/src/git/git.ts +50 -4
  26. package/src/index.ts +8 -0
  27. package/src/process/exec.test.ts +103 -0
  28. package/src/process/exec.ts +1 -2
  29. package/src/process/resolve-bin.test.ts +43 -0
  30. package/src/readme/default-templates.test.ts +179 -0
  31. package/src/readme/default-templates.ts +5 -10
  32. package/src/readme/readme-renderer.test.ts +121 -0
  33. package/src/readme/readme-renderer.ts +3 -7
  34. package/src/redocly/redocly-sync.test.ts +190 -0
  35. package/src/redocly/redocly-sync.ts +3 -1
  36. package/src/test-support/fixtures.ts +65 -0
  37. package/src/version/version.test.ts +93 -0
  38. package/src/version/version.ts +4 -2
@@ -25,16 +25,23 @@ export const varsTreeSchema: z.ZodType<VarsTree> = z.lazy(() =>
25
25
  ),
26
26
  );
27
27
 
28
- export const githubSchema = z.object({
29
- owner: z.string().min(1),
30
- repo: z.string().min(1),
31
- });
28
+ export const specFormatSchema = z.enum(["json", "yaml"]);
29
+ export type SpecFormat = z.infer<typeof specFormatSchema>;
30
+
31
+ export const specFormatListSchema = z
32
+ .union([specFormatSchema, z.array(specFormatSchema).min(1)])
33
+ .default("json")
34
+ .transform((value) => (Array.isArray(value) ? value : [value]))
35
+ .refine((formats) => new Set(formats).size === formats.length, {
36
+ message: "paths.specFormat entries must be unique",
37
+ });
32
38
 
33
39
  export const pathsSchema = z.object({
34
40
  dist: z.string().min(1).default("dist"),
35
41
  specs: z.string().min(1).optional(),
36
42
  docs: z.string().min(1).optional(),
37
43
  sdk: z.string().min(1).optional(),
44
+ specFormat: specFormatListSchema,
38
45
  });
39
46
 
40
47
  export const docsSchema = z.object({
@@ -73,8 +80,8 @@ export const mavenCoordsSchema = z.object({
73
80
  /**
74
81
  * Publishing conventions - git branch/tag naming, and where registry-backed
75
82
  * artifacts (npm, Maven) get pushed. Every field is a template supporting the
76
- * usual `{...}` placeholders (`{service}`, `{id}`, `{github.*}`, `{vars.*}`,
77
- * and for `tag` only, also `{version}`).
83
+ * usual `{...}` placeholders (`{service}`, `{id}`, `{vars.*}`, and for `tag`
84
+ * only, also `{version}`).
78
85
  *
79
86
  * Required at the root level - seagull has no built-in opinion on branch/tag
80
87
  * naming or which registry to use, so this has to come from the config, not
@@ -98,7 +105,7 @@ export const publishingSchema = z.object({
98
105
  /**
99
106
  * Template for the `repository.url` field written into generated
100
107
  * `package.json` (and shown in default README templates), e.g.
101
- * `"https://github.com/{github.owner}/{github.repo}"`.
108
+ * `"https://github.com/{vars.repository.owner}/{vars.repository.repo}"`.
102
109
  */
103
110
  repositoryUrl: z.string().min(1),
104
111
  npm: z.object({
@@ -246,13 +253,12 @@ export const contractSchema = z.object({
246
253
 
247
254
  export const rootConfigSchema = z.object({
248
255
  /**
249
- * The config schema version this file targets. Currently must be `1`
256
+ * The config schema version this file targets. Currently, must be `1`
250
257
  * (the only version that exists) - see {@link CONFIG_SCHEMA_VERSION}.
251
258
  */
252
259
  configVersion: z.literal(CONFIG_SCHEMA_VERSION),
253
- github: githubSchema,
254
260
  vars: varsTreeSchema.default({}),
255
- paths: pathsSchema.default({ dist: "dist" }),
261
+ paths: pathsSchema.default({ dist: "dist", specFormat: ["json"] }),
256
262
  docs: docsSchema,
257
263
  /**
258
264
  * Publishing conventions (branch/tag naming, registry URLs), applied to
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { parseBundledSpec, specFilename } from "./spec-format";
4
+
5
+ describe("specFilename", () => {
6
+ it("joins the contract name and format with a dot", () => {
7
+ expect(specFilename("auth", "json")).toBe("auth.json");
8
+ expect(specFilename("auth", "yaml")).toBe("auth.yaml");
9
+ });
10
+ });
11
+
12
+ describe("parseBundledSpec", () => {
13
+ it("parses 'json' as strict JSON", () => {
14
+ const result = parseBundledSpec(
15
+ '{"openapi":"3.1.0","info":{"version":"1.0.0"}}',
16
+ "json",
17
+ );
18
+
19
+ expect(result).toEqual({
20
+ openapi: "3.1.0",
21
+ info: { version: "1.0.0" },
22
+ });
23
+ });
24
+
25
+ it("rejects invalid JSON when format is 'json', even if it happens to be valid YAML", () => {
26
+ // A bare, unquoted top-level scalar - valid YAML, not valid JSON. Proves
27
+ // parseBundledSpec doesn't quietly fall back to a lenient parser.
28
+ expect(() => parseBundledSpec("not json", "json")).toThrow();
29
+ });
30
+
31
+ it("parses 'yaml' as YAML", () => {
32
+ const result = parseBundledSpec(
33
+ 'openapi: "3.1.0"\ninfo:\n version: "1.0.0"\n',
34
+ "yaml",
35
+ );
36
+
37
+ expect(result).toEqual({
38
+ openapi: "3.1.0",
39
+ info: { version: "1.0.0" },
40
+ });
41
+ });
42
+
43
+ it("accepts plain JSON text when format is 'yaml' (JSON is valid YAML)", () => {
44
+ const result = parseBundledSpec(
45
+ '{"openapi":"3.1.0","info":{"version":"1.0.0"}}',
46
+ "yaml",
47
+ );
48
+
49
+ expect(result).toEqual({
50
+ openapi: "3.1.0",
51
+ info: { version: "1.0.0" },
52
+ });
53
+ });
54
+ });
@@ -0,0 +1,48 @@
1
+ import { parse as parseYaml } from "yaml";
2
+
3
+ import type { SpecFormat } from "./schema";
4
+
5
+ export type { SpecFormat } from "./schema";
6
+
7
+ /**
8
+ * The "primary" format among a `paths.specFormat` list - the one SDK generation,
9
+ * version/hash resolution, and the docs site actually read from when more than
10
+ * one format is configured. By convention, that's whichever format was listed
11
+ * first.
12
+ *
13
+ * @param formats - `config.paths.specFormat` (always non-empty).
14
+ * @returns The primary format.
15
+ */
16
+ export function primarySpecFormat(formats: SpecFormat[]): SpecFormat {
17
+ return formats[0]!;
18
+ }
19
+
20
+ /**
21
+ * The filename a contract's bundled spec is written to/read from, e.g.
22
+ * `auth.json` or `auth.yaml` - one place computing this so `bundle`, `generate`,
23
+ * and `docs generate` can't disagree about the extension.
24
+ *
25
+ * @param contractName - The contract's `name`.
26
+ * @param format - `config.paths.specFormat`.
27
+ * @returns The filename (no directory), e.g. `"auth.yaml"`.
28
+ */
29
+ export function specFilename(contractName: string, format: SpecFormat): string {
30
+ return `${contractName}.${format}`;
31
+ }
32
+
33
+ /**
34
+ * Parses a bundled spec's raw file contents according to its configured format.
35
+ * JSON and YAML are both valid inputs to Redocly/openapi-generator/
36
+ * openapi-typescript, so seagull's own parsing has to match: `JSON.parse`
37
+ * rejects trailing commas and comments that valid YAML permits, and would
38
+ * silently misparse a YAML document that happens to look JSON-ish.
39
+ *
40
+ * @param raw - The raw bundled spec file contents.
41
+ * @param format - `config.paths.specFormat`.
42
+ * @returns The parsed document.
43
+ */
44
+ export function parseBundledSpec(raw: string, format: SpecFormat): unknown {
45
+ return format === "json"
46
+ ? (JSON.parse(raw) as unknown)
47
+ : parseYaml(raw, { merge: true });
48
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buildTemplateContext, interpolate, interpolateDeep } from "./template";
4
+
5
+ describe("interpolate", () => {
6
+ it("replaces a single placeholder", () => {
7
+ expect(interpolate("hello {name}", { name: "world" })).toBe("hello world");
8
+ });
9
+
10
+ it("replaces multiple placeholders, including repeats", () => {
11
+ expect(interpolate("{a}-{b}-{a}", { a: "x", b: "y" })).toBe("x-y-x");
12
+ });
13
+
14
+ it("supports dotted placeholder keys", () => {
15
+ expect(
16
+ interpolate("@{vars.org}/{service}-client", {
17
+ "vars.org": "octalmesh",
18
+ service: "auth",
19
+ }),
20
+ ).toBe("@octalmesh/auth-client");
21
+ });
22
+
23
+ it("returns the template unchanged when it has no placeholders", () => {
24
+ expect(interpolate("no placeholders here", {})).toBe(
25
+ "no placeholders here",
26
+ );
27
+ });
28
+
29
+ it("leaves non-placeholder braces-free text alone alongside real placeholders", () => {
30
+ expect(interpolate("v{version}!!", { version: "1.0.0" })).toBe("v1.0.0!!");
31
+ });
32
+
33
+ it("throws a descriptive error for an unknown placeholder", () => {
34
+ expect(() => interpolate("{missing}", { known: "x" })).toThrow(
35
+ /Unknown template placeholder "\{missing\}" in "\{missing\}" \(available: known\)/,
36
+ );
37
+ });
38
+
39
+ it("lists available keys sorted in the error message", () => {
40
+ expect(() => interpolate("{missing}", { zeta: "1", alpha: "2" })).toThrow(
41
+ /available: alpha, zeta/,
42
+ );
43
+ });
44
+
45
+ it("throws on an empty context when a placeholder is present", () => {
46
+ expect(() => interpolate("{x}", {})).toThrow(/available: /);
47
+ });
48
+
49
+ it("does not treat a placeholder-shaped key with invalid characters as one", () => {
50
+ expect(interpolate("{not a placeholder}", {})).toBe("{not a placeholder}");
51
+ });
52
+
53
+ it("resolves a value that itself contains no braces even if empty string", () => {
54
+ expect(interpolate("[{x}]", { x: "" })).toBe("[]");
55
+ });
56
+ });
57
+
58
+ describe("interpolateDeep", () => {
59
+ it("interpolates a bare string", () => {
60
+ expect(interpolateDeep("{a}", { a: "1" })).toBe("1");
61
+ });
62
+
63
+ it("leaves non-string primitives untouched", () => {
64
+ expect(interpolateDeep(42, {})).toBe(42);
65
+ expect(interpolateDeep(true, {})).toBe(true);
66
+ expect(interpolateDeep(null, {})).toBe(null);
67
+ expect(interpolateDeep(undefined, {})).toBe(undefined);
68
+ });
69
+
70
+ it("recurses into arrays", () => {
71
+ expect(interpolateDeep(["{a}", "{b}", 3], { a: "1", b: "2" })).toEqual([
72
+ "1",
73
+ "2",
74
+ 3,
75
+ ]);
76
+ });
77
+
78
+ it("recurses into plain objects, preserving shape", () => {
79
+ const result = interpolateDeep(
80
+ { npmName: "{pkg}", withGoMod: true, count: 3 },
81
+ { pkg: "@org/pkg" },
82
+ );
83
+
84
+ expect(result).toEqual({
85
+ npmName: "@org/pkg",
86
+ withGoMod: true,
87
+ count: 3,
88
+ });
89
+ });
90
+
91
+ it("recurses into nested structures (object of arrays of objects)", () => {
92
+ const result = interpolateDeep(
93
+ { list: [{ id: "{id}" }, { id: "static" }] },
94
+ { id: "42" },
95
+ );
96
+
97
+ expect(result).toEqual({ list: [{ id: "42" }, { id: "static" }] });
98
+ });
99
+
100
+ it("produces a deep copy rather than mutating the input", () => {
101
+ const input = { a: { b: "{x}" } };
102
+ const result = interpolateDeep(input, { x: "y" });
103
+
104
+ expect(result).not.toBe(input);
105
+ expect(result.a).not.toBe(input.a);
106
+ expect(input.a.b).toBe("{x}");
107
+ });
108
+ });
109
+
110
+ describe("buildTemplateContext", () => {
111
+ it("flattens a nested scope into dot-path keys", () => {
112
+ expect(
113
+ buildTemplateContext({
114
+ service: "auth",
115
+ repository: { owner: "OctalMesh", repo: "ows-contracts" },
116
+ vars: { org: "octalmesh", nested: { deep: "value" } },
117
+ }),
118
+ ).toEqual({
119
+ service: "auth",
120
+ "repository.owner": "OctalMesh",
121
+ "repository.repo": "ows-contracts",
122
+ "vars.org": "octalmesh",
123
+ "vars.nested.deep": "value",
124
+ });
125
+ });
126
+
127
+ it("stringifies numbers and booleans at leaf positions", () => {
128
+ expect(
129
+ buildTemplateContext({ vars: { port: 8080, enabled: true } }),
130
+ ).toEqual({ "vars.port": "8080", "vars.enabled": "true" });
131
+ });
132
+
133
+ it("skips undefined leaves entirely", () => {
134
+ expect(buildTemplateContext({ a: "x", b: undefined })).toEqual({ a: "x" });
135
+ });
136
+
137
+ it("does not descend into arrays as if they were nested scopes", () => {
138
+ const result = buildTemplateContext({ list: ["a", "b"] as never });
139
+
140
+ expect(result).toEqual({ list: "a,b" });
141
+ });
142
+
143
+ it("returns an empty object for an empty scope", () => {
144
+ expect(buildTemplateContext({})).toEqual({});
145
+ });
146
+
147
+ it("round-trips with interpolate for a realistic artifact context", () => {
148
+ const context = buildTemplateContext({
149
+ service: "auth",
150
+ id: "ts-client",
151
+ repository: { owner: "OctalMesh", repo: "ows-contracts" },
152
+ vars: { org: "octalmesh" },
153
+ });
154
+
155
+ expect(interpolate("sdk/svc-{service}/{id}", context)).toBe(
156
+ "sdk/svc-auth/ts-client",
157
+ );
158
+ expect(interpolate("@{vars.org}/{service}-client", context)).toBe(
159
+ "@octalmesh/auth-client",
160
+ );
161
+ expect(
162
+ interpolate(
163
+ "https://github.com/{repository.owner}/{repository.repo}",
164
+ context,
165
+ ),
166
+ ).toBe("https://github.com/OctalMesh/ows-contracts");
167
+ });
168
+ });
@@ -1,6 +1,6 @@
1
- import type { VarsTree } from "./schema";
1
+ import type { SpecFormat, VarsTree } from "./schema";
2
2
 
3
- export type { VarsTree } from "./schema";
3
+ export type { SpecFormat, VarsTree } from "./schema";
4
4
  export type SdkTool = "openapi-generator" | "openapi-typescript";
5
5
  export type SdkLang = "typescript" | "go" | "java";
6
6
  export type SdkKind = "client" | "server";
@@ -114,9 +114,9 @@ export interface ResolvedConfig {
114
114
  specs: string;
115
115
  docs: string;
116
116
  sdk: string;
117
+ specFormat: SpecFormat[];
117
118
  };
118
119
 
119
- github: { owner: string; repo: string };
120
120
  vars: VarsTree;
121
121
 
122
122
  docs: {
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import type { SdkTool } from "../config/types";
4
+ import { Generator } from "./generator";
5
+ import type { GenerateContext, PrepareContext } from "./types";
6
+
7
+ class StubGenerator extends Generator {
8
+ readonly tool: SdkTool = "openapi-typescript";
9
+ readonly generateSpy = vi.fn((_ctx: GenerateContext) => Promise.resolve());
10
+
11
+ async generate(ctx: GenerateContext): Promise<void> {
12
+ await this.generateSpy(ctx);
13
+ }
14
+ }
15
+
16
+ describe("Generator", () => {
17
+ it("does not require 'prepare' to be implemented", () => {
18
+ const generator = new StubGenerator();
19
+
20
+ expect(typeof generator.prepare).toBe("undefined");
21
+ });
22
+
23
+ it("subclasses can implement 'prepare' as an optional hook", async () => {
24
+ const prepareSpy = vi.fn((_ctx: PrepareContext) => Promise.resolve());
25
+
26
+ class WithPrepare extends Generator {
27
+ readonly tool: SdkTool = "openapi-generator";
28
+
29
+ override async prepare(ctx: PrepareContext): Promise<void> {
30
+ await prepareSpy(ctx);
31
+ }
32
+
33
+ generate(): Promise<void> {
34
+ return Promise.resolve();
35
+ }
36
+ }
37
+
38
+ const generator = new WithPrepare();
39
+
40
+ await generator.prepare?.({ rootDir: "/tmp", entries: [] });
41
+
42
+ expect(prepareSpy).toHaveBeenCalledOnce();
43
+ });
44
+
45
+ it("exposes 'tool' as a readonly discriminator", () => {
46
+ const generator = new StubGenerator();
47
+
48
+ expect(generator.tool).toBe("openapi-typescript");
49
+ });
50
+
51
+ it("requires 'generate' to be implemented and calls through to it", async () => {
52
+ const generator = new StubGenerator();
53
+ const ctx = {} as GenerateContext;
54
+
55
+ await generator.generate(ctx);
56
+
57
+ expect(generator.generateSpy).toHaveBeenCalledWith(ctx);
58
+ });
59
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { SdkTool } from "../config/types";
4
+ import { Generator } from "./generator";
5
+ import { GeneratorRegistry } from "./registry";
6
+ import type { GenerateContext } from "./types";
7
+
8
+ class FakeGenerator extends Generator {
9
+ readonly tool: SdkTool;
10
+
11
+ constructor(tool: SdkTool) {
12
+ super();
13
+ this.tool = tool;
14
+ }
15
+
16
+ generate(_ctx: GenerateContext): Promise<void> {
17
+ return Promise.resolve();
18
+ }
19
+ }
20
+
21
+ describe("GeneratorRegistry", () => {
22
+ it("resolves a generator by its registered tool", () => {
23
+ const registry = new GeneratorRegistry();
24
+ const generator = new FakeGenerator("openapi-generator");
25
+
26
+ registry.register(generator);
27
+
28
+ expect(registry.resolve("openapi-generator")).toBe(generator);
29
+ });
30
+
31
+ it("returns 'this' from register(), enabling chaining", () => {
32
+ const registry = new GeneratorRegistry();
33
+ const result = registry
34
+ .register(new FakeGenerator("openapi-generator"))
35
+ .register(new FakeGenerator("openapi-typescript"));
36
+
37
+ expect(result).toBe(registry);
38
+ });
39
+
40
+ it("throws a descriptive error for an unregistered tool", () => {
41
+ const registry = new GeneratorRegistry();
42
+
43
+ registry.register(new FakeGenerator("openapi-typescript"));
44
+
45
+ expect(() => registry.resolve("openapi-generator")).toThrow(
46
+ /No generator implementation registered for tool "openapi-generator" \(available: openapi-typescript\)/,
47
+ );
48
+ });
49
+
50
+ it("throws with an empty available-list when nothing is registered", () => {
51
+ const registry = new GeneratorRegistry();
52
+
53
+ expect(() => registry.resolve("openapi-generator")).toThrow(
54
+ /\(available: \)/,
55
+ );
56
+ });
57
+
58
+ it("re-registering the same tool replaces the previous generator", () => {
59
+ const registry = new GeneratorRegistry();
60
+ const first = new FakeGenerator("openapi-generator");
61
+ const second = new FakeGenerator("openapi-generator");
62
+
63
+ registry.register(first).register(second);
64
+
65
+ expect(registry.resolve("openapi-generator")).toBe(second);
66
+ });
67
+
68
+ it("tools() lists every distinct registered tool", () => {
69
+ const registry = new GeneratorRegistry();
70
+
71
+ registry
72
+ .register(new FakeGenerator("openapi-generator"))
73
+ .register(new FakeGenerator("openapi-typescript"));
74
+
75
+ expect(registry.tools().sort()).toEqual([
76
+ "openapi-generator",
77
+ "openapi-typescript",
78
+ ]);
79
+ });
80
+
81
+ it("tools() returns an empty array for a fresh registry", () => {
82
+ expect(new GeneratorRegistry().tools()).toEqual([]);
83
+ });
84
+ });
@@ -2,18 +2,12 @@ import type {
2
2
  ResolvedArtifact,
3
3
  ResolvedArtifactEntry,
4
4
  ResolvedContract,
5
- SdkTool,
6
5
  } from "../config/types";
7
6
 
8
- export type {
9
- ResolvedArtifact,
10
- ResolvedArtifactEntry,
11
- ResolvedContract,
12
- SdkTool,
13
- };
14
-
15
- /** Passed once per tool to {@link Generator.prepare}, before any of that
16
- * tool's {@link Generator.generate} calls run. */
7
+ /**
8
+ * Passed once per tool to {@link Generator.prepare}, before any of that
9
+ * tool's {@link Generator.generate} calls run.
10
+ */
17
11
  export interface PrepareContext {
18
12
  rootDir: string;
19
13
  /**
@@ -23,16 +17,17 @@ export interface PrepareContext {
23
17
  entries: ResolvedArtifactEntry[];
24
18
  }
25
19
 
26
- /** Passed once per artifact to {@link Generator.generate}. */
20
+ /**
21
+ * Passed once per artifact to {@link Generator.generate}.
22
+ */
27
23
  export interface GenerateContext {
28
24
  rootDir: string;
29
25
  contract: ResolvedContract;
30
26
  artifact: ResolvedArtifact;
31
27
  version: string;
32
- github: { owner: string; repo: string };
33
28
  /**
34
- * Absolute path to the contract's bundled JSON spec
35
- * (`<specsDir>/<contract>.json`).
29
+ * Absolute path to the contract's bundled spec
30
+ * (`<specsDir>/<contract>.<paths.specFormat>` - `json` or `yaml`).
36
31
  */
37
32
  specInputPath: string;
38
33
  }