@arc-e-tect/api-only-publisher 0.7.0 → 0.8.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/aggregate.js CHANGED
@@ -2,19 +2,40 @@
2
2
 
3
3
  // Generated aggregate bundles.
4
4
  //
5
- // A portfolio view of the OpenAPI surface is just another bundle root: every
6
- // path body already lives in a $ref'd fragment, so a whole-landscape table of
7
- // contents costs one hand-written file that duplicates no contract text.
5
+ // A portfolio view used to be a hand-written OpenAPI bundle root: every path body
6
+ // already lives in a $ref'd fragment, so a whole-landscape table of contents cost
7
+ // one file that duplicated no contract text. It can be written that way, but it
8
+ // does not follow that it should be: nothing stopped it silently falling behind
9
+ // the members it was meant to list, the moment a service grew an endpoint and
10
+ // nobody remembered the table of contents was a second place to add it.
8
11
  //
9
- // AsyncAPI cannot be written that way. Its operations use document-root pointers
10
- // -- `channel: {$ref: '#/channels/auditV1'}` -- and `#` resolves against whatever
12
+ // AsyncAPI never had the choice. Its operations use document-root pointers --
13
+ // `channel: {$ref: '#/channels/auditV1'}` -- and `#` resolves against whatever
11
14
  // file contains it, so moving an operation into a fragment breaks it. A
12
15
  // hand-written async portfolio would therefore have to copy every operation
13
16
  // verbatim, and that copy would start rotting the moment a member changed.
14
17
  //
15
- // So it is generated instead: the aggregate's bundle root is synthesised into the
18
+ // So both are generated: the aggregate's bundle root is synthesised into the
16
19
  // staged tree from its members, immediately before bundling. Nothing is
17
20
  // duplicated in the source, and the view cannot fall behind its members.
21
+ //
22
+ // Generation happens in two passes, because a member's bundle root is read
23
+ // *before* it is bundled -- staging only copies and substitutes placeholders --
24
+ // and at that point almost everything in it is still a $ref pointer, not the
25
+ // content behind it:
26
+ //
27
+ // Pass 1, here, merges what is visible unresolved: paths and security schemes
28
+ // are keyed by a map key that is already there in the bundle root, and a
29
+ // member's own root-level `security` is written inline, never $ref'd. OpenAPI
30
+ // paths are prefixed with the member's target at this pass, because that is a
31
+ // plain string rewrite of the key -- it needs nothing resolved.
32
+ //
33
+ // Pass 2, in pipeline.js, runs on the aggregate's own bundled output, once
34
+ // every $ref -- the members' and its own -- has been resolved. Only then are an
35
+ // operation's id, and whether it already sets its own `security`, visible at
36
+ // all, so that is where operation ids are prefixed, root security is pushed
37
+ // down onto the operations it reaches, and repeated tags are reconciled by the
38
+ // name inside them, which a $ref pointer to a tag fragment never shows.
18
39
 
19
40
  const fs = require("fs");
20
41
  const path = require("path");
@@ -22,40 +43,128 @@ const YAML = require("yaml");
22
43
 
23
44
  class AggregateError extends Error {}
24
45
 
46
+ const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
47
+
25
48
  /**
26
49
  * Merge one section of a member's document into the aggregate.
27
50
  *
51
+ * Three modes, for three different expectations of what a repeated key means:
52
+ *
53
+ * `unique` (the default) treats a repeated key as ambiguous ownership, whatever
54
+ * it says -- ordinary for a channel, an operation or a path, where one of two
55
+ * identical definitions silently not appearing in the aggregate is exactly the
56
+ * kind of mistake this is here to catch.
57
+ *
28
58
  * `agree` marks sections where members are expected to say the same thing.
29
59
  * Several services publishing to one broker all declare that broker, and that is
30
60
  * the ordinary case rather than a conflict -- so an identical definition merges
31
61
  * silently and only a genuine disagreement is an error.
32
62
  *
33
- * Everywhere else a repeated key is an error even when the definitions match,
34
- * because it makes ownership ambiguous: two members both defining a channel means
35
- * one of them would silently not appear in the aggregate.
63
+ * `reconcile` is for a section where members are expected to sometimes *disagree*
64
+ * without either being wrong -- a shared tag two members describe slightly
65
+ * differently is an inconsistency to fix, not a defect to fail the build over.
66
+ * An identical repeat merges silently, like `agree`; a differing one merges too,
67
+ * with the first contributing member's value kept and the disagreement reported,
68
+ * rather than failing the build.
69
+ *
70
+ * @returns {Array<{section: string, key: string, winner: string, loser: string}>}
71
+ * one entry per reconciled disagreement, always empty outside `reconcile` mode
36
72
  */
