@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.
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+
3
+ // The `config` command's one section so far: `portfolio`.
4
+ //
5
+ // `init` only ever adds this section when it is missing (init-config.js);
6
+ // reconfiguring what is already there is this file's job instead, and it works
7
+ // the opposite way on purpose -- it always asks, offering what is already
8
+ // configured as each default, and always rewrites the keys it asked about.
9
+ //
10
+ // A second configurable section extends this the same way a second kind
11
+ // extended init-config.js's slots: its own question list, its own current()
12
+ // reader and its own writer, called from the same `config` command. Nothing here
13
+ // is written as a registry ahead of that need -- there is exactly one section
14
+ // today, and one small module reads more easily than an abstraction for a
15
+ // second case that does not exist yet.
16
+
17
+ const YAML = require("yaml");
18
+ const { ConfigError } = require("./config");
19
+
20
+ const PATH_STRATEGIES = ["target-prefix", "none"];
21
+
22
+ /** The portfolio section's questions, in the order they are asked. */
23
+ const QUESTIONS = [
24
+ {
25
+ key: "paths", flag: "--portfolio-paths",
26
+ text: "Path prefix strategy for a portfolio: target-prefix or none",
27
+ check: (v) => (PATH_STRATEGIES.includes(v) ? null : `use ${PATH_STRATEGIES.join(" or ")}`),
28
+ },
29
+ {
30
+ key: "location", flag: "--portfolio-location",
31
+ text: "Where a portfolio's generated bundle root lands, as a directory beside bundles/",
32
+ check: (v) => (v.trim() ? null : "it may not be empty"),
33
+ },
34
+ ];
35
+
36
+ /**
37
+ * Resolves the portfolio section's values: a flag always wins. At a terminal,
38
+ * each question shows `current`'s value for it in brackets -- the section's own,
39
+ * if it has one, or the documented default when it does not -- and Enter keeps
40
+ * it; without a terminal, that same value is taken as given, exactly as if it
41
+ * had been typed.
42
+ *
43
+ * @param {object} options
44
+ * @param {object} [options.given] values given as flags, by key; never asked
45
+ * @param {object} options.current the value each question defaults to
46
+ * @param {function(string): Promise<string>} [options.ask] shows a question, resolves to the answer
47
+ * @param {function(string): void} [options.tell] shows why an answer was refused
48
+ * @returns {Promise<object>} the resolved values, one per question
49
+ * @throws {ConfigError} when a flag's value is invalid
50
+ */
51
+ async function resolvePortfolioValues({ given = {}, current, ask = null, tell = () => {} } = {}) {
52
+ const values = {};
53
+ for (const question of QUESTIONS) {
54
+ const flagged = given[question.key];
55
+ if (flagged !== undefined) {
56
+ const problem = question.check(flagged);
57
+ if (problem) throw new ConfigError(`${question.flag} ${problem}`);
58
+ values[question.key] = flagged;
59
+ continue;
60
+ }
61
+ const fallback = current[question.key];
62
+ if (!ask) {
63
+ values[question.key] = fallback;
64
+ continue;
65
+ }
66
+ const prompt = `${question.text} [${fallback}]: `;
67
+ for (;;) {
68
+ const text = (await ask(prompt)).trim();
69
+ if (!text) {
70
+ values[question.key] = fallback;
71
+ break;
72
+ }
73
+ const problem = question.check(text);
74
+ if (!problem) {
75
+ values[question.key] = text;
76
+ break;
77
+ }
78
+ tell(problem);
79
+ }
80
+ }
81
+ return values;
82
+ }
83
+
84
+ /**
85
+ * Writes `values` into apionly.yaml's portfolio section, wholesale for the keys
86
+ * asked about and untouched everywhere else: a hand-set portfolio.openapi.tags,
87
+ * for one, is not something `config portfolio` asked about, so it survives.
88
+ * YAML.parseDocument, never a parse and restringify -- the file's comments, and
89
+ * every value this did not ask about, are not this command's to change.
90
+ *
91
+ * @returns {string} the edited text
92
+ */
93
+ function writePortfolioSection(text, values) {
94
+ const doc = YAML.parseDocument(text);
95
+ doc.setIn(["portfolio", "openapi", "paths"], values.paths);
96
+ doc.setIn(["portfolio", "location"], values.location);
97
+ return doc.toString();
98
+ }
99
+
100
+ module.exports = { resolvePortfolioValues, writePortfolioSection, QUESTIONS };
package/src/config.js CHANGED
@@ -12,8 +12,52 @@ const YAML = require("yaml");
12
12
  const CONFIG_NAME = "apionly.yaml";
