@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
@@ -0,0 +1,43 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { resolveBinPath } from "./resolve-bin";
6
+
7
+ describe("resolveBinPath", () => {
8
+ it("resolves an installed package's default bin entry to an existing file", () => {
9
+ const bin = resolveBinPath("@redocly/cli", "redocly");
10
+
11
+ expect(existsSync(bin)).toBe(true);
12
+ expect(bin).toMatch(/cli\.js$/);
13
+ });
14
+
15
+ it("resolves a string-form 'bin' field (not just the object form)", () => {
16
+ const bin = resolveBinPath("prettier", "prettier");
17
+
18
+ expect(existsSync(bin)).toBe(true);
19
+ expect(bin).toMatch(/prettier\.cjs$/);
20
+ });
21
+
22
+ it("resolves a non-default bin entry when the package exposes multiple", () => {
23
+ const bin = resolveBinPath("@redocly/cli", "openapi");
24
+
25
+ expect(existsSync(bin)).toBe(true);
26
+ });
27
+
28
+ it("defaults 'binName' to the package's own unscoped name when omitted", () => {
29
+ expect(() => resolveBinPath("@redocly/cli")).toThrow(
30
+ /Could not resolve a "cli" bin entry for package "@redocly\/cli"/,
31
+ );
32
+ });
33
+
34
+ it("throws a descriptive error for a package with no matching bin entry", () => {
35
+ expect(() => resolveBinPath("@redocly/cli", "does-not-exist")).toThrow(
36
+ /Could not resolve a "does-not-exist" bin entry for package "@redocly\/cli" - is it installed, and does it expose that bin\?/,
37
+ );
38
+ });
39
+
40
+ it("throws when the package itself cannot be resolved", () => {
41
+ expect(() => resolveBinPath("@octalmesh/does-not-exist")).toThrow();
42
+ });
43
+ });
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { makeArtifact, makeContract } from "../test-support/fixtures";
4
+ import { renderDefaultReadme } from "./default-templates";
5
+
6
+ describe("renderDefaultReadme", () => {
7
+ it("renders a common header with title, source, version, and branch", () => {
8
+ const contract = makeContract();
9
+ const artifact = makeArtifact();
10
+
11
+ const readme = renderDefaultReadme({
12
+ contract,
13
+ artifact,
14
+ version: "1.2.3",
15
+ vars: {},
16
+ });
17
+
18
+ expect(readme).toContain("# Auth Service API - TypeScript Client SDK");
19
+ expect(readme).toContain(
20
+ "> Generated from `specs/auth/openapi.yaml` in https://github.com/OctalMesh/ows-contracts.",
21
+ );
22
+ expect(readme).toContain("Version: `1.2.3`");
23
+ expect(readme).toContain("Source branch: `sdk/svc-auth/ts-client`");
24
+ });
25
+
26
+ it.each([
27
+ ["typescript", "client", "TypeScript Client SDK"],
28
+ ["typescript", "server", "TypeScript Server Types"],
29
+ ["go", "client", "Go Client SDK"],
30
+ ["go", "server", "Go Server Stubs"],
31
+ ["java", "client", "Java Client SDK"],
32
+ ["java", "server", "Java Server Stubs"],
33
+ ] as const)("labels %s/%s as '%s'", (lang, kind, label) => {
34
+ const artifact = makeArtifact({
35
+ lang,
36
+ kind,
37
+ package: lang === "typescript" ? "@org/pkg" : undefined,
38
+ goModule: lang === "go" ? "github.com/org/repo" : undefined,
39
+ goPackageName: lang === "go" ? "authclient" : undefined,
40
+ maven:
41
+ lang === "java"
42
+ ? { groupId: "com.org.auth", artifactId: "auth-client" }
43
+ : undefined,
44
+ });
45
+
46
+ const readme = renderDefaultReadme({
47
+ contract: makeContract(),
48
+ artifact,
49
+ version: "1.0.0",
50
+ vars: {},
51
+ });
52
+
53
+ expect(readme).toContain(`- ${label}`);
54
+ });
55
+
56
+ it("typescript-client body includes npm install/usage instructions", () => {
57
+ const readme = renderDefaultReadme({
58
+ contract: makeContract(),
59
+ artifact: makeArtifact({
60
+ lang: "typescript",
61
+ kind: "client",
62
+ package: "@octalmesh/auth-client",
63
+ }),
64
+ version: "1.0.0",
65
+ vars: {},
66
+ });
67
+
68
+ expect(readme).toContain("npm install @octalmesh/auth-client@1.0.0");
69
+ expect(readme).toContain(
70
+ 'import { Configuration, DefaultApi } from "@octalmesh/auth-client";',
71
+ );
72
+ });
73
+
74
+ it("typescript-server body includes types-only usage, not a client import", () => {
75
+ const readme = renderDefaultReadme({
76
+ contract: makeContract(),
77
+ artifact: makeArtifact({
78
+ lang: "typescript",
79
+ kind: "server",
80
+ package: "@octalmesh/auth-server",
81
+ }),
82
+ version: "1.0.0",
83
+ vars: {},
84
+ });
85
+
86
+ expect(readme).toContain(
87
+ 'import type { components, operations } from "@octalmesh/auth-server";',
88
+ );
89
+ expect(readme).not.toContain("Configuration, DefaultApi");
90
+ });
91
+
92
+ it("go-client body renders an example tag using the artifact's own tag template", () => {
93
+ const readme = renderDefaultReadme({
94
+ contract: makeContract(),
95
+ artifact: makeArtifact({
96
+ id: "go-client",
97
+ lang: "go",
98
+ kind: "client",
99
+ package: undefined,
100
+ goModule: "github.com/octalmesh/ows-contracts",
101
+ goPackageName: "authclient",
102
+ branch: "sdk/svc-auth/go-client",
103
+ publishing: {
104
+ branch: "sdk/svc-auth/go-client",
105
+ tagTemplate: "svc-{service}-{id}-v{version}",
106
+ repositoryUrl: "https://github.com/OctalMesh/ows-contracts",
107
+ npmRegistry: "https://npm.pkg.github.com",
108
+ npmAccess: "public",
109
+ mavenRepositoryId: "github",
110
+ mavenRepositoryUrl:
111
+ "https://maven.pkg.github.com/OctalMesh/ows-contracts",
112
+ },
113
+ }),
114
+ version: "1.0.0",
115
+ vars: {},
116
+ });
117
+
118
+ expect(readme).toContain(
119
+ "go get github.com/octalmesh/ows-contracts@sdk/svc-auth/go-client",
120
+ );
121
+ expect(readme).toContain(
122
+ "go get github.com/octalmesh/ows-contracts@svc-auth-go-client-v<version>",
123
+ );
124
+ });
125
+
126
+ it("go-server body has no install-by-tag example, just go get + router wiring", () => {
127
+ const readme = renderDefaultReadme({
128
+ contract: makeContract(),
129
+ artifact: makeArtifact({
130
+ lang: "go",
131
+ kind: "server",
132
+ package: undefined,
133
+ goModule: "github.com/octalmesh/ows-contracts",
134
+ goPackageName: "authserver",
135
+ }),
136
+ version: "1.0.0",
137
+ vars: {},
138
+ });
139
+
140
+ expect(readme).toContain("authserver.NewRouter(");
141
+ });
142
+
143
+ it("java-client body renders Maven coordinates and a RestClient usage example", () => {
144
+ const readme = renderDefaultReadme({
145
+ contract: makeContract(),
146
+ artifact: makeArtifact({
147
+ lang: "java",
148
+ kind: "client",
149
+ package: undefined,
150
+ maven: { groupId: "com.octalmesh.auth", artifactId: "auth-client" },
151
+ }),
152
+ version: "2.0.0",
153
+ vars: {},
154
+ });
155
+
156
+ expect(readme).toContain("<groupId>com.octalmesh.auth</groupId>");
157
+ expect(readme).toContain("<artifactId>auth-client</artifactId>");
158
+ expect(readme).toContain("<version>2.0.0</version>");
159
+ expect(readme).toContain("<id>github</id>");
160
+ expect(readme).toContain("RestClient");
161
+ });
162
+
163
+ it("java-server body notes interfaceOnly generation, not a usable client", () => {
164
+ const readme = renderDefaultReadme({
165
+ contract: makeContract(),
166
+ artifact: makeArtifact({
167
+ lang: "java",
168
+ kind: "server",
169
+ package: undefined,
170
+ maven: { groupId: "com.octalmesh.auth", artifactId: "auth-server" },
171
+ }),
172
+ version: "1.0.0",
173
+ vars: {},
174
+ });
175
+
176
+ expect(readme).toContain("interfaceOnly=true");
177
+ expect(readme).toContain("implements SomeApi");
178
+ });
179
+ });
@@ -9,7 +9,6 @@ export interface DefaultReadmeArgs {
9
9
  contract: ResolvedContract;
10
10
  artifact: ResolvedArtifact;
11
11
  version: string;
12
- github: { owner: string; repo: string };
13
12
  vars: VarsTree;
14
13
  }
