@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/changed.js ADDED
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+
3
+ // Which targets a commit actually changed.
4
+ //
5
+ // Under repository-wide versioning this question does not arise, because
6
+ // everything bumps together. Under per-target versioning the release job has to
7
+ // answer it, or a change to one service's context releases every other service
8
+ // too, and the version numbers stop meaning anything.
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { execFileSync } = require("child_process");
13
+
14
+ const { forTargets } = require("./closure");
15
+
16
+ class ChangedError extends Error {}
17
+
18
+ function git(args, cwd) {
19
+ try {
20
+ return execFileSync("git", args, { cwd, encoding: "utf8" });
21
+ } catch (error) {
22
+ throw new ChangedError(`git ${args.join(" ")} failed: ${(error.stderr || error.message).trim()}`);
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Resolve symlinks before comparing paths.
28
+ *
29
+ * `git rev-parse --show-toplevel` answers with the canonical path, while the
30
+ * configuration's paths are whatever the caller passed. On any checkout reached
31
+ * through a symlink -- /var on macOS, a home directory behind one, a worktree
32
+ * under a linked parent -- the two spellings never match, every intersection
33
+ * comes out empty, and `changed` reports that nothing changed. For a command
34
+ * whose answer decides what gets released, being silently wrong in the direction
35
+ * of "release nothing" is the worst available failure.
36
+ */
37
+ function real(file) {
38
+ try {
39
+ return fs.realpathSync(file);
40
+ } catch {
41
+ return path.resolve(file);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Map a staged file back to the source file it was copied from.
47
+ *
48
+ * Closures are computed over the staged tree, because a placeholder's content
49
+ * genuinely changes the published document and only the staged copy has it
50
+ * substituted. Git, however, only knows about the source.
51
+ */
52
+ function toSource(config, stagedFile, kinds) {
53
+ for (const kind of kinds) {
54
+ const stagingRoot = config.stagingRoot(kind);
55
+ const rel = path.relative(stagingRoot, stagedFile);
56
+ if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
57
+ return path.join(config.sourceRoot(), rel);
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * @returns {Array<{target, changed: boolean, files: string[], closureSha256: string}>}
65
+ */
66
+ function changedSince(config, since, { kinds = ["openapi", "asyncapi"], log = () => {} } = {}) {
67
+ const repoRoot = real(git(["rev-parse", "--show-toplevel"], config.root).trim());
68
+
69
+ // Everything git says differs between `since` and the working tree.
70
+ const diff = new Set(
71
+ git(["diff", "--name-only", since, "--"], repoRoot)
72
+ .split("\n")
73
+ .filter(Boolean)
74
+ .map((rel) => real(path.resolve(repoRoot, rel)))
75
+ );
76
+
77
+ const closures = forTargets(config, kinds);
78
+ const results = [];
79
+
80
+ for (const [target, entry] of closures) {
81
+ const sources = entry.files
82
+ .map((f) => toSource(config, f, kinds))
83
+ .filter(Boolean)
84
+ .map(real);
85
+ // A change to the *shape* of a closure -- a bundle gaining or losing a
86
+ // $ref -- always means editing a file that is already in the closure, so
87
+ // membership changes are caught without diffing membership itself.
88
+ const touched = [...new Set(sources.filter((f) => diff.has(f)))].sort();
89
+ results.push({
90
+ target,
91
+ changed: touched.length > 0,
92
+ files: touched.map((f) => path.relative(repoRoot, f)),
93
+ closureSha256: entry.sha256,
94
+ });
95
+ log(`${target.padEnd(18)} ${touched.length > 0 ? "changed" : "unchanged"} ${entry.sha256.slice(0, 12)}`);
96
+ }
97
+ return results;
98
+ }
99
+
100
+ module.exports = { changedSince, toSource, ChangedError };
@@ -0,0 +1,284 @@
1
+ "use strict";
2
+
3
+ // Distribution channels.
4
+ //
5
+ // Every channel ships the bytes that `pack` produced, and never rebuilds them.
6
+ // Building per channel invites the copies of one version to differ -- by a line
7
+ // ending, by a timestamp -- which surfaces much later as an unexplainable verify
8
+ // failure in a consumer's build.
9
+
10
+ const fs = require("fs");
11
+ const os = require("os");
12
+ const path = require("path");
13
+ const { execFileSync } = require("child_process");
14
+
15
+ const { isPrerelease, npmDistTag } = require("./version-policy");
16
+
17
+ class ChannelError extends Error {}
18
+
19
+ /**
20
+ * `file` -- publish to a local directory.
21
+ *
22
+ * Not a real distribution mechanism. It exists so the pipeline is testable end
23
+ * to end before any remote exists, and as a local-iteration escape hatch
24
+ * afterwards.
25
+ */
26
+ function publishFile(archive, manifest, options, log) {
27
+ // Relative to the library root, not to wherever the CLI happened to be run.
28
+ const dir = path.resolve(options.baseDir || ".", options.directory || "publish");
29
+ const targetDir = path.join(dir, manifest.target, manifest.version);
30
+ fs.mkdirSync(targetDir, { recursive: true });
31
+
32
+ const archiveDest = path.join(targetDir, path.basename(archive));
33
+ fs.copyFileSync(archive, archiveDest);
34
+ fs.writeFileSync(path.join(targetDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
35
+
36
+ log(`-- Published ${manifest.target} ${manifest.version} to ${archiveDest}`);
37
+ return { location: archiveDest };
38
+ }
39
+
40
+ function pom(groupId, artifactId, version, packaging) {
41
+ return `<?xml version="1.0" encoding="UTF-8"?>
42
+ <project xmlns="http://maven.apache.org/POM/4.0.0"
43
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
44
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
45
+ <modelVersion>4.0.0</modelVersion>
46
+ <groupId>${groupId}</groupId>
47
+ <artifactId>${artifactId}</artifactId>
48
+ <version>${version}</version>
49
+ <packaging>${packaging}</packaging>
50
+ <description>API description documents for ${artifactId}.</description>
51
+ </project>
52
+ `;
53
+ }
54
+
55
+ /**
56
+ * `maven` -- publish into a Maven repository layout.
57
+ *
58
+ * Writing the layout directly rather than shelling out to `mvn` keeps Maven off
59
+ * the list of things a specification repository has to have installed. The
60
+ * layout is all that a resolver reads.
61
+ *
62
+ * This is what makes the Subscriber cheap: a Gradle consumer declares the
63
+ * artifact in a configuration and lets Gradle's own dependency resolution do the
64
+ * fetching, caching and up-to-date checking, instead of the plugin carrying a
65
+ * hand-rolled HTTP client, cache and retry policy.
66
+ */
67
+ function publishMaven(archive, manifest, options, log) {
68
+ const groupId = options.groupId;
69
+ if (!groupId) throw new ChannelError("the maven channel requires a groupId");
70
+
71
+ const configured = options.repository || path.join(os.homedir(), ".m2", "repository");
72
+ if (/^https?:\/\//.test(configured)) {
73
+ return publishMavenRemote(archive, manifest, { ...options, repository: configured }, log);
74
+ }
75
+
76
+ const repository = path.resolve(options.baseDir || ".", configured);
77
+ const artifactId = options.artifactId || manifest.target;
78
+ const version = manifest.version;
79
+ const extension = options.extension || "tgz";
80
+
81
+ const dir = path.join(repository, ...groupId.split("."), artifactId, version);
82
+ fs.mkdirSync(dir, { recursive: true });
83
+
84
+ const base = `${artifactId}-${version}`;
85
+ const artifactDest = path.join(dir, `${base}.${extension}`);
86
+ fs.copyFileSync(archive, artifactDest);
87
+ fs.writeFileSync(path.join(dir, `${base}.pom`), pom(groupId, artifactId, version, extension));
88
+
89
+ // The manifest travels beside the artifact as well as inside it, so a
90
+ // consumer can read provenance without unpacking anything.
91
+ fs.writeFileSync(path.join(dir, `${base}-manifest.json`), JSON.stringify(manifest, null, 2) + "\n");
92
+
93
+ log(`-- Published ${groupId}:${artifactId}:${version} to ${repository}`);
94
+ return { location: artifactDest, coordinates: `${groupId}:${artifactId}:${version}@${extension}` };
95
+ }
96
+
97
+ /**
98
+ * `npm` -- publish as an npm package.
99
+ *
100
+ * Real semver, integrity hashes for free, private scopes available, and the
101
+ * toolchain here is already Node. Its other advantage is social rather than
102
+ * technical: Renovate and Dependabot understand npm natively, so a contract bump
103
+ * arrives in an implementation repository as a pull request, without any bespoke
104
+ * machinery. Publishing is otherwise entirely passive -- the producer releases
105
+ * and nothing happens downstream until somebody looks.
106
+ */
107
+ function publishNpm(archive, manifest, options, log) {
108
+ const scope = options.scope;
109
+ const name = scope ? `${scope}/${manifest.target}` : (options.namePrefix || "") + manifest.target;
110
+ const version = manifest.version;
111
+ const distTag = npmDistTag(version);
112
+
113
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "api-only-npm-"));
114
+ try {
115
+ // The package contains exactly what was packed, extracted -- never a
116
+ // rebuild. Every channel ships the same bytes.
117
+ execFileSync("tar", ["-xzf", archive, "-C", workDir], { encoding: "utf8" });
118
+
119
+ fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({
120
+ name,
121
+ version,
122
+ description: `API description documents for ${manifest.target}.`,
123
+ license: options.license || "UNLICENSED",
124
+ files: fs.readdirSync(workDir).filter((f) => f !== "package.json").sort(),
125
+ // npm defaults a scoped package to restricted. A contract exists to be
126
+ // read by the people implementing against it, so the default here is
127
+ // the opposite; set access: restricted deliberately to narrow it.
128
+ publishConfig: scope ? { access: options.access || "public" } : undefined,
129
+ repository: manifest.source && manifest.source.repository
130
+ ? { type: "git", url: manifest.source.repository }
131
+ : undefined,
132
+ }, null, 2) + "\n");
133
+
134
+ const outDir = path.resolve(options.baseDir || ".", options.directory || "build/packages/npm");
135
+ fs.mkdirSync(outDir, { recursive: true });
136
+
137
+ if (options.publish === false) {
138
+ // `npm pack` alone is what makes this channel testable with no
139
+ // registry: the tarball can be installed from a path.
140
+ const packed = execFileSync("npm", ["pack", "--silent", "--pack-destination", outDir],
141
+ { cwd: workDir, encoding: "utf8" }).trim().split("\n").pop();
142
+ const location = path.join(outDir, packed);
143
+ log(`-- Packed npm ${name}@${version} (${distTag}) to ${location}`);
144
+ return { location, distTag, name };
145
+ }
146
+
147
+ const args = ["publish", "--tag", distTag];
148
+ if (options.registry) args.push("--registry", options.registry);
149
+ // A signed attestation tying this tarball to the commit and workflow run
150
+ // that produced it. A tool whose whole purpose is making provenance
151
+ // auditable should not ask to be taken on trust itself. Requires a public
152
+ // package and an OIDC-capable CI job; off by default because it fails
153
+ // outright anywhere else.
154
+ if (options.provenance) args.push("--provenance");
155
+ execFileSync("npm", args, { cwd: workDir, encoding: "utf8", stdio: "pipe" });
156
+ log(`-- Published npm ${name}@${version} under dist-tag '${distTag}'`);
157
+ return { location: name + "@" + version, distTag, name };
158
+ } finally {
159
+ fs.rmSync(workDir, { recursive: true, force: true });
160
+ }
161
+ }
162
+
163
+ /**
164
+ * `github-release` -- attach the archive to a GitHub release.
165
+ *
166
+ * The language-neutral floor: immutable per tag, works for private repositories
167
+ * with a token, and readable by a consumer with no JVM and no Node. It has no
168
+ * dependency-resolution semantics and no update notification, which is why it is
169
+ * the fallback rather than the default.
170
+ *
171
+ * Shells out to `gh` rather than carrying an HTTP client and a token-handling
172
+ * policy, because `gh` already solves authentication better than this tool would.
173
+ */
174
+ function publishGithubRelease(archive, manifest, options, log) {
175
+ const repository = options.repository;
176
+ if (!repository) throw new ChannelError("the github-release channel requires a repository");
177
+
178
+ const tag = (options.tagFormat || "{target}-v{version}")
179
+ .replace(/\{target\}/g, manifest.target)
180
+ .replace(/\{version\}/g, manifest.version);
181
+
182
+ const gh = (args) => execFileSync("gh", args, { encoding: "utf8", stdio: "pipe" });
183
+
184
+ let exists = true;
185
+ try {
186
+ gh(["release", "view", tag, "--repo", repository]);
187
+ } catch {
188
+ exists = false;
189
+ }
190
+
191
+ if (!exists) {
192
+ const args = ["release", "create", tag, "--repo", repository,
193
+ "--title", `${manifest.target} ${manifest.version}`,
194
+ "--notes", `API description documents for ${manifest.target}.`];
195
+ // A pre-release is marked as one, so that "latest release" never resolves
196
+ // to a contract that is not finished.
197
+ if (isPrerelease(manifest.version)) args.push("--prerelease");
198
+ gh(args);
199
+ }
200
+
201
+ gh(["release", "upload", tag, archive, "--repo", repository, "--clobber"]);
202
+ log(`-- Published ${manifest.target} ${manifest.version} to ${repository} release ${tag}`);
203
+ return { location: `${repository}@${tag}`, tag };
204
+ }
205
+
206
+
207
+ /**
208
+ * Deploying to a remote Maven repository.
209
+ *
210
+ * Uploading the two files a resolver needs is the whole protocol, so this does
211
+ * it directly rather than requiring Maven to be installed in a specification
212
+ * repository that otherwise has no use for it.
213
+ *
214
+ * The token is read from the environment, never from configuration: a
215
+ * configuration file gets committed, and a credential in a committed file is a
216
+ * credential that has leaked.
217
+ */
218
+ async function putFile(url, body, token, contentType) {
219
+ const response = await fetch(url, {
220
+ method: "PUT",
221
+ headers: {
222
+ "Authorization": `Bearer ${token}`,
223
+ "Content-Type": contentType,
224
+ "Content-Length": String(body.length),
225
+ },
226
+ body,
227
+ });
228
+ if (!response.ok) {
229
+ throw new ChannelError(
230
+ `PUT ${url} failed: ${response.status} ${response.statusText}. ` +
231
+ (response.status === 401 || response.status === 403
232
+ ? "Check the token in the environment variable named by channels.maven.tokenEnv."
233
+ : await response.text().catch(() => ""))
234
+ );
235
+ }
236
+ }
237
+
238
+ function publishMavenRemote(archive, manifest, options, log) {
239
+ const tokenEnv = options.tokenEnv || "MAVEN_TOKEN";
240
+ const token = process.env[tokenEnv];
241
+ if (!token) {
242
+ throw new ChannelError(
243
+ `no credential in $${tokenEnv}, which channels.maven.tokenEnv names. ` +
244
+ `Remote publication needs one; set it in the release job's environment.`
245
+ );
246
+ }
247
+
248
+ const artifactId = options.artifactId || manifest.target;
249
+ const version = manifest.version;
250
+ const extension = options.extension || "tgz";
251
+ const base = `${options.repository.replace(/\/+$/, "")}/` +
252
+ `${options.groupId.split(".").join("/")}/${artifactId}/${version}/${artifactId}-${version}`;
253
+
254
+ const work = (async () => {
255
+ await putFile(`${base}.${extension}`, fs.readFileSync(archive), token, "application/octet-stream");
256
+ await putFile(`${base}.pom`,
257
+ Buffer.from(pom(options.groupId, artifactId, version, extension), "utf8"),
258
+ token, "application/xml");
259
+ await putFile(`${base}-manifest.json`,
260
+ Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8"),
261
+ token, "application/json");
262
+ })();
263
+
264
+ // The CLI is synchronous throughout; surfacing the promise here would make
265
+ // every caller async for one channel's benefit.
266
+ return work.then(() => {
267
+ log(`-- Deployed ${options.groupId}:${artifactId}:${version} to ${options.repository}`);
268
+ return { location: `${base}.${extension}`, coordinates: `${options.groupId}:${artifactId}:${version}@${extension}` };
269
+ });
270
+ }
271
+
272
+ const CHANNELS = { file: publishFile, maven: publishMaven, npm: publishNpm, "github-release": publishGithubRelease };
273
+
274
+ function publish(archive, manifest, channel, options, log = () => {}) {
275
+ const handler = CHANNELS[channel];
276
+ if (!handler) {
277
+ throw new ChannelError(
278
+ `unknown channel '${channel}'; available channels are ${Object.keys(CHANNELS).join(", ")}`
279
+ );
280
+ }
281
+ return handler(archive, manifest, options || {}, log);
282
+ }
283
+
284
+ module.exports = { publish, CHANNELS, ChannelError, pom };
package/src/cli.js ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const path = require("path");
5
+
6
+ const { loadFrom, ConfigError } = require("./config");
7
+ const { build, prepare, BuildError } = require("./pipeline");
8
+ const { init } = require("./init");
9
+ const { PlaceholderError } = require("./placeholders");
10
+ const { VersionError } = require("./version");
11
+ const { forTargets, ClosureError } = require("./closure");
12
+ const { pack, PackError } = require("./pack");
13
+ const { changedSince, ChangedError } = require("./changed");
14
+ const { split, SplitError } = require("./split");
15
+ const { publish, ChannelError } = require("./channels");
16
+ const { VersionError: PolicyError, describe } = require("./version-policy");
17
+
18
+ const USAGE = `api-only-publisher -- build and distribute API description documents
19
+
20
+ Usage:
21
+ api-only-publisher init [dir] [--force]
22
+ api-only-publisher build [--target <name>]... [--version <v>] [--openapi|--asyncapi]
23
+ api-only-publisher lint [--target <name>]...
24
+ api-only-publisher targets
25
+ api-only-publisher closure [--target <name>]...
26
+ api-only-publisher changed --since <ref>
27
+ api-only-publisher pack --version <v> [--target <name>]... [--out <dir>]
28
+ api-only-publisher publish --version <v> [--channel <name>]... [--out <dir>]
29
+ api-only-publisher split --out <dir> [--by kind|target]
30
+
31
+ Options:
32
+ --target <name> Restrict to one target; repeat for several. Default: all.
33
+ --version <v> Stamp info.version on every built document.
34
+ --openapi Only build OpenAPI documents.
35
+ --asyncapi Only build AsyncAPI documents.
36
+ --force init only: overwrite files that already exist.
37
+ --since <ref> changed only: the git ref to compare the working tree against.
38
+ --out <dir> pack/publish/split: where to write.
39
+ --channel <name> publish only: repeat for several. Default: every configured channel.
40
+ --by <kind|target> split only: the boundary to split along. Default: kind.
41
+ -C <dir> Run as if started in <dir>.
42
+ -q, --quiet Only report errors.
43
+ -h, --help Show this help.
44
+
45
+ What gets built, and where each document goes, is declared in apionly.yaml.
46
+ `;
47
+
48
+ function parseArgs(argv) {
49
+ const options = {
50
+ targets: [], kinds: null, version: null, quiet: false, force: false,
51
+ dir: process.cwd(), since: null, out: null, channels: null, by: "kind",
52
+ };
53
+ const positional = [];
54
+
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const arg = argv[i];
57
+ const next = () => {
58
+ if (i + 1 >= argv.length) throw new ConfigError(`${arg} requires a value`);
59
+ return argv[++i];
60
+ };
61
+ switch (arg) {
62
+ case "--target": options.targets.push(next()); break;
63
+ case "--version": options.version = next(); break;
64
+ case "--openapi": options.kinds = (options.kinds || []).concat("openapi"); break;
65
+ case "--asyncapi": options.kinds = (options.kinds || []).concat("asyncapi"); break;
66
+ case "--force": options.force = true; break;
67
+ case "--since": options.since = next(); break;
68
+ case "--out": options.out = next(); break;
69
+ case "--channel": options.channels = (options.channels || []).concat(next()); break;
70
+ case "--by": options.by = next(); break;
71
+ case "-C": options.dir = path.resolve(next()); break;
72
+ case "-q": case "--quiet": options.quiet = true; break;
73
+ case "-h": case "--help": options.help = true; break;
74
+ default:
75
+ if (arg.startsWith("-")) throw new ConfigError(`unrecognized option '${arg}'`);
76
+ positional.push(arg);
77
+ }
78
+ }
79
+ return { options, positional };
80
+ }
81
+
82
+ async function main(argv) {
83
+ const { options, positional } = parseArgs(argv);
84
+ const command = positional[0];
85
+
86
+ if (options.help || !command) {
87
+ process.stdout.write(USAGE);
88
+ return 0;
89
+ }
90
+
91
+ const log = options.quiet ? () => {} : (message) => console.log(message);
92
+
93
+ if (command === "init") {
94
+ const dir = path.resolve(options.dir, positional[1] || ".");
95
+ log(`Scaffolding a specification library in ${dir}`);
96
+ init(dir, { force: options.force, log });
97
+ log(`\nNext: api-only-publisher build -C ${dir}`);
98
+ return 0;
99
+ }
100
+
101
+ const config = loadFrom(options.dir);
102
+ const targets = options.targets.length > 0 ? options.targets : null;
103
+
104
+ if (targets) {
105
+ for (const t of targets) {
106
+ if (!config.targets[t]) {
107
+ throw new ConfigError(
108
+ `unknown target '${t}'; declared targets are ${Object.keys(config.targets).join(", ")}`
109
+ );
110
+ }
111
+ }
112
+ }
113
+
114
+ switch (command) {
115
+ case "targets": {
116
+ for (const name of Object.keys(config.targets)) {
117
+ const kinds = ["openapi", "asyncapi"].filter((k) => config.targets[name][k]);
118
+ const published = config.isPublished(name) ? "" : " (publish: false)";
119
+ console.log(`${name} [${kinds.join(", ")}]${published}`);
120
+ }
121
+ return 0;
122
+ }
123
+ case "build": {
124
+ const results = build(config, { targets, version: options.version, kinds: options.kinds || undefined, log });
125
+ log(`\nBuilt ${results.length} document(s).`);
126
+ return 0;
127
+ }
128
+ case "lint": {
129
+ // Lint without rebuilding, for fast local feedback on what is already
130
+ // in dist/.
131
+ const fs = require("fs");
132
+ const { lint } = require("./pipeline");
133
+ let linted = 0;
134
+ for (const kind of ["openapi", "asyncapi"]) {
135
+ for (const target of config.targetsFor(kind)) {
136
+ if (targets && !targets.includes(target)) continue;
137
+ const file = path.join(config.distDir(target), config.outputName(kind));
138
+ if (!fs.existsSync(file)) {
139
+ throw new BuildError(`${file} does not exist; run 'build' first`);
140
+ }
141
+ log(`=== ${target} (${kind}) ===`);
142
+ lint(config, kind, file, log);
143
+ linted += 1;
144
+ }
145
+ }
146
+ log(`\nLinted ${linted} document(s).`);
147
+ return 0;
148
+ }
149
+ case "closure": {
150
+ // Prepared first: the closure is computed over substituted content,
151
+ // because a change to a Markdown snippet genuinely changes the
152
+ // published document.
153
+ prepare(config);
154
+ for (const [target, entry] of forTargets(config)) {
155
+ if (targets && !targets.includes(target)) continue;
156
+ console.log(`${target} ${entry.files.length} file(s) ${entry.sha256}`);
157
+ }
158
+ return 0;
159
+ }
160
+ case "changed": {
161
+ if (!options.since) throw new ConfigError("changed requires --since <ref>");
162
+ prepare(config);
163
+ const results = changedSince(config, options.since, { log });
164
+ const changed = results.filter((r) => r.changed);
165
+ if (options.quiet) for (const r of changed) console.log(r.target);
166
+ log(`\n${changed.length} of ${results.length} target(s) changed since ${options.since}.`);
167
+ return 0;
168
+ }
169
+ case "pack": {
170
+ if (!options.version) throw new ConfigError("pack requires --version <v>");
171
+ const outDir = path.resolve(options.dir, options.out || "build/packages");
172
+ const closures = forTargets(config);
173
+ let packed = 0;
174
+ for (const target of Object.keys(config.targets)) {
175
+ if (targets && !targets.includes(target)) continue;
176
+ if (!config.isPublished(target)) {
177
+ log(`${target}: not packed (publish: false)`);
178
+ continue;
179
+ }
180
+ const closure = closures.get(target);
181
+ pack(config, target, {
182
+ version: options.version,
183
+ closureSha256: closure ? closure.sha256 : null,
184
+ outDir, log,
185
+ });
186
+ packed += 1;
187
+ }
188
+ log(`\nPacked ${packed} target(s) into ${outDir}.`);
189
+ return 0;
190
+ }
191
+ case "publish": {
192
+ if (!options.version) throw new ConfigError("publish requires --version <v>");
193
+ const outDir = path.resolve(options.dir, options.out || "build/packages");
194
+ const configured = config.channels || {};
195
+ const names = options.channels || Object.keys(configured);
196
+ if (names.length === 0) {
197
+ throw new ConfigError("no channels configured; add a `channels:` block or pass --channel");
198
+ }
199
+ const closures = forTargets(config);
200
+ let published = 0;
201
+ for (const target of Object.keys(config.targets)) {
202
+ if (targets && !targets.includes(target)) continue;
203
+ if (!config.isPublished(target)) continue;
204
+ const closure = closures.get(target);
205
+ // Packed once, then shipped unchanged to every channel.
206
+ const { archive, manifest } = pack(config, target, {
207
+ version: options.version,
208
+ closureSha256: closure ? closure.sha256 : null,
209
+ outDir, log,
210
+ });
211
+ for (const name of names) {
212
+ // A remote channel returns a promise; a local one does not.
213
+ await publish(archive, manifest, name, { ...(configured[name] || {}), baseDir: config.root }, log);
214
+ published += 1;
215
+ }
216
+ }
217
+ log(`\nPublished ${published} artifact(s).`);
218
+ return 0;
219
+ }
220
+ case "split": {
221
+ const outDir = path.resolve(options.dir, options.out || "build/split");
222
+ prepare(config);
223
+ const parts = split(config, { by: options.by, outDir, log });
224
+ const duplicated = parts.reduce((n, p) => n + p.imported.length, 0);
225
+ log(`\nWrote ${parts.length} part(s) to ${outDir}.`);
226
+ if (duplicated > 0) {
227
+ log(`${duplicated} shared fragment(s) were copied so each part is self-contained.`);
228
+ log("Each part carrying copies has an IMPORTED.adoc saying which, and what it costs.");
229
+ }
230
+ return 0;
231
+ }
232
+ default:
233
+ throw new ConfigError(`unrecognized command '${command}'`);
234
+ }
235
+ }
236
+
237
+ function report(error) {
238
+ if (error instanceof ConfigError || error instanceof BuildError ||
239
+ error instanceof PlaceholderError || error instanceof VersionError ||
240
+ error instanceof ClosureError || error instanceof PackError ||
241
+ error instanceof ChangedError || error instanceof SplitError ||
242
+ error instanceof ChannelError || error instanceof PolicyError) {
243
+ console.error(`Error: ${error.message}`);
244
+ process.exitCode = 1;
245
+ } else {
246
+ throw error;
247
+ }
248
+ }
249
+
250
+ if (require.main === module) {
251
+ main(process.argv.slice(2)).then(
252
+ (code) => { process.exitCode = code; },
253
+ report
254
+ );
255
+ }
256
+
257
+ module.exports = { main, parseArgs };