13
13
  const SUPPORTED_SCHEMA_VERSION = 1;
14
14
 
15
+ // The keys allowed at the levels of apionly.yaml that have a fixed shape. `channels`
16
+ // and `toolchain` are deliberately absent: a channel's settings depend on its type,
17
+ // and a tool's name is whatever the author calls it, so neither has a closed
18
+ // vocabulary to check against. Everywhere else, a key nothing reads is far more
19
+ // often a typo or a bad indent than a value nobody has used yet -- see checkKeys.
20
+ const ROOT_KEYS = [
21
+ "schemaVersion", "sources", "defaults", "build", "reports", "lint",
22
+ "distribution", "channels", "targets", "toolchain", "portfolio",
23
+ ];
24
+ const SOURCES_KEYS = ["root", "openapi", "asyncapi"];
25
+ const DEFAULTS_KEYS = ["openapi", "asyncapi", "placeholders"];
26
+ const DEFAULTS_KIND_KEYS = { openapi: ["lint", "outputName", "fragmentPaths"], asyncapi: ["lint", "outputName", "fragmentPaths"] };
27
+ const PLACEHOLDERS_KEYS = ["strict"];
28
+ const BUILD_KEYS = ["staging", "dist"];
29
+ const REPORTS_KEYS = ["lint"];
30
+ const LINT_KEYS = ["unreferenced"];
31
+ const DISTRIBUTION_KEYS = ["root", "layout"];
32
+ const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile"];
33
+ const TARGET_KIND_KEYS = ["bundle", "aggregate", "info"];
34
+ const PORTFOLIO_KEYS = ["openapi", "security", "location"];
35
+ const PORTFOLIO_OPENAPI_KEYS = ["paths", "operationIds", "tags"];
36
+
37
+ // What every portfolio setting is when apionly.yaml says nothing: safe for the
38
+ // ordinary case (a gateway routing /<target>/** to each service), and named rather
39
+ // than boolean, so a third strategy can be added later without breaking the schema.
40
+ // Because a portfolio is regenerated on every build, none of this needs a migration
41
+ // when it changes -- the next build simply produces a different portfolio.
42
+ const PORTFOLIO_DEFAULTS = Object.freeze({
43
+ openapi: Object.freeze({ paths: "target-prefix", operationIds: "target-prefix", tags: "reconcile" }),
44
+ security: "push-down",
45
+ location: "portfolios",
46
+ });
47
+ const PATH_STRATEGIES = ["target-prefix", "none"];
48
+
15
49
  class ConfigError extends Error {}
16
50
 
51
+ /** Refuses a key `obj` has that is not in `allowed`, naming the key and where it is. */
52
+ function checkKeys(obj, allowed, where) {
53
+ if (!obj || typeof obj !== "object") return;
54
+ for (const key of Object.keys(obj)) {
55
+ if (!allowed.includes(key)) {
56
+ throw new ConfigError(`unknown key '${key}' in ${where}`);
57
+ }
58
+ }
59
+ }
60
+
17
61
  // Walk up from `startDir` looking for apionly.yaml, so the CLI can be run from
18
62
  // anywhere inside the specification repository.
19
63
  function locate(startDir) {
@@ -57,10 +101,38 @@ function load(configPath) {
57
101
  );
58
102
  }
59
103
 
104
+ checkKeys(parsed, ROOT_KEYS, CONFIG_NAME);
105
+
60
106
  const root = path.dirname(configPath);
61
107
  const sources = parsed.sources || {};
108
+ checkKeys(sources, SOURCES_KEYS, "sources");
62
109
  requireString(sources.root, "sources.root");
