@descent-vtt/spec-brief 0.6.0 → 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/CHANGELOG.md CHANGED
@@ -11,6 +11,102 @@ release that can turn a run red. This is the family's policy, recorded in
11
11
  releases up to 0.2.7 came before it, and several of their patches changed
12
12
  what a run reports.
13
13
 
14
+ ## 0.7.0
15
+
16
+ An input that is set and names nothing is refused, exit 2, by a line that
17
+ names it, where it was read as if it had not been given: a
18
+ `SOURCE_DATE_EPOCH` that is no date, an option given an empty value, an
19
+ argument a command does not take, a `--root` that is no directory, a list
20
+ that names no brief or has a place that names none.
21
+
22
+ ### Changed
23
+
24
+ - **A `SOURCE_DATE_EPOCH` that is set and cannot be read as a date is
25
+ refused, not read as unset.** `new` and `archive` without `--date` took
26
+ `tomorrow`, `1.5`, `-1`, a value with a space beside its digits and an
27
+ empty one for no value at all, wrote the clock's date and exited 0. Each
28
+ now writes nothing and ends with exit 2 and a line that names the variable
29
+ and shows what it holds, and `today()` from the package throws for them
30
+ (ADR-0004, [Today's date](README.md#todays-date)).
31
+ Upgrading: set it to whole seconds in digits alone, as `date +%s` prints
32
+ them, no more than `253402300799` (9999-12-31), or pass `--date`, or leave
33
+ it unset; set and empty is not unset.
34
+ - **An option given an empty value is refused by its name, not read as the
35
+ option left out.** `new "T" --id ""` wrote `briefs/_t.md`, a file no
36
+ command reads again; `--root ""` ran in the current directory; `archive
37
+ --summary ""` wrote a banner without the sentence; `init --briefs " "`
38
+ made a directory named with a space. Every option that takes a value now
39
+ ends the run with exit 2 and `--id is "", which is no value` for one that
40
+ is empty or only space, nothing written; the ones refused already say the
41
+ same line (ADR-0001, [An input that names nothing](README.md#an-input-that-names-nothing)).
42
+ Upgrading: where a script may hand an option nothing, leave the option
43
+ out, as `${ID:+--id "$ID"}` does in a shell.
44
+ - **`new --depends-on` refuses a list that names no brief, and a place in
45
+ one that names none.** `--depends-on ,` scaffolded a brief with no
46
+ dependency, and `7,8,` or `7,,8` one that depends on 7 and 8, as `"$A,$B"`
47
+ with `$B` unset made one that depends on `$A` alone, each exit 0.
48
+ Upgrading: leave the option out for a brief that depends on nothing, and
49
+ write the list with one comma between each two ids and none at either end.
50
+ - **`init`, `list`, `matrix` and `schedule` refuse an argument, which they
51
+ do not take.** `list nonsense` listed every brief, `schedule later
52
+ --write` ran as `schedule --write`, and `init mydir` set up the current
53
+ directory, each exit 0; each is now exit 2 with the argument named and the
54
+ command's usage. Upgrading: drop the argument; `init --root mydir` sets up another
55
+ directory, and `--briefs mydir` names the one for the briefs.
56
+ - **A `--root` that is no directory is refused.** Given a file or a path
57
+ that does not exist, the search for a configuration went on from its
58
+ parent: `lint --root tpyo` linted the tree above, and `new --root tpyo`
59
+ wrote into it, exit 0. `init` still makes a root that is not there.
60
+ Upgrading: give `--root` a directory that exists.
61
+
62
+ ### Fixed
63
+
64
+ - A terminal that says `TERM=dumb` gets no colour, unless `FORCE_COLOR` or
65
+ `--color` asks for it, as the family's other tools have it. It was sent
66
+ the escape sequences it had said it does not read.
67
+ - A `SOURCE_DATE_EPOCH` that cannot be read stops only a run that would
68
+ write its date. One of more seconds than any date has ended `lint`,
69
+ `list`, `matrix`, `schedule`, `init` and `unarchive`, and `new` and
70
+ `archive` given `--date`, with exit 2; they now run whatever it holds.
71
+ - A `SOURCE_DATE_EPOCH` in the years 10000 to 275760 is refused by name,
72
+ where `new` and `archive` said `"+010000-01" is not a date written
73
+ YYYY-MM-DD`. Exit 2, as before.
74
+ - The README says what `SOURCE_DATE_EPOCH` does, and `--help` names it
75
+ beside `--date`.
76
+ - A configuration that is a directory, named by `--config` or found as
77
+ `.spec-brief.json`, is said in one line, `adir: is a directory, not a
78
+ configuration file`, where the run ended on `unexpected error: Error:
79
+ EISDIR` and a stack. Exit 2, as before.
80
+ - A configuration that is not JSON is refused in one line whatever the file
81
+ holds. A line break the parser quoted from it made the refusal two lines,
82
+ and any other control character reached the terminal as it was; each is
83
+ now written as its escape. Exit 2, as before.
84
+
85
+ ## 0.6.1
86
+
87
+ Four ways a run ended with `unexpected error:` and a stack for something
88
+ that was no defect of spec-brief's. Each is one line now; every exit code is
89
+ as it was.
90
+
91
+ ### Fixed
92
+
93
+ - **A reader that closes the output is answered in one line, not a stack.**
94
+ `spec-brief list | head` could end with `spec-brief: unexpected error:
95
+ Error: EPIPE: broken pipe, write` and a stack once `head` had left; it now
96
+ prints `spec-brief: stdout was closed before all of the output was
97
+ written` on stderr, exit 2 as before
98
+ ([the family contract](https://github.com/DescentVTT/spec-core/blob/main/docs/adr/0005-the-family-contract.md)).
99
+ - A `SOURCE_DATE_EPOCH` of more seconds than any date has is named, with
100
+ what to do about it, where the run ended on `RangeError: Invalid time
101
+ value` and a stack. Exit 2, as before.
102
+ - `archive` reports what git was not handed or could not do by its message
103
+ alone: `"-x" is not a revision` for a `--commit`, a `--base` or an
104
+ `archiving.base` that would be read as an option, and git's own words
105
+ when its status or its diff fails. Exit 2, as before.
106
+ - `init --briefs ../docs` and `--archive` likewise are refused as the
107
+ configuration refuses them, `--briefs must be a directory inside the
108
+ repository`, where the path's error came with a stack. Exit 2, as before.
109
+
14
110
  ## 0.6.0
15
111
 
16
112
  An error spec-brief did not expect before a command started, or where
package/README.md CHANGED
@@ -95,11 +95,11 @@ A brief is read as CommonMark reads it wherever that decides what is code or a c
95
95
 
96
96
  ### `spec-brief init`
97
97
 
98
- Writes `.spec-brief.json` with every default spelled out, and creates the brief and archive directories. `--briefs <dir>` and `--archive <dir>` choose them.
98
+ Writes `.spec-brief.json` with every default spelled out, and creates the brief and archive directories. `--briefs <dir>` and `--archive <dir>` choose them, and `--root <dir>` the directory to set up, which `init` makes when it is not there. It takes no argument: `init mydir` is [refused](#an-input-that-names-nothing).
99
99
 
100
100
  ### `spec-brief new <title>`
101
101
 
102
- Scaffolds a brief with the next free number - one more than the highest id, live or archived, since ids are never reused - and every required section, each holding its `hint` in a comment. A comment is not content, so a fresh brief reads as unwritten until someone writes it. `--id`, `--type`, `--wave`, `--depends-on 7,8` and `--date` fill the front matter.
102
+ Scaffolds a brief with the next free number - one more than the highest id, live or archived, since ids are never reused - and every required section, each holding its `hint` in a comment. A comment is not content, so a fresh brief reads as unwritten until someone writes it. `--id`, `--type`, `--wave`, `--depends-on 7,8` and `--date` fill the front matter; without `--date` the date is [today's](#todays-date).
103
103
 
104
104
  ### `spec-brief lint [brief...]`
105
105
 
@@ -230,6 +230,7 @@ With `archiving.rewriteLinks` off, no link is rewritten, and the links other liv
230
230
  | `--base <rev>` | Measures from the merge base instead, for a branch of several commits. |
231
231
  | `--pr <n>` | Links the pull request. |
232
232
  | `--summary <text>` | What the round did, in the banner. |
233
+ | `--date <YYYY-MM-DD>` | The date in the banner, where it is not [today's](#todays-date). |
233
234
  | `--dry-run` | Prints the plan and the banner, and writes nothing. |
234
235
 
235
236
  spec-brief reads git and never writes it. Review the change and commit it with the round.
@@ -247,13 +248,13 @@ Reopens an archived brief: the banner and the hash come off, the status goes bac
247
248
 
248
249
  | Flag | What it does |
249
250
  | --- | --- |
250
- | `--root <dir>` | Runs from another directory. |
251
+ | `--root <dir>` | Runs from another directory, which must be there: only `init` makes one that is not. |
251
252
  | `--config <file>` | Names the configuration. |
252
253
  | `--no-config` | Uses the defaults. |
253
254
  | `--format <format>` | `pretty` by default, or `json`; `lint`, `matrix` and `schedule`, which report findings, also write `sarif`, `github` and `gitlab`, and `--help` lists each command's formats. |
254
255
  | `--strict` | Makes warnings fail the run. |
255
256
  | `--no-git` | Leaves git out even inside a repository: the tree is read from disk, and `archive` records no commit. |
256
- | `--color`, `--no-color` | Override `NO_COLOR`, `FORCE_COLOR` and the terminal check. |
257
+ | `--color`, `--no-color` | Override `NO_COLOR`, `FORCE_COLOR`, `TERM=dumb` and the terminal check. |
257
258
  | `--help`, `--version` | Do what they say. |
258
259
 
259
260
  **Exit codes:**
@@ -262,10 +263,28 @@ Reopens an archived brief: the banner and the hash come off, the status goes bac
262
263
  | --- | --- |
263
264
  | `0` | Clean. |
264
265
  | `1` | Findings, a collision, or a refused action. |
265
- | `2` | The run could not be trusted - a bad flag, a configuration that does not load, a brief that does not exist, or an error spec-brief did not expect, reported on stderr as `spec-brief: unexpected error:` with its stack. |
266
+ | `2` | The run could not be trusted, or its answer did not arrive - a bad flag, [an input that names nothing](#an-input-that-names-nothing), a configuration that does not load, a brief that does not exist, a git command that failed, a `SOURCE_DATE_EPOCH` that [cannot be read as a date](#todays-date) where one is to be written, a stdout its reader closed before all of the output was written, as a pipeline into `head` does, each said in one line on stderr, or an error spec-brief did not expect, reported on stderr as `spec-brief: unexpected error:` with its stack. |
266
267
 
267
268
  A run over a briefs directory that does not exist exits 2, because a check over nothing looks exactly like a clean one.
268
269
 
270
+ ### An input that names nothing
271
+
272
+ An option, an argument or a path that is given and names nothing is refused: exit 2, one line that names it, nothing written. None is read as if it had not been given. `--root "$DIR"` where the variable is not set would otherwise run in the current directory, and the answer would look like the one that was asked for.
273
+
274
+ - **An option given nothing.** Every option that takes a value refuses one that is empty or only space, by the option's name: `--id is "", which is no value`. No option gives an empty value a meaning; leave the option out for what it does by default.
275
+ - **A list that names nothing.** `--depends-on ,` names no brief, and a brief that depends on nothing is one scaffolded without the option. A place in a list that names no brief, the comma too many in `7,8,` or the gap in `7,,8`, is refused as well: it is a brief left out, as `"$A,$B"` is with one of the two unset.
276
+ - **An argument a command does not take.** `init`, `list`, `matrix` and `schedule` take none and refuse one by name, with their usage. `init mydir` set up the current directory; `init --root mydir` is what sets up another.
277
+ - **A `--root` that is no directory.** A file, or a path that does not exist, is refused: the search for a configuration would go on from its parent, and the run would report on, or `new` write into, the tree above. A directory below the configuration's is a place to start from, as the current directory is.
278
+ - **A configuration that is a directory**, named by `--config` or found under the configuration's name, does not load, and is said so in a line, as one that is not JSON is.
279
+
280
+ ### Today's date
281
+
282
+ `new` writes a date into the front matter and `archive` one into the banner: the one `--date` gives, or today's. Today is the clock's date in UTC, unless `SOURCE_DATE_EPOCH` is set, the variable a pipeline sets to have every run write the same bytes ([its specification](https://reproducible-builds.org/specs/source-date-epoch/)): then it is the date of that time, in UTC.
283
+
284
+ - **What it holds**: whole seconds since 1970-01-01 in digits alone, as `date +%s` prints them, from `0` to `253402300799`, the last second of 9999-12-31 and of the dates written `YYYY-MM-DD`.
285
+ - **Anything else is refused**, never read as unset: a word, `1.5`, `-1`, a space or a newline beside the digits, a later time, or nothing at all, which is what `SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)` leaves where git failed. `new` and `archive` then write nothing and exit 2 with a line that names the variable and shows what it holds. The clock's date, written where a pipeline asked for its own, would look like any other.
286
+ - **Only a run that would use it reads it**: `--date` gives the date instead, and `init`, `lint`, `list`, `matrix`, `schedule`, `unarchive`, `--version` and `--help` write none, so each runs whatever the variable holds.
287
+
269
288
  ## Configuration
270
289
 
271
290
  `.spec-brief.json` (or `spec-brief.json`) at the root; the nearest one above the working directory is used, and its directory is the root. It is JSON, validated against [`schema.json`](schema.json): an unknown key, a misspelt rule or a value of the wrong type stops the run with exit 2 rather than falling back to defaults and reporting on the wrong rules.
package/bin/spec-brief.js CHANGED
@@ -7,8 +7,15 @@
7
7
  // main answers the errors it awaits with exit 2. What nothing awaits - a
8
8
  // stream's error, a timer's, a promise nobody holds - Node ends with exit 1,
9
9
  // which a script reads as findings: the same answer for those.
10
+ //
11
+ // A reader that closed stdout, as `| head` does, is no defect, and it arrives
12
+ // here, as the stream's error: one line, with no stack to send a person
13
+ // looking for a bug. The code alone names it, since stdout and stderr are the
14
+ // only pipes spec-brief writes to, and a line about a closed stderr reaches
15
+ // nobody.
10
16
  process.on('uncaughtException', (error) => {
11
- process.stderr.write(`spec-brief: unexpected error: ${error?.stack ?? error}\n`);
17
+ const said = error?.code === 'EPIPE' ? 'stdout was closed before all of the output was written' : `unexpected error: ${error?.stack ?? error}`;
18
+ process.stderr.write(`spec-brief: ${said}\n`);
12
19
  process.exit(2);
13
20
  });
14
21
 
package/dist/cli.js CHANGED
@@ -6,13 +6,14 @@
6
6
  * that does not load, a brief that does not exist. A run that checked nothing
7
7
  * must never exit as though it found nothing.
8
8
  */
9
- import { readFile } from 'node:fs/promises';
9
+ import { readFile, stat } from 'node:fs/promises';
10
10
  import { parseArgs } from 'node:util';
11
11
  import { TransactionError, ConflictError } from './apply.js';
12
12
  import { reported } from './collisions.js';
13
13
  import { CONFIG_FILES, ConfigError, initialConfig } from './config.js';
14
14
  import { BriefEngine, EngineError, today } from './engine.js';
15
15
  import { NodeFileSystem } from './fs.js';
16
+ import { GitError } from './git.js';
16
17
  import { failing, sortFindings, summarise } from './lint.js';
17
18
  import { normalisePath } from './links.js';
18
19
  import { briefJson, findingJson, FORMATS, githubCommands, gitlabCodeQuality, jsonDocument, matrixJson, planJson, prettyFindings, prettyList, prettyMatrix, prettyPlan, prettySchedule, sarif, scheduleJson, sectionsJson, summaryLine, } from './report.js';
@@ -38,7 +39,11 @@ const GLOBAL = {
38
39
  version: { type: 'boolean', short: 'v' },
39
40
  };
40
41
  const TABLE = {
41
- init: { options: { briefs: { type: 'string' }, archive: { type: 'string' } }, formats: ['pretty', 'json'] },
42
+ init: {
43
+ options: { briefs: { type: 'string' }, archive: { type: 'string' } },
44
+ formats: ['pretty', 'json'],
45
+ noArgument: '; --root <dir> names the directory it sets up, and --briefs <dir> the one for the briefs',
46
+ },
42
47
  new: {
43
48
  options: {
44
49
  id: { type: 'string' },
@@ -50,9 +55,9 @@ const TABLE = {
50
55
  formats: ['pretty', 'json'],
51
56
  },
52
57
  lint: { options: {}, formats: FORMATS },
53
- list: { options: { ready: { type: 'boolean' }, archived: { type: 'boolean' } }, formats: ['pretty', 'json'] },
54
- matrix: { options: { 'all-waves': { type: 'boolean' } }, formats: FORMATS },
55
- schedule: { options: { write: { type: 'boolean' } }, formats: FORMATS },
58
+ list: { options: { ready: { type: 'boolean' }, archived: { type: 'boolean' } }, formats: ['pretty', 'json'], noArgument: '' },
59
+ matrix: { options: { 'all-waves': { type: 'boolean' } }, formats: FORMATS, noArgument: '' },
60
+ schedule: { options: { write: { type: 'boolean' } }, formats: FORMATS, noArgument: '' },
56
61
  archive: {
57
62
  options: {
58
63
  pr: { type: 'string' },
@@ -104,7 +109,7 @@ Commands:
104
109
  unarchive <brief> reopen an archived brief
105
110
 
106
111
  Options for every command:
107
- --root <dir> run from this directory instead of the current one
112
+ --root <dir> run from this directory instead of the current one; it must exist, unless init makes it
108
113
  --config <file> use this configuration file
109
114
  --no-config use the defaults, ignoring any configuration file
110
115
  --format <format> the output format, pretty by default; each command's are below
@@ -126,7 +131,7 @@ new:
126
131
  --type <type> a brief type from the configuration
127
132
  --wave <n> the wave it runs in
128
133
  --depends-on <ids> comma-separated ids it runs after
129
- --date <YYYY-MM-DD> the date in its front matter; default today
134
+ --date <YYYY-MM-DD> the date in its front matter; default SOURCE_DATE_EPOCH's, or today's in UTC
130
135
 
131
136
  list:
132
137
  --ready only briefs that can run now: dependencies archived, not drafts or deferred
@@ -143,13 +148,16 @@ archive:
143
148
  --commit <rev> the commit the round landed as
144
149
  --base <rev> the branch it started from; the diff runs from the merge base
145
150
  --summary <text> what the round did, for the banner
146
- --date <YYYY-MM-DD> the archival date; default today
151
+ --date <YYYY-MM-DD> the archival date; default SOURCE_DATE_EPOCH's, or today's in UTC
147
152
  --allow-dirty archive although the tree has uncommitted work
148
153
  --dry-run print the plan and change nothing
149
154
 
150
155
  unarchive:
151
156
  --dry-run print the plan and change nothing
152
157
 
158
+ An option given an empty value, a list that names nothing and an argument a
159
+ command does not take are refused, not read as if they were not there.
160
+
153
161
  Exit codes: 0 clean, 1 findings or refused, 2 the run could not be trusted.
154
162
  `;
155
163
  /** Global options that take a value, so the value is not mistaken for the command. */
@@ -174,18 +182,34 @@ export function parse(argv) {
174
182
  if (command !== undefined && spec === undefined) {
175
183
  throw new UsageError(`"${command}" is not a command; run spec-brief --help`);
176
184
  }
185
+ let parsed;
177
186
  try {
178
- const { values, positionals } = parseArgs({
187
+ parsed = parseArgs({
179
188
  args: [...argv],
180
189
  options: { ...GLOBAL, ...(spec?.options ?? {}) },
181
190
  allowPositionals: true,
182
191
  strict: true,
183
192
  });
184
- return { command, values: values, positionals: positionals.slice(1) };
185
193
  }
186
194
  catch (error) {
187
195
  throw new UsageError(error.message);
188
196
  }
197
+ const positionals = parsed.positionals.slice(1);
198
+ // What the parser takes is not yet what names something. An option whose
199
+ // value is empty, as `--root "$DIR"` is where the variable is not set, was
200
+ // read as the option left out, and an argument no command asked for was
201
+ // dropped: either way the run answered a question nobody had put, exit 0.
202
+ for (const [name, value] of Object.entries(parsed.values)) {
203
+ if (typeof value === 'string' && value.trim() === '') {
204
+ // Shown as JSON, where a tab or a line break in it can be seen.
205
+ throw new UsageError(`--${name} is ${JSON.stringify(value)}, which is no value; give it one, or leave the option out`);
206
+ }
207
+ }
208
+ if (spec?.noArgument !== undefined && positionals.length > 0) {
209
+ const extra = positionals.map((argument) => `"${argument}"`).join(', ');
210
+ throw new UsageError(`${command} takes no argument, not ${extra}: spec-brief ${command}${spec.noArgument}`);
211
+ }
212
+ return { command, values: parsed.values, positionals };
189
213
  }
190
214
  /** The value of an option declared a string, which is all `parseArgs` gives one. */
191
215
  function text(values, key) {
@@ -217,6 +241,10 @@ function useColor(values, io) {
217
241
  const force = env['FORCE_COLOR'];
218
242
  if (force !== undefined && force !== '')
219
243
  return force !== '0';
244
+ // A terminal that says it is dumb has asked for no escape sequence, and
245
+ // Node itself counts one colour for it: the family's tools read it alike.
246
+ if (env['TERM'] === 'dumb')
247
+ return false;
220
248
  return (io.stdout ?? process.stdout).isTTY === true;
221
249
  }
222
250
  async function openEngine(run) {
@@ -257,8 +285,18 @@ async function runInit(run) {
257
285
  if (await fs.exists(name))
258
286
  throw new UsageError(`${name} already exists here; edit it instead`);
259
287
  }
260
- const briefs = normalisePath(text(run.values, 'briefs') ?? 'briefs');
261
- const archive = normalisePath(text(run.values, 'archive') ?? `${briefs}/archive`);
288
+ // A directory above the root is refused as the configuration refuses one
289
+ // (config.ts), in the same words, before anything is written.
290
+ const inside = (option, given) => {
291
+ try {
292
+ return normalisePath(given);
293
+ }
294
+ catch {
295
+ throw new UsageError(`--${option} must be a directory inside the repository, not "${given}"`);
296
+ }
297
+ };
298
+ const briefs = inside('briefs', text(run.values, 'briefs') ?? 'briefs');
299
+ const archive = inside('archive', text(run.values, 'archive') ?? `${briefs}/archive`);
262
300
  if (briefs === '' || archive === '' || briefs === archive)
263
301
  throw new UsageError('--briefs and --archive must be two directories');
264
302
  const config = `${JSON.stringify(initialConfig(briefs, archive), null, 2)}\n`;
@@ -281,18 +319,26 @@ async function runNew(run) {
281
319
  const title = run.positionals.join(' ').trim();
282
320
  if (title === '')
283
321
  throw new UsageError('new needs a title: spec-brief new "<title>"');
322
+ const listed = text(run.values, 'depends-on');
323
+ const dependsOn = listed?.split(',').map((d) => d.trim());
324
+ // A list that names no brief is not a brief that depends on none: written
325
+ // as given, "," scaffolded a brief with no dependency and said nothing.
326
+ if (dependsOn?.every((d) => d === '')) {
327
+ throw new UsageError(`--depends-on is ${JSON.stringify(listed)}, which names no brief; give it ids with commas between them, as in 7,8, or leave the option out`);
328
+ }
329
+ // And a place in it that names none is a brief left out: "$A,$B" with one
330
+ // of the two unset scaffolded a brief that depends on the other alone.
331
+ if (dependsOn?.includes('')) {
332
+ throw new UsageError(`--depends-on is ${JSON.stringify(listed)}, which names no brief in one of its places; give it ids with one comma between each two, as in 7,8, and none before or after`);
333
+ }
284
334
  const engine = await openEngine(run);
285
- const dependsOn = text(run.values, 'depends-on')
286
- ?.split(',')
287
- .map((d) => d.trim())
288
- .filter((d) => d !== '');
289
335
  const created = await engine.create({
290
336
  title,
291
337
  id: text(run.values, 'id'),
292
338
  type: text(run.values, 'type'),
293
339
  wave: integer(run.values, 'wave', 0),
294
340
  dependsOn,
295
- date: text(run.values, 'date') ?? run.today,
341
+ date: text(run.values, 'date') ?? run.today(),
296
342
  });
297
343
  if (run.format === 'json')
298
344
  run.out(jsonDocument('new', run.version, { ok: true, file: created.file }));
@@ -378,7 +424,7 @@ async function runTransition(run, action) {
378
424
  commit: text(run.values, 'commit'),
379
425
  base: text(run.values, 'base'),
380
426
  summary: text(run.values, 'summary'),
381
- date: text(run.values, 'date') ?? run.today,
427
+ date: text(run.values, 'date') ?? run.today(),
382
428
  allowDirty: flag(run.values, 'allow-dirty'),
383
429
  strict: run.strict,
384
430
  noGit: flag(run.values, 'no-git'),
@@ -409,7 +455,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
409
455
  }
410
456
  const { command, values, positionals } = parsed;
411
457
  // From here on, so that what comes before a command is covered as the
412
- // command is: --version and --help, the package's own manifest, the date.
458
+ // command is: --version and --help, the package's own manifest, the colour.
413
459
  // An error there left main as a rejection, which the launcher ended as
414
460
  // Node's uncaught error with exit 1, "findings" to a script.
415
461
  try {
@@ -428,8 +474,20 @@ export async function main(argv = process.argv.slice(2), io = {}) {
428
474
  err(`--format for ${command} is one of ${spec.formats.join(', ')}, not "${format}"`);
429
475
  return EXIT_ERROR;
430
476
  }
477
+ // A file, or a name with nothing behind it, had the search for a
478
+ // configuration go on from its parent: the run reported on a tree nobody
479
+ // had named, and `new` wrote into it. A path that cannot be reached is
480
+ // not there to the run either. `init` alone is given a directory that
481
+ // does not exist yet, which it makes.
482
+ const root = text(values, 'root');
483
+ if (root !== undefined) {
484
+ const found = await stat(root).catch(() => null);
485
+ if (found === null ? command !== 'init' : !found.isDirectory()) {
486
+ throw new UsageError(`--root is "${root}", which is no directory; give it one, or leave the option out`);
487
+ }
488
+ }
431
489
  const run = {
432
- today: today(io.env ?? process.env),
490
+ today: () => today(io.env ?? process.env),
433
491
  out: (t) => {
434
492
  stdout.write(t);
435
493
  },
@@ -463,12 +521,24 @@ export async function main(argv = process.argv.slice(2), io = {}) {
463
521
  }
464
522
  }
465
523
  catch (error) {
466
- const expected = [ConfigError, UsageError, EngineError, ConflictError, TransactionError];
524
+ // What git refused or could not do is git's message, and no line here.
525
+ const expected = [ConfigError, UsageError, EngineError, ConflictError, TransactionError, GitError];
467
526
  if (expected.some((kind) => error instanceof kind)) {
468
527
  err(error.message);
469
528
  return EXIT_ERROR;
470
529
  }
471
- err(`unexpected error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
530
+ // A write its reader had closed the pipe for is no defect either: the
531
+ // answer was not delivered, which is still 2, and no stack sends a person
532
+ // looking for a bug. The process's own streams report it as an event,
533
+ // which the launcher answers in the same words; here it is a caller's
534
+ // stream that throws it. It is read by its code: stdout and stderr are the
535
+ // only pipes spec-brief writes to.
536
+ if (!(error instanceof Error))
537
+ err(`unexpected error: ${String(error)}`);
538
+ else if (error.code === 'EPIPE')
539
+ err('stdout was closed before all of the output was written');
540
+ else
541
+ err(`unexpected error: ${error.stack ?? error.message}`);
472
542
  return EXIT_ERROR;
473
543
  }
474
544
  }