@arc-e-tect/api-only-publisher 0.6.2 → 0.7.1

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/README.adoc CHANGED
@@ -6,7 +6,7 @@
6
6
  :source-highlighter: rouge
7
7
  // The released version of this component: its snippets use it. The release
8
8
  // workflow updates it; do not change it by hand.
9
- :api-only-publisher-version: 0.6.2
9
+ :api-only-publisher-version: 0.7.1
10
10
 
11
11
  image:https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/actions/workflows/nvd-cache-refresh.yml/badge.svg[Vulnerability Scan,link=https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/actions/workflows/nvd-cache-refresh.yml]
12
12
  image:https://img.shields.io/npm/v/@arc-e-tect/api-only-publisher[npm,link=https://www.npmjs.com/package/@arc-e-tect/api-only-publisher]
@@ -42,6 +42,8 @@ It is the producer half of a pair.
42
42
  The consumer half, the link:../api-only-subscriber/README.adoc[API-Only Subscriber →], fetches a published document into an implementation project and verifies that it has not drifted.
43
43
  The two are coupled only by the published artifact and its manifest; neither knows anything else about the other.
44
44
 
45
+ The link:../api-only-transcriberj/README.adoc[API-Only TranscriberJ →] builds on what the Subscriber fetched: it generates Java classes for contract tests from the published document, named after the fragments the Publisher stamps into it with `x-fragment-path`.
46
+
45
47
  A *target* is whatever gets its own contract document: a microservice, a modular monolith, a backend-for-frontend, or a documentation aggregate spanning all of them.
46
48
  It need not be a deployable, which is why this is not called `services`.
47
49
 
@@ -136,8 +138,9 @@ However the Publisher itself is started, it fetches them with `npx`, at the vers
136
138
  |===
137
139
  |Command |Purpose
138
140
 
139
- |`init [dir] [--force]`
140
- |Scaffold `apionly.yaml` and a reference directory layout that builds as it stands.
141
+ |`init [dir] [--yes] [--force] [value flags]`
142
+ |Scaffold `apionly.yaml` and a reference directory layout that builds as it stands: OpenAPI, AsyncAPI or both.
143
+ At a terminal it asks for each value first, as <<init,Scaffolding a library §>> describes.
141
144
 
142
145
  |`build [--target <t>]... [--pre-release <ids>] [--openapi\|--asyncapi]`
143
146
  |Stage, substitute placeholders, bundle, stamp each published target's version, and lint.
@@ -178,6 +181,131 @@ npm run apionly -- publish --target orders --pre-release rc.1
178
181
  npm run apionly -- changed --since origin/main --quiet
179
182
  ----
180
183
 