63
110
 
111
+ const defaults = parsed.defaults || {};
112
+ checkKeys(defaults, DEFAULTS_KEYS, "defaults");
113
+ for (const kind of ["openapi", "asyncapi"]) {
114
+ if (defaults[kind]) checkKeys(defaults[kind], DEFAULTS_KIND_KEYS[kind], `defaults.${kind}`);
115
+ }
116
+ if (defaults.placeholders) checkKeys(defaults.placeholders, PLACEHOLDERS_KEYS, "defaults.placeholders");
117
+
118
+ const build = parsed.build || {};
119
+ checkKeys(build, BUILD_KEYS, "build");
120
+ const reports = parsed.reports || {};
121
+ checkKeys(reports, REPORTS_KEYS, "reports");
122
+ const lint = parsed.lint || {};
123
+ checkKeys(lint, LINT_KEYS, "lint");
124
+ if (parsed.distribution) checkKeys(parsed.distribution, DISTRIBUTION_KEYS, "distribution");
125
+
126
+ const portfolio = parsed.portfolio || {};
127
+ checkKeys(portfolio, PORTFOLIO_KEYS, "portfolio");
128
+ if (portfolio.openapi) checkKeys(portfolio.openapi, PORTFOLIO_OPENAPI_KEYS, "portfolio.openapi");
129
+ const pathStrategy = portfolio.openapi && portfolio.openapi.paths;
130
+ if (pathStrategy !== undefined && !PATH_STRATEGIES.includes(pathStrategy)) {
131
+ throw new ConfigError(
132
+ `portfolio.openapi.paths must be ${PATH_STRATEGIES.join(" or ")}, not ${JSON.stringify(pathStrategy)}`
133
+ );
134
+ }
135
+
64
136
  const targets = parsed.targets;
