rman 2.2.0 → 2.3.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/cli.js CHANGED
@@ -21,6 +21,7 @@ import { LOG_LEVELS, Logger, resolveRootLogLevel } from './utils/logger.js';
21
21
  import { filterPackages, readFromRootOption, readPackageFilterOptions } from './utils/package-filter.js';
22
22
  import { printableConfig, withoutContributions } from './utils/printable-config.js';
23
23
  import { runBin } from './utils/run-bin.js';
24
+ import { StatusRegion } from './utils/status-region.js';
24
25
  export async function runCli(options) {
25
26
  const _argv = options?.argv || hideBin(process.argv);
26
27
  /**
@@ -97,6 +98,14 @@ export async function runCli(options) {
97
98
  * quietly does nothing on whichever command forgot it is worse than no flag.
98
99
  */
99
100
  interceptConfigFlag(repository, program);
101
+ /**
102
+ * **A command that says nothing is indistinguishable from one that did not run.** `rman lint`
103
+ * on a clean repository starts and ends in silence - eslint prints nothing when it has nothing
104
+ * to say - and the reader is left wondering whether the command exists. One line when it
105
+ * starts and one when it ends answers that for every command at once, which is the same reason
106
+ * `--config` is applied here rather than per command.
107
+ */
108
+ interceptStatusLines(repository, program);
100
109
  /**
101
110
  * **Built-ins come from `commandRegistry`, in the order their modules were imported.** Each
102
111
  * entry is a register function that has not run yet - it runs here, with the repository, and
@@ -106,9 +115,10 @@ export async function runCli(options) {
106
115
  * The thirteen hand-written `initCli(repository, program)` calls this replaces were the second
107
116
  * place a command had to be listed, and the list the shadow check guards with was a third.
108
117
  */
118
+ /** Registered further down, once the contributed commands are known - a `shadowable` built-in
119
+ * whose name one of them took must not be registered at all, or `--help` lists the name twice
120
+ * with two descriptions and nothing says which runs. */
109
121
  const builtIns = commandRegistry.map(register => register(app));
110
- for (const meta of builtIns)
111
- program.command(toYargsCommand(meta));
112
122
  /**
113
123
  * Commands that are not built in, all from **one** key: `.rmanrc "commands"`, whose default
114
124
  * value is `.rman/*.{js,mjs,cjs}`.
@@ -184,6 +194,34 @@ export async function runCli(options) {
184
194
  }
185
195
  }
186
196
  assertNoBuiltinShadowing(commands, builtInNames(builtIns));
197
+ /**
198
+ * **Built-ins first, minus any `shadowable` one a contributed command has taken.**
199
+ *
200
+ * Skipping the registration rather than letting yargs' last-wins settle it is the same fix
201
+ * `byName` above is: the override already resolved correctly either way, but both rows appeared
202
+ * in `--help`, each with its own description and nothing to say which would run. Measured here
203
+ * on `test`, with rman's alias and `@panates/rman-preset`'s command both listed.
204
+ *
205
+ * Order is unchanged - built-ins are still registered before the contributed ones, so help
206
+ * reads as it always has.
207
+ */
208
+ const taken = new Set(commands.map(c => c.name));
209
+ const shadowed = builtIns.filter(meta => meta.shadowable && taken.has(commandName(meta.command)));
210
+ for (const meta of builtIns) {
211
+ if (shadowed.includes(meta))
212
+ continue;
213
+ program.command(toYargsCommand(meta));
214
+ }
215
+ /** Said out loud at `verbose`, for the reason the contributed-on-contributed note above gives:
216
+ * an override is correct and should not nag, but "rman test does something else here" needs a
217
+ * thread to pull. */
218
+ if (shadowed.length) {
219
+ const logger = new Logger(argvLogLevel(_argv) ?? resolveRootLogLevel(repository));
220
+ for (const meta of shadowed) {
221
+ const name = commandName(meta.command);
222
+ logger.verbose(`rman's own "${name}" is overridden by ${describeCommandSource(byName.get(name).file)}.`);
223
+ }
224
+ }
187
225
  for (const { module } of commands)
188
226
  program.command(module);
189
227
  /** Warned about, not thrown: one unparseable file must not take the other commands with it.
@@ -305,6 +343,74 @@ function interceptConfigFlag(repository, program) {
305
343
  });
306
344
  };
307
345
  }
346
+ /**
347
+ * Replaces `program.command` with a version that brackets every handler in a line naming the
348
+ * command and a line giving its result and elapsed time.
349
+ *
350
+ * One interception point rather than a line written into each command - the same reason
351
+ * `interceptConfigFlag` is one, and it has to go in **before** any command is registered.
352
+ */
353
+ /* **Why this exists at all**: a command that prints nothing is indistinguishable from one that
354
+ * never ran. `rman lint` on a clean repository is exactly that - eslint says nothing when it has
355
+ * nothing to say - and the reader is left asking whether the command took.
356
+ *
357
+ * **Four cases stay silent**, and each is about not corrupting something:
358
+ *
359
+ * - `printsDocument` - the command's stdout *is* its answer. `rman config` writes a loadable YAML
360
+ * document; a line above it makes the document unparseable, which is the same failure its own
361
+ * colour handling already avoids for a pipe.
362
+ * - `--json` and `--config` - machine output, whichever command produced it. `--json` is checked
363
+ * rather than declared because any command may grow one, and a consumer doing
364
+ * `rman version --json | jq` must never receive prose.
365
+ * - `--log-level silent`, which is what it is for.
366
+ *
367
+ * **Written to stderr, not stdout**, so `rman changelog > NOTES.md` keeps the notes alone in the
368
+ * file even for a command that declares no `printsDocument`. The status is about the run, not the
369
+ * answer, and stderr is where that belongs.
370
+ *
371
+ * **The end line reports failure too, and then rethrows.** A command that throws still deserves the
372
+ * bracket closed - a start line with nothing after it reads as a hang. The error itself is left to
373
+ * the caller, which already prints it. */
374
+ function interceptStatusLines(repository, program) {
375
+ const register = program.command.bind(program);
376
+ program.command = (spec) => {
377
+ if (!spec || typeof spec !== 'object' || typeof spec.handler !== 'function')
378
+ return register(spec);
379
+ const run = spec.handler;
380
+ return register({
381
+ ...spec,
382
+ handler: async (args) => {
383
+ /** The flag when it was given, the repository's own `logLevel` otherwise - the same
384
+ * precedence every other reader of it applies. `argvLogLevel` is not used here: that one
385
+ * exists for diagnostics emitted *before* parsing, and by now yargs has the answer. */
386
+ const level = args.logLevel ?? repository.config?.logLevel;
387
+ if (spec.printsDocument || args.json || args.config || level === 'silent')
388
+ return run(args);
389
+ const name = commandName(spec.command ?? '');
390
+ /** Held on the application so `runBin` can find it through the `app` it already receives -
391
+ * see `RmanApplication.statusRegion` for why that rather than ambient state. */
392
+ const region = new StatusRegion(name, repository.name ?? '');
393
+ repository.app.statusRegion = region;
394
+ region.start();
395
+ try {
396
+ const result = await run(args);
397
+ region.stop('ok');
398
+ return result;
399
+ }
400
+ catch (e) {
401
+ /** **The bracket is closed even on a failure.** A spinner that simply stops, leaving its
402
+ * last frame on screen, reads as a hang - which is the thing this exists to prevent. The
403
+ * error itself is left to the caller, which already prints it. */
404
+ region.stop('fail');
405
+ throw e;
406
+ }
407
+ finally {
408
+ repository.app.statusRegion = undefined;
409
+ }
410
+ },
411
+ });
412
+ };
413
+ }
308
414
  /**
309
415
  * What a command would run with: its resolved options, the packages it would act on, and the
310
416
  * `.rmanrc` those packages carry.
@@ -420,7 +526,13 @@ if (isMain())
420
526
  * yargs' own command rather than one of ours, so it is the one name still written here.
421
527
  */
422
528
  function builtInNames(metas) {
423
- const names = metas.flatMap(meta => [commandName(meta.command), ...(meta.aliases ?? [])]);
529
+ /** **A `shadowable` built-in is left out, so a contributed command may take its name.** Only
530
+ * `build` and `test` are, and only because they carry no logic of their own - see
531
+ * `CommandMetadata.shadowable`. Everything else defends its name, which is what keeps
532
+ * `rman publish` from resolving to two different things. */
533
+ const names = metas
534
+ .filter(meta => !meta.shadowable)
535
+ .flatMap(meta => [commandName(meta.command), ...(meta.aliases ?? [])]);
424
536
  return [...names, 'completion'];
425
537
  }
426
538
  /**
@@ -83,6 +83,9 @@ type Args = RmanConfig.ArgsOf<typeof config, typeof COMMAND>;
83
83
  declare const buildCommand: (app: import("../index.js").RmanApplication) => {
84
84
  command: "build";
85
85
  describe: string;
86
+ /** An alias carries no logic of its own, so a repository with a better answer for the name may
87
+ * take it - see `CommandMetadata.shadowable`. */
88
+ shadowable: true;
86
89
  /**
87
90
  * Read, not owned - and it owns nothing at all. `build` is `run build` under another name, so its
88
91
  * settings live in `run.build`, which belongs to `run`. Two commands cannot contribute under one
@@ -8,6 +8,9 @@ const buildCommand = registerCommand(app => {
8
8
  return {
9
9
  command: COMMAND,
10
10
  describe: 'Alias for "run build"',
11
+ /** An alias carries no logic of its own, so a repository with a better answer for the name may
12
+ * take it - see `CommandMetadata.shadowable`. */
13
+ shadowable: true,
11
14
  /**
12
15
  * Read, not owned - and it owns nothing at all. `build` is `run build` under another name, so its
13
16
  * settings live in `run.build`, which belongs to `run`. Two commands cannot contribute under one
@@ -174,6 +174,8 @@ export interface ChangelogExtraKeys {
174
174
  type Args = RmanConfig.ArgsOf<typeof config, typeof COMMAND>;
175
175
  declare const changelogCommand: (app: import("../index.js").RmanApplication) => {
176
176
  command: "changelog";
177
+ /** Prints the notes themselves, which a caller redirects into a file. */
178
+ printsDocument: true;
177
179
  describe: string;
178
180
  /** Read, not owned: `publish.skip` is `publish`'s key, reused here on purpose - a package that is
179
181
  * never distributed gets no release notes either. */
@@ -135,6 +135,8 @@ const changelogCommand = registerCommand(app => {
135
135
  const repository = app.repository;
136
136
  return {
137
137
  command: COMMAND,
138
+ /** Prints the notes themselves, which a caller redirects into a file. */
139
+ printsDocument: true,
138
140
  describe: 'Generates a changelog per package from unreleased commits',
139
141
  /** Read, not owned: `publish.skip` is `publish`'s key, reused here on purpose - a package that is
140
142
  * never distributed gets no release notes either. */
@@ -19,6 +19,8 @@ type Args = RmanConfig.ArgsOf<typeof config, typeof COMMAND>;
19
19
  /** Prints config; declares none of its own, so it contributes nothing to `RmanConfig`. */
20
20
  declare const configCommand: (app: import("../index.js").RmanApplication) => {
21
21
  command: "config";
22
+ /** Prints a loadable YAML document - a line above it is what makes it unparseable. */
23
+ printsDocument: true;
22
24
  describe: string;
23
25
  config: {
24
26
  json: {
@@ -22,6 +22,8 @@ const configCommand = registerCommand(app => {
22
22
  const repository = app.repository;
23
23
  return {
24
24
  command: COMMAND,
25
+ /** Prints a loadable YAML document - a line above it is what makes it unparseable. */
26
+ printsDocument: true,
25
27
  describe: 'Prints the effective .rmanrc config for the package of the current directory',
26
28
  config,
27
29
  examples: [
@@ -12,6 +12,8 @@ declare const config: {
12
12
  type Args = RmanConfig.ArgsOf<typeof config, typeof COMMAND>;
13
13
  declare const diffCommand: (app: import("../index.js").RmanApplication) => {
14
14
  command: "diff [package]";
15
+ /** Prints git's own diff, passed through. */
16
+ printsDocument: true;
15
17
  describe: string;
16
18
  config: {
17
19
  fromRoot: {
@@ -10,6 +10,8 @@ const diffCommand = registerCommand(app => {
10
10
  const repository = app.repository;
11
11
  return {
12
12
  command: COMMAND,
13
+ /** Prints git's own diff, passed through. */
14
+ printsDocument: true,
13
15
  describe: "Shows the git diff since a package's (or the whole repository's) last release tag",
14
16
  config,
15
17
  positionals: {
@@ -12,6 +12,8 @@ type Args = RmanConfig.ArgsOf<typeof config, typeof COMMAND>;
12
12
  /** Reports; declares no config key of its own. */
13
13
  declare const infoCommand: (app: import("../index.js").RmanApplication) => {
14
14
  command: "info";
15
+ /** Prints a report, which is the whole output. */
16
+ printsDocument: true;
15
17
  describe: string;
16
18
  config: {
17
19
  json: {
@@ -11,6 +11,8 @@ const infoCommand = registerCommand(app => {
11
11
  const repository = app.repository;
12
12
  return {
13
13
  command: COMMAND,
14
+ /** Prints a report, which is the whole output. */
15
+ printsDocument: true,
14
16
  describe: 'Prints local environment and repository information',
15
17
  config,
16
18
  examples: [
@@ -79,6 +79,8 @@ declare const config: {
79
79
  type Args = RmanConfig.ArgsOf<typeof config, typeof COMMAND>;
80
80
  declare const listCommand: (app: import("../index.js").RmanApplication) => {
81
81
  command: "list [options...]";
82
+ /** Prints a table, and `--json` beside it. */
83
+ printsDocument: true;
82
84
  aliases: string[];
83
85
  describe: string;
84
86
  config: {
@@ -43,6 +43,8 @@ const config = {
43
43
  const listCommand = registerCommand(app => {
44
44
  return {
45
45
  command: COMMAND,
46
+ /** Prints a table, and `--json` beside it. */
47
+ printsDocument: true,
46
48
  aliases: ['ls'],
47
49
  describe: 'Lists packages in repository',
48
50
  config,
@@ -83,6 +83,9 @@ type Args = RmanConfig.ArgsOf<typeof config, typeof COMMAND>;
83
83
  declare const testCommand: (app: import("../index.js").RmanApplication) => {
84
84
  command: "test";
85
85
  describe: string;
86
+ /** An alias carries no logic of its own, so a repository with a better answer for the name may
87
+ * take it - see `CommandMetadata.shadowable`. */
88
+ shadowable: true;
86
89
  /** Owns nothing, for the same reason `build` does not - see there. */
87
90
  configKeys: string[];
88
91
  config: {
@@ -8,6 +8,9 @@ const testCommand = registerCommand(app => {
8
8
  return {
9
9
  command: COMMAND,
10
10
  describe: 'Alias for "run test"',
11
+ /** An alias carries no logic of its own, so a repository with a better answer for the name may
12
+ * take it - see `CommandMetadata.shadowable`. */
13
+ shadowable: true,
11
14
  /** Owns nothing, for the same reason `build` does not - see there. */
12
15
  configKeys: ['run.test'],
13
16
  config,
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.2.0';
1
+ export const version = '2.3.0';
@@ -1,5 +1,6 @@
1
1
  import type { VersionPlanService } from '../services/version-plan.service.js';
2
2
  import { Logger, type LogLevel } from '../utils/logger.js';
3
+ import type { StatusRegion } from '../utils/status-region.js';
3
4
  import { Registry } from './classes/registry.js';
4
5
  import type { Repository } from './classes/repository.js';
5
6
  import type { ServiceFactory, ServiceMap } from './classes/service.js';
@@ -32,6 +33,15 @@ export declare class RmanApplication {
32
33
  * puts none.
33
34
  */
34
35
  readonly platforms: Registry<Platform>;
36
+ /**
37
+ * The live status line, while a command is running under one - `cli.ts` sets it and clears it.
38
+ *
39
+ * Read by whatever spawns a child process: a region draws a block at the bottom of the terminal
40
+ * and redraws it in place, so a child writing straight to the terminal scrolls the screen and the
41
+ * next redraw erases the wrong rows. `runBin` therefore pipes the child while this is set and
42
+ * routes its output through `passThrough`.
43
+ */
44
+ statusRegion?: StatusRegion;
35
45
  /**
36
46
  * The **plugins** themselves, in declaration order - what a config named, before the platforms
37
47
  * were taken out of them.
@@ -37,6 +37,19 @@ export class RmanApplication {
37
37
  * puts none.
38
38
  */
39
39
  platforms = new Registry();
40
+ /**
41
+ * The live status line, while a command is running under one - `cli.ts` sets it and clears it.
42
+ *
43
+ * Read by whatever spawns a child process: a region draws a block at the bottom of the terminal
44
+ * and redraws it in place, so a child writing straight to the terminal scrolls the screen and the
45
+ * next redraw erases the wrong rows. `runBin` therefore pipes the child while this is set and
46
+ * routes its output through `passThrough`.
47
+ */
48
+ /* **On the application rather than a module-level singleton**, which is the same seam `BinPath`
49
+ * already uses: `runBin` and `exec` are handed an `app`, so nothing has to reach for ambient
50
+ * state, and a spec that builds its own application cannot be affected by one that built
51
+ * another. The root-hook era this repository removed is what that rule is about. */
52
+ statusRegion;
40
53
  /**
41
54
  * The **plugins** themselves, in declaration order - what a config named, before the platforms
42
55
  * were taken out of them.
@@ -13,6 +13,11 @@ export function toYargsCommand(meta) {
13
13
  aliases: meta.aliases,
14
14
  describe: meta.describe,
15
15
  configKeys: configKeysOf(meta),
16
+ /** Carried through like `configKeys`, and for the same reason: `cli.ts` reads it off the
17
+ * registration, which is the only shape that reaches `program.command`. Left out, the flag is
18
+ * a field nothing ever sees - measured, `rman config` printed a status line above its own YAML
19
+ * document while declaring `printsDocument: true`. */
20
+ printsDocument: meta.printsDocument,
16
21
  builder: (cmd) => {
17
22
  if (meta.parserConfiguration)
18
23
  cmd.parserConfiguration(meta.parserConfiguration);
@@ -68,6 +68,10 @@ export interface CustomCommand {
68
68
  declare module 'yargs' {
69
69
  interface CommandModule<T = {}, U = {}> {
70
70
  configKeys?: ConfigKeys;
71
+ /** Whether this command's stdout is a document nothing may be printed around - see
72
+ * `CommandMetadata.printsDocument`. Here as well as there because `cli.ts` reads it off the
73
+ * *registration*, which is the only shape that reaches `program.command`. */
74
+ printsDocument?: boolean;
71
75
  }
72
76
  }
73
77
  /**
@@ -536,6 +536,27 @@ export declare namespace RmanConfig {
536
536
  * parses.
537
537
  */
538
538
  positionals?: Record<string, PositionalOption>;
539
+ /**
540
+ * **This command's stdout *is* its answer, so nothing may be printed around it.** `rman config`
541
+ * writes a loadable YAML document, `list` a table, `changelog` the notes themselves - a status
542
+ * line above or below any of them is content the reader did not ask for, and for `config` it
543
+ * makes the output unloadable.
544
+ *
545
+ * Everything else gets a line naming the command when it starts and one giving the result and
546
+ * the elapsed time when it ends (`cli.ts`'s `interceptStatusLines`).
547
+ */
548
+ printsDocument?: boolean;
549
+ /**
550
+ * **Whether a contributed command may take this name.** `build` and `test` are the only two,
551
+ * and they are the only two that carry no logic of their own - both are `run <script>` under a
552
+ * shorter name. A name whose holder has nothing to defend should belong to whoever has the
553
+ * better answer for it.
554
+ *
555
+ * Read by `cli.ts`'s `builtInNames`, which is what `assertNoBuiltinShadowing` checks against.
556
+ * The existing "one registration per name, keeping the last" rule then does the rest, and
557
+ * announces the override at `--log-level verbose`.
558
+ */
559
+ shadowable?: boolean;
539
560
  /**
540
561
  * **The parameter is the command's own business, so this states no type for it.**
541
562
  * A handler annotates itself with `ArgsOf<typeof config, typeof COMMAND>`, which this interface
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.2.0",
4
+ "version": "2.3.0",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -9,7 +9,12 @@
9
9
  export declare class LiveRegion {
10
10
  private lineCount;
11
11
  readonly enabled: boolean;
12
- constructor(enabled?: boolean);
12
+ /** Where the block is drawn. **stdout by default**, which is what the progress panel has always
13
+ * used; a caller whose *answer* goes to stdout passes stderr instead, so redirecting the answer
14
+ * into a file does not capture cursor-movement codes. Both land on the same terminal, so only
15
+ * one region may be live at a time either way. */
16
+ private readonly stream;
17
+ constructor(enabled?: boolean, stream?: NodeJS.WriteStream);
13
18
  render(lines: string[]): void;
14
19
  /** Erases the block entirely, leaving the cursor where it started. */
15
20
  clear(): void;
@@ -9,13 +9,19 @@
9
9
  export class LiveRegion {
10
10
  lineCount = 0;
11
11
  enabled;
12
- constructor(enabled = !!process.stdout.isTTY) {
13
- this.enabled = enabled;
12
+ /** Where the block is drawn. **stdout by default**, which is what the progress panel has always
13
+ * used; a caller whose *answer* goes to stdout passes stderr instead, so redirecting the answer
14
+ * into a file does not capture cursor-movement codes. Both land on the same terminal, so only
15
+ * one region may be live at a time either way. */
16
+ stream;
17
+ constructor(enabled, stream = process.stdout) {
18
+ this.stream = stream;
19
+ this.enabled = enabled ?? !!stream.isTTY;
14
20
  }
15
21
  render(lines) {
16
22
  if (!this.enabled)
17
23
  return;
18
- const width = process.stdout.columns || 80;
24
+ const width = this.stream.columns || 80;
19
25
  /** Measure by visible length, not raw length - a heavily-colored line has far more
20
26
  * bytes than visible characters, and slicing the raw string would cut mid-escape-code. */
21
27
  let clipped = lines.map(l => {
@@ -27,7 +33,7 @@ export class LiveRegion {
27
33
  * scrolls the screen, "move up N rows" can't actually get back N rows, and every
28
34
  * redraw lands lower than the last, leaving a trail instead of a single updating block.
29
35
  * Callers should size their own content to fit, but this is a hard backstop. */
30
- const maxRows = Math.max(1, (process.stdout.rows || 24) - 1);
36
+ const maxRows = Math.max(1, (this.stream.rows || 24) - 1);
31
37
  if (clipped.length > maxRows)
32
38
  clipped = clipped.slice(0, maxRows);
33
39
  let out = this.lineCount ? `\x1b[${this.lineCount}A` : '';
@@ -37,7 +43,7 @@ export class LiveRegion {
37
43
  out += '\r\x1b[2K\n';
38
44
  if (clipped.length < this.lineCount)
39
45
  out += `\x1b[${this.lineCount - clipped.length}A`;
40
- process.stdout.write(out);
46
+ this.stream.write(out);
41
47
  this.lineCount = clipped.length;
42
48
  }
43
49
  /** Erases the block entirely, leaving the cursor where it started. */
@@ -48,7 +54,7 @@ export class LiveRegion {
48
54
  for (let i = 0; i < this.lineCount; i++)
49
55
  out += '\r\x1b[2K\n';
50
56
  out += `\x1b[${this.lineCount}A`;
51
- process.stdout.write(out);
57
+ this.stream.write(out);
52
58
  this.lineCount = 0;
53
59
  }
54
60
  }
package/utils/run-bin.js CHANGED
@@ -26,23 +26,52 @@ export async function runBin(bin, argv, options = {}) {
26
26
  const cwd = options.cwd ?? process.cwd();
27
27
  const logLevel = options.logLevel ?? 'info';
28
28
  const atLeast = (level) => LOG_LEVELS.indexOf(logLevel) >= LOG_LEVELS.indexOf(level);
29
+ /**
30
+ * **A live status region forces `pipe`, whatever the log level would have chosen.** The region
31
+ * draws a block at the bottom of the terminal and redraws it in place; a child writing straight
32
+ * to the terminal scrolls the screen, and the next redraw's "move up N rows" then erases what the
33
+ * child just printed. So the child is piped and every line goes through `passThrough`, which
34
+ * erases the block, writes, and redraws below it - the same arrangement `RunService` makes for the
35
+ * progress panel, for the same reason.
36
+ *
37
+ * An explicit `options.stdio` still wins: a caller that has thought about it outranks this.
38
+ */
39
+ const region = options.app?.statusRegion?.live ? options.app.statusRegion : undefined;
29
40
  /** Below 'info' the output is captured rather than streamed, so a quiet run stays quiet and a
30
41
  * failing one can still say what went wrong. */
31
- const stdio = options.stdio ?? (atLeast('info') ? 'inherit' : 'pipe');
42
+ const stdio = options.stdio ?? (region ? 'pipe' : atLeast('info') ? 'inherit' : 'pipe');
32
43
  if (atLeast('verbose'))
33
44
  console.log(colors.magenta('verbose'), colors.gray('$'), bin, argv.join(' '));
34
45
  const child = spawn(process.platform === 'win32' ? `${bin}.cmd` : bin, argv, {
35
46
  cwd,
36
47
  stdio: stdio === 'inherit' ? 'inherit' : 'pipe',
37
- env: BinPath.env({ cwd, env: options.env, app: options.app }),
48
+ /** **`FORCE_COLOR` when a region made us pipe.** A child checks `isTTY` to decide whether to
49
+ * colour, and a pipe is not one - so routing eslint's output through the region would
50
+ * otherwise strip the colour it had when it inherited the terminal. Set only for that case:
51
+ * a caller that asked for `pipe` itself is usually capturing text to read, not to show. */
52
+ env: BinPath.env({
53
+ cwd,
54
+ env: region && atLeast('info') ? { FORCE_COLOR: '1', ...options.env } : options.env,
55
+ app: options.app,
56
+ }),
38
57
  windowsHide: true,
39
58
  });
40
59
  /** So an interrupted rman does not leave this running - `exec` always did this and this did not,
41
60
  * which meant a plugin command's child outlived a Ctrl-C. See `trackChild`. */
42
61
  trackChild(child);
43
62
  let output = '';
44
- child.stdout?.on('data', (d) => (output += d.toString()));
45
- child.stderr?.on('data', (d) => (output += d.toString()));
63
+ /** **Streamed through the region as it arrives, not held to the end.** Piping is how the region
64
+ * stays intact; buffering would additionally make a long command look silent, which is the very
65
+ * thing the region exists to fix. Only when a region is live - otherwise `pipe` keeps meaning
66
+ * "capture, and surface it if this fails". */
67
+ const collect = (d) => {
68
+ const text = d.toString();
69
+ output += text;
70
+ if (region && atLeast('info'))
71
+ region.passThrough(text);
72
+ };
73
+ child.stdout?.on('data', collect);
74
+ child.stderr?.on('data', collect);
46
75
  return new Promise((resolve, reject) => {
47
76
  child.on('error', (e) => {
48
77
  reject(e?.code === 'ENOENT' ? new Error(`"${bin}" was not found - is it installed in this repository?`) : e);
@@ -52,8 +81,10 @@ export async function runBin(bin, argv, options = {}) {
52
81
  return resolve({ code: 0, output });
53
82
  /** Captured output has to be surfaced here or it is lost with the process - the one thing
54
83
  * worse than a noisy failure is a silent one. 'silent' is the caller saying otherwise. */
55
- if (stdio === 'pipe' && output && logLevel !== 'silent')
84
+ /** Already streamed through the region, so printing it again would double it. */
85
+ if (stdio === 'pipe' && output && logLevel !== 'silent' && !(region && atLeast('info'))) {
56
86
  process.stderr.write(output);
87
+ }
57
88
  const err = new Error(`"${bin} ${argv.join(' ')}" exited with code ${code}`);
58
89
  err.code = code;
59
90
  err.output = output;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * One line at the bottom of the terminal while a command runs - a spinner, the command's name, the
3
+ * repository, and the elapsed time counting up - replaced by a result line when it ends.
4
+ *
5
+ * ```
6
+ * ⠹ lint postgrejs 2.4s <- while running, redrawn in place
7
+ * ✔ lint 3.6s <- once it ends
8
+ * ```
9
+ */
10
+ export declare class StatusRegion {
11
+ private readonly label;
12
+ private readonly detail;
13
+ private readonly region;
14
+ private readonly startedAt;
15
+ private timer?;
16
+ private frame;
17
+ constructor(label: string, detail?: string, enabled?: boolean);
18
+ /** Whether the spinner is actually drawn. `false` leaves `passThrough` a plain write and `stop`
19
+ * its single line, which is what a non-TTY wants. */
20
+ get live(): boolean;
21
+ /** Starts the spinner. Safe to call when not live - it does nothing. */
22
+ start(): void;
23
+ /** Writes `text` without corrupting the block: erase, write, redraw underneath. */
24
+ passThrough(text: string): void;
25
+ /** Erases the spinner and leaves one line saying how it went. */
26
+ stop(outcome: 'ok' | 'fail'): void;
27
+ private draw;
28
+ /** `0.4s` while running and `950ms` / `4.1s` / `2m 03s` at the end - one formatter, because a
29
+ * duration that changes shape as it ticks reads as a glitch. */
30
+ private elapsed;
31
+ }
32
+ /** `950ms`, `4.1s`, `2m 03s` - the three magnitudes a command run lands in. Seconds to one decimal
33
+ * because the difference between 4.1s and 4.9s is worth seeing and 4.13s is not. */
34
+ export declare function formatElapsed(ms: number): string;
@@ -0,0 +1,109 @@
1
+ import colors from 'ansi-colors';
2
+ import { LiveRegion } from './live-region.js';
3
+ /**
4
+ * One line at the bottom of the terminal while a command runs - a spinner, the command's name, the
5
+ * repository, and the elapsed time counting up - replaced by a result line when it ends.
6
+ *
7
+ * ```
8
+ * ⠹ lint postgrejs 2.4s <- while running, redrawn in place
9
+ * ✔ lint 3.6s <- once it ends
10
+ * ```
11
+ */
12
+ /* **Why a live line rather than one printed at the start**: a command that prints nothing is
13
+ * indistinguishable from one that never ran, and a static start line answers "did it start" without
14
+ * answering "is it still going". `rman lint` on a clean repository is silent for several seconds
15
+ * because eslint says nothing when it has nothing to say.
16
+ *
17
+ * **On stderr, unlike `ProgressPanel`'s region.** A command's *answer* goes to stdout, and
18
+ * `rman changelog > NOTES.md` has to leave the notes alone in the file - cursor-movement codes in
19
+ * there would be worse than noise. The two streams share a terminal, so only one region is ever
20
+ * live.
21
+ *
22
+ * **Anything printed while it is live has to go through `passThrough`**, which erases the block,
23
+ * writes, and redraws below. A write that bypasses it scrolls the screen, and the next redraw's
24
+ * "move up N rows" then lands on the wrong rows and erases what was just printed - the same cursor
25
+ * arithmetic `LiveRegion` documents as its own hard edge. That is why `runBin` pipes a child while
26
+ * a region is live instead of letting it inherit the terminal.
27
+ *
28
+ * **No-op when stderr is not a TTY** (CI, a pipe), where the escape codes mean nothing. There the
29
+ * caller still gets the result line, which is the part that carries information. */
30
+ export class StatusRegion {
31
+ label;
32
+ detail;
33
+ region;
34
+ startedAt = Date.now();
35
+ timer;
36
+ frame = 0;
37
+ constructor(label, detail = '', enabled) {
38
+ this.label = label;
39
+ this.detail = detail;
40
+ this.region = new LiveRegion(enabled, process.stderr);
41
+ }
42
+ /** Whether the spinner is actually drawn. `false` leaves `passThrough` a plain write and `stop`
43
+ * its single line, which is what a non-TTY wants. */
44
+ get live() {
45
+ return this.region.enabled;
46
+ }
47
+ /** Starts the spinner. Safe to call when not live - it does nothing. */
48
+ start() {
49
+ if (!this.region.enabled)
50
+ return;
51
+ this.draw();
52
+ /** **`unref`, so a spinner never holds the process open.** A command that finishes its work and
53
+ * leaves nothing else pending would otherwise wait out the interval before exiting. */
54
+ this.timer = setInterval(() => {
55
+ this.frame++;
56
+ this.draw();
57
+ }, FRAME_MS);
58
+ this.timer.unref();
59
+ }
60
+ /** Writes `text` without corrupting the block: erase, write, redraw underneath. */
61
+ passThrough(text) {
62
+ if (!this.region.enabled) {
63
+ process.stderr.write(text);
64
+ return;
65
+ }
66
+ this.region.clear();
67
+ process.stderr.write(text);
68
+ this.draw();
69
+ }
70
+ /** Erases the spinner and leaves one line saying how it went. */
71
+ stop(outcome) {
72
+ if (this.timer)
73
+ clearInterval(this.timer);
74
+ this.timer = undefined;
75
+ this.region.clear();
76
+ const mark = outcome === 'ok' ? colors.green('✔') : colors.red('✖');
77
+ process.stderr.write(`${mark} ${colors.bold(this.label)} ${colors.gray(this.elapsed())}\n`);
78
+ }
79
+ draw() {
80
+ const spinner = colors.cyan(FRAMES[this.frame % FRAMES.length]);
81
+ const parts = [
82
+ spinner,
83
+ colors.bold(this.label),
84
+ this.detail && colors.gray(this.detail),
85
+ colors.gray(this.elapsed()),
86
+ ];
87
+ this.region.render([parts.filter(Boolean).join(' ')]);
88
+ }
89
+ /** `0.4s` while running and `950ms` / `4.1s` / `2m 03s` at the end - one formatter, because a
90
+ * duration that changes shape as it ticks reads as a glitch. */
91
+ elapsed() {
92
+ return formatElapsed(Date.now() - this.startedAt);
93
+ }
94
+ }
95
+ /** `950ms`, `4.1s`, `2m 03s` - the three magnitudes a command run lands in. Seconds to one decimal
96
+ * because the difference between 4.1s and 4.9s is worth seeing and 4.13s is not. */
97
+ export function formatElapsed(ms) {
98
+ if (ms < 1000)
99
+ return `${ms}ms`;
100
+ if (ms < 60_000)
101
+ return `${(ms / 1000).toFixed(1)}s`;
102
+ const m = Math.floor(ms / 60_000);
103
+ return `${m}m ${String(Math.round((ms % 60_000) / 1000)).padStart(2, '0')}s`;
104
+ }
105
+ /** Braille dots - one cell wide in every terminal font that has them, where an emoji spinner is two
106
+ * cells in some and one in others and makes the line jitter. */
107
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
108
+ /** Fast enough to read as motion, slow enough that a redraw is not most of what the process does. */
109
+ const FRAME_MS = 80;