@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.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@octalmesh/seagull-core",
3
+ "version": "0.0.2",
4
+ "description": "The Seagull engine - config loading and SDK generators - as an internal library.",
5
+ "author": "OctalMesh <contact@octalmesh.com> (https://octalmesh.com)",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/OctalMesh/Seagull/tree/main/packages/core",
8
+ "type": "module",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/OctalMesh/Seagull.git",
12
+ "directory": "packages/core"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/OctalMesh/Seagull/issues",
16
+ "email": "security@octalmesh.com"
17
+ },
18
+ "main": "./dist/index.mjs",
19
+ "types": "./dist/index.d.mts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.mts",
23
+ "default": "./dist/index.mjs"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "engines": {
31
+ "node": ">=22.22.0"
32
+ },
33
+ "dependencies": {
34
+ "@openapitools/openapi-generator-cli": "^2.41.0",
35
+ "@redocly/cli": "^2.51.1",
36
+ "openapi-typescript": "^7.13.0",
37
+ "yaml": "^2.9.0",
38
+ "zod": "^4.5.4"
39
+ },
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "dev": "tsdown --watch",
43
+ "typecheck": "tsc --noEmit"
44
+ }
45
+ }
@@ -0,0 +1,353 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { parse as parseYaml } from "yaml";
5
+ import { z } from "zod";
6
+
7
+ import {
8
+ type ArtifactRefInput,
9
+ type ContractInput,
10
+ type GeneratorDefInput,
11
+ type PublishingInput,
12
+ type PublishingOverrideInput,
13
+ type RootConfigInput,
14
+ rootConfigSchema,
15
+ } from "./schema";
16
+ import { buildTemplateContext, interpolate, interpolateDeep } from "./template";
17
+ import type {
18
+ ResolvedArtifact,
19
+ ResolvedConfig,
20
+ ResolvedContract,
21
+ ResolvedPublishing,
22
+ } from "./types";
23
+
24
+ /**
25
+ * Reads and validates a CLI config file, throwing a readable, multi-issue error
26
+ * message if it doesn't match the schema.
27
+ *
28
+ * @param configPath - Absolute path to the YAML config file.
29
+ * @returns The validated (but not yet resolved) raw config.
30
+ */
31
+ function readRawConfig(configPath: string): RootConfigInput {
32
+ const raw: unknown = parseYaml(readFileSync(configPath, "utf8"));
33
+ const result = rootConfigSchema.safeParse(raw);
34
+
35
+ if (!result.success) {
36
+ const issues = z.prettifyError(result.error);
37
+
38
+ throw new Error(`Invalid ${path.basename(configPath)}:\n${issues}`);
39
+ }
40
+
41
+ return result.data;
42
+ }
43
+
44
+ /**
45
+ * Deep-merges a generator override (from an artifact's `overrides:` block)
46
+ * onto its base generator def. `additionalProperties`, `maven`, and
47
+ * `publishing` are merged key-by-key; every other field is a plain override.
48
+ *
49
+ * @param base - The base generator def, looked up by id from `generators:`.
50
+ * @param overrides - The partial override from the artifact reference, if any.
51
+ * @returns The merged generator def.
52
+ */
53
+ function mergeGeneratorOverride(
54
+ base: GeneratorDefInput,
55
+ overrides: Partial<GeneratorDefInput> | undefined,
56
+ ): GeneratorDefInput {
57
+ if (!overrides) {
58
+ return base;
59
+ }
60
+
61
+ return {
62
+ ...base,
63
+ ...overrides,
64
+ maven: overrides.maven ? { ...base.maven, ...overrides.maven } : base.maven,
65
+ additionalProperties: {
66
+ ...base.additionalProperties,
67
+ ...overrides.additionalProperties,
68
+ },
69
+ publishing: mergePublishingOverride(base.publishing, overrides.publishing),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Deep-merges two partial `publishing:` overrides (from a generator def and
75
+ * an artifact-ref's `overrides:` block) into one.
76
+ *
77
+ * @param base - The base override (maybe undefined).
78
+ * @param overrides - The overriding override (maybe undefined).
79
+ * @returns The merged override, or undefined if both inputs were.
80
+ */
81
+ function mergePublishingOverride(
82
+ base: PublishingOverrideInput | undefined,
83
+ overrides: PublishingOverrideInput | undefined,
84
+ ): PublishingOverrideInput | undefined {
85
+ if (!overrides) {
86
+ return base;
87
+ }
88
+
89
+ return {
90
+ ...base,
91
+ ...overrides,
92
+ npm: overrides.npm ? { ...base?.npm, ...overrides.npm } : base?.npm,
93
+ maven: overrides.maven
94
+ ? { ...base?.maven, ...overrides.maven }
95
+ : base?.maven,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Applies a (possibly partial) `publishing:` override onto the required
101
+ * root-level `publishing:` block, producing a fully complete result - the
102
+ * root block is guaranteed complete by the schema, so there's no fallback
103
+ * case to handle here (unlike {@link mergePublishingOverride}).
104
+ *
105
+ * @param root - The root-level `publishing:` config (required, always
106
+ * complete).
107
+ * @param override - The generator/artifact-level override, if any.
108
+ * @returns The fully complete, merged publishing config.
109
+ */
110
+ function applyPublishingOverride(
111
+ root: PublishingInput,
112
+ override: PublishingOverrideInput | undefined,
113
+ ): PublishingInput {
114
+ if (!override) {
115
+ return root;
116
+ }
117
+
118
+ return {
119
+ ...root,
120
+ ...override,
121
+ npm: override.npm ? { ...root.npm, ...override.npm } : root.npm,
122
+ maven: override.maven ? { ...root.maven, ...override.maven } : root.maven,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Resolves a single artifact reference (string id, or `{ generator, overrides,
128
+ * as }`) into its `{ id, def }` pair, looking up the base generator by id and
129
+ * applying any overrides.
130
+ *
131
+ * @param ref - The artifact reference from a contract's `artifacts:`
132
+ * list.
133
+ * @param generators - The full `generators:` map from the raw config.
134
+ * @param contractName - The owning contract's name.
135
+ * @returns The artifact's resolved id and generator def (templates not yet
136
+ * interpolated).
137
+ */
138
+ function resolveArtifactRef(
139
+ ref: ArtifactRefInput,
140
+ generators: RootConfigInput["generators"],
141
+ contractName: string,
142
+ ): { id: string; def: GeneratorDefInput } {
143
+ const generatorId = typeof ref === "string" ? ref : ref.generator;
144
+ const base = generators[generatorId];
145
+
146
+ if (!base) {
147
+ const available = Object.keys(generators).sort().join(", ");
148
+
149
+ throw new Error(
150
+ `Contract "${contractName}" references unknown generator "${generatorId}" ` +
151
+ `(available: ${available})`,
152
+ );
153
+ }
154
+
155
+ const id = typeof ref === "string" ? ref : (ref.as ?? ref.generator);
156
+ const overrides = typeof ref === "string" ? undefined : ref.overrides;
157
+
158
+ return { id, def: mergeGeneratorOverride(base, overrides) };
159
+ }
160
+
161
+ /**
162
+ * Resolves an artifact's publishing conventions: applies the (already
163
+ * override-merged) generator-level `publishing:` override onto the required
164
+ * root-level `publishing:` block, then interpolates every field except `tag`
165
+ * (which keeps `{version}` unresolved, since it isn't known until publish time;
166
+ * see `config/publishing.ts`).
167
+ *
168
+ * @param generatorPublishing - The (override-merged) generator's own
169
+ * `publishing:` override, if any.
170
+ * @param rootPublishing - The required root-level `publishing:` config
171
+ * from the config file.
172
+ * @param context - The flattened template context for this artifact
173
+ * (`service`, `id`, `github.*`, `vars.*`).
174
+ * @returns The fully resolved publishing conventions for this artifact.
175
+ */
176
+ function resolvePublishing(
177
+ generatorPublishing: PublishingOverrideInput | undefined,
178
+ rootPublishing: PublishingInput,
179
+ context: Record<string, string>,
180
+ ): ResolvedPublishing {
181
+ const merged = applyPublishingOverride(rootPublishing, generatorPublishing);
182
+
183
+ return {
184
+ branch: interpolate(merged.branch, context),
185
+ tagTemplate: merged.tag,
186
+ repositoryUrl: interpolate(merged.repositoryUrl, context),
187
+ npmRegistry: interpolate(merged.npm.registry, context),
188
+ npmAccess: merged.npm.access,
189
+ mavenRepositoryId: interpolate(merged.maven.repositoryId, context),
190
+ mavenRepositoryUrl: interpolate(merged.maven.repositoryUrl, context),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Interpolates templates and resolves absolute paths for a single artifact.
196
+ *
197
+ * @param id - The artifact's resolved id (output folder / branch /
198
+ * tag segment).
199
+ * @param def - The (override-merged, not-yet-interpolated)
200
+ * generator def.
201
+ * @param rootDir - Absolute repo root, `readme` template paths are
202
+ * resolved relative to this.
203
+ * @param sdkDir - Absolute path to the SDK output root (`<dist>/sdk`).
204
+ * @param contractName - The owning contract's name.
205
+ * @param contractContext - The flattened template context for this contract
206
+ * (`service`, `github.*`, `vars.*` - not yet `id`).
207
+ * @param rootPublishing - The required root-level `publishing:` config from
208
+ * the config file.
209
+ * @returns The fully resolved artifact.
210
+ */
211
+ function resolveArtifact(
212
+ id: string,
213
+ def: GeneratorDefInput,
214
+ rootDir: string,
215
+ sdkDir: string,
216
+ contractName: string,
217
+ contractContext: Record<string, string>,
218
+ rootPublishing: PublishingInput,
219
+ ): ResolvedArtifact {
220
+ const context = { ...contractContext, id };
221
+ const resolved = interpolateDeep(def, context);
222
+ const publishing = resolvePublishing(def.publishing, rootPublishing, context);
223
+
224
+ return {
225
+ id,
226
+ tool: resolved.tool,
227
+ lang: resolved.lang,
228
+ kind: resolved.kind,
229
+ generator: resolved.generator,
230
+ outputDir: path.join(sdkDir, contractName, id),
231
+ branch: publishing.branch,
232
+ publishing,
233
+ additionalProperties: resolved.additionalProperties,
234
+ package: resolved.package,
235
+ goModule: resolved.goModule,
236
+ goPackageName: resolved.goPackageName,
237
+ maven: resolved.maven,
238
+ readmeTemplate: resolved.readme
239
+ ? path.resolve(rootDir, resolved.readme)
240
+ : undefined,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Resolves a single contract: its entrypoint path and every artifact in its
246
+ * `artifacts:` list.
247
+ *
248
+ * @param input - The raw contract config.
249
+ * @param rootDir - Absolute repo root, entrypoints/`readme` paths are
250
+ * resolved relative to this.
251
+ * @param sdkDir - Absolute path to the SDK output root (`<dist>/sdk`).
252
+ * @param generators - The full `generators:` map from the raw config.
253
+ * @param githubCtx - `{ owner, repo }`, exposed to templates as
254
+ * `{github.owner}`/`{github.repo}`.
255
+ * @param vars - The `vars:` tree from the raw config, exposed as
256
+ * `{vars.*}`.
257
+ * @param rootPublishing - The required root-level `publishing:` config from the
258
+ * config file.
259
+ * @returns The fully resolved contract.
260
+ */
261
+ function resolveContract(
262
+ input: ContractInput,
263
+ rootDir: string,
264
+ sdkDir: string,
265
+ generators: RootConfigInput["generators"],
266
+ githubCtx: { owner: string; repo: string },
267
+ vars: RootConfigInput["vars"],
268
+ rootPublishing: PublishingInput,
269
+ ): ResolvedContract {
270
+ const context = buildTemplateContext({
271
+ service: input.name,
272
+ github: githubCtx,
273
+ vars,
274
+ });
275
+
276
+ const artifacts = input.artifacts.map((ref): ResolvedArtifact => {
277
+ const { id, def } = resolveArtifactRef(ref, generators, input.name);
278
+
279
+ return resolveArtifact(
280
+ id,
281
+ def,
282
+ rootDir,
283
+ sdkDir,
284
+ input.name,
285
+ context,
286
+ rootPublishing,
287
+ );
288
+ });
289
+
290
+ const entrypoint = path.join(rootDir, input.entrypoint);
291
+
292
+ return {
293
+ name: input.name,
294
+ title: input.title,
295
+ entrypoint,
296
+ entrypointRelative: path.relative(rootDir, entrypoint),
297
+ artifacts,
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Loads, validates, and fully resolves a CLI config file - the single entry
303
+ * point every command uses to get its configuration.
304
+ *
305
+ * Unlike a build tool bundled into the consumer's own repo, seagull is
306
+ * installed as a dependency, so it has no way to guess where the consumer's
307
+ * config lives on its own - `configPath` must be supplied by the caller (the
308
+ * CLI resolves it via `resolveConfigPath()` in `config/resolve-config-file.ts`,
309
+ * or `--config`).
310
+ *
311
+ * @param configPath - Absolute path to the CLI config file.
312
+ * @returns The fully resolved config.
313
+ */
314
+ export function loadConfig(configPath: string): ResolvedConfig {
315
+ const rootDir = path.dirname(configPath);
316
+ const raw = readRawConfig(configPath);
317
+
318
+ const distDir = path.resolve(rootDir, raw.paths.dist);
319
+ const specsDir = raw.paths.specs
320
+ ? path.resolve(rootDir, raw.paths.specs)
321
+ : path.join(distDir, "specs");
322
+ const docsDir = raw.paths.docs
323
+ ? path.resolve(rootDir, raw.paths.docs)
324
+ : path.join(distDir, "docs");
325
+ const sdkDir = raw.paths.sdk
326
+ ? path.resolve(rootDir, raw.paths.sdk)
327
+ : path.join(distDir, "sdk");
328
+
329
+ const contracts = raw.contracts.map((contract) =>
330
+ resolveContract(
331
+ contract,
332
+ rootDir,
333
+ sdkDir,
334
+ raw.generators,
335
+ raw.github,
336
+ raw.vars,
337
+ raw.publishing,
338
+ ),
339
+ );
340
+
341
+ return {
342
+ configVersion: raw.configVersion,
343
+ rootDir,
344
+ paths: { dist: distDir, specs: specsDir, docs: docsDir, sdk: sdkDir },
345
+ github: raw.github,
346
+ vars: raw.vars,
347
+ docs: raw.docs,
348
+ contracts,
349
+ allArtifacts: contracts.flatMap((contract) =>
350
+ contract.artifacts.map((artifact) => ({ contract, artifact })),
351
+ ),
352
+ };
353
+ }
@@ -0,0 +1,37 @@
1
+ import { buildTemplateContext, interpolate } from "./template";
2
+ import type { ResolvedArtifact, VarsTree } from "./types";
3
+
4
+ /**
5
+ * Renders an artifact's final git tag from its `publishing.tagTemplate` -
6
+ * the one piece of `publishing:` config that can't be resolved at config-load
7
+ * time, since it needs the artifact's version, which is only known once the
8
+ * contract's spec has been bundled.
9
+ *
10
+ * @param artifact - The resolved artifact (for `id` and
11
+ * `publishing.tagTemplate`).
12
+ * @param contractName - The owning contract's name, exposed to the template as
13
+ * `{service}`.
14
+ * @param version - The resolved SDK version, exposed to the template as
15
+ * `{version}`.
16
+ * @param github - `{ owner, repo }`, exposed as `{github.owner}`/
17
+ * `{github.repo}`.
18
+ * @param vars - The config's `vars:` tree, exposed as `{vars.*}`.
19
+ * @returns The rendered tag name.
20
+ */
21
+ export function renderArtifactTag(
22
+ artifact: ResolvedArtifact,
23
+ contractName: string,
24
+ version: string,
25
+ github: { owner: string; repo: string },
26
+ vars: VarsTree,
27
+ ): string {
28
+ const context = buildTemplateContext({
29
+ service: contractName,
30
+ id: artifact.id,
31
+ version,
32
+ github,
33
+ vars,
34
+ });
35
+
36
+ return interpolate(artifact.publishing.tagTemplate, context);
37
+ }
@@ -0,0 +1,38 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Config filenames CLI recognizes, checked in this order.
6
+ */
7
+ export const CONFIG_FILENAMES = [
8
+ ".seagull",
9
+ ".seagull.yaml",
10
+ ".seagull.yml",
11
+ "seagull.yaml",
12
+ "seagull.yml",
13
+ ] as const;
14
+
15
+ /**
16
+ * Finds the CLI config file in a directory, trying each of
17
+ * {@link CONFIG_FILENAMES} in order.
18
+ *
19
+ * @param cwd - The directory to look in (typically `process.cwd()`).
20
+ * @returns The absolute path to the first matching config file.
21
+ * @throws Error if none of the candidate filenames exist in `cwd`.
22
+ *
23
+ * @see {@link CONFIG_FILENAMES} - the list of filenames checked, in order.
24
+ */
25
+ export function resolveConfigPath(cwd: string): string {
26
+ for (const filename of CONFIG_FILENAMES) {
27
+ const candidate = path.join(cwd, filename);
28
+
29
+ if (existsSync(candidate)) {
30
+ return candidate;
31
+ }
32
+ }
33
+
34
+ throw new Error(
35
+ `No CLI config found in ${cwd} - looked for: ${CONFIG_FILENAMES.join(", ")}. ` +
36
+ `Create one of these, or pass --config <path>.`,
37
+ );
38
+ }