65
137
  if (!targets || typeof targets !== "object" || Object.keys(targets).length === 0) {
66
138
  throw new ConfigError(`${configPath} declares no targets`);
@@ -72,14 +144,84 @@ function load(configPath) {
72
144
  if (!target || typeof target !== "object") {
73
145
  throw new ConfigError(`target '${name}' is not a mapping`);
74
146
  }
147
+ checkKeys(target, TARGET_KEYS, `targets.${name}`);
75
148
  const kinds = ["openapi", "asyncapi"].filter((k) => target[k]);
76
149
  if (kinds.length === 0) {
77
150
  throw new ConfigError(
78
151
  `target '${name}' declares neither an openapi nor an asyncapi bundle`
79
152
  );
80
153
  }
154
+
155
+ // A portfolio is a catalogue, never a contract of its own: declaring an
156
+ // aggregate for one kind means the target IS a portfolio, so it must
157
+ // aggregate every kind it declares, and it is never published.
158
+ const aggregateKinds = kinds.filter((k) => target[k].aggregate !== undefined);
159
+ if (aggregateKinds.length > 0 && aggregateKinds.length !== kinds.length) {
160
+ const bundled = kinds.find((k) => target[k].aggregate === undefined);
161
+ throw new ConfigError(
162
+ `target '${name}' aggregates ${aggregateKinds[0]} but has a hand-written bundle for ${bundled}; ` +
163
+ "a target may not mix an aggregate with a hand-written bundle across kinds -- a hand-written " +
164
+ "AsyncAPI portfolio is an ordinary bundle, not an aggregate that happens to look like one"
165
+ );
166
+ }
167
+ if (aggregateKinds.length > 0 && target.publish === true) {
168
+ throw new ConfigError(
169
+ `target '${name}' declares an aggregate; it is never published, whatever publish says. ` +
170
+ "Prefixed operation ids match no member's, transcribed classes would not match a member's " +
171
+ "either, and a portfolio's version answers no useful question, since it moves whenever " +
172
+ "anything anywhere moves. A combined surface that genuinely needs publishing is a bundle " +
173
+ "somebody authors deliberately, with its own owner and version -- author one instead."
174
+ );
175
+ }
176
+
81
177
  for (const kind of kinds) {
82
- requireString(target[kind].bundle, `targets.${name}.${kind}.bundle`);
178
+ checkKeys(target[kind], TARGET_KIND_KEYS, `targets.${name}.${kind}`);
179
+ if (target[kind].aggregate !== undefined) {
180
+ if (target[kind].bundle !== undefined) {
181
+ throw new ConfigError(
182
+ `target '${name}' declares both aggregate and bundle for ${kind}; an aggregate is ` +
183
+ "generated whole, so it needs no hand-written bundle root"
184
+ );
185
+ }
186
+ const members = target[kind].aggregate;
187
+ if (!Array.isArray(members) || members.length === 0) {
188
+ throw new ConfigError(`targets.${name}.${kind}.aggregate must list at least one target`);
189
+ }
190
+ if (members.includes(name)) {
191
+ throw new ConfigError(`target '${name}' aggregates itself`);
192
+ }
193
+ } else {
194
+ requireString(target[kind].bundle, `targets.${name}.${kind}.bundle`);
195
+ if (target[kind].info !== undefined) {
196
+ throw new ConfigError(`targets.${name}.${kind}.info only applies to an aggregate`);
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ // Every member an aggregate lists is checked by name, and a member that is
203
+ // itself an aggregate is refused outright: it has no hand-written bundle for
204
+ // the generator to read, and reading its *generated* one would mean generating
205
+ // aggregates in dependency order, which nothing here attempts. Because an
206
+ // aggregate can only ever reach a hand-written bundle this way, refusing that
207
+ // one step also catches every transitive self-reference, not only the direct
208
+ // one the loop above already refused.
209
+ for (const [name, target] of Object.entries(targets)) {
210
+ for (const kind of ["openapi", "asyncapi"].filter((k) => target[k] && target[k].aggregate !== undefined)) {
211
+ for (const member of target[kind].aggregate) {
212
+ const memberTarget = targets[member];
213
+ if (!memberTarget || memberTarget[kind] === undefined) {
214
+ throw new ConfigError(
215
+ `target '${name}': aggregate member '${member}' declares no ${kind} bundle`
216
+ );
217
+ }
218
+ if (memberTarget[kind].aggregate !== undefined) {
219
+ throw new ConfigError(
220
+ `target '${name}' aggregates '${member}', which is itself an aggregate; ` +
221
+ "nesting is not supported"
222
+ );
223
+ }
224
+ }
83
225
  }
84
226
  }
85
227
 
@@ -88,13 +230,14 @@ function load(configPath) {
88
230
  root,
89
231
  schemaVersion: parsed.schemaVersion,
90
232
  sources,
91
- defaults: parsed.defaults || {},
233
+ defaults,
92
234
  toolchain: parsed.toolchain || {},
93
- build: parsed.build || {},
94
- reports: parsed.reports || {},
95
- lint: parsed.lint || {},
235
+ build,
236
+ reports,
237
+ lint,
96
238
  distribution: parsed.distribution || null,
97
239
  channels: parsed.channels || {},
240
+ portfolio,
98
241
  targets,
99
242
 
100
243
  // --- derived accessors, so callers never re-derive a path themselves ---
@@ -148,19 +291,79 @@ function load(configPath) {
148
291
  targetsFor(kind) {
149
292
  return Object.keys(this.targets).filter((t) => this.targets[t][kind]);
150
293
  },
294
+ // Whether this target is itself an aggregate, for any kind it declares.
295
+ // load() already refuses a target that aggregates one kind and hand-writes
296
+ // another, so any one kind answers for the whole target.
297
+ isAggregate(target) {
298
+ return ["openapi", "asyncapi"].some((k) => this.targets[target][k] && this.targets[target][k].aggregate !== undefined);
299
+ },
151
300
  // A target with `publish: false` is a documentation view rather than a
152
301
  // contract any one project implements: built and linted, never shipped.
302
+ // An aggregate is never published either, whether or not it says so --
303
+ // load() already refuses the one case that would contradict this, `publish:
304
+ // true` alongside an aggregate, so nothing here needs to re-check that.
153
305
  isPublished(target) {
154
- return this.targets[target].publish !== false;
306
+ return this.targets[target].publish !== false && !this.isAggregate(target);
155
307
  },
156
308
  // A target's bundle root in a staged tree: the build's own, unless another
157
- // staged copy is named.
309
+ // staged copy is named. Only a hand-written bundle has one; an aggregate's
310
+ // generated root is aggregatePath(), below.
158
311
  bundlePath(target, kind, stagingRoot = this.stagingRoot(kind)) {
159
312
  return path.join(
160
313
  stagingRoot,
161
314
  requireString(this.sources[kind], `sources.${kind}`),
162
315
  this.targets[target][kind].bundle);
163
316
  },
317
+ // Where an aggregate's generated bundle root lands in a staged tree: outside
318
+ // bundles/, at the same depth, so every $ref a member contributes -- carried
319
+ // over verbatim -- still resolves. The path is the tool's to decide, not the
320
+ // author's: an aggregate declares its members and its info, never where its
321
+ // generated root lands.
322
+ aggregatePath(target, kind, stagingRoot = this.stagingRoot(kind)) {
323
+ return path.join(
324
+ stagingRoot,
325
+ requireString(this.sources[kind], `sources.${kind}`),
326
+ this.portfolioLocation(),
327
+ `${target}_${kind}_structure.yaml`);
328
+ },
329
+ // Where a target's document root sits in a staged tree, hand-written or
330
+ // generated -- whichever this target is.
331
+ bundleRootPath(target, kind, stagingRoot = this.stagingRoot(kind)) {
332
+ return this.isAggregate(target)
333
+ ? this.aggregatePath(target, kind, stagingRoot)
334
+ : this.bundlePath(target, kind, stagingRoot);
335
+ },
336
+ // The path prefix strategy an OpenAPI portfolio merges its members' paths
337
+ // and operation ids with: `target-prefix` (the default) for a gateway
338
+ // routing /<target>/** to each service, `none` where routing is by host and
339
+ // a prefixed path would exist nowhere.
340
+ portfolioPathStrategy() {
341
+ return (this.portfolio.openapi && this.portfolio.openapi.paths) || PORTFOLIO_DEFAULTS.openapi.paths;
342
+ },
343
+ // How a portfolio's operation ids are told apart: currently always prefixed
344
+ // with the target, the same way paths are -- named, like every portfolio
345
+ // setting, so a second strategy can be added later without a breaking change.
346
+ portfolioOperationIdStrategy() {
347
+ return (this.portfolio.openapi && this.portfolio.openapi.operationIds) || PORTFOLIO_DEFAULTS.openapi.operationIds;
348
+ },
349
+ // How two members contributing the same tag name are merged: `reconcile`,
350
+ // today the only mode -- an identical body merges silently, a differing one
351
+ // merges too, reported, with the first contributing member's body winning.
352
+ portfolioTagStrategy() {
353
+ return (this.portfolio.openapi && this.portfolio.openapi.tags) || PORTFOLIO_DEFAULTS.openapi.tags;
354
+ },
355
+ // How a portfolio merges root-level `security`: `push-down`, today the only
356
+ // mode -- each member's root security becomes each of its own contributed
357
+ // operations' security, so the portfolio never has to say something false
358
+ // about any one of them. See the Security section of the README.
359
+ portfolioSecurityStrategy() {
360
+ return this.portfolio.security || PORTFOLIO_DEFAULTS.security;
361
+ },
362
+ // The directory an aggregate's generated bundle root lands in, sibling to
363
+ // bundles/, at the same depth under each kind's own source tree.
364
+ portfolioLocation() {
365
+ return this.portfolio.location || PORTFOLIO_DEFAULTS.location;
366
+ },
164
367
  // Whether built documents of this kind carry x-fragment-path: on each
165
368
  // component of an OpenAPI document, on each fragment of an AsyncAPI one.
166
369
  // On unless turned off, for both kinds.
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+
3
+ // Additively completing an apionly.yaml that already exists.
4
+ //
5
+ // `init` creates a file that is not there; this is the same rule applied to
6
+ // configuration. When a kind's support is missing from an existing apionly.yaml --
7
+ // in whole, because the library never had it, or in part, because a run was
8
+ // interrupted between writing the fragments and writing the config for them -- the
9
+ // slots it needs are added. What is already there, however it reads, is never
10
+ // touched: a `defaults.openapi` block with no `lint` is a choice, not an absence.
11
+ //
12
+ // The unit added is a *slot*: one of the handful of places a kind's support in
13
+ // apionly.yaml lives -- never a key inside one, which is why `slots()` below stops
14
+ // at the map a kind occupies and goes no further into it.
15
+
16
+ const YAML = require("yaml");
17
+
18
+ /** Where a kind's tool is pinned in `toolchain`; the two kinds do not share a key name. */
19
+ const TOOLCHAIN_KEY = { openapi: "redocly", asyncapi: "asyncapi" };
20
+ const TOOLCHAIN_PIN = { openapi: "@redocly/cli@2.52.0", asyncapi: "@asyncapi/cli@6.0.2" };
21
+
22
+ /**
23
+ * The slots scaffold(v) would fill for one kind, in the order config(v) declares them,
24
+ * each naming the sibling keys -- in the order to prefer -- a slot goes before when one
25
+ * of them is already there. `openapi` always precedes `asyncapi`, in every section that
26
+ * holds both; `asyncapi` precedes `placeholders`, the one section-closing key that is
27
+ * always there.
28
+ */
29
+ function slotsFor(kind, v) {
30
+ const target = v.target;
31
+ if (kind === "openapi") {
32
+ return [
33
+ { path: ["sources"], key: "openapi", value: "openapi", before: ["asyncapi"] },
34
+ {
35
+ path: ["defaults"], key: "openapi", before: ["asyncapi", "placeholders"],
36
+ value: { lint: ".redocly.yaml", outputName: "openapi.yaml" },
37
+ },
38
+ { path: ["toolchain"], key: "redocly", value: TOOLCHAIN_PIN.openapi, before: ["asyncapi"] },
39
+ {
40
+ path: ["targets", target], key: "openapi", before: ["asyncapi"],
41
+ value: { bundle: `bundles/${target}_openapi_structure.yaml` },
42
+ },
43
+ ];
44
+ }
45
+ return [
46
+ { path: ["sources"], key: "asyncapi", value: "asyncapi", before: [] },
47
+ { path: ["defaults"], key: "asyncapi", value: { outputName: "asyncapi.yaml" }, before: ["placeholders"] },
48
+ { path: ["toolchain"], key: "asyncapi", value: TOOLCHAIN_PIN.asyncapi, before: [] },
49
+ {
50
+ path: ["targets", target], key: "asyncapi", before: [],
51
+ value: { bundle: `bundles/${target}_asyncapi_structure.yaml` },
52
+ },
53
+ ];
54
+ }
55
+
56
+ /** A slot's YAML path, dot-separated, as the report names it. */
57
+ function slotName(slot) {
58
+ return [...slot.path, slot.key].join(".");
59
+ }
60
+
61
+ /**
62
+ * Sets `key: value` in the map at `parentPath`, before the first of `before` that is
63
+ * already a sibling there, or at the end when none is. Creates `parentPath` itself,
64
+ * as `Document#setIn` does, when it is not there yet -- there being nothing in a map
65
+ * that does not exist yet to come before.
66
+ */
67
+ function place(doc, parentPath, key, value, before) {
68
+ const parent = doc.getIn(parentPath, true);
69
+ if (YAML.isMap(parent)) {
70
+ for (const candidate of before) {
71
+ const sibling = parent.items.find((pair) => String(pair.key) === candidate);
72
+ if (sibling) {
73
+ parent.items.splice(parent.items.indexOf(sibling), 0, doc.createPair(key, value));
74
+ return;
75
+ }
76
+ }
77
+ }
78
+ doc.setIn([...parentPath, key], value);
79
+ }
80
+
81
+ /**
82
+ * Adds to `text` -- an existing apionly.yaml -- whatever configuration slot
83
+ * scaffold(values) would write and this file lacks. Every value, key and comment
84
+ * already there survives unchanged; a slot already present, however it reads, is
85
+ * never touched, and neither is anything inside it.
86
+ *
87
+ * `portfolio`, unlike a kind, is never added on its own account: whether a
88
+ * library is about to have more than one target, and so whether the question is
89
+ * even asked, is the caller's decision, made once, before this runs -- give the
90
+ * strategies it resolved here to have the section written with them, or leave
91
+ * this out entirely to leave the section alone, present or not.
92
+ *
93
+ * @param {string} text apionly.yaml, as it is on disk
94
+ * @param {object} v the values scaffold(values) was given, resolved: every value
95
+ * DEFAULTS has, `values`' own where it gives one -- the shape scaffold() itself
96
+ * works from, so a caller that already has that object need not rebuild it
97
+ * @param {{paths: string, location: string}} [portfolio] the portfolio section to
98
+ * add, with every strategy resolved, when it is missing; omitted, the section
99
+ * is never considered at all, whether or not the file already has one
100
+ * @returns {{text: string, added: string[]}} the edited text, and the slots added, in
101
+ * the order scaffold() declares them, portfolio last; `added` is empty, and
102
+ * `text` is `text` itself, when nothing was missing.
103
+ */
104
+ function addMissingConfig(text, v, portfolio) {
105
+ const doc = YAML.parseDocument(text);
106
+ const added = [];
107
+
108
+ // Not every file that differs is a library with a kind missing. One with a YAML
109
+ // error cannot be edited at all -- parseDocument does not throw on one, it just
110
+ // records it, and a document with errors refuses to be stringified back. One that
111
+ // does not even declare sources.root, whatever it parses to, is not an apionly.yaml
112
+ // this can complete either way; force, or a person, is what either file needs.
113
+ if (doc.errors.length > 0 || !doc.hasIn(["sources", "root"])) return { text, added };
114
+
115
+ for (const kind of ["openapi", "asyncapi"].filter((k) => v.kinds.includes(k))) {
116
+ for (const slot of slotsFor(kind, v)) {
117
+ if (doc.hasIn([...slot.path, slot.key])) continue;
118
+ place(doc, slot.path, slot.key, slot.value, slot.before);
119
+ added.push(slotName(slot));
120
+ }
121
+ }
122
+
123
+ if (portfolio && !doc.hasIn(["portfolio"])) {
124
+ doc.setIn(["portfolio"], {
125
+ openapi: { paths: portfolio.paths, operationIds: "target-prefix", tags: "reconcile" },
126
+ security: "push-down",
127
+ location: portfolio.location,
128
+ });
129
+ added.push("portfolio");
130
+ }
131
+
132
+ return added.length === 0 ? { text, added } : { text: doc.toString(), added };
133
+ }
134
+
135
+ module.exports = { addMissingConfig };
package/src/init.js CHANGED
@@ -15,6 +15,7 @@
15
15
  const fs = require("fs");
16
16
  const path = require("path");
17
17
  const YAML = require("yaml");
18
+ const { addMissingConfig } = require("./init-config");
18
19
 
19
20
  /** Every value the scaffold is written from, as it is when nobody chooses otherwise. */
20
21
  const DEFAULTS = Object.freeze({
@@ -307,17 +308,39 @@ function normalised(text) {
307
308
  return text.replace(/\r\n?/g, "\n").replace(/\n+$/, "");
308
309
  }
309
310
 
311
+ const CONFIG_FILE = "apionly.yaml";
312
+
310
313
  /**
311
314
  * What writing these files would do to each: create it, leave it because it is
312
315
  * identical, or find it different from what would be written.
313
316
  *
314
- * @returns {{rel: string, status: "missing"|"identical"|"differs"}[]}
317
+ * apionly.yaml gets one more thing tried, ahead of "differs": whatever slot
318
+ * scaffold(values) would write and it lacks is added, in place, before the
319
+ * comparison that decides "identical" or "differs" -- so a file that is missing only
320
+ * a kind it never had is never reported as differing, and neither is one that is
321
+ * missing nothing at all. `values`, resolved as scaffold() resolves it, is what that
322
+ * completion is measured against; without it, apionly.yaml is compared as every
323
+ * other file is.
324
+ *
325
+ * @param {{paths: string, location: string}} [portfolio] the portfolio section to
326
+ * add when apionly.yaml lacks one; see addMissingConfig for what it does with it
327
+ * @returns {{rel: string, status: "missing"|"identical"|"differs", added?: string[], text?: string}[]}
328
+ * `added` and `text` -- the completed content -- are there only for apionly.yaml,
329
+ * and only when something was missing from it.
315
330
  */
316
- function plan(targetDir, files) {
331
+ function plan(targetDir, files, values, portfolio) {
317
332
  return Object.entries(files).map(([rel, content]) => {
318
333
  const file = path.join(targetDir, rel);
319
334
  if (!fs.existsSync(file)) return { rel, status: "missing" };
320
- const same = normalised(fs.readFileSync(file, "utf8")) === normalised(content);
335
+ const onDisk = fs.readFileSync(file, "utf8");
336
+ if (rel === CONFIG_FILE && values) {
337
+ const completed = addMissingConfig(onDisk, { ...DEFAULTS, ...values }, portfolio);
338
+ if (completed.added.length > 0) {
339
+ const status = normalised(completed.text) === normalised(content) ? "identical" : "differs";
340
+ return { rel, status, added: completed.added, text: completed.text };
341
+ }
342
+ }
343
+ const same = normalised(onDisk) === normalised(content);
321
344
  return { rel, status: same ? "identical" : "differs" };
322
345
  });
323
346
  }
@@ -327,13 +350,31 @@ function plan(targetDir, files) {
327
350
  *
328
351
  * A file that is not there is created. One that is there already is left alone,
329
352
  * reported as identical to what would have been written or as differing from it;
330
- * with `force`, one that differs is overwritten.
353
+ * with `force`, one that differs is overwritten, wholesale, as if this were the
354
+ * first time -- apionly.yaml included, whatever else it declares.
355
+ *
356
+ * Short of `force`, apionly.yaml gets one more chance: whatever configuration a
357
+ * missing kind needs and it lacks is added to it, leaving every value, key and
358
+ * comment already there exactly as it was. A kind already declared, however it
359
+ * reads, is never touched -- what is already there is not changed, only what is
360
+ * not there is added.
361
+ *
362
+ * `portfolio`, given, is added the same way, as one more slot: whether the
363
+ * library is about to have more than one target -- and so whether this is worth
364
+ * asking about at all -- is decided before init is called, not here.
331
365
  */
332
- function init(targetDir, { values = DEFAULTS, force = false, log = () => {} } = {}) {
366
+ function init(targetDir, { values = DEFAULTS, force = false, log = () => {}, portfolio = null } = {}) {
333
367
  const files = scaffold(values);
334
- const report = { created: [], overwritten: [], identical: [], differing: [] };
335
-
336
- for (const { rel, status } of plan(targetDir, files)) {
368
+ const report = { created: [], overwritten: [], identical: [], differing: [], updated: [] };
369
+ const updates = [];
370
+
371
+ for (const { rel, status, added, text } of plan(targetDir, files, values, portfolio)) {
372
+ if (added && !force) {
373
+ fs.writeFileSync(path.join(targetDir, rel), text);
374
+ report.updated.push(rel);
375
+ updates.push({ rel, added });
376
+ continue;
377
+ }
337
378
  if (status === "identical") {
338
379
  report.identical.push(rel);
339
380
  continue;
@@ -350,6 +391,7 @@ function init(targetDir, { values = DEFAULTS, force = false, log = () => {} } =
350
391
 
351
392
  for (const rel of report.created) log(` created ${rel}`);
352
393
  for (const rel of report.overwritten) log(` overwrote ${rel}`);
394
+ for (const { rel, added } of updates) log(` updated ${rel} (added ${added.join(", ")})`);
353
395
  for (const rel of report.identical) log(` identical ${rel}`);
354
396
  for (const rel of report.differing) log(` differs ${rel} (left alone; --force overwrites)`);
355
397
  return { ...report, skipped: [...report.identical, ...report.differing] };