@octalmesh/seagull-core 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.
@@ -0,0 +1,122 @@
1
+ import { mkdir } from "node:fs/promises";
2
+
3
+ import type { ResolvedArtifact, SdkTool } from "../../config/types";
4
+ import { Generator } from "../../generator/generator";
5
+ import type { GenerateContext } from "../../generator/types";
6
+ import { run } from "../../process/exec";
7
+ import { resolveBinPath } from "../../process/resolve-bin";
8
+ import { GoModulePatcher } from "./patchers/go-module.patcher";
9
+ import { MavenPomPatcher } from "./patchers/maven.patcher";
10
+ import { NpmPackagePatcher } from "./patchers/npm.patcher";
11
+ import type { Patcher } from "./patchers/patcher";
12
+
13
+ /**
14
+ * Additional-properties that are fully derivable from an artifact's own
15
+ * `package`/`goPackageName`/`maven` fields - conventional openapi-generator
16
+ * knobs (`npmName`, `groupId`, ...) that would otherwise have to be duplicated
17
+ * by hand in every generator's `additionalProperties:` block, in lockstep with
18
+ * those same fields. Tool-specific tuning that isn't derivable this way
19
+ * (`library=restclient`, `withGoMod`, ...) still lives in
20
+ * `additionalProperties:` and is layered on top of these.
21
+ *
22
+ * @param artifact - The resolved artifact to derive properties for.
23
+ * @returns The derived additional-properties, before the artifact's own
24
+ * `additionalProperties` are layered on top.
25
+ */
26
+ function deriveAdditionalProperties(
27
+ artifact: ResolvedArtifact,
28
+ ): Record<string, string> {
29
+ if (artifact.lang === "typescript" && artifact.package) {
30
+ return { npmName: artifact.package };
31
+ }
32
+
33
+ if (artifact.lang === "go" && artifact.goPackageName) {
34
+ return { packageName: artifact.goPackageName };
35
+ }
36
+
37
+ if (artifact.lang === "java" && artifact.maven) {
38
+ const invokerPackage = `${artifact.maven.groupId}.${artifact.kind}`;
39
+
40
+ return {
41
+ groupId: artifact.maven.groupId,
42
+ artifactId: artifact.maven.artifactId,
43
+ invokerPackage,
44
+ apiPackage: `${invokerPackage}.api`,
45
+ modelPackage: `${invokerPackage}.model`,
46
+ };
47
+ }
48
+
49
+ return {};
50
+ }
51
+
52
+ /**
53
+ * Renders an artifact's additional-properties (derived + explicit, explicit
54
+ * wins on conflicts) as the `key=value,key=value` string
55
+ * `openapi-generator-cli --additional-properties` expects.
56
+ *
57
+ * @param artifact - The resolved artifact.
58
+ * @returns The rendered `--additional-properties` value.
59
+ */
60
+ function buildAdditionalPropertiesArg(artifact: ResolvedArtifact): string {
61
+ const merged = {
62
+ ...deriveAdditionalProperties(artifact),
63
+ ...artifact.additionalProperties,
64
+ };
65
+
66
+ return Object.entries(merged)
67
+ .map(([key, value]) => `${key}=${String(value)}`)
68
+ .join(",");
69
+ }
70
+
71
+ /**
72
+ * Wraps `openapi-generator-cli` - the single tool implementation behind every
73
+ * `-g` template (`typescript-fetch`, `go`, `go-server`, `java`, `spring`, ...),
74
+ * regardless of language. Language-specific output patching is delegated to a
75
+ * {@link Patcher}, selected by `artifact.lang`.
76
+ */
77
+ export class OpenApiGeneratorCli extends Generator {
78
+ readonly tool: SdkTool = "openapi-generator";
79
+
80
+ private readonly patchers: Partial<
81
+ Record<ResolvedArtifact["lang"], Patcher>
82
+ > = {
83
+ go: new GoModulePatcher(),
84
+ typescript: new NpmPackagePatcher(),
85
+ java: new MavenPomPatcher(),
86
+ };
87
+
88
+ async generate(ctx: GenerateContext): Promise<void> {
89
+ const { artifact, rootDir, specInputPath } = ctx;
90
+
91
+ if (!artifact.generator) {
92
+ throw new Error(
93
+ `Artifact "${artifact.id}" uses tool "openapi-generator" but has no "generator" value`,
94
+ );
95
+ }
96
+
97
+ await mkdir(artifact.outputDir, { recursive: true });
98
+
99
+ const bin = resolveBinPath(
100
+ "@openapitools/openapi-generator-cli",
101
+ "openapi-generator-cli",
102
+ );
103
+
104
+ await run(
105
+ "node",
106
+ [
107
+ bin,
108
+ "generate",
109
+ "-i",
110
+ specInputPath,
111
+ "-g",
112
+ artifact.generator,
113
+ "-o",
114
+ artifact.outputDir,
115
+ `--additional-properties=${buildAdditionalPropertiesArg(artifact)}`,
116
+ ],
117
+ rootDir,
118
+ );
119
+
120
+ await this.patchers[artifact.lang]?.patch(ctx);
121
+ }
122
+ }
@@ -0,0 +1,31 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { GenerateContext } from "../../../generator/types";
5
+ import type { Patcher } from "./patcher";
6
+
7
+ /**
8
+ * Patches the `go.mod` file in a generated Go SDK package to set the correct
9
+ * module path (`openapi-generator-cli` has no way to be told this up front for
10
+ * every template).
11
+ */
12
+ export class GoModulePatcher implements Patcher {
13
+ async patch({ artifact }: GenerateContext): Promise<void> {
14
+ if (!artifact.goModule) {
15
+ return;
16
+ }
17
+
18
+ const moduleFile = path.join(artifact.outputDir, "go.mod");
19
+
20
+ try {
21
+ const contents = await readFile(moduleFile, "utf8");
22
+
23
+ await writeFile(
24
+ moduleFile,
25
+ contents.replace(/^module .*$/m, `module ${artifact.goModule}`),
26
+ );
27
+ } catch {
28
+ // go-server templates don't always emit go.mod
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,44 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { GenerateContext } from "../../../generator/types";
5
+ import type { Patcher } from "./patcher";
6
+
7
+ /**
8
+ * Patches the `pom.xml` file in a generated Java SDK package to set the correct
9
+ * version and distribution management information for publishing, using the
10
+ * artifact's resolved `publishing:` config rather than a hardcoded registry.
11
+ */
12
+ export class MavenPomPatcher implements Patcher {
13
+ async patch({ artifact, version }: GenerateContext): Promise<void> {
14
+ const pomFile = path.join(artifact.outputDir, "pom.xml");
15
+
16
+ try {
17
+ let pom = await readFile(pomFile, "utf8");
18
+
19
+ pom = pom.replace(
20
+ /<version>[^<]*<\/version>/,
21
+ `<version>${version}</version>`,
22
+ );
23
+
24
+ if (!pom.includes("<distributionManagement>")) {
25
+ pom = pom.replace(
26
+ "</project>",
27
+ [
28
+ " <distributionManagement>",
29
+ " <repository>",
30
+ ` <id>${artifact.publishing.mavenRepositoryId}</id>`,
31
+ ` <url>${artifact.publishing.mavenRepositoryUrl}</url>`,
32
+ " </repository>",
33
+ " </distributionManagement>",
34
+ "</project>",
35
+ ].join("\n"),
36
+ );
37
+ }
38
+
39
+ await writeFile(pomFile, pom);
40
+ } catch {
41
+ // pom.xml absent (e.g. Gradle build selected)
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,32 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { GenerateContext } from "../../../generator/types";
5
+ import type { Patcher } from "./patcher";
6
+
7
+ /**
8
+ * Patches the `package.json` file in a generated TypeScript SDK package (the
9
+ * client target - `openapi-generator-cli` produces its own `package.json`, this
10
+ * just fills in the version and publishing metadata) to set the correct version
11
+ * and repository information, using the artifact's resolved `publishing:`
12
+ * config rather than a hardcoded registry.
13
+ */
14
+ export class NpmPackagePatcher implements Patcher {
15
+ async patch({ artifact, version }: GenerateContext): Promise<void> {
16
+ const pkgFile = path.join(artifact.outputDir, "package.json");
17
+ const pkgData = await readFile(pkgFile, "utf8");
18
+ const pkg = JSON.parse(pkgData) as Record<string, unknown>;
19
+
20
+ pkg.version = version;
21
+ pkg.repository = {
22
+ type: "git",
23
+ url: `git+${artifact.publishing.repositoryUrl}.git`,
24
+ };
25
+ pkg.publishConfig = {
26
+ registry: artifact.publishing.npmRegistry,
27
+ access: artifact.publishing.npmAccess,
28
+ };
29
+
30
+ await writeFile(pkgFile, JSON.stringify(pkg, null, 2));
31
+ }
32
+ }
@@ -0,0 +1,17 @@
1
+ import type { GenerateContext } from "../../../generator/types";
2
+
3
+ /**
4
+ * A strategy that post-processes an `openapi-generator-cli` output directory
5
+ * after generation - patching in the version, repository metadata, and
6
+ * publishing config that the generator itself doesn't know about. One
7
+ * implementation per language (`npm`, `go-module`, `maven`), selected by
8
+ * openapi-generator-cli based on `artifact.lang`, so the base generator class
9
+ * stays language-agnostic.
10
+ */
11
+ export interface Patcher {
12
+ /**
13
+ * @param ctx - The generate context (contract, artifact, version, ...) for
14
+ * the artifact that was just generated.
15
+ */
16
+ patch(ctx: GenerateContext): Promise<void>;
17
+ }
@@ -0,0 +1 @@
1
+ export { OpenApiTypescriptGenerator } from "./openapi-typescript.generator";
@@ -0,0 +1,62 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { SdkTool } from "../../config/types";
5
+ import { Generator } from "../../generator/generator";
6
+ import type { GenerateContext, PrepareContext } from "../../generator/types";
7
+ import { run } from "../../process/exec";
8
+ import { resolveBinPath } from "../../process/resolve-bin";
9
+
10
+ /**
11
+ * Wraps `openapi-typescript`. Unlike `openapi-generator-cli`, it isn't invoked
12
+ * once per artifact - it reads `redocly.yaml`'s `apis:` map (kept in sync with
13
+ * the CLI config by `core/redocly/redocly-sync.ts`) and writes every contract's
14
+ * `index.d.ts` to its configured `x-openapi-ts.output` path in a single run,
15
+ * so that single global invocation happens once in {@link prepare}.
16
+ * {@link generate} then only has to write each artifact's package.json` -
17
+ * `openapi-typescript` emits `index.d.ts` alone, with no package manifest of
18
+ * its own to patch.
19
+ */
20
+ export class OpenApiTypescriptGenerator extends Generator {
21
+ readonly tool: SdkTool = "openapi-typescript";
22
+
23
+ override async prepare({ rootDir, entries }: PrepareContext): Promise<void> {
24
+ await Promise.all(
25
+ entries.map((entry) =>
26
+ mkdir(entry.artifact.outputDir, { recursive: true }),
27
+ ),
28
+ );
29
+
30
+ const bin = resolveBinPath("openapi-typescript", "openapi-typescript");
31
+
32
+ await run("node", [bin], rootDir);
33
+ }
34
+
35
+ async generate({
36
+ contract,
37
+ artifact,
38
+ version,
39
+ }: GenerateContext): Promise<void> {
40
+ const pkg = {
41
+ name: artifact.package,
42
+ version,
43
+ description: `Types-only OpenAPI contract for the ${contract.name} service.`,
44
+ types: "./index.d.ts",
45
+ files: ["index.d.ts"],
46
+ license: "MIT",
47
+ repository: {
48
+ type: "git",
49
+ url: `git+${artifact.publishing.repositoryUrl}.git`,
50
+ },
51
+ publishConfig: {
52
+ registry: artifact.publishing.npmRegistry,
53
+ access: artifact.publishing.npmAccess,
54
+ },
55
+ };
56
+
57
+ await writeFile(
58
+ path.join(artifact.outputDir, "package.json"),
59
+ JSON.stringify(pkg, null, 2),
60
+ );
61
+ }
62
+ }
package/src/git/git.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ /**
4
+ * The result of executing a git command.
5
+ *
6
+ * @see {@link git} - The function that executes the git command.
7
+ */
8
+ export interface GitResult {
9
+ status: number;
10
+ stdout: string;
11
+ stderr: string;
12
+ }
13
+
14
+ /**
15
+ * Execute a git command in a given working directory and return the result.
16
+ *
17
+ * @param args - The command-line arguments to pass to the git command.
18
+ * @param cwd - The working directory in which to execute the git command.
19
+ * @returns The result of the git command.
20
+ *
21
+ * @see {@link GitResult} - The result of executing the git command.
22
+ */
23
+ export function git(args: string[], cwd: string): GitResult {
24
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
25
+
26
+ return {
27
+ status: result.status ?? 1,
28
+ stdout: (result.stdout ?? "").trim(),
29
+ stderr: (result.stderr ?? "").trim(),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Check if a remote branch exists in the given repository.
35
+ *
36
+ * @param repoRoot - The root directory of the repository.
37
+ * @param branch - The name of the branch to check.
38
+ * @returns Whether the remote branch exists (true) or not (false).
39
+ */
40
+ export function remoteBranchExists(repoRoot: string, branch: string): boolean {
41
+ return (
42
+ git(["ls-remote", "--exit-code", "--heads", "origin", branch], repoRoot)
43
+ .status === 0
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Check if a remote tag exists in the given repository.
49
+ *
50
+ * @param repoRoot - The root directory of the repository.
51
+ * @param tag - The name of the tag to check.
52
+ * @returns Whether the remote tag exists (true) or not (false).
53
+ */
54
+ export function tagExists(repoRoot: string, tag: string): boolean {
55
+ return (
56
+ git(["ls-remote", "--exit-code", "--tags", "origin", tag], repoRoot)
57
+ .status === 0
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Fetch a single tag's object from origin into the local repo, without
63
+ * fetching the rest of history/tags.
64
+ *
65
+ * @param repoRoot - The root directory of the repository.
66
+ * @param tag - The name of the tag to fetch.
67
+ * @returns The result of the underlying `git fetch` command.
68
+ */
69
+ function fetchTag(repoRoot: string, tag: string): GitResult {
70
+ return git(
71
+ ["fetch", "origin", `refs/tags/${tag}:refs/tags/${tag}`, "--force"],
72
+ repoRoot,
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Read a single file's content as it existed at a given git tag, without
78
+ * checking out a worktree.
79
+ *
80
+ * Returns `null` (rather than throwing) both when the tag can't be fetched and
81
+ * when the tag exists but doesn't contain the requested file - the latter is
82
+ * expected for tags published before that file was introduced, and callers
83
+ * should treat "unknown" the same as "no mismatch to report".
84
+ *
85
+ * @param repoRoot - The root directory of the repository.
86
+ * @param tag - The tag to read the file from.
87
+ * @param filePath - The path of the file within that tag's tree.
88
+ * @returns The file's content, or `null` if it couldn't be read.
89
+ */
90
+ export function readFileAtTag(
91
+ repoRoot: string,
92
+ tag: string,
93
+ filePath: string,
94
+ ): string | null {
95
+ const fetch = fetchTag(repoRoot, tag);
96
+
97
+ if (fetch.status !== 0) {
98
+ return null;
99
+ }
100
+
101
+ const show = git(["show", `${tag}:${filePath}`], repoRoot);
102
+
103
+ return show.status === 0 ? show.stdout.trim() : null;
104
+ }
105
+
106
+ /**
107
+ * Require that a git command succeeded, throwing an error with the given
108
+ * message if it did not.
109
+ *
110
+ * @param result - The result of the git command to check.
111
+ * @param message - The error message to throw if the command failed.
112
+ * @throws Error if the git command failed (non-zero exit code).
113
+ */
114
+ export function requireOk(result: GitResult, message: string): void {
115
+ if (result.status !== 0) {
116
+ throw new Error(`${message}: ${result.stderr || result.stdout}`);
117
+ }
118
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ export { CONFIG_SCHEMA_VERSION } from "./config/schema";
2
+ export { loadConfig } from "./config/loader";
3
+ export { renderArtifactTag } from "./config/publishing";
4
+ export {
5
+ CONFIG_FILENAMES,
6
+ resolveConfigPath,
7
+ } from "./config/resolve-config-file";
8
+ export type {
9
+ ResolvedArtifact,
10
+ ResolvedArtifactEntry,
11
+ ResolvedConfig,
12
+ ResolvedContract,
13
+ ResolvedPublishing,
14
+ SdkKind,
15
+ SdkLang,
16
+ SdkTool,
17
+ VarsTree,
18
+ } from "./config/types";
19
+
20
+ export { Generator } from "./generator/generator";
21
+ export { GeneratorRegistry } from "./generator/registry";
22
+ export type { GenerateContext, PrepareContext } from "./generator/types";
23
+
24
+ export { run, runSync } from "./process/exec";
25
+ export { resolveBinPath } from "./process/resolve-bin";
26
+
27
+ export {
28
+ git,
29
+ readFileAtTag,
30
+ remoteBranchExists,
31
+ requireOk,
32
+ tagExists,
33
+ } from "./git/git";
34
+ export type { GitResult } from "./git/git";
35
+
36
+ export { hashSpec, resolveVersion } from "./version/version";
37
+ export type { BundledSpec } from "./version/version";
38
+
39
+ export { renderReadme } from "./readme/readme-renderer";
40
+ export type { RenderReadmeArgs } from "./readme/readme-renderer";
41
+
42
+ export { syncRedoclyConfig } from "./redocly/redocly-sync";
43
+
44
+ export { OpenApiGeneratorCli } from "./generators/openapi-generator-cli";
45
+ export { OpenApiTypescriptGenerator } from "./generators/openapi-typescript";
@@ -0,0 +1,53 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+
3
+ /**
4
+ * Runs a command to completion, streaming its stdio straight through
5
+ * (`inherit`), and rejects if it exits non-zero.
6
+ *
7
+ * This is the async counterpart used for the "one long-running tool" commands
8
+ * (`redocly`, `openapi-generator-cli`, `openapi-typescript`); for short
9
+ * synchronous calls (git plumbing, `npm publish`/`mvn deploy`), see
10
+ * {@link runSync}.
11
+ *
12
+ * @param command - The executable to run.
13
+ * @param args - Arguments to pass to it.
14
+ * @param cwd - The working directory to run it in.
15
+ * @returns A promise that resolves on exit code 0, and rejects otherwise.
16
+ */
17
+ export function run(
18
+ command: string,
19
+ args: string[],
20
+ cwd: string,
21
+ ): Promise<void> {
22
+ return new Promise<void>((resolvePromise, reject) => {
23
+ const child = spawn(command, args, { cwd, stdio: "inherit", shell: true });
24
+
25
+ child.on("close", (code) => {
26
+ if (code === 0) {
27
+ resolvePromise();
28
+ return;
29
+ }
30
+
31
+ reject(new Error(`${command} ${args.join(" ")} exited with ${code}`));
32
+ });
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Runs a command to completion synchronously, streaming its stdio straight
38
+ * through (`inherit`).
39
+ *
40
+ * @param command - The executable to run.
41
+ * @param args - Arguments to pass to it.
42
+ * @param cwd - The working directory to run it in.
43
+ * @returns The exit status (0 on success).
44
+ */
45
+ export function runSync(command: string, args: string[], cwd: string): number {
46
+ const result = spawnSync(command, args, {
47
+ cwd,
48
+ stdio: "inherit",
49
+ shell: true,
50
+ });
51
+
52
+ return result.status ?? 1;
53
+ }
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ /**
8
+ * Resolves the absolute path to an installed npm package's own CLI entrypoint
9
+ * script, using Node's standard module resolution algorithm - so it works the
10
+ * same way regardless of which package manager (npm/pnpm/yarn) installed CLI
11
+ * and its dependencies, or how deeply they get hoisted. Shelling out to
12
+ * `pnpm exec`/`npx` instead would assume a specific package manager and a
13
+ * particular install layout, which doesn't hold once CLI is just another
14
+ * dependency in someone else's project.
15
+ *
16
+ * @param pkgName - The npm package name, e.g. `"@org/cli"`.
17
+ * @param binName - Which entry to resolve from that package's `bin` field.
18
+ * Defaults to the package's own unscoped name.
19
+ * @returns The absolute path to the resolved bin script.
20
+ * @throws Error if the package or the requested bin entry can't be found.
21
+ */
22
+ export function resolveBinPath(pkgName: string, binName?: string): string {
23
+ const pkgJsonPath = require.resolve(`${pkgName}/package.json`);
24
+ const pkgDir = path.dirname(pkgJsonPath);
25
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as {
26
+ name: string;
27
+ bin?: string | Record<string, string>;
28
+ };
29
+
30
+ const key = binName ?? pkg.name.split("/").pop()!;
31
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[key];
32
+
33
+ if (!bin) {
34
+ throw new Error(
35
+ `Could not resolve a "${key}" bin entry for package "${pkgName}" - ` +
36
+ `is it installed, and does it expose that bin?`,
37
+ );
38
+ }
39
+
40
+ return path.join(pkgDir, bin);
41
+ }