@arc-e-tect/api-only-publisher 0.6.1 → 0.7.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/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.1
9
+ :api-only-publisher-version: 0.7.0
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,101 @@ 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
+ `differs`:: The file was there, and does not match: where the library has diverged from the scaffold, usually on purpose.
274
+ It is left alone.
275
+ `overwrote`:: The file differed, and `--force` replaced it.
276
+ +
277
+ At a terminal, `--force` first lists the files that differ and asks once whether to overwrite them; without a terminal, or with `--yes`, it overwrites them without asking.
278
+
181
279
  [#what-build-does]
182
280
  == What `build` does
183
281
 
@@ -199,6 +297,7 @@ An OpenAPI document's components each carry the path of the fragment they came f
199
297
  A `publish: false` target keeps the version its source declares.
200
298
 
201
299
  . **Lint** the finished document, so a broken fragment surfaces against the target it actually affects.
300
+ `build` prints the linter's output only when the lint fails, and writes no report; to see warnings, and to write each document's report under `reports.lint`, run `lint`.
202
301
 
203
302
  [#versions]
204
303
  == Versions
@@ -295,7 +394,7 @@ Declare the members and the aggregate's own identity:
295
394
  portfolio:
296
395
  publish: false
297
396
  asyncapi:
298
- bundle: portfolio_asyncapi_structure.yaml
397
+ bundle: bundles/portfolio_asyncapi_structure.yaml
299
398
  aggregate:
300
399
  - orders
301
400
  - payments
@@ -369,20 +468,25 @@ Adapting the tool to another project means editing configuration.
369
468
 
370
469
  [source,yaml]
371
470
  ----
471
+ # The version of this file's own format -- which keys apionly.yaml has and what
472
+ # they mean -- not of any API. This Publisher understands 1, and refuses others.
372
473
  schemaVersion: 1
373
474
 
374
475
  sources:
375
476
  root: specs # staged wholesale; the kinds share fragments
376
- openapi: openapi
477
+ openapi: openapi # each kind's directory under root; bundle paths start here
377
478
  asyncapi: asyncapi
378
479
 
379
480
  defaults:
380
481
  openapi:
381
482
  lint: .redocly.yaml
483
+ # The file name of every built OpenAPI document: build/dist/<target>/openapi.yaml.
484
+ # The Subscriber looks for exactly this name.
382
485
  outputName: openapi.yaml
383
486
  fragmentPaths: true # x-fragment-path on every component; the default
384
487
  asyncapi:
385
- outputName: asyncapi.yaml
488
+ outputName: asyncapi.yaml # as for OpenAPI: build/dist/<target>/asyncapi.yaml
489
+ fragmentPaths: true # x-fragment-path on every fragment; the default
386
490
  placeholders:
387
491
  strict: true # an unresolved {{token}} fails the build
388
492
 
@@ -408,17 +512,36 @@ targets:
408
512
  # versionFile: versions/orders.properties
409
513
  # default: orders.bundle.properties beside the first bundle root
410
514
  openapi:
411
- bundle: bundles/orders.yaml
515
+ # The bundle root the document is built from, relative to specs/openapi/.
516
+ # It is built into build/dist/orders/openapi.yaml, named by outputName.
517
+ bundle: bundles/orders_openapi_structure.yaml
412
518
  asyncapi:
413
- bundle: orders_asyncapi.yaml
519
+ # Relative to specs/asyncapi/; built into build/dist/orders/asyncapi.yaml.
520
+ bundle: bundles/orders_asyncapi_structure.yaml
414
521
  portfolio:
415
522
  publish: false # built and linted, never published
416
523
  openapi:
417
- bundle: bundles/portfolio.yaml
524
+ bundle: bundles/portfolio_openapi_structure.yaml
418
525
  ----
419
526
 
420
527
  `targets` is the single source of truth for what gets built.
421
528
 
529
+ A target's `bundle` and a kind's `outputName` are the two ends of one build: the bundle root is what a document is built *from*, in the library, and `outputName` is what it is built *to*, in `<build.dist>/<target>/`.
530
+ Every target's document of one kind has the same `outputName`; keep the defaults, `openapi.yaml` and `asyncapi.yaml`, because the Subscriber looks for exactly those names.
531
+
532
+ [#bundle-roots]
533
+ === Where bundle roots go
534
+
535
+ 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`.
536
+ The `init` scaffold lays out its bundle roots this way, for each kind it writes.
537
+
538
+ * A bundle root is a table of contents rather than a fragment, and a `bundles/` directory keeps the two apart.
539
+ * 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.
540
+ * The version file's default place is beside the target's first bundle root (<<versions,Versions §>>), so it lands in `bundles/` too.
541
+
542
+ The Publisher does not require it: a bundle path is any path relative to the kind's source directory.
543
+ A bundle root moved into or out of `bundles/` needs its relative `$ref`s adjusted by one `../`, since they resolve against the file's own directory; the built document is otherwise the same.
544
+
422
545
  [#pinning-the-bundlers]
423
546
  === Pinning the bundlers
424
547
 
@@ -463,6 +586,11 @@ Any other YAML file under the source root counts.
463
586
  [#version-stamping]
464
587
  === Version stamping edits one scalar
465
588
 
589
+ Stamping sets `info.version` in the *built* document, `<build.dist>/<target>/<outputName>`, after bundling.
590
+ The value it replaces comes from the bundle root's `info` block, written in the bundle root or `$ref`'d from a fragment, which the bundler puts into the document: every bundle root therefore needs an `info` block with a `version`, conventionally `0.0.0`.
591
+ The `init` scaffold's bundle root refers to `specs/openapi/shared/info.yaml`, which declares `version: 0.0.0`.
592
+ The version that replaces it is the target's version file's (<<versions,Versions §>>).
593
+
466
594
  `info.version` is located structurally and that one scalar is spliced.
467
595
  It is deliberately not a re-serialisation: re-emitting a document reformats everything around the edit, because the AsyncAPI CLI wraps long descriptions at a width no YAML emitter reproduces.
468
596
  Splicing keeps every other byte exactly as the bundler wrote it.
@@ -492,12 +620,14 @@ It is how the API-Only TranscriberJ ties generated code to the fragment it came
492
620
  It is on by default, for both specification kinds.
493
621
  `defaults.openapi.fragmentPaths: false` and `defaults.asyncapi.fragmentPaths: false` turn it off, each for its own kind.
494
622
 
495
- How it is done::
623
+ How it is done, for OpenAPI::
496
624
  The bundler is not told anything; it is shown stamped fragments, in copies of the staged tree under `<build.staging>/fragment-paths/<target>/`.
497
625
  The first bundle is built with every fragment stamped, to learn which fragments become components.
498
626
  The second, the one that is published, is built with only those stamped.
499
627
  So a document differs from one built with `fragmentPaths: false` by exactly one line per component, and in no other byte.
500
628
  The cost is a second bundler run per OpenAPI target.
629
+ +
630
+ An AsyncAPI target is stamped differently, in one run and with every fragment stamped, because its bundler inlines rather than hoists: <<fragment-paths-asyncapi,AsyncAPI, where the bundler inlines rather than hoists §>> describes it.
501
631
 
502
632
  What stops the build::
503
633
  A fragment that writes `x-fragment-path` itself: the Publisher sets that key and nothing else may.
@@ -540,7 +670,9 @@ A stamp in the built document that names no file in the library, which would mea
540
670
  [#fragment-path-versions]
541
671
  ==== What a change to `x-fragment-path` means for a contract's version
542
672
 
543
- Semantic versioning describes the contract, so the version follows from what a change does to the contract's promises:
673
+ Semantic versioning describes the contract, so the version follows from what a change does to the contract's promises.
674
+ The Publisher does not decide it: it stamps whatever the target's version file declares.
675
+ Choosing the next version is the API designer's responsibility, and this table is their guideline:
544
676
 
545
677
  [cols="2,1,3", options="header"]
546
678
  |===
@@ -594,11 +726,11 @@ In order of what usually needs changing:
594
726
  |The lint rules
595
727
  |`defaults.openapi.lint`
596
728
 
597
- |Whether components carry `x-fragment-path`
598
- |`defaults.openapi.fragmentPaths`, defaulting to `true`
729
+ |Whether built documents carry `x-fragment-path`
730
+ |`defaults.openapi.fragmentPaths` and `defaults.asyncapi.fragmentPaths`, each defaulting to `true`
599
731
 
600
732
  |Where lint reports go
601
- |`reports.lint`, defaulting to `build/reports/lint/<target>/<kind>.txt`
733
+ |`reports.lint`, a directory, defaulting to `build/reports/lint`: each document's report is `<target>/<kind>.txt` in it, beside `unreferenced.txt`
602
734
 
603
735
  |Where built documents go
604
736
  |`build.dist`
@@ -623,6 +755,14 @@ A library with no `{{token}}` anywhere simply has nothing substituted.
623
755
  The tool does not police them.
624
756
  `changed` tells a release which targets a commit altered, and every manifest records the closure hash of what was published, so the hooks a diff gate would need already exist, but the gate itself is deliberately not built.
625
757
 
758
+ [IMPORTANT]
759
+ .The API designer defines the contract's version, not the Publisher
760
+ ====
761
+ The Publisher reads a target's version from its version file and stamps it; it refuses a version that is not a semantic release version, and nothing else.
762
+ It never works out a version from what changed, and never checks that a version matches the change.
763
+ Whether a change is major, minor or patch is the API designer's decision, and so is its consequence: raising the version in the target's version file.
764
+ ====
765
+
626
766
  A breaking change is declared where it is made: the major version in the target's version file changes in the same commit as the fragments it breaks.
627
767
  A specification library should write down its own policy for what counts as breaking.
628
768
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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)).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;
package/src/config.js CHANGED
@@ -161,8 +161,9 @@ function load(configPath) {
161
161
  requireString(this.sources[kind], `sources.${kind}`),
162
162
  this.targets[target][kind].bundle);
163
163
  },
164
- // Whether built documents of this kind carry x-fragment-path on each
165
- // component. On unless turned off; OpenAPI only.
164
+ // Whether built documents of this kind carry x-fragment-path: on each
165
+ // component of an OpenAPI document, on each fragment of an AsyncAPI one.
166
+ // On unless turned off, for both kinds.
166
167
  fragmentPaths(kind) {
167
168
  if (kind !== "openapi" && kind !== "asyncapi") return false;
168
169
  const configured = (this.defaults[kind] || {}).fragmentPaths;
@@ -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,85 @@
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");
12
18
 
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
- `;
19
+ /** Every value the scaffold is written from, as it is when nobody chooses otherwise. */
20
+ const DEFAULTS = Object.freeze({
21
+ kinds: Object.freeze(["openapi"]),
22
+ target: "example-service",
23
+ title: "Example API",
24
+ contractVersion: "0.1.0",
25
+ contactName: "Example Team",
26
+ contactUrl: "https://example.invalid",
27
+ license: "Apache-2.0",
28
+ licenseUrl: "https://www.apache.org/licenses/LICENSE-2.0.html",
29
+ serverUrl: "https://api.example.invalid",
30
+ brokerHost: "kafka:9092",
31
+ });
32
+
33
+ /** A value as a YAML scalar: plain where YAML reads it back unchanged, quoted where it would not. */
34
+ function scalar(value) {
35
+ return YAML.stringify(value, { lineWidth: 0 }).trimEnd();
36
+ }
48
37
 
49
- const INFO = `title: Example API
38
+ function config(v) {
39
+ const openapi = v.kinds.includes("openapi");
40
+ const asyncapi = v.kinds.includes("asyncapi");
41
+ const lines = [
42
+ "# apionly.yaml",
43
+ "#",
44
+ "# Declares what this library builds, from where, and where each document goes.",
45
+ "",
46
+ "schemaVersion: 1",
47
+ "",
48
+ "sources:",
49
+ " root: specs",
50
+ ];
51
+ if (openapi) lines.push(" openapi: openapi");
52
+ if (asyncapi) lines.push(" asyncapi: asyncapi");
53
+ lines.push("", "defaults:");
54
+ if (openapi) lines.push(" openapi:", " lint: .redocly.yaml", " outputName: openapi.yaml");
55
+ if (asyncapi) lines.push(" asyncapi:", " outputName: asyncapi.yaml");
56
+ lines.push(
57
+ " placeholders:",
58
+ " # Fail the build on a {{token}} with no matching Markdown file, rather than",
59
+ " # emitting a marker into a published contract.",
60
+ " strict: true",
61
+ "",
62
+ "build:",
63
+ " staging: build/staging",
64
+ " dist: dist",
65
+ "",
66
+ "toolchain:",
67
+ );
68
+ if (openapi) lines.push(' redocly: "@redocly/cli@2.52.0"');
69
+ if (asyncapi) lines.push(' asyncapi: "@asyncapi/cli@6.0.2"');
70
+ lines.push("", "targets:", ` ${v.target}:`);
71
+ if (openapi) lines.push(" openapi:", ` bundle: bundles/${v.target}_openapi_structure.yaml`);
72
+ if (asyncapi) lines.push(" asyncapi:", ` bundle: bundles/${v.target}_asyncapi_structure.yaml`);
73
+ return lines.join("\n") + "\n";
74
+ }
75
+
76
+ const info = (v) => `title: ${scalar(v.title)}
50
77
  version: 0.0.0
51
78
  description: |
52
79
  What this API is for.
53
80
 
54
81
  {{conventions}}
55
82
  contact:
56
- name: Example Team
57
- url: https://example.invalid
83
+ name: ${scalar(v.contactName)}
84
+ url: ${scalar(v.contactUrl)}
58
85
  license:
59
- name: Apache-2.0
60
- url: https://www.apache.org/licenses/LICENSE-2.0.html
86
+ name: ${scalar(v.license)}
87
+ url: ${scalar(v.licenseUrl)}
61
88
  `;
62
89
 
63
90
  const CONVENTIONS = `## Conventions
@@ -68,7 +95,7 @@ with whatever your own API consumers need to know up front -- pagination, status
68
95
  codes, error shapes.
69
96
  `;
70
97
 
71
- const SERVERS = `- url: https://api.example.invalid
98
+ const servers = (v) => `- url: ${scalar(v.serverUrl)}
72
99
  description: Production.
73
100
  `;
74
101
 
@@ -117,7 +144,9 @@ content:
117
144
  - status
118
145
  `;
119
146
 
120
- const PATH_FRAGMENT = `get:
147
+ // With AsyncAPI beside it, the examples are identified by a schema both protocols
148
+ // share; on its own, the OpenAPI scaffold stays as small as it always was.
149
+ const pathFragment = (shared) => `get:
121
150
  operationId: listExamples
122
151
  summary: List examples.
123
152
  responses:
@@ -128,11 +157,17 @@ const PATH_FRAGMENT = `get:
128
157
  schema:
129
158
  type: array
130
159
  items:
131
- type: string
160
+ ${shared ? " $ref: '../../components/common/schemas/ExampleIdV1.yaml'" : " type: string"}
132
161
  '400':
133
162
  $ref: '../../components/common/responses/errors/InvalidRequestProblemV1.yaml'
134
163
  `;
135
164
 
165
+ const EXAMPLE_ID = `# One definition, two protocols: the HTTP response and the event both refer to this
166
+ # fragment, so an example's identifier means the same thing wherever it appears.
167
+ type: string
168
+ description: An example's identifier.
169
+ `;
170
+
136
171
  const REDOCLY = `# Lint rules for this library.
137
172
  #
138
173
  # 'recommended' is Redocly's own baseline. Narrow or widen it as the library
@@ -147,44 +182,177 @@ dist/
147
182
  node_modules/
148
183
  `;
149
184
 
150
- const VERSION = `# The version of the example-service contract, for every document it builds.
185
+ const version = (v) => `# The version of the ${v.target} contract, for every document it builds.
151
186
  # Semantic: major for a breaking change, minor for an additive one, patch for
152
187
  # anything else. Change it in the same commit as the fragments it describes.
153
- version=0.1.0
188
+ version=${v.contractVersion}
189
+ `;
190
+
191
+ // The AsyncAPI bundle root holds its operations itself: they point into the document
192
+ // with #/channels/..., which only resolves in the file that contains it.
193
+ const asyncBundle = (v) => `asyncapi: 3.1.0
194
+ info:
195
+ title: ${scalar(v.title)}
196
+ version: 0.0.0
197
+ description: |
198
+ The events this API publishes.
199
+
200
+ {{conventions}}
201
+ contact:
202
+ name: ${scalar(v.contactName)}
203
+ url: ${scalar(v.contactUrl)}
204
+ license:
205
+ name: ${scalar(v.license)}
206
+ url: ${scalar(v.licenseUrl)}
207
+ defaultContentType: application/json
208
+ servers:
209
+ production:
210
+ host: ${scalar(v.brokerHost)}
211
+ protocol: kafka
212
+ description: Production.
213
+ channels:
214
+ examplesV1:
215
+ $ref: '../channels/example/ExamplesV1.yaml'
216
+ operations:
217
+ publishExampleCreated:
218
+ action: send
219
+ channel:
220
+ $ref: '#/channels/examplesV1'
221
+ summary: Publish an ExampleCreated event when an example is created.
222
+ messages:
223
+ - $ref: '#/channels/examplesV1/messages/exampleCreated'
224
+ `;
225
+
226
+ const CHANNEL = `address: examples.v1
227
+ title: Examples
228
+ description: Events about examples.
229
+ messages:
230
+ exampleCreated:
231
+ $ref: '../../messages/example/ExampleCreatedMessageV1.yaml'
232
+ `;
233
+
234
+ const MESSAGE = `name: ExampleCreated
235
+ title: An example was created.
236
+ contentType: application/json
237
+ payload:
238
+ $ref: '../../components/example/schemas/ExampleCreatedEventV1.yaml'
154
239
  `;
155
240
 
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)) {
241
+ const event = (shared) => `type: object
242
+ description: An example was created.
243
+ required:
244
+ - id
245
+ - occurredAt
246
+ properties:
247
+ id:
248
+ ${shared
249
+ ? " # The OpenAPI tree's schema: an event and an HTTP response mean one identifier.\n" +
250
+ " $ref: '../../../../openapi/components/common/schemas/ExampleIdV1.yaml'"
251
+ : " type: string\n description: The example's identifier."}
252
+ occurredAt:
253
+ type: string
254
+ format: date-time
255
+ description: When the example was created.
256
+ `;
257
+
258
+ /**
259
+ * The files a library of these values starts with, keyed by their path relative to it.
260
+ *
261
+ * @param {object} values the values, as DEFAULTS has them
262
+ * @returns {Object<string, string>}
263
+ */
264
+ function scaffold(values = DEFAULTS) {
265
+ const v = { ...DEFAULTS, ...values };
266
+ const openapi = v.kinds.includes("openapi");
267
+ const asyncapi = v.kinds.includes("asyncapi");
268
+ const both = openapi && asyncapi;
269
+ const files = { "apionly.yaml": config(v) };
270
+ if (openapi) files[".redocly.yaml"] = REDOCLY;
271
+ files[".gitignore"] = GITIGNORE;
272
+
273
+ if (openapi) {
274
+ Object.assign(files, {
275
+ "specs/openapi/shared/info.yaml": info(v),
276
+ "specs/openapi/shared/conventions.md": CONVENTIONS,
277
+ "specs/openapi/shared/servers.yaml": servers(v),
278
+ [`specs/openapi/bundles/${v.target}_openapi_structure.yaml`]: BUNDLE,
279
+ [`specs/openapi/bundles/${v.target}.bundle.properties`]: version(v),
280
+ "specs/openapi/paths/example/ExamplesV1.yaml": pathFragment(both),
281
+ "specs/openapi/components/common/security/BearerAuth.yaml": SECURITY_SCHEME,
282
+ "specs/openapi/components/common/responses/errors/InvalidRequestProblemV1.yaml": PROBLEM,
283
+ });
284
+ if (both) files["specs/openapi/components/common/schemas/ExampleIdV1.yaml"] = EXAMPLE_ID;
285
+ }
286
+ if (asyncapi) {
287
+ // The version file sits beside the target's first bundle root: the OpenAPI one
288
+ // when there is one. The conventions snippet is found anywhere under specs/.
289
+ if (!openapi) {
290
+ files[`specs/asyncapi/bundles/${v.target}.bundle.properties`] = version(v);
291
+ files["specs/asyncapi/shared/conventions.md"] = CONVENTIONS;
292
+ }
293
+ Object.assign(files, {
294
+ [`specs/asyncapi/bundles/${v.target}_asyncapi_structure.yaml`]: asyncBundle(v),
295
+ "specs/asyncapi/channels/example/ExamplesV1.yaml": CHANNEL,
296
+ "specs/asyncapi/messages/example/ExampleCreatedMessageV1.yaml": MESSAGE,
297
+ "specs/asyncapi/components/example/schemas/ExampleCreatedEventV1.yaml": event(both),
298
+ });
299
+ }
300
+ return files;
301
+ }
302
+
303
+ const FILES = scaffold(DEFAULTS);
304
+
305
+ /** Text as a comparison sees it: line endings and final newlines are not differences. */
306
+ function normalised(text) {
307
+ return text.replace(/\r\n?/g, "\n").replace(/\n+$/, "");
308
+ }
309
+
310
+ /**
311
+ * What writing these files would do to each: create it, leave it because it is
312
+ * identical, or find it different from what would be written.
313
+ *
314
+ * @returns {{rel: string, status: "missing"|"identical"|"differs"}[]}
315
+ */
316
+ function plan(targetDir, files) {
317
+ return Object.entries(files).map(([rel, content]) => {
175
318
  const file = path.join(targetDir, rel);
176
- if (fs.existsSync(file) && !force) {
177
- skipped.push(rel);
319
+ if (!fs.existsSync(file)) return { rel, status: "missing" };
320
+ const same = normalised(fs.readFileSync(file, "utf8")) === normalised(content);
321
+ return { rel, status: same ? "identical" : "differs" };
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Writes the scaffold of these values into a directory, and reports each file.
327
+ *
328
+ * A file that is not there is created. One that is there already is left alone,
329
+ * reported as identical to what would have been written or as differing from it;
330
+ * with `force`, one that differs is overwritten.
331
+ */
332
+ function init(targetDir, { values = DEFAULTS, force = false, log = () => {} } = {}) {
333
+ const files = scaffold(values);
334
+ const report = { created: [], overwritten: [], identical: [], differing: [] };
335
+
336
+ for (const { rel, status } of plan(targetDir, files)) {
337
+ if (status === "identical") {
338
+ report.identical.push(rel);
339
+ continue;
340
+ }
341
+ if (status === "differs" && !force) {
342
+ report.differing.push(rel);
178
343
  continue;
179
344
  }
345
+ const file = path.join(targetDir, rel);
180
346
  fs.mkdirSync(path.dirname(file), { recursive: true });
181
- fs.writeFileSync(file, content);
182
- created.push(rel);
347
+ fs.writeFileSync(file, files[rel]);
348
+ (status === "missing" ? report.created : report.overwritten).push(rel);
183
349
  }
184
350
 
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 };
351
+ for (const rel of report.created) log(` created ${rel}`);
352
+ for (const rel of report.overwritten) log(` overwrote ${rel}`);
353
+ for (const rel of report.identical) log(` identical ${rel}`);
354
+ for (const rel of report.differing) log(` differs ${rel} (left alone; --force overwrites)`);
355
+ return { ...report, skipped: [...report.identical, ...report.differing] };
188
356
  }
189
357
 
190
- module.exports = { init, FILES };
358
+ module.exports = { init, scaffold, plan, FILES, DEFAULTS };