37
- function mergeSection(into, from, section, member, seen, { agree = false } = {}) {
38
- if (!from[section]) return;
73
+ function mergeSection(into, from, section, member, seen, { mode = "unique", label = section } = {}) {
74
+ const reconciled = [];
75
+ if (!from[section]) return reconciled;
39
76
  for (const [key, value] of Object.entries(from[section])) {
40
77
  const previous = seen[section] && seen[section][key];
41
78
  if (previous) {
42
79
  const identical = JSON.stringify(into[section][key]) === JSON.stringify(value);
43
- if (!agree || !identical) {
44
- throw new AggregateError(
45
- `aggregate: '${member}' ${agree && !identical ? "disagrees about" : "redefines"} ` +
46
- `${section}.${key}, already contributed by '${previous}'. ` +
47
- (agree
48
- ? "Members may share a server, but not define it differently."
49
- : "Rename it, or leave it out of the aggregate.")
50
- );
80
+ if (identical) {
81
+ if (mode === "unique") {
82
+ throw new AggregateError(
83
+ `aggregate: '${member}' redefines ${label}.${key}, already contributed by '${previous}'. ` +
84
+ "Rename it, or leave it out of the aggregate."
85
+ );
86
+ }
87
+ continue;
88
+ }
89
+ if (mode === "reconcile") {
90
+ reconciled.push({ section, key, winner: previous, loser: member });
91
+ continue;
51
92
  }
52
- continue;
93
+ throw new AggregateError(
94
+ `aggregate: '${member}' ${mode === "agree" ? "disagrees about" : "redefines"} ` +
95
+ `${label}.${key}, already contributed by '${previous}'. ` +
96
+ (mode === "agree"
97
+ ? "Members may share a server, but not define it differently."
98
+ : "Rename it, or leave it out of the aggregate.")
99
+ );
53
100
  }
54
101
  into[section] = into[section] || {};
55
102
  into[section][key] = value;
56
103
  seen[section] = seen[section] || {};
57
104
  seen[section][key] = member;
58
105
  }
106
+ return reconciled;
107
+ }
108
+
109
+ /** Merges every sub-map of `components` present in `from`, `mode` for each. */
110
+ function mergeComponents(into, from, member, seen, mode) {
111
+ if (!from.components) return;
112
+ into.components = into.components || {};
113
+ seen.components = seen.components || {};
114
+ for (const sub of Object.keys(from.components)) {
115
+ into.components[sub] = into.components[sub] || {};
116
+ seen.components[sub] = seen.components[sub] || {};
117
+ mergeSection(into.components, from.components, sub, member, seen.components, { mode, label: `components.${sub}` });
118
+ }
119
+ }
120
+
121
+ function requireMember(config, target, kind, member) {
122
+ if (!config.targets[member] || !config.targets[member][kind]) {
123
+ throw new AggregateError(`target '${target}': aggregate member '${member}' declares no ${kind} bundle`);
124
+ }
125
+ if (config.targets[member][kind].aggregate !== undefined) {
126
+ throw new AggregateError(
127
+ `target '${target}': aggregate member '${member}' is itself an aggregate; nesting is not supported`
128
+ );
129
+ }
130
+ }
131
+
132
+ function memberDocument(config, target, kind, member) {
133
+ requireMember(config, target, kind, member);
134
+ const file = path.join(config.stagingDir(kind), config.targets[member][kind].bundle);
135
+ if (!fs.existsSync(file)) {
136
+ throw new AggregateError(`aggregate member '${member}': bundle root not found at ${file}`);
137
+ }
138
+ return YAML.parse(fs.readFileSync(file, "utf8"));
139
+ }
140
+
141
+ function membersOf(config, target, kind) {
142
+ const members = config.targets[target][kind].aggregate;
143
+ if (!Array.isArray(members) || members.length === 0) {
144
+ throw new AggregateError(`target '${target}': ${kind}.aggregate must list at least one target`);
145
+ }
146
+ return members;
147
+ }
148
+
149
+ /** The aggregate's own identity: its own info, or a generated stand-in naming its members. */
150
+ function aggregateInfo(spec, target, members, forKind) {
151
+ return spec.info || {
152
+ title: `${target} (aggregate)`,
153
+ version: "0.0.0",
154
+ description: forKind === "asyncapi"
155
+ ? `Every event contract published across ${members.join(", ")}.`
156
+ : `Every API published across ${members.join(", ")}.`,
157
+ };
158
+ }
159
+
160
+ function writeGenerated(out, members, content) {
161
+ fs.mkdirSync(path.dirname(out), { recursive: true });
162
+ fs.writeFileSync(
163
+ out,
164
+ `# GENERATED by api-only-publisher from: ${members.join(", ")}\n` +
165
+ "# Do not edit, and do not commit: it is rebuilt into the staging tree on every build.\n" +
166
+ YAML.stringify(content)
167
+ );
59
168
  }
60
169
 
61
170
  /**
@@ -65,54 +174,169 @@ function mergeSection(into, from, section, member, seen, { agree = false } = {})
65
174
  */
66
175
  function generateAsyncApi(config, target, { log = () => {} } = {}) {
67
176
  const spec = config.targets[target].asyncapi;
68
- const members = spec.aggregate;
69
- if (!Array.isArray(members) || members.length === 0) {
70
- throw new AggregateError(`target '${target}': asyncapi.aggregate must list at least one target`);
71
- }
177
+ const members = membersOf(config, target, "asyncapi");
72
178
 
73
179
  const merged = { asyncapi: null, info: null, servers: {}, channels: {}, operations: {} };
74
180
  const seen = {};
75
181
 
76
182
  for (const member of members) {
77
- if (!config.targets[member] || !config.targets[member].asyncapi) {
78
- throw new AggregateError(
79
- `target '${target}': aggregate member '${member}' declares no asyncapi bundle`
80
- );
81
- }
82
- const file = path.join(config.stagingDir("asyncapi"), config.targets[member].asyncapi.bundle);
83
- if (!fs.existsSync(file)) {
84
- throw new AggregateError(`aggregate member '${member}': bundle root not found at ${file}`);
85
- }
86
- const doc = YAML.parse(fs.readFileSync(file, "utf8"));
183
+ const doc = memberDocument(config, target, "asyncapi", member);
87
184
 
88
185
  merged.asyncapi = merged.asyncapi || doc.asyncapi;
89
- mergeSection(merged, doc, "servers", member, seen, { agree: true });
186
+ mergeSection(merged, doc, "servers", member, seen, { mode: "agree" });
90
187
  mergeSection(merged, doc, "channels", member, seen);
91
188
  mergeSection(merged, doc, "operations", member, seen);
92
189
  }
93
190
 
94
- // The aggregate's own identity, not any member's.
95
- merged.info = spec.info || {
96
- title: `${target} (aggregate)`,
97
- version: "0.0.0",
98
- description: `Every event contract published across ${members.join(", ")}.`,
99
- };
191
+ merged.info = aggregateInfo(spec, target, members, "asyncapi");
100
192
 
101
- const out = path.join(config.stagingDir("asyncapi"), spec.bundle);
102
- fs.mkdirSync(path.dirname(out), { recursive: true });
103
- fs.writeFileSync(
104
- out,
105
- `# GENERATED by api-only-publisher from: ${members.join(", ")}\n` +
106
- `# Do not edit, and do not commit: it is rebuilt into the staging tree on every build.\n` +
107
- YAML.stringify(merged)
108
- );
193
+ const out = config.aggregatePath(target, "asyncapi");
194
+ writeGenerated(out, members, merged);
109
195
  log(`-- Generated aggregate ${path.basename(out)} from ${members.join(", ")}`);
110
196
  return out;
111
197
  }
112
198
 
199
+ /**
200
+ * Synthesise an aggregate OpenAPI bundle root into the staged tree.
201
+ *
202
+ * Only what a member's bundle root holds unresolved: paths (prefixed, so keys are
203
+ * unique by construction -- see openapiPushDown for what happens to the
204
+ * operations behind them once they are resolved), servers, root-level security,
205
+ * and every `components.*` sub-map. Tags are carried over as whatever the member
206
+ * declared -- usually a list of $ref pointers -- and reconciled by name in
207
+ * openapiPushDown, once bundling has resolved them into `{name, ...}` objects a
208
+ * $ref pointer never shows.
209
+ *
210
+ * @returns {string} the path of the generated bundle root
211
+ */
212
+ function generateOpenApi(config, target, { log = () => {} } = {}) {
213
+ const spec = config.targets[target].openapi;
214
+ const members = membersOf(config, target, "openapi");
215
+ const prefixPaths = config.portfolioPathStrategy() === "target-prefix";
216
+
217
+ const merged = { openapi: null, info: null, servers: {}, tags: [], paths: {}, components: {} };
218
+ const seen = {};
219
+ // Recorded here, in pass 1, because only here is it still known which member a
220
+ // path came from: once paths are merged, one document can no longer tell.
221
+ const owners = {};
222
+
223
+ for (const member of members) {
224
+ const doc = memberDocument(config, target, "openapi", member);
225
+
226
+ merged.openapi = merged.openapi || doc.openapi;
227
+ mergeSection(merged, doc, "servers", member, seen, { mode: "agree" });
228
+ mergeComponents(merged, doc, member, seen, "agree");
229
+ if (Array.isArray(doc.tags)) merged.tags.push(...doc.tags);
230
+
231
+ const prefixed = {};
232
+ for (const [rawPath, item] of Object.entries(doc.paths || {})) {
233
+ prefixed[prefixPaths ? `/${member}${rawPath}` : rawPath] = item;
234
+ }
235
+ mergeSection(merged, { paths: prefixed }, "paths", member, seen);
236
+ for (const finalPath of Object.keys(prefixed)) {
237
+ owners[finalPath] = { member, security: doc.security };
238
+ }
239
+ }
240
+ if (Object.keys(merged.components).length === 0) delete merged.components;
241
+ if (merged.tags.length === 0) delete merged.tags;
242
+
243
+ merged.info = aggregateInfo(spec, target, members, "openapi");
244
+
245
+ const out = config.aggregatePath(target, "openapi");
246
+ writeGenerated(out, members, merged);
247
+ log(`-- Generated aggregate ${path.basename(out)} from ${members.join(", ")}`);
248
+ return { out, owners };
249
+ }
250
+
251
+ const SECURITY_NOTE =
252
+ "Each member's own root-level `security` has been moved onto the operations it contributes " +
253
+ "-- see each operation's own `security`, and `components.securitySchemes` for what each scheme requires.";
254
+
255
+ /**
256
+ * Pass 2: what generateOpenApi could not do until its own output was bundled.
257
+ * Run on `text` -- the aggregate's *bundled* document -- after every $ref, the
258
+ * members' and its own, has been resolved.
259
+ *
260
+ * - Every operation id is prefixed with its path's member, the same way the path
261
+ * itself already was, so two members sharing an operation id -- coincidence,
262
+ * not intent -- cannot collide in the portfolio.
263
+ * - Every member's root-level `security` -- explicit `[]` included -- becomes
264
+ * every operation it contributes to's own `security`, unless the operation
265
+ * already set one; the portfolio itself keeps no root `security`. See the
266
+ * Security section of the README for why: union and intersection are both
267
+ * unsound merges of a security requirement, and requiring every member to
268
+ * agree would make a landscape where they legitimately differ unbuildable.
269
+ * Where anything was pushed down, `info.description` and `x-security-note`
270
+ * both say so -- a comment would not survive bundling, so neither carries this.
271
+ * - Two members contributing the same tag name are one tag; an identical body
272
+ * merges silently, a differing one is reported and the first contributor's
273
+ * body wins, deterministically.
274
+ *
275
+ * Edits the document in place -- `YAML.parseDocument`, never a parse and
276
+ * re-stringify -- so everything this pass does not touch keeps the bundler's own
277
+ * formatting exactly, the same reason version.js splices rather than re-emits.
278
+ *
279
+ * @returns {{text: string, reconciled: Array<{section: string, key: string}>}}
280
+ */
281
+ function openapiPushDown(text, owners, { operationIdStrategy = "target-prefix" } = {}) {
282
+ const doc = YAML.parseDocument(text);
283
+ const plain = doc.toJS();
284
+ let pushedDown = false;
285
+
286
+ for (const [pathKey, item] of Object.entries(plain.paths || {})) {
287
+ const owner = owners[pathKey];
288
+ if (!owner || !item || typeof item !== "object") continue;
289
+ for (const httpMethod of HTTP_METHODS) {
290
+ const op = item[httpMethod];
291
+ if (!op || typeof op !== "object") continue;
292
+ if (op.operationId && operationIdStrategy === "target-prefix") {
293
+ doc.setIn(["paths", pathKey, httpMethod, "operationId"], `${pascalCase(owner.member)}${op.operationId}`);
294
+ }
295
+ if (op.security === undefined && owner.security !== undefined) {
296
+ doc.setIn(["paths", pathKey, httpMethod, "security"], owner.security);
297
+ pushedDown = true;
298
+ }
299
+ }
300
+ }
301
+
302
+ if (pushedDown) {
303
+ const description = doc.getIn(["info", "description"]);
304
+ doc.setIn(["info", "description"], description ? `${description}\n\n${SECURITY_NOTE}` : SECURITY_NOTE);
305
+ doc.setIn(["x-security-note"], SECURITY_NOTE);
306
+ }
307
+
308
+ const reconciled = [];
309
+ if (Array.isArray(plain.tags)) {
310
+ const byName = new Map();
311
+ const order = [];
312
+ for (const tag of plain.tags) {
313
+ const existing = byName.get(tag.name);
314
+ if (existing === undefined) {
315
+ byName.set(tag.name, tag);
316
+ order.push(tag.name);
317
+ continue;
318
+ }
319
+ if (JSON.stringify(existing) !== JSON.stringify(tag)) {
320
+ reconciled.push({ section: "tags", key: tag.name });
321
+ }
322
+ // The first contributor's body wins either way -- identical or not.
323
+ }
324
+ doc.setIn(["tags"], order.map((name) => byName.get(name)));
325
+ }
326
+
327
+ return { text: doc.toString(), reconciled };
328
+ }
329
+
330
+ /** A target name, in PascalCase: user-account -> UserAccount. */
331
+ function pascalCase(target) {
332
+ return target.split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
333
+ }
334
+
113
335
  function isAggregate(config, target, kind) {
114
336
  const spec = config.targets[target][kind];
115
337
  return Boolean(spec && spec.aggregate);
116
338
  }
117
339
 
118
- module.exports = { generateAsyncApi, isAggregate, AggregateError };
340
+ module.exports = {
341
+ generateAsyncApi, generateOpenApi, openapiPushDown, isAggregate, mergeSection, pascalCase, AggregateError,
342
+ };
package/src/cli.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
 
4
+ const fs = require("fs");
4
5
  const path = require("path");
6
+ const YAML = require("yaml");
5
7
 
6
8
  const { loadFrom, ConfigError } = require("./config");
7
9
  const { build, prepare, BuildError } = require("./pipeline");
8
10
  const { init, scaffold, plan } = require("./init");
9
11
  const { resolveValues } = require("./init-questions");
12
+ const { resolvePortfolioValues, writePortfolioSection } = require("./config-command");
10
13
  const { PlaceholderError } = require("./placeholders");
11
14
  const { VersionError } = require("./version");
12
15
  const { forTargets, ClosureError } = require("./closure");
@@ -26,6 +29,8 @@ Usage:
26
29
  [--contact-name <text>] [--contact-url <url>]
27
30
  [--license <spdx-id>] [--license-url <url>]
28
31
  [--server-url <url>] [--broker-host <host[:port]>]
32
+ api-only-publisher config [section] [--yes] [--portfolio-paths <target-prefix|none>]
33
+ [--portfolio-location <dir>]
29
34
  api-only-publisher build [--target <name>]... [--pre-release <ids>] [--openapi|--asyncapi]
30
35
  api-only-publisher lint [--target <name>]...
31
36
  api-only-publisher targets
@@ -44,14 +49,16 @@ Options:
44
49
  --asyncapi Only build AsyncAPI documents.
45
50
  --force init only: overwrite files that differ from the scaffold;
46
51
  at a terminal, after listing them and asking once.
47
- -y, --yes init only: ask nothing, even at a terminal; take the defaults
48
- for every value no flag gives. Without a terminal, init never asks.
52
+ -y, --yes init/config only: ask nothing, even at a terminal; take the
53
+ current or default value for anything no flag gives.
49
54
  --openapi, --asyncapi
50
55
  init: the kinds of document the library holds; both flags for both.
51
56
  --target <name> init: the target's name, in lowercase kebab-case.
52
57
  --title, --contract-version, --contact-name, --contact-url, --license,
53
58
  --license-url, --server-url, --broker-host
54
59
  init only: the value of that question; a flag always wins.
60
+ --portfolio-paths <target-prefix|none>, --portfolio-location <dir>
61
+ config only: the value of that question; a flag always wins.
55
62
  --since <ref> changed only: the git ref to compare the working tree against.
56
63
  --out <dir> pack/publish/split: where to write.
57
64
  --channel <name> publish only: repeat for several. Default: every configured channel.
@@ -69,13 +76,14 @@ function parseArgs(argv) {
69
76
  const options = {
70
77
  targets: [], kinds: null, preRelease: null, quiet: false, force: false,
71
78
  dir: process.cwd(), since: null, out: null, channels: null, by: "kind",
72
- yes: false, init: {},
79
+ yes: false, init: {}, config: {},
73
80
  };
74
81
  const INIT_VALUES = {
75
82
  "--title": "title", "--contract-version": "contractVersion", "--contact-name": "contactName",
76
83
  "--contact-url": "contactUrl", "--license": "license", "--license-url": "licenseUrl",
77
84
  "--server-url": "serverUrl", "--broker-host": "brokerHost",
78
85
  };
86
+ const CONFIG_VALUES = { "--portfolio-paths": "paths", "--portfolio-location": "location" };
79
87
  const positional = [];
80
88
 
81
89
  for (let i = 0; i < argv.length; i++) {
@@ -109,6 +117,10 @@ function parseArgs(argv) {
109
117
  options.init[INIT_VALUES[arg]] = next();
110
118
  break;
111
119
  }
120
+ if (CONFIG_VALUES[arg]) {
121
+ options.config[CONFIG_VALUES[arg]] = next();
122
+ break;
123
+ }
112
124
  if (arg.startsWith("-")) throw new ConfigError(`unrecognized option '${arg}'`);
113
125
  positional.push(arg);
114
126
  }
@@ -136,6 +148,28 @@ function initKinds(kinds) {
136
148
  return kinds ? ["openapi", "asyncapi"].filter((kind) => kinds.includes(kind)) : undefined;
137
149
  }
138
150
 
151
+ /**
152
+ * Whether init should ask about the portfolio section this run: "ask", once the
153
+ * library is about to have more than one target -- there is nothing to aggregate
154
+ * with just one -- and it does not have a portfolio section yet; "present" the
155
+ * same way, but the section is already there, which init never changes, and
156
+ * reports rather than silently doing nothing about; "not-yet" while there is
157
+ * still only one target, which is not worth mentioning at all.
158
+ *
159
+ * @returns {"ask"|"present"|"not-yet"}
160
+ */
161
+ function portfolioStatus(dir, target) {
162
+ const file = path.join(dir, "apionly.yaml");
163
+ if (!fs.existsSync(file)) return "not-yet";
164
+ const doc = YAML.parseDocument(fs.readFileSync(file, "utf8"));
165
+ if (doc.errors.length > 0) return "not-yet";
166
+ const targets = doc.get("targets", true);
167
+ const names = YAML.isMap(targets) ? new Set(targets.items.map((pair) => String(pair.key))) : new Set();
168
+ names.add(target);
169
+ if (names.size <= 1) return "not-yet";
170
+ return doc.hasIn(["portfolio"]) ? "present" : "ask";
171
+ }
172
+
139
173
  /**
140
174
  * Scaffolds a library. At a terminal, and without --yes, it asks for every value no
141
175
  * flag gives; anywhere else it takes the defaults, as it always has.
@@ -163,19 +197,36 @@ async function runInit(options, positional, io, log) {
163
197
  ask: terminal && ((question) => terminal.question(question)),
164
198
  tell: (message) => output.write(`${message}\n`),
165
199
  });
200
+
201
+ // Asked, or defaulted, the same way a kind's own questions are -- but only
202
+ // once there is something to aggregate. A section already there is never
203
+ // asked about or changed, and reported as present rather than passed over
204
+ // in silence.
205
+ const portfolioStatusThisRun = portfolioStatus(dir, values.target);
206
+ let portfolio = null;
207
+ if (portfolioStatusThisRun === "ask") {
208
+ portfolio = await resolvePortfolioValues({
209
+ given: options.config,
210
+ current: { paths: "target-prefix", location: "portfolios" },
211
+ ask: terminal && ((question) => terminal.question(question)),
212
+ tell: (message) => output.write(`${message}\n`),
213
+ });
214
+ }
215
+
166
216
  log(`Scaffolding a specification library in ${dir}`);
167
217
  let force = options.force;
168
218
  if (terminal && force) {
169
219
  // Asked, --force means "after showing me": it overwrites what differs only
170
220
  // once the list has been seen and agreed to.
171
- const differing = plan(dir, scaffold(values)).filter((entry) => entry.status === "differs");
221
+ const differing = plan(dir, scaffold(values), values, portfolio).filter((entry) => entry.status === "differs");
172
222
  if (differing.length > 0) {
173
223
  output.write(`These files differ from the scaffold:\n${differing.map((d) => ` ${d.rel}\n`).join("")}`);
174
224
  const answer = await terminal.question(`Overwrite these ${differing.length} file(s)? [y/N] `);
175
225
  force = /^y(es)?$/i.test(answer.trim());
176
226
  }
177
227
  }
178
- init(dir, { values, force, log });
228
+ init(dir, { values, force, log, portfolio });
229
+ if (portfolioStatusThisRun === "present") log(" present portfolio (already configured; init never changes it)");
179
230
  log(`\nNext: api-only-publisher build -C ${dir}`);
180
231
  } finally {
181
232
  if (terminal) terminal.close();
@@ -183,6 +234,50 @@ async function runInit(options, positional, io, log) {
183
234
  return 0;
184
235
  }
185
236
 
237
+ const CONFIGURABLE_SECTIONS = ["portfolio"];
238
+
239
+ /**
240
+ * Reconfigures a section of apionly.yaml. Unlike init, this always asks -- at a
241
+ * terminal, each question shows what is already configured, or the documented
242
+ * default when there is nothing yet, and Enter keeps it -- and always rewrites
243
+ * the keys it asked about, whatever else the section or the file holds.
244
+ */
245
+ async function runConfig(options, positional, io, log) {
246
+ const section = positional[1];
247
+ if (section !== undefined && !CONFIGURABLE_SECTIONS.includes(section)) {
248
+ throw new ConfigError(
249
+ `'${section}' is not a configurable section; there is: ${CONFIGURABLE_SECTIONS.join(", ")}`
250
+ );
251
+ }
252
+ const config = loadFrom(options.dir);
253
+ const input = io.input || process.stdin;
254
+ const output = io.output || process.stdout;
255
+ const interactive = io.interactive !== undefined ? io.interactive : Boolean(input.isTTY && output.isTTY);
256
+ const terminal = interactive && !options.yes
257
+ ? require("node:readline/promises").createInterface({ input, output })
258
+ : null;
259
+ try {
260
+ for (const name of section ? [section] : CONFIGURABLE_SECTIONS) {
261
+ // The only configurable section today; a second one gets its own current
262
+ // values, its own given-flags and its own writer, called the same way.
263
+ const current = { paths: config.portfolioPathStrategy(), location: config.portfolioLocation() };
264
+ const given = { ...options.config };
265
+ for (const key of Object.keys(given)) if (given[key] === undefined) delete given[key];
266
+
267
+ const values = await resolvePortfolioValues({
268
+ given, current,
269
+ ask: terminal && ((question) => terminal.question(question)),
270
+ tell: (message) => output.write(`${message}\n`),
271
+ });
272
+ fs.writeFileSync(config.path, writePortfolioSection(fs.readFileSync(config.path, "utf8"), values));
273
+ log(`Configured ${name}: paths=${values.paths}, location=${values.location}`);
274
+ }
275
+ } finally {
276
+ if (terminal) terminal.close();
277
+ }
278
+ return 0;
279
+ }
280
+
186
281
  async function main(argv, io = {}) {
187
282
  const { options, positional } = parseArgs(argv);
188
283
  const command = positional[0];
@@ -195,6 +290,7 @@ async function main(argv, io = {}) {
195
290
  const log = options.quiet ? () => {} : (message) => console.log(message);
196
291
 
197
292
  if (command === "init") return runInit(options, positional, io, log);
293
+ if (command === "config") return runConfig(options, positional, io, log);
198
294
 
199
295
  const config = loadFrom(options.dir);
200
296
  const targets = options.targets.length > 0 ? options.targets : null;
@@ -236,7 +332,6 @@ async function main(argv, io = {}) {
236
332
  // Lint without rebuilding, for fast local feedback on what is already
237
333
  // in dist/. Every selected document is linted even when one fails, so
238
334
  // one run reports every failure rather than only the first.
239
- const fs = require("fs");
240
335
  const { lint } = require("./pipeline");
241
336
  const failures = [];
242
337
  let linted = 0;
package/src/closure.js CHANGED
@@ -104,7 +104,7 @@ function forTargets(config, kinds = ["openapi", "asyncapi"], only = null) {
104
104
  for (const kind of kinds) {
105
105
  for (const target of config.targetsFor(kind)) {
106
106
  if (only && !only.includes(target)) continue;
107
- const entry = config.bundlePath(target, kind);
107
+ const entry = config.bundleRootPath(target, kind);
108
108
  if (!fs.existsSync(entry)) {
109
109
  throw new ClosureError(
110
110
  `target '${target}': ${kind} bundle root not found at ${entry}; stage the tree first`