rman 2.2.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +115 -3
- package/commands/build.command.d.ts +3 -0
- package/commands/build.command.js +3 -0
- package/commands/changelog.command.d.ts +2 -0
- package/commands/changelog.command.js +2 -0
- package/commands/config.command.d.ts +2 -0
- package/commands/config.command.js +2 -0
- package/commands/diff.command.d.ts +2 -0
- package/commands/diff.command.js +2 -0
- package/commands/info.command.d.ts +2 -0
- package/commands/info.command.js +2 -0
- package/commands/list.command.d.ts +2 -0
- package/commands/list.command.js +2 -0
- package/commands/test.command.d.ts +3 -0
- package/commands/test.command.js +3 -0
- package/constants.js +1 -1
- package/core/application.d.ts +10 -0
- package/core/application.js +13 -0
- package/core/command-builder.js +5 -0
- package/core/interfaces/custom-command.d.ts +4 -0
- package/interfaces/rman-config.interface.d.ts +21 -0
- package/package.json +1 -1
- package/utils/live-region.d.ts +6 -1
- package/utils/live-region.js +12 -6
- package/utils/run-bin.js +43 -5
- package/utils/status-region.d.ts +34 -0
- package/utils/status-region.js +109 -0
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
|
-
|
|
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: {
|
package/commands/diff.command.js
CHANGED
|
@@ -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: {
|
package/commands/info.command.js
CHANGED
|
@@ -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: {
|
package/commands/list.command.js
CHANGED
|
@@ -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: {
|
package/commands/test.command.js
CHANGED
|
@@ -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.
|
|
1
|
+
export const version = '2.3.1';
|
package/core/application.d.ts
CHANGED
|
@@ -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.
|
package/core/application.js
CHANGED
|
@@ -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.
|
package/core/command-builder.js
CHANGED
|
@@ -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
package/utils/live-region.d.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
export declare class LiveRegion {
|
|
10
10
|
private lineCount;
|
|
11
11
|
readonly enabled: boolean;
|
|
12
|
-
|
|
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;
|
package/utils/live-region.js
CHANGED
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
export class LiveRegion {
|
|
10
10
|
lineCount = 0;
|
|
11
11
|
enabled;
|
|
12
|
-
|
|
13
|
-
|
|
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 =
|
|
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, (
|
|
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
|
-
|
|
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
|
-
|
|
57
|
+
this.stream.write(out);
|
|
52
58
|
this.lineCount = 0;
|
|
53
59
|
}
|
|
54
60
|
}
|
package/utils/run-bin.js
CHANGED
|
@@ -26,23 +26,59 @@ 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
|
-
|
|
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
|
+
/* **It is added *onto* a base environment, never written as one.** `BinPath.env`'s `env` is the
|
|
53
|
+
* environment to derive from - it replaces `process.env` rather than extending it - so
|
|
54
|
+
* `{ FORCE_COLOR: '1' }` handed over alone left the child with that one variable plus a PATH
|
|
55
|
+
* holding only `node_modules/.bin`. Measured on `panates/sqb` at 2.3.0: `rman test` found npm
|
|
56
|
+
* (the walk ends at the running node's own directory) and npm then died with
|
|
57
|
+
* `spawn sh ENOENT`, because `/bin` was not on the PATH it was given. Every `runBin` call made
|
|
58
|
+
* while a status region is live had it, which since 2.3.0 is every one of them. */
|
|
59
|
+
env: BinPath.env({
|
|
60
|
+
cwd,
|
|
61
|
+
env: region && atLeast('info') ? { FORCE_COLOR: '1', ...(options.env ?? process.env) } : options.env,
|
|
62
|
+
app: options.app,
|
|
63
|
+
}),
|
|
38
64
|
windowsHide: true,
|
|
39
65
|
});
|
|
40
66
|
/** So an interrupted rman does not leave this running - `exec` always did this and this did not,
|
|
41
67
|
* which meant a plugin command's child outlived a Ctrl-C. See `trackChild`. */
|
|
42
68
|
trackChild(child);
|
|
43
69
|
let output = '';
|
|
44
|
-
|
|
45
|
-
|
|
70
|
+
/** **Streamed through the region as it arrives, not held to the end.** Piping is how the region
|
|
71
|
+
* stays intact; buffering would additionally make a long command look silent, which is the very
|
|
72
|
+
* thing the region exists to fix. Only when a region is live - otherwise `pipe` keeps meaning
|
|
73
|
+
* "capture, and surface it if this fails". */
|
|
74
|
+
const collect = (d) => {
|
|
75
|
+
const text = d.toString();
|
|
76
|
+
output += text;
|
|
77
|
+
if (region && atLeast('info'))
|
|
78
|
+
region.passThrough(text);
|
|
79
|
+
};
|
|
80
|
+
child.stdout?.on('data', collect);
|
|
81
|
+
child.stderr?.on('data', collect);
|
|
46
82
|
return new Promise((resolve, reject) => {
|
|
47
83
|
child.on('error', (e) => {
|
|
48
84
|
reject(e?.code === 'ENOENT' ? new Error(`"${bin}" was not found - is it installed in this repository?`) : e);
|
|
@@ -52,8 +88,10 @@ export async function runBin(bin, argv, options = {}) {
|
|
|
52
88
|
return resolve({ code: 0, output });
|
|
53
89
|
/** Captured output has to be surfaced here or it is lost with the process - the one thing
|
|
54
90
|
* worse than a noisy failure is a silent one. 'silent' is the caller saying otherwise. */
|
|
55
|
-
|
|
91
|
+
/** Already streamed through the region, so printing it again would double it. */
|
|
92
|
+
if (stdio === 'pipe' && output && logLevel !== 'silent' && !(region && atLeast('info'))) {
|
|
56
93
|
process.stderr.write(output);
|
|
94
|
+
}
|
|
57
95
|
const err = new Error(`"${bin} ${argv.join(' ')}" exited with code ${code}`);
|
|
58
96
|
err.code = code;
|
|
59
97
|
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;
|