184
+ [#init]
185
+ == Scaffolding a library
186
+
187
+ `init` writes a library that builds, lints and publishes as it stands, from a handful of values: which kinds of document it holds, its target's name, the API's title and so on.
188
+
189
+ [source,console,subs="attributes+"]
190
+ ----
191
+ npx @arc-e-tect/api-only-publisher@{api-only-publisher-version} init my-api-library
192
+ ----
193
+
194
+ At a terminal it asks for each value, showing the default in brackets; Enter takes it.
195
+ An answer that would break the build later is refused, and the question asked again.
196
+
197
+ [source,text]
198
+ ----
199
+ Kinds of document: openapi, asyncapi or both [openapi]: both
200
+ Target name [example-service]: Customer_Orders
201
+ 'Customer_Orders' is not a target name: use lowercase letters and digits, in words joined by single hyphens, starting with a letter, such as customer-orders
202
+ Target name [example-service]: customer-orders
203
+ API title [Example API]: Customer Orders API
204
+ Initial contract version [0.1.0]:
205
+ ...
206
+ ----
207
+
208
+ Where nobody is watching -- no terminal, as in CI, a script or an AI assistant -- it asks nothing and takes the defaults, as it always has.
209
+ `--yes` (or `-y`) does the same at a terminal.
210
+
211
+ [cols="2,2,3", options="header"]
212
+ |===
213
+ |Question |Flag |Default, and what is accepted
214
+
215
+ |Kinds of document
216
+ |`--openapi`, `--asyncapi`; both flags for both
217
+ |`openapi`. The library's trees and targets follow from it.
218
+
219
+ |Target name
220
+ |`--target <name>`
221
+ |`example-service`. Lowercase kebab-case: a lowercase letter first, then lowercase letters and digits, in words joined by single hyphens -- `orders`, `customer-orders`, `orders-v2`.
222
+ The name becomes file names, a properties key, a Maven artifactId, a git tag and, through the Subscriber, a Gradle task name, which treats `-`, `_` and `.` alike; one separator keeps two targets from claiming one task.
223
+
224
+ |API title
225
+ |`--title <text>`
226
+ |`Example API`. Any text.
227
+
228
+ |Initial contract version
229
+ |`--contract-version <version>`
230
+ |`0.1.0`. A semantic release version, not a pre-release: it goes in the target's version file.
231
+
232
+ |Contact name
233
+ |`--contact-name <text>`
234
+ |`Example Team`.
235
+
236
+ |Contact URL
237
+ |`--contact-url <url>`
238
+ |`https://example.invalid`. An http or https URL.
239
+
240
+ |Licence
241
+ |`--license <spdx-id>`
242
+ |`Apache-2.0`. An SPDX identifier; the URL of a well-known licence follows from it.
243
+
244
+ |Licence URL
245
+ |`--license-url <url>`
246
+ |Asked only for a licence whose URL the tool does not know, and then required.
247
+
248
+ |Production server URL
249
+ |`--server-url <url>`
250
+ |`https://api.example.invalid`. Asked only for a library with OpenAPI.
251
+
252
+ |Broker host
253
+ |`--broker-host <host[:port]>`
254
+ |`kafka:9092`. Asked only for a library with AsyncAPI.
255
+ |===
256
+
257
+ A flag always wins: its question is not asked, and an invalid value, or a flag for a kind the library does not hold, stops `init` before it writes anything.
258
+
259
+ What each kind of library gets::
260
+ * *OpenAPI*: the OpenAPI tree under `specs/openapi/`, its target, and `.redocly.yaml`, the lint rules Redocly applies.
261
+ `apionly.yaml` holds no `asyncapi` keys.
262
+ * *AsyncAPI*: the AsyncAPI tree under `specs/asyncapi/` -- a channel, a message and its payload -- and its target.
263
+ `apionly.yaml` holds no `openapi` keys, and there is no `.redocly.yaml` or Redocly pin: an AsyncAPI document is bundled and validated with the AsyncAPI CLI alone.
264
+ * *Both*: both trees, one target building both documents, and one schema they share: `specs/openapi/components/common/schemas/ExampleIdV1.yaml`, which the HTTP response and the event payload both refer to.
265
+ That is the library's convention in miniature -- a definition used by both protocols is one fragment -- and why the whole source root is staged together (<<what-build-does,What `build` does §>>).
266
+
267
+ What it reports::
268
+ `init` never overwrites what is there unless told to, and says for every file what it did:
269
+ +
270
+ [horizontal]
271
+ `created`:: The file was not there, and is now.
272
+ `identical`:: The file was there, and matches what `init` would write, ignoring line endings and final newlines.
273
+ `updated`:: `apionly.yaml` was there, and lacked a kind's configuration; it does now.
274
+ The message names every slot added -- see <<init-additive,Adding a kind to an existing library §>>.
275
+ `differs`:: The file was there, and does not match: where the library has diverged from the scaffold, usually on purpose.
276
+ It is left alone.
277
+ `overwrote`:: The file differed, and `--force` replaced it.
278
+ +
279
+ At a terminal, `--force` first lists the files that still differ -- after any additive update apionly.yaml already got -- and asks once whether to overwrite them; without a terminal, or with `--yes`, it overwrites them without asking.
280
+
281
+ [#init-additive]
282
+ === Adding a kind to an existing library
283
+
284
+ Running `init` again, with a kind the library did not have, is how a library grows: the fragments the new kind needs, and the configuration that makes the build see them.
285
+
286
+ *Files* stay create-only, as everywhere else in `init`: a fragment not written before is written now; one already there, however it now reads, is left alone -- even where the new kind changes what the scaffold would write for it, such as `specs/openapi/paths/example/ExamplesV1.yaml` once an AsyncAPI event comes to share its schema.
287
+
288
+ *Configuration* is additive: whichever of `sources.<kind>`, `defaults.<kind>`, the kind's pin in `toolchain`, and `targets.<target>.<kind>` `apionly.yaml` lacks is added to it, in the position a library scaffolded with both kinds from the start would have it in.
289
+ Every value, key and comment already in the file survives untouched, and a slot that is already there, however it reads, is never touched either -- not even to add something missing from *inside* it: a `defaults.openapi` with no `lint` is a choice, and `init` leaves it that way.
290
+
291
+ [source,console]
292
+ ----
293
+ $ api-only-publisher init . --yes --openapi --target orders
294
+ created apionly.yaml
295
+ created specs/openapi/...
296
+
297
+ $ api-only-publisher init . --yes --openapi --asyncapi --target orders
298
+ created specs/asyncapi/...
299
+ updated apionly.yaml (added sources.asyncapi, defaults.asyncapi, toolchain.asyncapi, targets.orders.asyncapi)
300
+ differs specs/openapi/paths/example/ExamplesV1.yaml (left alone; --force overwrites)
301
+ ----
302
+
303
+ Nothing is asked before completing `apionly.yaml`: unlike `--force`, an additive update never discards anything, so there is nothing to confirm.
304
+ Run `init` again with the same kinds, and it reports nothing left to add.
305
+
306
+ `--force` is the other tool for an apionly.yaml that differs, and it does the opposite of the additive update: it reinitialises the file wholesale, exactly as a first run with these values would write it -- any target or key it holds that these values would not produce is gone, additive or not.
307
+ Use it to discard a customisation on purpose; use a plain `init` to add a kind without touching anything else.
308
+
181
309
  [#what-build-does]
182
310
  == What `build` does
183
311
 
@@ -435,7 +563,7 @@ Every target's document of one kind has the same `outputName`; keep the defaults
435
563
  === Where bundle roots go
436
564
 
437
565
  Put every bundle root in a `bundles/` directory of its kind's source directory, named `<target>_<kind>_structure.yaml`: `specs/openapi/bundles/orders_openapi_structure.yaml` and `specs/asyncapi/bundles/orders_asyncapi_structure.yaml`.
438
- The `init` scaffold lays out its OpenAPI bundle root this way.
566
+ The `init` scaffold lays out its bundle roots this way, for each kind it writes.
439
567
 
440
568
  * A bundle root is a table of contents rather than a fragment, and a `bundles/` directory keeps the two apart.
441
569
  * The name says which target and which kind, so the two roots of one target are never confused, in the tree or in a review.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "Builds, packs and publishes API description documents from a library of reusable fragments.",
5
5
  "license": "MIT",
6
6
  "author": "Arc-E-Tect",
package/src/cli.js CHANGED
@@ -5,7 +5,8 @@ const path = require("path");
5
5
 
6
6
  const { loadFrom, ConfigError } = require("./config");
7
7
  const { build, prepare, BuildError } = require("./pipeline");
8
- const { init } = require("./init");
8
+ const { init, scaffold, plan } = require("./init");
9
+ const { resolveValues } = require("./init-questions");
9
10
  const { PlaceholderError } = require("./placeholders");
10
11
  const { VersionError } = require("./version");
11
12
  const { forTargets, ClosureError } = require("./closure");
@@ -20,7 +21,11 @@ const { unreferenced } = require("./unreferenced");
20
21
  const USAGE = `api-only-publisher -- build and distribute API description documents
21
22
 
22
23
  Usage:
23
- api-only-publisher init [dir] [--force]
24
+ api-only-publisher init [dir] [--yes] [--force] [--openapi] [--asyncapi] [--target <name>]
25
+ [--title <text>] [--contract-version <version>]
26
+ [--contact-name <text>] [--contact-url <url>]
27
+ [--license <spdx-id>] [--license-url <url>]
28
+ [--server-url <url>] [--broker-host <host[:port]>]
24
29
  api-only-publisher build [--target <name>]... [--pre-release <ids>] [--openapi|--asyncapi]
25
30
  api-only-publisher lint [--target <name>]...
26
31
  api-only-publisher targets
@@ -37,7 +42,16 @@ Options:
37
42
  rc.1, to each target's version.
38
43
  --openapi Only build OpenAPI documents.
39
44
  --asyncapi Only build AsyncAPI documents.
40
- --force init only: overwrite files that already exist.
45
+ --force init only: overwrite files that differ from the scaffold;
46
+ 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.
49
+ --openapi, --asyncapi
50
+ init: the kinds of document the library holds; both flags for both.
51
+ --target <name> init: the target's name, in lowercase kebab-case.
52
+ --title, --contract-version, --contact-name, --contact-url, --license,
53
+ --license-url, --server-url, --broker-host
54
+ init only: the value of that question; a flag always wins.
41
55
  --since <ref> changed only: the git ref to compare the working tree against.
42
56
  --out <dir> pack/publish/split: where to write.
43
57
  --channel <name> publish only: repeat for several. Default: every configured channel.
@@ -55,6 +69,12 @@ function parseArgs(argv) {
55
69
  const options = {
56
70
  targets: [], kinds: null, preRelease: null, quiet: false, force: false,
57
71
  dir: process.cwd(), since: null, out: null, channels: null, by: "kind",
72
+ yes: false, init: {},
73
+ };
74
+ const INIT_VALUES = {
75
+ "--title": "title", "--contract-version": "contractVersion", "--contact-name": "contactName",
76
+ "--contact-url": "contactUrl", "--license": "license", "--license-url": "licenseUrl",
77
+ "--server-url": "serverUrl", "--broker-host": "brokerHost",
58
78
  };
59
79
  const positional = [];
60
80
 
@@ -83,7 +103,12 @@ function parseArgs(argv) {
83
103
  case "-C": options.dir = path.resolve(next()); break;
84
104
  case "-q": case "--quiet": options.quiet = true; break;
85
105
  case "-h": case "--help": options.help = true; break;
106
+ case "-y": case "--yes": options.yes = true; break;
86
107
  default:
108
+ if (INIT_VALUES[arg]) {
109
+ options.init[INIT_VALUES[arg]] = next();
110
+ break;
111
+ }
87
112
  if (arg.startsWith("-")) throw new ConfigError(`unrecognized option '${arg}'`);
88
113
  positional.push(arg);
89
114
  }
@@ -106,7 +131,59 @@ function versionsOf(config, names, preRelease) {
106
131
  return new Map(names.map((target) => [target, versionOf(config, target, { preRelease })]));
107
132
  }
108
133
 
109
- async function main(argv) {
134
+ /** The kinds --openapi and --asyncapi name, in the order the scaffold writes them. */
135
+ function initKinds(kinds) {
136
+ return kinds ? ["openapi", "asyncapi"].filter((kind) => kinds.includes(kind)) : undefined;
137
+ }
138
+
139
+ /**
140
+ * Scaffolds a library. At a terminal, and without --yes, it asks for every value no
141
+ * flag gives; anywhere else it takes the defaults, as it always has.
142
+ *
143
+ * @param {object} io where a terminal is: `interactive`, and the `input` and `output`
144
+ * streams to ask on; process.stdin and process.stdout unless the caller says otherwise
145
+ */
146
+ async function runInit(options, positional, io, log) {
147
+ if (options.targets.length > 1) {
148
+ throw new ConfigError(`init scaffolds one target; --target was given ${options.targets.length} times`);
149
+ }
150
+ const dir = path.resolve(options.dir, positional[1] || ".");
151
+ const input = io.input || process.stdin;
152
+ const output = io.output || process.stdout;
153
+ const interactive = io.interactive !== undefined ? io.interactive : Boolean(input.isTTY && output.isTTY);
154
+ const given = { ...options.init, target: options.targets[0], kinds: initKinds(options.kinds) };
155
+ for (const key of Object.keys(given)) if (given[key] === undefined) delete given[key];
156
+
157
+ const terminal = interactive && !options.yes
158
+ ? require("node:readline/promises").createInterface({ input, output })
159
+ : null;
160
+ try {
161
+ const values = await resolveValues({
162
+ given,
163
+ ask: terminal && ((question) => terminal.question(question)),
164
+ tell: (message) => output.write(`${message}\n`),
165
+ });
166
+ log(`Scaffolding a specification library in ${dir}`);
167
+ let force = options.force;
168
+ if (terminal && force) {
169
+ // Asked, --force means "after showing me": it overwrites what differs only
170
+ // once the list has been seen and agreed to.
171
+ const differing = plan(dir, scaffold(values), values).filter((entry) => entry.status === "differs");
172
+ if (differing.length > 0) {
173
+ output.write(`These files differ from the scaffold:\n${differing.map((d) => ` ${d.rel}\n`).join("")}`);
174
+ const answer = await terminal.question(`Overwrite these ${differing.length} file(s)? [y/N] `);
175
+ force = /^y(es)?$/i.test(answer.trim());
176
+ }
177
+ }
178
+ init(dir, { values, force, log });
179
+ log(`\nNext: api-only-publisher build -C ${dir}`);
180
+ } finally {
181
+ if (terminal) terminal.close();
182
+ }
183
+ return 0;
184
+ }
185
+
186
+ async function main(argv, io = {}) {
110
187
  const { options, positional } = parseArgs(argv);
111
188
  const command = positional[0];
112
189
 
@@ -117,13 +194,7 @@ async function main(argv) {
117
194
 
118
195
  const log = options.quiet ? () => {} : (message) => console.log(message);
119
196
 
120
- if (command === "init") {
121
- const dir = path.resolve(options.dir, positional[1] || ".");
122
- log(`Scaffolding a specification library in ${dir}`);
123
- init(dir, { force: options.force, log });
124
- log(`\nNext: api-only-publisher build -C ${dir}`);
125
- return 0;
126
- }
197
+ if (command === "init") return runInit(options, positional, io, log);
127
198
 
128
199
  const config = loadFrom(options.dir);
129
200
  const targets = options.targets.length > 0 ? options.targets : null;
@@ -0,0 +1,115 @@
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
+ * @param {string} text apionly.yaml, as it is on disk
88
+ * @param {object} v the values scaffold(values) was given, resolved: every value
89
+ * DEFAULTS has, `values`' own where it gives one -- the shape scaffold() itself
90
+ * works from, so a caller that already has that object need not rebuild it
91
+ * @returns {{text: string, added: string[]}} the edited text, and the slots added, in
92
+ * the order scaffold() declares them; `added` is empty, and `text` is `text`
93
+ * itself, when nothing was missing.
94
+ */
95
+ function addMissingConfig(text, v) {
96
+ const doc = YAML.parseDocument(text);
97
+ const added = [];
98
+
99
+ // Not every file that differs is a library with a kind missing -- one that does not
100
+ // even declare sources.root is not an apionly.yaml this can complete, whatever it
101
+ // holds instead; force, or a person, is what that file needs.
102
+ if (!doc.hasIn(["sources", "root"])) return { text, added };
103
+
104
+ for (const kind of ["openapi", "asyncapi"].filter((k) => v.kinds.includes(k))) {
105
+ for (const slot of slotsFor(kind, v)) {
106
+ if (doc.hasIn([...slot.path, slot.key])) continue;
107
+ place(doc, slot.path, slot.key, slot.value, slot.before);
108
+ added.push(slotName(slot));
109
+ }
110
+ }
111
+
112
+ return added.length === 0 ? { text, added } : { text: doc.toString(), added };
113
+ }
114
+
115
+ module.exports = { addMissingConfig };
@@ -0,0 +1,186 @@
1
+ "use strict";
2
+
3
+ // The values `init` scaffolds from: asked for at a terminal, taken from flags, or
4
+ // defaulted -- in that order of precedence, a flag always winning.
5
+ //
6
+ // Nothing here reads input. The caller hands in `ask`, a function that shows a
7
+ // question and resolves to the answer, which is how the CLI connects a terminal
8
+ // and how the tests connect a script; without one, nobody is asked.
9
+
10
+ const { DEFAULTS } = require("./init");
11
+ const { ConfigError } = require("./config");
12
+ const { parse } = require("./version-policy");
13
+
14
+ /** SPDX identifiers whose licence text has a well-known URL, so it need not be asked for. */
15
+ const LICENSE_URLS = Object.freeze({
16
+ "Apache-2.0": "https://www.apache.org/licenses/LICENSE-2.0.html",
17
+ "MIT": "https://opensource.org/license/mit",
18
+ "BSD-2-Clause": "https://opensource.org/license/bsd-2-clause",
19
+ "BSD-3-Clause": "https://opensource.org/license/bsd-3-clause",
20
+ "MPL-2.0": "https://www.mozilla.org/en-US/MPL/2.0/",
21
+ "EPL-2.0": "https://www.eclipse.org/legal/epl-2.0/",
22
+ "GPL-3.0-only": "https://www.gnu.org/licenses/gpl-3.0.html",
23
+ "LGPL-3.0-only": "https://www.gnu.org/licenses/lgpl-3.0.html",
24
+ });
25
+
26
+ // A target name becomes a file name, a properties key, a Maven artifactId, a git tag
27
+ // and, through the Subscriber, a Gradle task name -- which treats -, _ and . alike, so
28
+ // only one of them may separate words, or two targets could claim one task.
29
+ const TARGET = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
30
+
31
+ function checkTarget(value) {
32
+ return TARGET.test(value) ? null
33
+ : "use lowercase letters and digits, in words joined by single hyphens, starting with a letter, " +
34
+ "such as customer-orders";
35
+ }
36
+
37
+ function checkVersion(value) {
38
+ let version;
39
+ try {
40
+ version = parse(value);
41
+ } catch (error) {
42
+ return "it is not a semantic version, such as 0.1.0";
43
+ }
44
+ return version.prerelease ? "a contract starts at a release version, not a pre-release" : null;
45
+ }
46
+
47
+ function checkUrl(value) {
48
+ try {
49
+ const url = new URL(value);
50
+ if (url.protocol === "http:" || url.protocol === "https:") return null;
51
+ } catch (error) {
52
+ // Not a URL at all; reported below like any other.
53
+ }
54
+ return "it is not an http or https URL";
55
+ }
56
+
57
+ function checkHost(value) {
58
+ return /^[A-Za-z0-9.-]+(:\d{1,5})?$/.test(value) ? null : "give a host, optionally with a port, such as kafka:9092";
59
+ }
60
+
61
+ function checkText(value) {
62
+ return value.trim() ? null : "it may not be empty";
63
+ }
64
+
65
+ /** The kinds a library holds, from what someone typed; null when it names none. */
66
+ function parseKinds(text) {
67
+ switch (text.trim().toLowerCase()) {
68
+ case "openapi": return ["openapi"];
69
+ case "asyncapi": return ["asyncapi"];
70
+ case "both": return ["openapi", "asyncapi"];
71
+ default: return null;
72
+ }
73
+ }
74
+
75
+ const hasOpenapi = (v) => v.kinds.includes("openapi");
76
+ const hasAsyncapi = (v) => v.kinds.includes("asyncapi");
77
+
78
+ /**
79
+ * The questions, in the order they are asked. `when` says whether a question applies
80
+ * to the values chosen so far; `flag` is the command-line option that answers it.
81
+ */
82
+ const QUESTIONS = [
83
+ {
84
+ key: "kinds", flag: "--openapi/--asyncapi", text: "Kinds of document: openapi, asyncapi or both",
85
+ show: (kinds) => (kinds.length === 2 ? "both" : kinds[0]),
86
+ read: (text) => {
87
+ const kinds = parseKinds(text);
88
+ return kinds ? { value: kinds } : { problem: `'${text}' is not openapi, asyncapi or both` };
89
+ },
90
+ },
91
+ { key: "target", flag: "--target", text: "Target name", check: checkTarget, name: "a target name" },
92
+ { key: "title", flag: "--title", text: "API title", check: checkText, name: "a title" },
93
+ { key: "contractVersion", flag: "--contract-version", text: "Initial contract version", check: checkVersion, name: "a release version" },
94
+ { key: "contactName", flag: "--contact-name", text: "Contact name", check: checkText, name: "a contact name" },
95
+ { key: "contactUrl", flag: "--contact-url", text: "Contact URL", check: checkUrl },
96
+ { key: "license", flag: "--license", text: "Licence, as an SPDX identifier", check: checkText, name: "a licence" },
97
+ {
98
+ key: "licenseUrl", flag: "--license-url", text: "Licence URL", check: checkUrl,
99
+ derive: (v) => LICENSE_URLS[v.license],
100
+ },
101
+ { key: "serverUrl", flag: "--server-url", text: "Production server URL", check: checkUrl, when: hasOpenapi },
102
+ { key: "brokerHost", flag: "--broker-host", text: "Broker host, with its port", check: checkHost, when: hasAsyncapi, name: "a broker host" },
103
+ ];
104
+
105
+ /** Reads an answer to a question: the value, or what is wrong with it. */
106
+ function read(question, text) {
107
+ if (question.read) return question.read(text);
108
+ const problem = question.check(text);
109
+ if (!problem) return { value: text };
110
+ // "'x' is not a target name: use ..." reads better than "'x': use ...", where a
111
+ // question names what it asks for; "it is not ..." reasons stand on their own.
112
+ return { problem: problem.startsWith("it ") ? `'${text}' ${problem.slice(3)}` : `'${text}' is not ${question.name}: ${problem}` };
113
+ }
114
+
115
+ /**
116
+ * Works out every value `init` scaffolds from.
117
+ *
118
+ * @param {object} options
119
+ * @param {object} [options.given] values given as flags, by key; they are never asked
120
+ * @param {function(string): Promise<string>} [options.ask] shows a question, resolves to the answer
121
+ * @param {function(string): void} [options.tell] shows why an answer was refused
122
+ * @returns {Promise<object>} the values, as DEFAULTS has them
123
+ * @throws {ConfigError} when a flag's value is invalid, or does not apply to the kinds chosen
124
+ */
125
+ async function resolveValues({ given = {}, ask = null, tell = () => {} } = {}) {
126
+ const values = {};
127
+ for (const question of QUESTIONS) {
128
+ const applies = !question.when || question.when({ ...DEFAULTS, ...values });
129
+ const flagged = given[question.key];
130
+ if (!applies) {
131
+ if (flagged !== undefined) {
132
+ throw new ConfigError(`${question.flag} applies only to a library with ${
133
+ question.key === "serverUrl" ? "OpenAPI" : "AsyncAPI"}`);
134
+ }
135
+ continue;
136
+ }
137
+ if (flagged !== undefined) {
138
+ if (question.key === "kinds") {
139
+ values.kinds = flagged;
140
+ continue;
141
+ }
142
+ const answer = read(question, flagged);
143
+ if (answer.problem) throw new ConfigError(`${question.flag} ${answer.problem}`);
144
+ values[question.key] = answer.value;
145
+ continue;
146
+ }
147
+ // A question that derives its value is not asked when it can be derived -- a known
148
+ // licence brings its URL -- and has no default when it cannot: one must be given.
149
+ const fallback = question.derive ? question.derive({ ...DEFAULTS, ...values }) : DEFAULTS[question.key];
150
+ if (question.derive && fallback !== undefined) {
151
+ values[question.key] = fallback;
152
+ continue;
153
+ }
154
+ if (!ask) {
155
+ if (fallback === undefined) {
156
+ throw new ConfigError(`--license ${values.license} has no known URL: give one with --license-url`);
157
+ }
158
+ values[question.key] = fallback;
159
+ continue;
160
+ }
161
+ const prompt = fallback === undefined ? `${question.text}: `
162
+ : `${question.text} [${question.show ? question.show(fallback) : fallback}]: `;
163
+ for (;;) {
164
+ const text = (await ask(prompt)).trim();
165
+ if (!text && fallback !== undefined) {
166
+ values[question.key] = fallback;
167
+ break;
168
+ }
169
+ if (!text) {
170
+ tell(`${values.license} is not a licence this tool knows the URL of: give it`);
171
+ continue;
172
+ }
173
+ const answer = read(question, text);
174
+ if (!answer.problem) {
175
+ values[question.key] = answer.value;
176
+ break;
177
+ }
178
+ tell(answer.problem);
179
+ }
180
+ }
181
+ return { ...DEFAULTS, ...values };
182
+ }
183
+
184
+ module.exports = {
185
+ resolveValues, checkTarget, checkVersion, checkUrl, checkHost, parseKinds, QUESTIONS, LICENSE_URLS,
186
+ };
package/src/init.js CHANGED
@@ -6,58 +6,86 @@
6
6
  // layout below is the one the library documentation describes; a project that
7
7
  // starts from it inherits the common/<product> split, the shared info block with
8
8
  // placeholder snippets, and a configuration that already builds.
9
+ //
10
+ // What it writes follows from a handful of values -- which kinds of document the
11
+ // library holds, the target's name, the API's title and so on -- that
12
+ // init-questions.js asks for at a terminal and defaults everywhere else. This file
13
+ // only turns values into files and writes them; it never reads input itself.
9
14
 
10
15
  const fs = require("fs");
11
16
  const path = require("path");
17
+ const YAML = require("yaml");
18
+ const { addMissingConfig } = require("./init-config");
12
19
 
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
- `;
20
+ /** Every value the scaffold is written from, as it is when nobody chooses otherwise. */
21
+ const DEFAULTS = Object.freeze({
22
+ kinds: Object.freeze(["openapi"]),
23
+ target: "example-service",
24
+ title: "Example API",
25
+ contractVersion: "0.1.0",
26
+ contactName: "Example Team",
27
+ contactUrl: "https://example.invalid",
28
+ license: "Apache-2.0",
29
+ licenseUrl: "https://www.apache.org/licenses/LICENSE-2.0.html",
30
+ serverUrl: "https://api.example.invalid",
31
+ brokerHost: "kafka:9092",
32
+ });
33
+
34
+ /** A value as a YAML scalar: plain where YAML reads it back unchanged, quoted where it would not. */
35
+ function scalar(value) {
36
+ return YAML.stringify(value, { lineWidth: 0 }).trimEnd();
37
+ }
48
38
 
49
- const INFO = `title: Example API
39
+ function config(v) {
40
+ const openapi = v.kinds.includes("openapi");
41
+ const asyncapi = v.kinds.includes("asyncapi");
42
+ const lines = [
43
+ "# apionly.yaml",
44
+ "#",
45
+ "# Declares what this library builds, from where, and where each document goes.",
46
+ "",
47
+ "schemaVersion: 1",
48
+ "",
49
+ "sources:",
50
+ " root: specs",
51
+ ];
52
+ if (openapi) lines.push(" openapi: openapi");
53
+ if (asyncapi) lines.push(" asyncapi: asyncapi");
54
+ lines.push("", "defaults:");
55
+ if (openapi) lines.push(" openapi:", " lint: .redocly.yaml", " outputName: openapi.yaml");
56
+ if (asyncapi) lines.push(" asyncapi:", " outputName: asyncapi.yaml");
57
+ lines.push(
58
+ " placeholders:",
59
+ " # Fail the build on a {{token}} with no matching Markdown file, rather than",
60
+ " # emitting a marker into a published contract.",
61
+ " strict: true",
62
+ "",
63
+ "build:",
64
+ " staging: build/staging",
65
+ " dist: dist",
66
+ "",
67
+ "toolchain:",
68
+ );
69
+ if (openapi) lines.push(' redocly: "@redocly/cli@2.52.0"');
70
+ if (asyncapi) lines.push(' asyncapi: "@asyncapi/cli@6.0.2"');
71
+ lines.push("", "targets:", ` ${v.target}:`);
72
+ if (openapi) lines.push(" openapi:", ` bundle: bundles/${v.target}_openapi_structure.yaml`);
73
+ if (asyncapi) lines.push(" asyncapi:", ` bundle: bundles/${v.target}_asyncapi_structure.yaml`);
74
+ return lines.join("\n") + "\n";
75
+ }
76
+
77
+ const info = (v) => `title: ${scalar(v.title)}
50
78
  version: 0.0.0
51
79
  description: |
52
80
  What this API is for.
53
81
 
54
82
  {{conventions}}
55
83
  contact:
56
- name: Example Team
57
- url: https://example.invalid
84
+ name: ${scalar(v.contactName)}
85
+ url: ${scalar(v.contactUrl)}
58
86
  license:
59
- name: Apache-2.0
60
- url: https://www.apache.org/licenses/LICENSE-2.0.html
87
+ name: ${scalar(v.license)}
88
+ url: ${scalar(v.licenseUrl)}
61
89
  `;
62
90
 
63
91
  const CONVENTIONS = `## Conventions
@@ -68,7 +96,7 @@ with whatever your own API consumers need to know up front -- pagination, status
68
96
  codes, error shapes.
69
97
  `;
70
98
 
71
- const SERVERS = `- url: https://api.example.invalid
99
+ const servers = (v) => `- url: ${scalar(v.serverUrl)}
72
100
  description: Production.
73
101
  `;
74
102
 
@@ -117,7 +145,9 @@ content:
117
145
  - status
118
146
  `;
119
147
 
120
- const PATH_FRAGMENT = `get:
148
+ // With AsyncAPI beside it, the examples are identified by a schema both protocols
149
+ // share; on its own, the OpenAPI scaffold stays as small as it always was.
150
+ const pathFragment = (shared) => `get:
121
151
  operationId: listExamples
122
152
  summary: List examples.
123
153
  responses:
@@ -128,11 +158,17 @@ const PATH_FRAGMENT = `get:
128
158
  schema:
129
159
  type: array
130
160
  items:
131
- type: string
161
+ ${shared ? " $ref: '../../components/common/schemas/ExampleIdV1.yaml'" : " type: string"}
132
162
  '400':
133
163
  $ref: '../../components/common/responses/errors/InvalidRequestProblemV1.yaml'
134
164
  `;
135
165
 
166
+ const EXAMPLE_ID = `# One definition, two protocols: the HTTP response and the event both refer to this
167
+ # fragment, so an example's identifier means the same thing wherever it appears.
168
+ type: string
169
+ description: An example's identifier.
170
+ `;
171
+
136
172
  const REDOCLY = `# Lint rules for this library.
137
173
  #
138
174
  # 'recommended' is Redocly's own baseline. Narrow or widen it as the library
@@ -147,44 +183,212 @@ dist/
147
183
  node_modules/
148
184
  `;
149
185
 
150
- const VERSION = `# The version of the example-service contract, for every document it builds.
186
+ const version = (v) => `# The version of the ${v.target} contract, for every document it builds.
151
187
  # Semantic: major for a breaking change, minor for an additive one, patch for
152
188
  # anything else. Change it in the same commit as the fragments it describes.
153
- version=0.1.0
189
+ version=${v.contractVersion}
190
+ `;
191
+
192
+ // The AsyncAPI bundle root holds its operations itself: they point into the document
193
+ // with #/channels/..., which only resolves in the file that contains it.
194
+ const asyncBundle = (v) => `asyncapi: 3.1.0
195
+ info:
196
+ title: ${scalar(v.title)}
197
+ version: 0.0.0
198
+ description: |
199
+ The events this API publishes.
200
+
201
+ {{conventions}}
202
+ contact:
203
+ name: ${scalar(v.contactName)}
204
+ url: ${scalar(v.contactUrl)}
205
+ license:
206
+ name: ${scalar(v.license)}
207
+ url: ${scalar(v.licenseUrl)}
208
+ defaultContentType: application/json
209
+ servers:
210
+ production:
211
+ host: ${scalar(v.brokerHost)}
212
+ protocol: kafka
213
+ description: Production.
214
+ channels:
215
+ examplesV1:
216
+ $ref: '../channels/example/ExamplesV1.yaml'
217
+ operations:
218
+ publishExampleCreated:
219
+ action: send
220
+ channel:
221
+ $ref: '#/channels/examplesV1'
222
+ summary: Publish an ExampleCreated event when an example is created.
223
+ messages:
224
+ - $ref: '#/channels/examplesV1/messages/exampleCreated'
225
+ `;
226
+
227
+ const CHANNEL = `address: examples.v1
228
+ title: Examples
229
+ description: Events about examples.
230
+ messages:
231
+ exampleCreated:
232
+ $ref: '../../messages/example/ExampleCreatedMessageV1.yaml'
233
+ `;
234
+
235
+ const MESSAGE = `name: ExampleCreated
236
+ title: An example was created.
237
+ contentType: application/json
238
+ payload:
239
+ $ref: '../../components/example/schemas/ExampleCreatedEventV1.yaml'
240
+ `;
241
+
242
+ const event = (shared) => `type: object
243
+ description: An example was created.
244
+ required:
245
+ - id
246
+ - occurredAt
247
+ properties:
248
+ id:
249
+ ${shared
250
+ ? " # The OpenAPI tree's schema: an event and an HTTP response mean one identifier.\n" +
251
+ " $ref: '../../../../openapi/components/common/schemas/ExampleIdV1.yaml'"
252
+ : " type: string\n description: The example's identifier."}
253
+ occurredAt:
254
+ type: string
255
+ format: date-time
256
+ description: When the example was created.
154
257
  `;
155
258
 
156
- const FILES = {
157
- "apionly.yaml": CONFIG,
158
- ".redocly.yaml": REDOCLY,
159
- ".gitignore": GITIGNORE,
160
- "specs/openapi/shared/info.yaml": INFO,
161
- "specs/openapi/shared/conventions.md": CONVENTIONS,
162
- "specs/openapi/shared/servers.yaml": SERVERS,
163
- "specs/openapi/bundles/example-service_openapi_structure.yaml": BUNDLE,
164
- "specs/openapi/bundles/example-service.bundle.properties": VERSION,
165
- "specs/openapi/paths/example/ExamplesV1.yaml": PATH_FRAGMENT,
166
- "specs/openapi/components/common/security/BearerAuth.yaml": SECURITY_SCHEME,
167
- "specs/openapi/components/common/responses/errors/InvalidRequestProblemV1.yaml": PROBLEM,
168
- };
169
-
170
- function init(targetDir, { force = false, log = () => {} } = {}) {
171
- const created = [];
172
- const skipped = [];
173
-
174
- for (const [rel, content] of Object.entries(FILES)) {
259
+ /**
260
+ * The files a library of these values starts with, keyed by their path relative to it.
261
+ *
262
+ * @param {object} values the values, as DEFAULTS has them
263
+ * @returns {Object<string, string>}
264
+ */
265
+ function scaffold(values = DEFAULTS) {
266
+ const v = { ...DEFAULTS, ...values };
267
+ const openapi = v.kinds.includes("openapi");
268
+ const asyncapi = v.kinds.includes("asyncapi");
269
+ const both = openapi && asyncapi;
270
+ const files = { "apionly.yaml": config(v) };
271
+ if (openapi) files[".redocly.yaml"] = REDOCLY;
272
+ files[".gitignore"] = GITIGNORE;
273
+
274
+ if (openapi) {
275
+ Object.assign(files, {
276
+ "specs/openapi/shared/info.yaml": info(v),
277
+ "specs/openapi/shared/conventions.md": CONVENTIONS,
278
+ "specs/openapi/shared/servers.yaml": servers(v),
279
+ [`specs/openapi/bundles/${v.target}_openapi_structure.yaml`]: BUNDLE,
280
+ [`specs/openapi/bundles/${v.target}.bundle.properties`]: version(v),
281
+ "specs/openapi/paths/example/ExamplesV1.yaml": pathFragment(both),
282
+ "specs/openapi/components/common/security/BearerAuth.yaml": SECURITY_SCHEME,
283
+ "specs/openapi/components/common/responses/errors/InvalidRequestProblemV1.yaml": PROBLEM,
284
+ });
285
+ if (both) files["specs/openapi/components/common/schemas/ExampleIdV1.yaml"] = EXAMPLE_ID;
286
+ }
287
+ if (asyncapi) {
288
+ // The version file sits beside the target's first bundle root: the OpenAPI one
289
+ // when there is one. The conventions snippet is found anywhere under specs/.
290
+ if (!openapi) {
291
+ files[`specs/asyncapi/bundles/${v.target}.bundle.properties`] = version(v);
292
+ files["specs/asyncapi/shared/conventions.md"] = CONVENTIONS;
293
+ }
294
+ Object.assign(files, {
295
+ [`specs/asyncapi/bundles/${v.target}_asyncapi_structure.yaml`]: asyncBundle(v),
296
+ "specs/asyncapi/channels/example/ExamplesV1.yaml": CHANNEL,
297
+ "specs/asyncapi/messages/example/ExampleCreatedMessageV1.yaml": MESSAGE,
298
+ "specs/asyncapi/components/example/schemas/ExampleCreatedEventV1.yaml": event(both),
299
+ });
300
+ }
301
+ return files;
302
+ }
303
+
304
+ const FILES = scaffold(DEFAULTS);
305
+
306
+ /** Text as a comparison sees it: line endings and final newlines are not differences. */
307
+ function normalised(text) {
308
+ return text.replace(/\r\n?/g, "\n").replace(/\n+$/, "");
309
+ }
310
+
311
+ const CONFIG_FILE = "apionly.yaml";
312
+
313
+ /**
314
+ * What writing these files would do to each: create it, leave it because it is
315
+ * identical, or find it different from what would be written.
316
+ *
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
+ * @returns {{rel: string, status: "missing"|"identical"|"differs", added?: string[], text?: string}[]}
326
+ * `added` and `text` -- the completed content -- are there only for apionly.yaml,
327
+ * and only when something was missing from it.
328
+ */
329
+ function plan(targetDir, files, values) {
330
+ return Object.entries(files).map(([rel, content]) => {
175
331
  const file = path.join(targetDir, rel);
176
- if (fs.existsSync(file) && !force) {
177
- skipped.push(rel);
332
+ if (!fs.existsSync(file)) return { rel, status: "missing" };
333
+ const onDisk = fs.readFileSync(file, "utf8");
334
+ if (rel === CONFIG_FILE && values) {
335
+ const completed = addMissingConfig(onDisk, { ...DEFAULTS, ...values });
336
+ if (completed.added.length > 0) {
337
+ const status = normalised(completed.text) === normalised(content) ? "identical" : "differs";
338
+ return { rel, status, added: completed.added, text: completed.text };
339
+ }
340
+ }
341
+ const same = normalised(onDisk) === normalised(content);
342
+ return { rel, status: same ? "identical" : "differs" };
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Writes the scaffold of these values into a directory, and reports each file.
348
+ *
349
+ * A file that is not there is created. One that is there already is left alone,
350
+ * reported as identical to what would have been written or as differing from it;
351
+ * with `force`, one that differs is overwritten, wholesale, as if this were the
352
+ * first time -- apionly.yaml included, whatever else it declares.
353
+ *
354
+ * Short of `force`, apionly.yaml gets one more chance: whatever configuration a
355
+ * missing kind needs and it lacks is added to it, leaving every value, key and
356
+ * comment already there exactly as it was. A kind already declared, however it
357
+ * reads, is never touched -- what is already there is not changed, only what is
358
+ * not there is added.
359
+ */
360
+ function init(targetDir, { values = DEFAULTS, force = false, log = () => {} } = {}) {
361
+ const files = scaffold(values);
362
+ const report = { created: [], overwritten: [], identical: [], differing: [], updated: [] };
363
+ const updates = [];
364
+
365
+ for (const { rel, status, added, text } of plan(targetDir, files, values)) {
366
+ if (added && !force) {
367
+ fs.writeFileSync(path.join(targetDir, rel), text);
368
+ report.updated.push(rel);
369
+ updates.push({ rel, added });
178
370
  continue;
179
371
  }
372
+ if (status === "identical") {
373
+ report.identical.push(rel);
374
+ continue;
375
+ }
376
+ if (status === "differs" && !force) {
377
+ report.differing.push(rel);
378
+ continue;
379
+ }
380
+ const file = path.join(targetDir, rel);
180
381
  fs.mkdirSync(path.dirname(file), { recursive: true });
181
- fs.writeFileSync(file, content);
182
- created.push(rel);
382
+ fs.writeFileSync(file, files[rel]);
383
+ (status === "missing" ? report.created : report.overwritten).push(rel);
183
384
  }
184
385
 
185
- for (const rel of created) log(` created ${rel}`);
186
- for (const rel of skipped) log(` exists ${rel} (left alone; --force overwrites)`);
187
- return { created, skipped };
386
+ for (const rel of report.created) log(` created ${rel}`);
387
+ for (const rel of report.overwritten) log(` overwrote ${rel}`);
388
+ for (const { rel, added } of updates) log(` updated ${rel} (added ${added.join(", ")})`);
389
+ for (const rel of report.identical) log(` identical ${rel}`);
390
+ for (const rel of report.differing) log(` differs ${rel} (left alone; --force overwrites)`);
391
+ return { ...report, skipped: [...report.identical, ...report.differing] };
188
392
  }
189
393
 
190
- module.exports = { init, FILES };
394
+ module.exports = { init, scaffold, plan, FILES, DEFAULTS };