15
14
 
@@ -22,27 +21,26 @@ export interface DefaultReadmeArgs {
22
21
  * come from the artifact's resolved `publishing:` config, so a repo that
23
22
  * overrides its registry sees that reflected here automatically.
24
23
  *
25
- * @param args - The contract, artifact, version, and github/vars coordinates
26
- * to render for.
24
+ * @param args - The contract, artifact, version, and vars coordinates to render
25
+ * for.
27
26
  * @returns The rendered README content.
28
27
  */
29
28
  export function renderDefaultReadme({
30
29
  contract,
31
30
  artifact,
32
31
  version,
33
- github,
34
32
  vars,
35
33
  }: DefaultReadmeArgs): string {
36
34
  const header = `# ${contract.title} - ${label(artifact)}
37
35
 
38
- > Generated from \`${contract.entrypointRelative}\` in [${github.owner}/${github.repo}](https://github.com/${github.owner}/${github.repo}).
36
+ > Generated from \`${contract.entrypointRelative}\` in ${artifact.publishing.repositoryUrl}.
39
37
  > Do not edit by hand - this package is regenerated and republished on every release.
40
38
 
41
39
  Version: \`${version}\`
42
40
  Source branch: \`${artifact.branch}\`
43
41
  `;
44
42
 
45
- return `${header}\n${body(contract, artifact, version, github, vars)}\n`;
43
+ return `${header}\n${body(contract, artifact, version, vars)}\n`;
46
44
  }
47
45
 
48
46
  //<editor-fold desc="README Template Helpers" defaultstate="collapsed">
@@ -65,7 +63,6 @@ function body(
65
63
  contract: ResolvedContract,
66
64
  artifact: ResolvedArtifact,
67
65
  version: string,
68
- github: { owner: string; repo: string },
69
66
  vars: VarsTree,
70
67
  ): string {
71
68
  switch (`${artifact.lang}-${artifact.kind}`) {
@@ -74,7 +71,7 @@ function body(
74
71
  case "typescript-server":
75
72
  return tsServer(artifact, version);
76
73
  case "go-client":
77
- return goClient(contract, artifact, github, vars);
74
+ return goClient(contract, artifact, vars);
78
75
  case "go-server":
79
76
  return goServer(artifact);
80
77
  case "java-client":
@@ -146,7 +143,6 @@ app.post("/login", (req, res) => {
146
143
  function goClient(
147
144
  contract: ResolvedContract,
148
145
  artifact: ResolvedArtifact,
149
- github: { owner: string; repo: string },
150
146
  vars: VarsTree,
151
147
  ): string {
152
148
  // Illustrative only - renders the tag template with a literal "<version>"
@@ -156,7 +152,6 @@ function goClient(
156
152
  artifact,
157
153
  contract.name,
158
154
  "<version>",
159
- github,
160
155
  vars,
161
156
  );
162
157
 
@@ -0,0 +1,121 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+
7
+ import { makeArtifact, makeContract } from "../test-support/fixtures";
8
+ import { renderReadme } from "./readme-renderer";
9
+
10
+ describe("renderReadme", () => {
11
+ let dir: string;
12
+
13
+ beforeEach(async () => {
14
+ dir = await mkdtemp(path.join(tmpdir(), "seagull-readme-"));
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await rm(dir, { recursive: true, force: true });
19
+ });
20
+
21
+ it("falls back to the built-in default template when no readmeTemplate is set", async () => {
22
+ const readme = await renderReadme({
23
+ contract: makeContract(),
24
+ artifact: makeArtifact({ readmeTemplate: undefined }),
25
+ version: "1.0.0",
26
+ vars: {},
27
+ });
28
+
29
+ expect(readme).toContain("# Auth Service API - TypeScript Client SDK");
30
+ });
31
+
32
+ it("reads and interpolates a custom readmeTemplate file when set", async () => {
33
+ const templatePath = path.join(dir, "custom-readme.md");
34
+
35
+ await writeFile(
36
+ templatePath,
37
+ [
38
+ "# {title} - custom",
39
+ "",
40
+ "Install `{artifact.package}@{version}` from {artifact.npmRegistry}.",
41
+ "Branch: {artifact.branch}, tag: {artifact.tag}.",
42
+ "Org: {vars.org}, repo: {vars.repository.owner}/{vars.repository.repo}.",
43
+ ].join("\n"),
44
+ );
45
+
46
+ const readme = await renderReadme({
47
+ contract: makeContract({ title: "Auth Service API" }),
48
+ artifact: makeArtifact({ readmeTemplate: templatePath }),
49
+ version: "1.2.3",
50
+ vars: {
51
+ org: "octalmesh",
52
+ repository: { owner: "OctalMesh", repo: "ows-contracts" },
53
+ },
54
+ });
55
+
56
+ expect(readme).toBe(
57
+ [
58
+ "# Auth Service API - custom",
59
+ "",
60
+ "Install `@octalmesh/auth-client@1.2.3` from https://npm.pkg.github.com.",
61
+ "Branch: sdk/svc-auth/ts-client, tag: svc-auth-ts-client-v1.2.3.",
62
+ "Org: octalmesh, repo: OctalMesh/ows-contracts.",
63
+ ].join("\n"),
64
+ );
65
+ });
66
+
67
+ it("exposes artifact.id/lang/kind and maven coordinates to the custom template", async () => {
68
+ const templatePath = path.join(dir, "java-readme.md");
69
+
70
+ await writeFile(
71
+ templatePath,
72
+ "{artifact.id} / {artifact.lang} / {artifact.kind} - {artifact.maven.groupId}:{artifact.maven.artifactId}",
73
+ );
74
+
75
+ const readme = await renderReadme({
76
+ contract: makeContract(),
77
+ artifact: makeArtifact({
78
+ id: "java-client",
79
+ lang: "java",
80
+ kind: "client",
81
+ package: undefined,
82
+ maven: { groupId: "com.octalmesh.auth", artifactId: "auth-client" },
83
+ readmeTemplate: templatePath,
84
+ }),
85
+ version: "1.0.0",
86
+ vars: {},
87
+ });
88
+
89
+ expect(readme).toBe(
90
+ "java-client / java / client - com.octalmesh.auth:auth-client",
91
+ );
92
+ });
93
+
94
+ it("throws when the custom template references an unknown placeholder", async () => {
95
+ const templatePath = path.join(dir, "broken.md");
96
+
97
+ await writeFile(templatePath, "{artifact.doesNotExist}");
98
+
99
+ await expect(
100
+ renderReadme({
101
+ contract: makeContract(),
102
+ artifact: makeArtifact({ readmeTemplate: templatePath }),
103
+ version: "1.0.0",
104
+ vars: {},
105
+ }),
106
+ ).rejects.toThrow(/Unknown template placeholder/);
107
+ });
108
+
109
+ it("rejects when the custom template file doesn't exist", async () => {
110
+ await expect(
111
+ renderReadme({
112
+ contract: makeContract(),
113
+ artifact: makeArtifact({
114
+ readmeTemplate: path.join(dir, "missing.md"),
115
+ }),
116
+ version: "1.0.0",
117
+ vars: {},
118
+ }),
119
+ ).rejects.toThrow();
120
+ });
121
+ });
@@ -13,7 +13,6 @@ export interface RenderReadmeArgs {
13
13
  contract: ResolvedContract;
14
14
  artifact: ResolvedArtifact;
15
15
  version: string;
16
- github: { owner: string; repo: string };
17
16
  vars: VarsTree;
18
17
  }
19
18
 
@@ -23,13 +22,12 @@ export interface RenderReadmeArgs {
23
22
  * If the artifact has a `readme:` path configured (resolved at config-load time
24
23
  * to `artifact.readmeTemplate`), that file is read and interpolated with the
25
24
  * same `{...}` placeholder engine naming templates use - `{service}`,
26
- * `{title}`, `{version}`, `{vars.*}`, `{github.owner}`, `{github.repo}`, plus
27
- * `{artifact.*}` (id/lang/kind/package/goModule/goPackageName/maven.groupId/
25
+ * `{title}`, `{version}`, `{vars.*}`, plus `{artifact.*}`
26
+ * (id/lang/kind/package/goModule/goPackageName/maven.groupId/
28
27
  * maven.artifactId/branch/tag/npmRegistry/mavenRepositoryUrl). Otherwise,
29
28
  * falls back to a built-in default template for the artifact's language/kind.
30
29
  *
31
- * @param args - The contract, artifact, version, and github/vars context to
32
- * render for.
30
+ * @param args - The contract, artifact, version, and vars context to render for.
33
31
  * @returns The rendered README content.
34
32
  */
35
33
  export async function renderReadme(args: RenderReadmeArgs): Promise<string> {
@@ -42,7 +40,6 @@ export async function renderReadme(args: RenderReadmeArgs): Promise<string> {
42
40
  service: args.contract.name,
43
41
  title: args.contract.title,
44
42
  version: args.version,
45
- github: args.github,
46
43
  vars: args.vars,
47
44
  artifact: {
48
45
  id: args.artifact.id,
@@ -57,7 +54,6 @@ export async function renderReadme(args: RenderReadmeArgs): Promise<string> {
57
54
  args.artifact,
58
55
  args.contract.name,
59
56
  args.version,
60
- args.github,
61
57
  args.vars,
62
58
  ),
63
59
  npmRegistry: args.artifact.publishing.npmRegistry,
@@ -0,0 +1,190 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { parse as parseYaml } from "yaml";
7
+
8
+ import type { ResolvedConfig } from "../config/types";
9
+ import { makeArtifact, makeContract } from "../test-support/fixtures";
10
+ import { syncRedoclyConfig } from "./redocly-sync";
11
+
12
+ function makeConfig(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
13
+ return {
14
+ configVersion: 1,
15
+ rootDir: "/repo",
16
+ paths: {
17
+ dist: "/repo/dist",
18
+ specs: "/repo/dist/specs",
19
+ docs: "/repo/dist/docs",
20
+ sdk: "/repo/dist/sdk",
21
+ specFormat: ["json"],
22
+ },
23
+ vars: {},
24
+ docs: {
25
+ server: { host: "localhost", port: 8080 },
26
+ metadata: {
27
+ title: "t",
28
+ description: "d",
29
+ favicon: "f",
30
+ baseServerUrl: "https://example.com",
31
+ },
32
+ },
33
+ contracts: [makeContract()],
34
+ allArtifacts: [],
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe("syncRedoclyConfig", () => {
40
+ let dir: string;
41
+
42
+ beforeEach(async () => {
43
+ dir = await mkdtemp(path.join(tmpdir(), "seagull-redocly-"));
44
+ await writeFile(
45
+ path.join(dir, "redocly.base.yaml"),
46
+ [
47
+ "extends:",
48
+ ' - "recommended"',
49
+ "rules:",
50
+ ' operation-operationId: "error"',
51
+ ].join("\n"),
52
+ );
53
+ });
54
+
55
+ afterEach(async () => {
56
+ await rm(dir, { recursive: true, force: true });
57
+ });
58
+
59
+ it("writes redocly.yaml merging the base extends/rules with generated apis", async () => {
60
+ const config = makeConfig({ rootDir: dir });
61
+
62
+ await syncRedoclyConfig(config);
63
+
64
+ const written = await readFile(path.join(dir, "redocly.yaml"), "utf8");
65
+ const parsed = parseYaml(written) as {
66
+ extends: string[];
67
+ rules: Record<string, string>;
68
+ apis: Record<string, unknown>;
69
+ };
70
+
71
+ expect(parsed.extends).toEqual(["recommended"]);
72
+ expect(parsed.rules).toEqual({ "operation-operationId": "error" });
73
+ expect(parsed.apis).toHaveProperty("auth@v1");
74
+ });
75
+
76
+ it("prefixes the generated file with the auto-generated header comment", async () => {
77
+ const config = makeConfig({ rootDir: dir });
78
+
79
+ await syncRedoclyConfig(config);
80
+
81
+ const written = await readFile(path.join(dir, "redocly.yaml"), "utf8");
82
+
83
+ expect(written).toMatch(/^# AUTO-GENERATED by Seagull/);
84
+ expect(written).toMatch(/Do not edit directly/);
85
+ });
86
+
87
+ it("sets each api's 'root' to the contract's entrypoint relative to rootDir", async () => {
88
+ const config = makeConfig({ rootDir: dir });
89
+
90
+ await syncRedoclyConfig(config);
91
+
92
+ const parsed = parseYaml(
93
+ await readFile(path.join(dir, "redocly.yaml"), "utf8"),
94
+ ) as { apis: Record<string, { root: string }> };
95
+
96
+ expect(parsed.apis["auth@v1"]!.root).toBe("specs/auth/openapi.yaml");
97
+ });
98
+
99
+ it("adds x-openapi-ts.output only for contracts with an openapi-typescript artifact", async () => {
100
+ const withTypes = makeContract({
101
+ name: "auth",
102
+ artifacts: [
103
+ makeArtifact({
104
+ tool: "openapi-typescript",
105
+ id: "ts-server",
106
+ outputDir: path.join(dir, "dist", "sdk", "auth", "ts-server"),
107
+ }),
108
+ ],
109
+ });
110
+ const withoutTypes = makeContract({
111
+ name: "catalog",
112
+ entrypoint: "/repo/specs/catalog/openapi.yaml",
113
+ entrypointRelative: path.join("specs", "catalog", "openapi.yaml"),
114
+ artifacts: [
115
+ makeArtifact({
116
+ tool: "openapi-generator",
117
+ id: "java-client",
118
+ }),
119
+ ],
120
+ });
121
+
122
+ const config = makeConfig({
123
+ rootDir: dir,
124
+ contracts: [withTypes, withoutTypes],
125
+ });
126
+
127
+ await syncRedoclyConfig(config);
128
+
129
+ const parsed = parseYaml(
130
+ await readFile(path.join(dir, "redocly.yaml"), "utf8"),
131
+ ) as {
132
+ apis: Record<string, { "x-openapi-ts"?: { output: string } }>;
133
+ };
134
+
135
+ expect(parsed.apis["auth@v1"]!["x-openapi-ts"]).toEqual({
136
+ output: path.join("dist", "sdk", "auth", "ts-server", "index.d.ts"),
137
+ });
138
+ expect(parsed.apis["catalog@v1"]!["x-openapi-ts"]).toBeUndefined();
139
+ });
140
+
141
+ it("includes every contract as its own '<name>@v1' api entry", async () => {
142
+ const config = makeConfig({
143
+ rootDir: dir,
144
+ contracts: [
145
+ makeContract({ name: "auth" }),
146
+ makeContract({
147
+ name: "catalog",
148
+ entrypoint: "/repo/specs/catalog/openapi.yaml",
149
+ entrypointRelative: path.join("specs", "catalog", "openapi.yaml"),
150
+ }),
151
+ ],
152
+ });
153
+
154
+ await syncRedoclyConfig(config);
155
+
156
+ const parsed = parseYaml(
157
+ await readFile(path.join(dir, "redocly.yaml"), "utf8"),
158
+ ) as { apis: Record<string, unknown> };
159
+
160
+ expect(Object.keys(parsed.apis).sort()).toEqual(["auth@v1", "catalog@v1"]);
161
+ });
162
+
163
+ it("rejects when redocly.base.yaml is missing", async () => {
164
+ const emptyDir = await mkdtemp(
165
+ path.join(tmpdir(), "seagull-redocly-empty-"),
166
+ );
167
+
168
+ try {
169
+ await expect(
170
+ syncRedoclyConfig(makeConfig({ rootDir: emptyDir })),
171
+ ).rejects.toThrow();
172
+ } finally {
173
+ await rm(emptyDir, { recursive: true, force: true });
174
+ }
175
+ });
176
+
177
+ it("overwrites a pre-existing redocly.yaml", async () => {
178
+ await writeFile(
179
+ path.join(dir, "redocly.yaml"),
180
+ "# stale content\napis: {}\n",
181
+ );
182
+
183
+ await syncRedoclyConfig(makeConfig({ rootDir: dir }));
184
+
185
+ const written = await readFile(path.join(dir, "redocly.yaml"), "utf8");
186
+
187
+ expect(written).not.toContain("stale content");
188
+ expect(written).toContain("auth@v1");
189
+ });
190
+ });
@@ -31,7 +31,9 @@ interface RedoclyBase {
31
31
  */
32
32
  export async function syncRedoclyConfig(config: ResolvedConfig): Promise<void> {
33
33
  const basePath = path.join(config.rootDir, "redocly.base.yaml");
34
- const base = parseYaml(await readFile(basePath, "utf8")) as RedoclyBase;
34
+ const base = parseYaml(await readFile(basePath, "utf8"), {
35
+ merge: true,
36
+ }) as RedoclyBase;
35
37
 
36
38
  const apis = Object.fromEntries(
37
39
  config.contracts.map((contract) => {