@arc-e-tect/api-only-publisher 0.0.0

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/src/closure.js ADDED
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+
3
+ // The dependency closure of a target.
4
+ //
5
+ // Independent per-target versioning has one non-obvious consequence, and this is
6
+ // the machinery that pays for it. A change under components/common/ affects every
7
+ // target that reaches it; a change under one product's own context affects only
8
+ // that one. Under repository-wide versioning that distinction is invisible,
9
+ // because everything bumps together. Under per-target versioning the release job
10
+ // has to work out which targets a commit actually changed.
11
+ //
12
+ // The closure is computed over the *staged, substituted* tree rather than the raw
13
+ // source, so that a change to a Markdown snippet -- which genuinely changes the
14
+ // published document -- is detected.
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const crypto = require("crypto");
19
+
20
+ class ClosureError extends Error {}
21
+
22
+ // $ref values, in both the quoted and unquoted YAML forms the fragments use.
23
+ const REF = /\$ref:\s*(?:'([^']+)'|"([^"]+)"|([^\s#'"][^\s]*))/g;
24
+
25
+ /**
26
+ * Every file reachable from `entry` by following $refs transitively.
27
+ *
28
+ * A JSON-pointer-only reference ("#/channels/auditV1") points inside the file
29
+ * that contains it and adds nothing to the closure. A reference with a path
30
+ * before the "#" contributes that file.
31
+ *
32
+ * @returns {string[]} absolute paths, sorted, including the entry file
33
+ */
34
+ function resolve(entry, { onMissing = "throw" } = {}) {
35
+ const seen = new Set();
36
+ const missing = [];
37
+ const queue = [path.resolve(entry)];
38
+
39
+ while (queue.length > 0) {
40
+ const file = queue.shift();
41
+ if (seen.has(file)) continue;
42
+
43
+ if (!fs.existsSync(file)) {
44
+ missing.push(file);
45
+ if (onMissing === "throw") {
46
+ throw new ClosureError(`$ref target does not exist: ${file}`);
47
+ }
48
+ continue;
49
+ }
50
+ seen.add(file);
51
+
52
+ let text;
53
+ try {
54
+ text = fs.readFileSync(file, "utf8");
55
+ } catch {
56
+ continue;
57
+ }
58
+
59
+ for (const match of text.matchAll(REF)) {
60
+ const ref = match[1] || match[2] || match[3];
61
+ if (!ref || ref.startsWith("#")) continue;
62
+ const [relPath] = ref.split("#");
63
+ if (!relPath) continue;
64
+ queue.push(path.resolve(path.dirname(file), relPath));
65
+ }
66
+ }
67
+
68
+ return { files: [...seen].sort(), missing };
69
+ }
70
+
71
+ /**
72
+ * A content hash over a target's closure.
73
+ *
74
+ * Content-addressed and order-stable: each file contributes its path relative to
75
+ * `root` and the SHA-256 of its bytes, in sorted order. Recording this alongside
76
+ * a released version is what lets a later commit decide whether the target
77
+ * actually changed, without re-reading the release.
78
+ */
79
+ function hash(files, root) {
80
+ const digest = crypto.createHash("sha256");
81
+ for (const file of [...files].sort()) {
82
+ const rel = path.relative(root, file).split(path.sep).join("/");
83
+ digest.update(rel);
84
+ digest.update("\0");
85
+ digest.update(crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
86
+ digest.update("\n");
87
+ }
88
+ return digest.digest("hex");
89
+ }
90
+
91
+ /**
92
+ * Closures for every target that declares a bundle of the given kinds.
93
+ *
94
+ * The staged tree must already exist; callers stage first so that the closure
95
+ * covers substituted content.
96
+ *
97
+ * @returns {Map<string, {files: string[], sha256: string, byKind: object}>}
98
+ */
99
+ function forTargets(config, kinds = ["openapi", "asyncapi"]) {
100
+ const result = new Map();
101
+
102
+ for (const kind of kinds) {
103
+ for (const target of config.targetsFor(kind)) {
104
+ const entry = config.bundlePath(target, kind);
105
+ if (!fs.existsSync(entry)) {
106
+ throw new ClosureError(
107
+ `target '${target}': ${kind} bundle root not found at ${entry}; stage the tree first`
108
+ );
109
+ }
110
+ const { files } = resolve(entry);
111
+ const existing = result.get(target) || { files: [], byKind: {} };
112
+ existing.byKind[kind] = files;
113
+ existing.files = [...new Set(existing.files.concat(files))].sort();
114
+ result.set(target, existing);
115
+ }
116
+ }
117
+
118
+ // Hash against the staging root of whichever kind the files came from, so
119
+ // the recorded paths are stable across machines and checkouts.
120
+ for (const [target, entry] of result) {
121
+ const roots = Object.keys(entry.byKind).map((k) => config.stagingRoot(k));
122
+ entry.sha256 = hash(entry.files, roots[0]);
123
+ result.set(target, entry);
124
+ }
125
+ return result;
126
+ }
127
+
128
+ module.exports = { resolve, hash, forTargets, ClosureError, REF };
package/src/config.js ADDED
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+
3
+ // Reading and validating apionly.yaml.
4
+ //
5
+ // The configuration is the single source of truth for what this library builds:
6
+ // it replaced the per-script SERVICES arrays, and the `apis:` map that said an
7
+ // overlapping thing under a different name.
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const YAML = require("yaml");
12
+
13
+ const CONFIG_NAME = "apionly.yaml";
14
+ const SUPPORTED_SCHEMA_VERSION = 1;
15
+
16
+ class ConfigError extends Error {}
17
+
18
+ // Walk up from `startDir` looking for apionly.yaml, so the CLI can be run from
19
+ // anywhere inside the specification repository.
20
+ function locate(startDir) {
21
+ let dir = path.resolve(startDir);
22
+ for (;;) {
23
+ const candidate = path.join(dir, CONFIG_NAME);
24
+ if (fs.existsSync(candidate)) return candidate;
25
+ const parent = path.dirname(dir);
26
+ if (parent === dir) {
27
+ throw new ConfigError(
28
+ `no ${CONFIG_NAME} found in ${path.resolve(startDir)} or any parent directory`
29
+ );
30
+ }
31
+ dir = parent;
32
+ }
33
+ }
34
+
35
+ function requireString(value, what) {
36
+ if (typeof value !== "string" || value.trim() === "") {
37
+ throw new ConfigError(`${what} must be a non-empty string`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function load(configPath) {
43
+ const raw = fs.readFileSync(configPath, "utf8");
44
+ let parsed;
45
+ try {
46
+ parsed = YAML.parse(raw);
47
+ } catch (error) {
48
+ throw new ConfigError(`${configPath} is not valid YAML: ${error.message}`);
49
+ }
50
+ if (parsed === null || typeof parsed !== "object") {
51
+ throw new ConfigError(`${configPath} is empty`);
52
+ }
53
+
54
+ if (parsed.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
55
+ throw new ConfigError(
56
+ `${configPath} declares schemaVersion ${JSON.stringify(parsed.schemaVersion)}; ` +
57
+ `this version of api-only-publisher understands ${SUPPORTED_SCHEMA_VERSION}`
58
+ );
59
+ }
60
+
61
+ const root = path.dirname(configPath);
62
+ const sources = parsed.sources || {};
63
+ requireString(sources.root, "sources.root");
64
+
65
+ const targets = parsed.targets;
66
+ if (!targets || typeof targets !== "object" || Object.keys(targets).length === 0) {
67
+ throw new ConfigError(`${configPath} declares no targets`);
68
+ }
69
+
70
+ // Declaration order is preserved deliberately: it is what makes one run's
71
+ // console output comparable with the next.
72
+ for (const [name, target] of Object.entries(targets)) {
73
+ if (!target || typeof target !== "object") {
74
+ throw new ConfigError(`target '${name}' is not a mapping`);
75
+ }
76
+ const kinds = ["openapi", "asyncapi"].filter((k) => target[k]);
77
+ if (kinds.length === 0) {
78
+ throw new ConfigError(
79
+ `target '${name}' declares neither an openapi nor an asyncapi bundle`
80
+ );
81
+ }
82
+ for (const kind of kinds) {
83
+ requireString(target[kind].bundle, `targets.${name}.${kind}.bundle`);
84
+ }
85
+ }
86
+
87
+ return {
88
+ path: configPath,
89
+ root,
90
+ schemaVersion: parsed.schemaVersion,
91
+ sources,
92
+ defaults: parsed.defaults || {},
93
+ toolchain: parsed.toolchain || {},
94
+ build: parsed.build || {},
95
+ distribution: parsed.distribution || null,
96
+ channels: parsed.channels || {},
97
+ targets,
98
+
99
+ // --- derived accessors, so callers never re-derive a path themselves ---
100
+
101
+ sourceRoot() {
102
+ return path.resolve(this.root, this.sources.root);
103
+ },
104
+ stagingRoot(kind) {
105
+ const staging = this.build.staging || "build/staging";
106
+ return path.resolve(this.root, staging, kind);
107
+ },
108
+ // The subtree of the staged mirror that holds one specification type.
109
+ stagingDir(kind) {
110
+ return path.join(this.stagingRoot(kind), requireString(this.sources[kind], `sources.${kind}`));
111
+ },
112
+ distDir(target) {
113
+ const dist = this.build.dist || "dist";
114
+ return path.resolve(this.root, dist, target);
115
+ },
116
+ outputName(kind) {
117
+ const name = (this.defaults[kind] || {}).outputName;
118
+ return requireString(name, `defaults.${kind}.outputName`);
119
+ },
120
+ lintConfig(kind) {
121
+ const lint = (this.defaults[kind] || {}).lint;
122
+ return lint ? path.resolve(this.root, lint) : null;
123
+ },
124
+ tool(name) {
125
+ return requireString(this.toolchain[name], `toolchain.${name}`);
126
+ },
127
+ // Targets declaring a bundle of this kind, in declaration order.
128
+ targetsFor(kind) {
129
+ return Object.keys(this.targets).filter((t) => this.targets[t][kind]);
130
+ },
131
+ // A target with `publish: false` is a documentation view rather than a
132
+ // contract any one project implements: built and linted, never shipped.
133
+ isPublished(target) {
134
+ return this.targets[target].publish !== false;
135
+ },
136
+ bundlePath(target, kind) {
137
+ return path.join(this.stagingDir(kind), this.targets[target][kind].bundle);
138
+ },
139
+ // Where a distributed document is copied to, from the transitional
140
+ // `distribution` block. Null once that block is gone.
141
+ destinationDir(target) {
142
+ if (!this.distribution) return null;
143
+ const layout = requireString(this.distribution.layout, "distribution.layout");
144
+ return path.resolve(
145
+ this.root,
146
+ this.distribution.root || ".",
147
+ layout.replace(/\{target\}/g, target)
148
+ );
149
+ },
150
+ };
151
+ }
152
+
153
+ function loadFrom(startDir) {
154
+ return load(locate(startDir));
155
+ }
156
+
157
+ module.exports = { load, loadFrom, locate, ConfigError, CONFIG_NAME, SUPPORTED_SCHEMA_VERSION };
package/src/index.js ADDED
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+
3
+ // The programmatic surface.
4
+ //
5
+ // The CLI is the usual way in, but a release job that wants to decide something
6
+ // for itself -- which targets changed, what a closure hashes to, whether a
7
+ // version is a pre-release -- should not have to parse console output to find
8
+ // out. Everything the CLI does is available here directly.
9
+
10
+ module.exports = {
11
+ ...require("./config"),
12
+ ...require("./placeholders"),
13
+ ...require("./version"),
14
+ ...require("./version-policy"),
15
+ ...require("./closure"),
16
+ ...require("./aggregate"),
17
+ ...require("./pipeline"),
18
+ ...require("./pack"),
19
+ ...require("./changed"),
20
+ ...require("./split"),
21
+ ...require("./channels"),
22
+ };
package/src/init.js ADDED
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+
3
+ // `init` scaffolds a specification repository.
4
+ //
5
+ // This is how the *conventions* become reusable, as opposed to the code. The
6
+ // layout below is the one the library documentation describes; a project that
7
+ // starts from it inherits the common/<product> split, the shared info block with
8
+ // placeholder snippets, and a configuration that already builds.
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+
13
+ const CONFIG = `# apionly.yaml
14
+ #
15
+ # Declares what this library builds, from where, and where each document goes.
16
+
17
+ schemaVersion: 1
18
+
19
+ sources:
20
+ root: specs
21
+ openapi: openapi
22
+ asyncapi: asyncapi
23
+
24
+ defaults:
25
+ openapi:
26
+ lint: .redocly.yaml
27
+ outputName: openapi.yaml
28
+ asyncapi:
29
+ outputName: asyncapi.yaml
30
+ placeholders:
31
+ # Fail the build on a {{token}} with no matching Markdown file, rather than
32
+ # emitting a marker into a published contract.
33
+ strict: true
34
+
35
+ build:
36
+ staging: build/staging
37
+ dist: dist
38
+
39
+ toolchain:
40
+ redocly: "@redocly/cli@2.52.0"
41
+ asyncapi: "@asyncapi/cli@6.0.2"
42
+
43
+ targets:
44
+ example-service:
45
+ openapi:
46
+ bundle: bundles/example-service_openapi_structure.yaml
47
+ `;
48
+
49
+ const INFO = `title: Example API
50
+ version: 0.0.0
51
+ description: |
52
+ What this API is for.
53
+
54
+ {{conventions}}
55
+ contact:
56
+ name: Example Team
57
+ url: https://example.invalid
58
+ license:
59
+ name: Apache-2.0
60
+ url: https://www.apache.org/licenses/LICENSE-2.0.html
61
+ `;
62
+
63
+ const CONVENTIONS = `## Conventions
64
+
65
+ Prose shared by every document in this library lives in a Markdown snippet like
66
+ this one, pulled into the specification by a placeholder token. Replace this file
67
+ with whatever your own API consumers need to know up front -- pagination, status
68
+ codes, error shapes.
69
+ `;
70
+
71
+ const SERVERS = `- url: https://api.example.invalid
72
+ description: Production.
73
+ `;
74
+
75
+ const BUNDLE = `openapi: 3.1.1
76
+ info:
77
+ $ref: '../shared/info.yaml'
78
+ servers:
79
+ $ref: '../shared/servers.yaml'
80
+ security:
81
+ - bearerAuth: []
82
+ tags: []
83
+ paths:
84
+ /v1/examples:
85
+ $ref: '../paths/example/ExamplesV1.yaml'
86
+ components:
87
+ securitySchemes:
88
+ bearerAuth:
89
+ $ref: '../components/common/security/BearerAuth.yaml'
90
+ `;
91
+
92
+ const SECURITY_SCHEME = `type: http
93
+ scheme: bearer
94
+ bearerFormat: JWT
95
+ `;
96
+
97
+ const PROBLEM = `description: The request was not valid.
98
+ content:
99
+ application/problem+json:
100
+ schema:
101
+ # RFC 9457. A problem shape is product-wide infrastructure, so it lives
102
+ # under components/common/ however service-specific its meaning.
103
+ type: object
104
+ properties:
105
+ type:
106
+ type: string
107
+ format: uri
108
+ title:
109
+ type: string
110
+ status:
111
+ type: integer
112
+ detail:
113
+ type: string
114
+ required:
115
+ - type
116
+ - title
117
+ - status
118
+ `;
119
+
120
+ const PATH_FRAGMENT = `get:
121
+ operationId: listExamples
122
+ summary: List examples.
123
+ responses:
124
+ '200':
125
+ description: The examples.
126
+ content:
127
+ application/json:
128
+ schema:
129
+ type: array
130
+ items:
131
+ type: string
132
+ '400':
133
+ $ref: '../../components/common/responses/errors/InvalidRequestProblemV1.yaml'
134
+ `;
135
+
136
+ const REDOCLY = `# Lint rules for this library.
137
+ #
138
+ # 'recommended' is Redocly's own baseline. Narrow or widen it as the library
139
+ # grows -- these rules are the governance the library applies to itself, which is
140
+ # why they live here rather than in any project that consumes the output.
141
+ extends:
142
+ - recommended
143
+ `;
144
+
145
+ const GITIGNORE = `build/
146
+ dist/
147
+ node_modules/
148
+ `;
149
+
150
+ const FILES = {
151
+ "apionly.yaml": CONFIG,
152
+ ".redocly.yaml": REDOCLY,
153
+ ".gitignore": GITIGNORE,
154
+ "specs/openapi/shared/info.yaml": INFO,
155
+ "specs/openapi/shared/conventions.md": CONVENTIONS,
156
+ "specs/openapi/shared/servers.yaml": SERVERS,
157
+ "specs/openapi/bundles/example-service_openapi_structure.yaml": BUNDLE,
158
+ "specs/openapi/paths/example/ExamplesV1.yaml": PATH_FRAGMENT,
159
+ "specs/openapi/components/common/security/BearerAuth.yaml": SECURITY_SCHEME,
160
+ "specs/openapi/components/common/responses/errors/InvalidRequestProblemV1.yaml": PROBLEM,
161
+ };
162
+
163
+ function init(targetDir, { force = false, log = () => {} } = {}) {
164
+ const created = [];
165
+ const skipped = [];
166
+
167
+ for (const [rel, content] of Object.entries(FILES)) {
168
+ const file = path.join(targetDir, rel);
169
+ if (fs.existsSync(file) && !force) {
170
+ skipped.push(rel);
171
+ continue;
172
+ }
173
+ fs.mkdirSync(path.dirname(file), { recursive: true });
174
+ fs.writeFileSync(file, content);
175
+ created.push(rel);
176
+ }
177
+
178
+ for (const rel of created) log(` created ${rel}`);
179
+ for (const rel of skipped) log(` exists ${rel} (left alone; --force overwrites)`);
180
+ return { created, skipped };
181
+ }
182
+
183
+ module.exports = { init, FILES };
package/src/pack.js ADDED
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+
3
+ // Packing a built target into a distributable archive plus its manifest.
4
+ //
5
+ // The manifest is what makes provenance auditable, and it is the only thing the
6
+ // Publisher and the Subscriber agree on. Treat its shape as expensive to change.
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const crypto = require("crypto");
11
+ const { execFileSync } = require("child_process");
12
+ const YAML = require("yaml");
13
+
14
+ const MANIFEST_NAME = "manifest.json";
15
+ const MANIFEST_SCHEMA_VERSION = 1;
16
+
17
+ class PackError extends Error {}
18
+
19
+ function sha256(file) {
20
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
21
+ }
22
+
23
+ function gitInfo(cwd) {
24
+ const git = (args) => {
25
+ try {
26
+ return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
27
+ } catch {
28
+ return null;
29
+ }
30
+ };
31
+ return {
32
+ repository: git(["config", "--get", "remote.origin.url"]),
33
+ commit: git(["rev-parse", "HEAD"]),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Build the manifest for one target.
39
+ *
40
+ * Computed once, before any channel fan-out, so that every channel ships the
41
+ * same bytes. Building it per channel invites the npm copy and the Maven copy of
42
+ * one version to differ by a line ending, which surfaces months later as an
43
+ * unexplainable verify failure.
44
+ */
45
+ function manifest(config, target, { version, files, closureSha256, producedAt = new Date() }) {
46
+ if (!version) throw new PackError(`target '${target}': pack requires a version`);
47
+
48
+ const source = gitInfo(config.root);
49
+ return {
50
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
51
+ target,
52
+ version,
53
+ producedAt: producedAt.toISOString().replace(/\.\d{3}Z$/, "Z"),
54
+ closureSha256,
55
+ source,
56
+ files: files
57
+ .map((file) => ({ path: path.basename(file), sha256: sha256(file) }))
58
+ .sort((a, b) => (a.path < b.path ? -1 : 1)),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Write the manifest into a target's dist directory and archive the whole thing.
64
+ *
65
+ * @returns {{archive: string, manifest: object, manifestPath: string}}
66
+ */
67
+ function pack(config, target, { version, closureSha256, outDir, log = () => {} }) {
68
+ const distDir = config.distDir(target);
69
+ if (!fs.existsSync(distDir)) {
70
+ throw new PackError(`target '${target}': nothing built at ${distDir}; run 'build' first`);
71
+ }
72
+
73
+ const documents = fs
74
+ .readdirSync(distDir)
75
+ .filter((name) => /\.ya?ml$/.test(name))
76
+ .map((name) => path.join(distDir, name))
77
+ .sort();
78
+ if (documents.length === 0) {
79
+ throw new PackError(`target '${target}': no documents in ${distDir}`);
80
+ }
81
+
82
+ // A manifest that claims a version the documents were not stamped with is
83
+ // worse than no manifest: every downstream check would agree with it, and the
84
+ // mismatch would only surface as a consumer reading a contract whose
85
+ // info.version is not the version they asked for. Nothing else in the
86
+ // pipeline catches this, because `pack` is the first step that takes the
87
+ // version on trust from its caller rather than deriving it.
88
+ for (const document of documents) {
89
+ let declared;
90
+ try {
91
+ const parsed = YAML.parse(fs.readFileSync(document, "utf8"));
92
+ declared = parsed && parsed.info ? String(parsed.info.version) : null;
93
+ } catch (error) {
94
+ throw new PackError(`${document} is not valid YAML: ${error.message}`);
95
+ }
96
+ if (declared !== String(version)) {
97
+ throw new PackError(
98
+ `target '${target}': ${path.basename(document)} declares info.version '${declared}' ` +
99
+ `but is being packed as '${version}'. Build with --version ${version} first.`
100
+ );
101
+ }
102
+ }
103
+
104
+ const data = manifest(config, target, { version, files: documents, closureSha256 });
105
+ const manifestPath = path.join(distDir, MANIFEST_NAME);
106
+ fs.writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n");
107
+
108
+ fs.mkdirSync(outDir, { recursive: true });
109
+ const archive = path.join(outDir, `${target}-${version}.tgz`);
110
+
111
+ // Deterministic archive: sorted entry names, and no mtime/owner noise, so the
112
+ // same inputs produce the same bytes on any machine.
113
+ const entries = documents.map((d) => path.basename(d)).concat([MANIFEST_NAME]).sort();
114
+ execFileSync("tar", [
115
+ "-czf", archive,
116
+ "-C", distDir,
117
+ "--numeric-owner",
118
+ ...entries,
119
+ ], { encoding: "utf8" });
120
+
121
+ log(`-- Packed ${path.basename(archive)} (${data.files.length} document(s))`);
122
+ return { archive, manifest: data, manifestPath };
123
+ }
124
+
125
+ module.exports = { pack, manifest, sha256, MANIFEST_NAME, MANIFEST_SCHEMA_VERSION, PackError };