rman 2.6.1 → 2.8.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/builtins/platforms/node/services/ci.service.js +4 -0
- package/builtins/platforms/node/services/clean.service.js +1 -0
- package/commands/changelog.command.js +1 -0
- package/constants.js +1 -1
- package/package.json +1 -1
- package/services/exec.service.js +5 -0
- package/services/run.service.d.ts +10 -1
- package/services/run.service.js +96 -25
- package/utils/progress-panel.d.ts +6 -0
- package/utils/progress-panel.js +126 -18
- package/utils/run-bin.d.ts +4 -0
- package/utils/run-bin.js +14 -2
- package/utils/status-region.d.ts +9 -1
- package/utils/status-region.js +16 -2
|
@@ -72,6 +72,7 @@ export var CiService;
|
|
|
72
72
|
const progress = options.progress ?? true;
|
|
73
73
|
const panel = new ProgressPanel('CI', !!process.stdout.isTTY && progress);
|
|
74
74
|
const runStartedAt = Date.now();
|
|
75
|
+
panel.detail = repository.name;
|
|
75
76
|
panel.start(repository.app.statusRegion);
|
|
76
77
|
const items = [];
|
|
77
78
|
let failed = false;
|
|
@@ -122,6 +123,9 @@ const LOCK_FILES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.loc
|
|
|
122
123
|
* log uses. `logLine` is expected to go through a `Logger` (so it respects `--log-level`), not a
|
|
123
124
|
* raw `console.log`. */
|
|
124
125
|
async function runStep(app, item, panelEnabled, cwd, command, logLine) {
|
|
126
|
+
/** **Set here rather than at each of the three call sites**, which is also what keeps `wipe` out
|
|
127
|
+
* of it: a wipe runs no command, and this is the one place a `ci` step has one. */
|
|
128
|
+
item.currentCommand = command;
|
|
125
129
|
if (panelEnabled) {
|
|
126
130
|
await exec(command, {
|
|
127
131
|
app,
|
|
@@ -53,6 +53,7 @@ export var CleanService;
|
|
|
53
53
|
const progress = options.progress ?? true;
|
|
54
54
|
const logger = new Logger(options.logLevel ?? resolveRootLogLevel(repository));
|
|
55
55
|
const panel = new ProgressPanel(dryRun ? 'CLEAN (dry-run)' : 'CLEAN', !!process.stdout.isTTY && progress);
|
|
56
|
+
panel.detail = repository.name;
|
|
56
57
|
panel.start(repository.app.statusRegion);
|
|
57
58
|
let failed = false;
|
|
58
59
|
try {
|
package/constants.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '2.
|
|
1
|
+
export const version = '2.8.0';
|
package/package.json
CHANGED
package/services/exec.service.js
CHANGED
|
@@ -52,9 +52,14 @@ export class ExecService extends Service {
|
|
|
52
52
|
const names = new Set(packages.map(p => p.name));
|
|
53
53
|
const children = packages.map(pkg => {
|
|
54
54
|
const ctx = panel.addItem(pkg.name);
|
|
55
|
+
/** One command for every package and it never changes, so it is set once here rather than
|
|
56
|
+
* threaded into `execForPackage` - the row is the only thing that was not saying what `exec`
|
|
57
|
+
* is actually running. */
|
|
58
|
+
ctx.currentCommand = command;
|
|
55
59
|
const dependencies = topo ? pkg.dependencies.filter(d => names.has(d.name)).map(d => d.name) : [];
|
|
56
60
|
return new Task(() => execForPackage(this.app, ctx, panel.enabled, pkg.dirname, command, logger, bail ? () => rootTask?.abort() : () => { }), { name: pkg.name, dependencies });
|
|
57
61
|
});
|
|
62
|
+
panel.detail = repository.name;
|
|
58
63
|
panel.start(repository.app.statusRegion);
|
|
59
64
|
let failed = false;
|
|
60
65
|
try {
|
|
@@ -168,7 +168,16 @@ export declare namespace RunService {
|
|
|
168
168
|
* differently, which is how `version.<slot>` came to join its array with `' && '`.
|
|
169
169
|
*/
|
|
170
170
|
export function normalizeScriptValue(value: unknown, at: string): RunStepValue[];
|
|
171
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Runs `fn` with `console` captured into `onLine`, and leaves the real `console` behind when the
|
|
173
|
+
* last concurrent step is done.
|
|
174
|
+
*/
|
|
175
|
+
export function withCapturedConsole<T>(onLine: (line: string) => void, fn: () => T | Promise<T>): Promise<T>;
|
|
176
|
+
/**
|
|
177
|
+
* `onCommand` is told what the step is spawning, for as long as it runs - the argv while a child
|
|
178
|
+
* is up, `undefined` when it returns.
|
|
179
|
+
*/
|
|
180
|
+
export function createStepContext(pkg: Package, cwd: string, onCommand?: (command: string | undefined) => void, onLine?: (line: string) => void): RunStepContext;
|
|
172
181
|
/**
|
|
173
182
|
* `.rmanrc` conditional execution, GitHub Actions-`if`-flavored but a small closed grammar
|
|
174
183
|
* instead of a full expression language (less to get wrong, still covers what's asked for) -
|
package/services/run.service.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
1
2
|
import os from 'node:os';
|
|
2
3
|
import { inspect } from 'node:util';
|
|
3
4
|
import colors from 'ansi-colors';
|
|
@@ -66,7 +67,13 @@ export class RunService extends Service {
|
|
|
66
67
|
* does not distinguish a JS step from a shell one. `step.label` is not used directly
|
|
67
68
|
* because for a function it already folds the slot in (`before (js)`), which the row
|
|
68
69
|
* prints two characters to its left. */
|
|
69
|
-
|
|
70
|
+
const stepLabel = step.command ?? (step.run?.name ? `${step.run.name}()` : '(js function)');
|
|
71
|
+
ctx.currentCommand = stepLabel;
|
|
72
|
+
/** A function step names itself until it spawns something, and takes its name back when
|
|
73
|
+
* that returns - so the row says `tsc -b tsconfig.json` for as long as tsc is up. */
|
|
74
|
+
const onCommand = (command) => {
|
|
75
|
+
ctx.currentCommand = command ?? stepLabel;
|
|
76
|
+
};
|
|
70
77
|
ctx.stepIndex = i;
|
|
71
78
|
if (panel.enabled) {
|
|
72
79
|
const onLine = (line) => {
|
|
@@ -74,7 +81,7 @@ export class RunService extends Service {
|
|
|
74
81
|
ctx.lastLine = line;
|
|
75
82
|
};
|
|
76
83
|
if (step.run)
|
|
77
|
-
await runFunctionStep(step.run, pkg, cwd, onLine);
|
|
84
|
+
await runFunctionStep(step.run, pkg, cwd, onLine, onCommand);
|
|
78
85
|
else
|
|
79
86
|
await exec(step.command, { cwd, stdio: 'pipe', onLine, app: pkg.repository.app });
|
|
80
87
|
}
|
|
@@ -88,7 +95,7 @@ export class RunService extends Service {
|
|
|
88
95
|
/** No capture with the panel off: the step owns the terminal, exactly as a shell
|
|
89
96
|
* step's `stdio: 'inherit'` does. */
|
|
90
97
|
if (step.run)
|
|
91
|
-
await runFunctionStep(step.run, pkg, cwd);
|
|
98
|
+
await runFunctionStep(step.run, pkg, cwd, undefined, onCommand);
|
|
92
99
|
else
|
|
93
100
|
await exec(step.command, { cwd, stdio: 'inherit', app: pkg.repository.app });
|
|
94
101
|
}
|
|
@@ -228,6 +235,7 @@ export class RunService extends Service {
|
|
|
228
235
|
err.logged = true;
|
|
229
236
|
throw err;
|
|
230
237
|
}
|
|
238
|
+
panel.detail = repository.name;
|
|
231
239
|
panel.start(repository.app.statusRegion);
|
|
232
240
|
try {
|
|
233
241
|
/** bail:false here - each package's own resolved bail setting decides whether to call
|
|
@@ -361,13 +369,87 @@ export class RunService extends Service {
|
|
|
361
369
|
return steps;
|
|
362
370
|
}
|
|
363
371
|
RunService.normalizeScriptValue = normalizeScriptValue;
|
|
364
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Runs `fn` with `console` captured into `onLine`, and leaves the real `console` behind when the
|
|
374
|
+
* last concurrent step is done.
|
|
375
|
+
*/
|
|
376
|
+
/* **One patch for the whole run, routed per step - and the obvious alternative is broken under
|
|
377
|
+
* concurrency.** Patching per step and restoring "the original" works for one step and corrupts the
|
|
378
|
+
* console for every run with two:
|
|
379
|
+
*
|
|
380
|
+
* A patches -> A's "original" is the real console
|
|
381
|
+
* B patches -> B's "original" is *A's patch*
|
|
382
|
+
* A restores -> console is real again
|
|
383
|
+
* B restores -> console is A's patch, for the rest of the process
|
|
384
|
+
*
|
|
385
|
+
* Measured on a twenty-package build with sixteen running at once: afterwards `printSummary`'s own
|
|
386
|
+
* `console.log` calls went into a finished package's log array, so the run printed **no recap and
|
|
387
|
+
* no failure logs at all** - every error silently swallowed, which is the opposite of what the
|
|
388
|
+
* capture is for. The depth counter is what restores the pristine console rather than whatever was
|
|
389
|
+
* installed when this step started.
|
|
390
|
+
*
|
|
391
|
+
* `AsyncLocalStorage` is what makes one shared patch route to the right step: the steps interleave
|
|
392
|
+
* on the event loop, so the call stack cannot say which package a `console.log` belongs to, and
|
|
393
|
+
* `await` inside an author's function is exactly what it propagates through. A log from outside any
|
|
394
|
+
* step - a timer a step left running - finds no store and goes to the real console, which is right.
|
|
395
|
+
*/
|
|
396
|
+
function withCapturedConsole(onLine, fn) {
|
|
397
|
+
const console_ = globalThis.console;
|
|
398
|
+
if (consoleDepth++ === 0) {
|
|
399
|
+
const pristine = {};
|
|
400
|
+
for (const method of CAPTURED_CONSOLE) {
|
|
401
|
+
pristine[method] = console_[method];
|
|
402
|
+
console_[method] = (...args) => {
|
|
403
|
+
const sink = consoleSink.getStore();
|
|
404
|
+
if (!sink)
|
|
405
|
+
return pristine[method](...args);
|
|
406
|
+
/** Split, because one `console.log` may carry several lines and the panel's log is a list
|
|
407
|
+
* of them - a multi-line entry would render as one unreadable row. */
|
|
408
|
+
for (const line of format(args).split('\n'))
|
|
409
|
+
sink(line);
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
pristineConsole = pristine;
|
|
413
|
+
}
|
|
414
|
+
/** `Promise.resolve`, because a step may be a plain function - `RunStepFn` returns
|
|
415
|
+
* `void | Promise<void>` and only the promise form can be awaited. */
|
|
416
|
+
return Promise.resolve(consoleSink.run(onLine, fn)).finally(() => {
|
|
417
|
+
if (--consoleDepth > 0 || !pristineConsole)
|
|
418
|
+
return;
|
|
419
|
+
for (const method of CAPTURED_CONSOLE)
|
|
420
|
+
console_[method] = pristineConsole[method];
|
|
421
|
+
pristineConsole = undefined;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
RunService.withCapturedConsole = withCapturedConsole;
|
|
425
|
+
/**
|
|
426
|
+
* `onCommand` is told what the step is spawning, for as long as it runs - the argv while a child
|
|
427
|
+
* is up, `undefined` when it returns.
|
|
428
|
+
*/
|
|
429
|
+
/* **Without it a function step can only name itself.** The row showed `buildWithTsc()` for the
|
|
430
|
+
* whole of a build, because that is all the slot knows; what a reader wants is the `tsc -b
|
|
431
|
+
* <tsconfig>` it is sitting in. A function step is the shape every shared config uses, so this is
|
|
432
|
+
* the common case rather than an edge one. */
|
|
433
|
+
function createStepContext(pkg, cwd, onCommand, onLine) {
|
|
365
434
|
const logLevel = resolveRootLogLevel(pkg.repository);
|
|
366
435
|
return {
|
|
367
436
|
pkg,
|
|
368
437
|
repository: pkg.repository,
|
|
369
438
|
cwd,
|
|
370
|
-
runBin: (bin, argv, opts) =>
|
|
439
|
+
runBin: async (bin, argv, opts) => {
|
|
440
|
+
onCommand?.([bin, ...argv].join(' '));
|
|
441
|
+
const result = await runBin(bin, argv, { cwd, logLevel, app: pkg.repository.app, onLine, ...opts });
|
|
442
|
+
/**
|
|
443
|
+
* **Handed back on success only, so a failure leaves the command that failed on the row.**
|
|
444
|
+
*/
|
|
445
|
+
/* A rejection skips this line, which is the whole point: the step is over, its row becomes
|
|
446
|
+
* a failed one, and `buildWithTsc()` is a worse thing to read there than the
|
|
447
|
+
* `tsc -b <tsconfig>` that actually exited non-zero. On the way out of a *successful* call
|
|
448
|
+
* the name does have to come back, or a step that spawns once would wear that command for
|
|
449
|
+
* the rest of its run. */
|
|
450
|
+
onCommand?.(undefined);
|
|
451
|
+
return result;
|
|
452
|
+
},
|
|
371
453
|
logger: new Logger(logLevel),
|
|
372
454
|
};
|
|
373
455
|
}
|
|
@@ -510,8 +592,8 @@ function toStep(slot, value) {
|
|
|
510
592
|
* the panel on (the step's own log, which is what the panel shows for a failed item), and to
|
|
511
593
|
* stderr with it off, ahead of the `failed` line - the order a shell step already produces.
|
|
512
594
|
*/
|
|
513
|
-
async function runFunctionStep(run, pkg, cwd, onLine) {
|
|
514
|
-
const context = RunService.createStepContext(pkg, cwd);
|
|
595
|
+
async function runFunctionStep(run, pkg, cwd, onLine, onCommand) {
|
|
596
|
+
const context = RunService.createStepContext(pkg, cwd, onCommand, onLine);
|
|
515
597
|
if (!onLine) {
|
|
516
598
|
try {
|
|
517
599
|
await run(context);
|
|
@@ -522,32 +604,21 @@ async function runFunctionStep(run, pkg, cwd, onLine) {
|
|
|
522
604
|
}
|
|
523
605
|
return;
|
|
524
606
|
}
|
|
525
|
-
const console_ = globalThis.console;
|
|
526
|
-
const original = {};
|
|
527
|
-
for (const method of CAPTURED_CONSOLE) {
|
|
528
|
-
original[method] = console_[method];
|
|
529
|
-
console_[method] = (...args) => {
|
|
530
|
-
/** Split, because one `console.log` may carry several lines and the panel's log is a list of
|
|
531
|
-
* them - a multi-line entry would render as one unreadable row. */
|
|
532
|
-
for (const line of format(args).split('\n'))
|
|
533
|
-
onLine(line);
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
607
|
try {
|
|
537
|
-
await run(context);
|
|
608
|
+
await RunService.withCapturedConsole(onLine, () => run(context));
|
|
538
609
|
}
|
|
539
610
|
catch (e) {
|
|
540
|
-
/**
|
|
541
|
-
*
|
|
611
|
+
/** Straight to `onLine`, so the message lands in this step's log rather than over the panel it
|
|
612
|
+
* is drawn inside. */
|
|
542
613
|
for (const line of messageOf(e).split('\n'))
|
|
543
614
|
onLine(line);
|
|
544
615
|
throw e;
|
|
545
616
|
}
|
|
546
|
-
finally {
|
|
547
|
-
for (const method of CAPTURED_CONSOLE)
|
|
548
|
-
console_[method] = original[method];
|
|
549
|
-
}
|
|
550
617
|
}
|
|
618
|
+
/** Which step a captured `console` call belongs to - see `withCapturedConsole`. */
|
|
619
|
+
const consoleSink = new AsyncLocalStorage();
|
|
620
|
+
let consoleDepth = 0;
|
|
621
|
+
let pristineConsole;
|
|
551
622
|
/** What a thrown value has to say for itself. A step may throw anything, and `String(undefined)`
|
|
552
623
|
* reading as `undefined` in a run log is worse than saying nothing was said. */
|
|
553
624
|
function messageOf(error) {
|
|
@@ -49,6 +49,12 @@ export declare class ProgressPanel implements TerminalRegion {
|
|
|
49
49
|
private renderLoop?;
|
|
50
50
|
/** The region this panel took the terminal from, handed back in `stop`. */
|
|
51
51
|
private statusRegion?;
|
|
52
|
+
/**
|
|
53
|
+
* What the run is about - the repository name, shown beside the title. Assigned rather than
|
|
54
|
+
* constructed with, the way a `ProgressItem`'s fields are: it keeps `stream` from needing an
|
|
55
|
+
* `undefined` placeholder at four of the five call sites.
|
|
56
|
+
*/
|
|
57
|
+
detail?: string;
|
|
52
58
|
private spinnerFrame;
|
|
53
59
|
private startedAt;
|
|
54
60
|
/**
|
package/utils/progress-panel.js
CHANGED
|
@@ -21,6 +21,12 @@ export class ProgressPanel {
|
|
|
21
21
|
renderLoop;
|
|
22
22
|
/** The region this panel took the terminal from, handed back in `stop`. */
|
|
23
23
|
statusRegion;
|
|
24
|
+
/**
|
|
25
|
+
* What the run is about - the repository name, shown beside the title. Assigned rather than
|
|
26
|
+
* constructed with, the way a `ProgressItem`'s fields are: it keeps `stream` from needing an
|
|
27
|
+
* `undefined` placeholder at four of the five call sites.
|
|
28
|
+
*/
|
|
29
|
+
detail;
|
|
24
30
|
spinnerFrame = 0;
|
|
25
31
|
startedAt = 0;
|
|
26
32
|
/**
|
|
@@ -39,7 +45,7 @@ export class ProgressPanel {
|
|
|
39
45
|
/** Registers an item and returns it - callers mutate the returned object directly (`status`,
|
|
40
46
|
* `currentStep`, `lastLine`, ...) to drive the next render tick. */
|
|
41
47
|
addItem(name, stepsTotal) {
|
|
42
|
-
const item =
|
|
48
|
+
const item = new PanelItem(name, stepsTotal);
|
|
43
49
|
this.items.set(name, item);
|
|
44
50
|
return item;
|
|
45
51
|
}
|
|
@@ -111,29 +117,67 @@ export class ProgressPanel {
|
|
|
111
117
|
const bar = renderProgressBar(filled, total);
|
|
112
118
|
const totalElapsed = formatDuration(Date.now() - this.startedAt);
|
|
113
119
|
const failedText = failedSoFar > 0 ? colors.red.bold(`${failedSoFar} failed`) : colors.gray(`${failedSoFar} failed`);
|
|
120
|
+
/**
|
|
121
|
+
* **The header says what is running, because nothing else does any more.** The status line used
|
|
122
|
+
* to carry `ci opra` beside this panel; it is suspended now so the two stop redrawing over each
|
|
123
|
+
* other, and suppressing it without moving its content here took the command and the repository
|
|
124
|
+
* off the screen entirely. The badge already names the command, so what had to move is the
|
|
125
|
+
* repository.
|
|
126
|
+
*
|
|
127
|
+
* **At the right end, where the line has room.** Between the badge and the bar it would push
|
|
128
|
+
* every column right by the length of a repository name - so the bar, the counts and the clock
|
|
129
|
+
* would sit at a different place in each repository, and the bar would shift the moment a
|
|
130
|
+
* panel's detail were set. The tail is empty space in every terminal this fits in.
|
|
131
|
+
*/
|
|
132
|
+
const detail = this.detail ? ` ${colors.gray(this.detail)}` : '';
|
|
114
133
|
const lines = [
|
|
115
|
-
`${header} ${bar} ${colors.bold(`${done}/${total}`)} ${colors.cyan(`${running} running`)} ${failedText} ${colors.yellow(totalElapsed)}`,
|
|
134
|
+
`${header} ${bar} ${colors.bold(`${done}/${total}`)} ${colors.cyan(`${running} running`)} ${failedText} ${colors.yellow(totalElapsed)}${detail}`,
|
|
116
135
|
];
|
|
117
136
|
const runningList = [...this.items.values()].filter(i => i.status === 'running');
|
|
118
|
-
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
137
|
+
/**
|
|
138
|
+
* **A package that failed stays on the list, under the ones still working.**
|
|
139
|
+
* It used to vanish the moment it failed, so on a long run the only sign that anything had gone
|
|
140
|
+
* wrong was a count in the header - and whatever it printed was not read until the recap, by
|
|
141
|
+
* which time the run had been going for another half-minute.
|
|
142
|
+
*
|
|
143
|
+
* **Below the running rows, and only with the space they leave.** Work in progress is what the
|
|
144
|
+
* panel is for; a repository that fails early would otherwise fill the block with corpses and
|
|
145
|
+
* push the live rows off the screen. One line each rather than two, so more of them fit - a
|
|
146
|
+
* failed row's value is that it is *named*, and its output is replayed in full at the end.
|
|
147
|
+
*/
|
|
148
|
+
const failedList = [...this.items.values()].filter(i => i.status === 'failed');
|
|
149
|
+
/** Leave room for the header, a safety margin, and a possible "N more" line - a block taller
|
|
150
|
+
* than the terminal breaks cursor-up math (the terminal scrolls instead of the cursor moving,
|
|
151
|
+
* so redraws land in the wrong place and pile up). */
|
|
121
152
|
const budget = Math.max(0, (process.stdout.rows || 24) - 3);
|
|
153
|
+
const width = process.stdout.columns || 80;
|
|
122
154
|
let used = 0;
|
|
123
|
-
let
|
|
124
|
-
|
|
125
|
-
|
|
155
|
+
let shownRunning = 0;
|
|
156
|
+
let shownFailed = 0;
|
|
157
|
+
/** The fixed-width half of a row, plus the command truncated into whatever is left. */
|
|
158
|
+
const describe = (item, elapsed) => {
|
|
126
159
|
const step = item.stepsTotal && item.stepsTotal > 1 && item.stepIndex != null
|
|
127
160
|
? `${item.currentStep} (${item.stepIndex + 1}/${item.stepsTotal})`
|
|
128
161
|
: item.currentStep || '';
|
|
129
|
-
/** Measured against the *plain* text: every piece
|
|
162
|
+
/** Measured against the *plain* text: every piece is wrapped in escape sequences, and
|
|
130
163
|
* `String.length` counts those, so budgeting on the rendered string wraps a row that fits. */
|
|
131
|
-
const fixed = ` ${item.name} ${step} ${elapsed}`.length;
|
|
132
|
-
|
|
164
|
+
const fixed = ` ${item.name} ${step} ${elapsed} | `.length;
|
|
165
|
+
return { step, command: truncate(item.currentCommand ?? '', width - fixed - 2) };
|
|
166
|
+
};
|
|
167
|
+
for (const item of runningList) {
|
|
168
|
+
const elapsed = item.startedAt ? formatDuration(Date.now() - item.startedAt) : '';
|
|
169
|
+
const { step, command } = describe(item, elapsed);
|
|
170
|
+
/**
|
|
171
|
+
* **The command last, after a `|`, and that is not only layout.** It is the one field with no
|
|
172
|
+
* bound on its length - a `tsc -b` line carries a path, a `run` step carries whatever the
|
|
173
|
+
* author wrote - so between the step and the clock it pushed the elapsed time to a different
|
|
174
|
+
* column on every row, and off the end entirely once a command was long. Everything
|
|
175
|
+
* fixed-width now reads down a straight edge and the variable part runs off to the right,
|
|
176
|
+
* where `truncate` cuts it.
|
|
177
|
+
*/
|
|
133
178
|
const group = [
|
|
134
|
-
`${spinner} ${colors.bold(item.name)} ${colors.gray(step)}` +
|
|
135
|
-
(command ? ` ${colors.cyan(command)}` : '')
|
|
136
|
-
` ${colors.yellow(elapsed)}`,
|
|
179
|
+
`${spinner} ${colors.bold(item.name)} ${colors.gray(step)} ${colors.yellow(elapsed)}` +
|
|
180
|
+
(command ? ` ${colors.gray('|')} ${colors.cyan(command)}` : ''),
|
|
137
181
|
];
|
|
138
182
|
if (item.lastLine)
|
|
139
183
|
group.push(` ${colors.dim(item.lastLine)}`);
|
|
@@ -141,11 +185,27 @@ export class ProgressPanel {
|
|
|
141
185
|
break;
|
|
142
186
|
lines.push(...group);
|
|
143
187
|
used += group.length;
|
|
144
|
-
|
|
188
|
+
shownRunning++;
|
|
189
|
+
}
|
|
190
|
+
for (const item of failedList) {
|
|
191
|
+
if (used + 1 > budget)
|
|
192
|
+
break;
|
|
193
|
+
const elapsed = item.startedAt && item.finishedAt ? formatDuration(item.finishedAt - item.startedAt) : '';
|
|
194
|
+
const { step, command } = describe(item, elapsed);
|
|
195
|
+
lines.push(`${colors.red.bold('✖')} ${colors.bold(item.name)} ${colors.gray(step)} ${colors.yellow(elapsed)}` +
|
|
196
|
+
(command ? ` ${colors.gray('|')} ${colors.red(command)}` : ''));
|
|
197
|
+
used++;
|
|
198
|
+
shownFailed++;
|
|
145
199
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
200
|
+
/** One line for both, or a run with rows cut from each would need two and the block is already
|
|
201
|
+
* budgeted to the row. */
|
|
202
|
+
const hidden = [];
|
|
203
|
+
if (runningList.length > shownRunning)
|
|
204
|
+
hidden.push(`${runningList.length - shownRunning} more running`);
|
|
205
|
+
if (failedList.length > shownFailed)
|
|
206
|
+
hidden.push(`${failedList.length - shownFailed} more failed`);
|
|
207
|
+
if (hidden.length)
|
|
208
|
+
lines.push(colors.gray(`… and ${hidden.join(', ')}`));
|
|
149
209
|
this.live.render(lines);
|
|
150
210
|
}
|
|
151
211
|
/** Stops the redraw loop and erases the panel, leaving the cursor where it started. */
|
|
@@ -241,3 +301,51 @@ function renderProgressBar(done, total, width = 24) {
|
|
|
241
301
|
const filled = Math.max(0, Math.min(width, cells));
|
|
242
302
|
return colors.green('█'.repeat(filled)) + colors.gray('░'.repeat(width - filled));
|
|
243
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* What `addItem` hands back - a `ProgressItem` whose `currentStep` and `currentCommand` clear
|
|
306
|
+
* `lastLine` when they change.
|
|
307
|
+
*/
|
|
308
|
+
/* **Accessors rather than a line at each call site**, because the call sites are four services and
|
|
309
|
+
* a command and the one that forgets is invisible: stale output under a new command does not look
|
|
310
|
+
* like a bug, it looks like output. Reported from `ci`, where the row read
|
|
311
|
+
* `install (2/2) | npm install` over `removed node_modules, package-lock.json` - the wipe's line,
|
|
312
|
+
* sitting under the install's command as though it belonged to it.
|
|
313
|
+
*
|
|
314
|
+
* **Cleared where it changes, not at render time.** The panel redraws every 100ms, so comparing
|
|
315
|
+
* there would race a line that arrived between the change and the next frame and throw away real
|
|
316
|
+
* output. This is synchronous with the assignment.
|
|
317
|
+
*/
|
|
318
|
+
class PanelItem {
|
|
319
|
+
name;
|
|
320
|
+
status = 'pending';
|
|
321
|
+
stepIndex;
|
|
322
|
+
stepsTotal;
|
|
323
|
+
lastLine;
|
|
324
|
+
startedAt;
|
|
325
|
+
finishedAt;
|
|
326
|
+
log = [];
|
|
327
|
+
_currentStep;
|
|
328
|
+
_currentCommand;
|
|
329
|
+
constructor(name, stepsTotal) {
|
|
330
|
+
this.name = name;
|
|
331
|
+
this.stepsTotal = stepsTotal;
|
|
332
|
+
}
|
|
333
|
+
get currentStep() {
|
|
334
|
+
return this._currentStep;
|
|
335
|
+
}
|
|
336
|
+
set currentStep(value) {
|
|
337
|
+
if (value === this._currentStep)
|
|
338
|
+
return;
|
|
339
|
+
this._currentStep = value;
|
|
340
|
+
this.lastLine = undefined;
|
|
341
|
+
}
|
|
342
|
+
get currentCommand() {
|
|
343
|
+
return this._currentCommand;
|
|
344
|
+
}
|
|
345
|
+
set currentCommand(value) {
|
|
346
|
+
if (value === this._currentCommand)
|
|
347
|
+
return;
|
|
348
|
+
this._currentCommand = value;
|
|
349
|
+
this.lastLine = undefined;
|
|
350
|
+
}
|
|
351
|
+
}
|
package/utils/run-bin.d.ts
CHANGED
|
@@ -27,6 +27,10 @@ export interface RunBinOptions {
|
|
|
27
27
|
* even then. An explicit `stdio` still wins over all of this.
|
|
28
28
|
*/
|
|
29
29
|
logLevel?: LogLevel;
|
|
30
|
+
/**
|
|
31
|
+
* Called once per line of the child's output **instead of writing it to the terminal**.
|
|
32
|
+
*/
|
|
33
|
+
onLine?: (line: string) => void;
|
|
30
34
|
env?: Record<string, string | undefined>;
|
|
31
35
|
}
|
|
32
36
|
export interface RunBinResult {
|
package/utils/run-bin.js
CHANGED
|
@@ -39,7 +39,11 @@ export async function runBin(bin, argv, options = {}) {
|
|
|
39
39
|
const region = options.app?.statusRegion?.live ? options.app.statusRegion : undefined;
|
|
40
40
|
/** Below 'info' the output is captured rather than streamed, so a quiet run stays quiet and a
|
|
41
41
|
* failing one can still say what went wrong. */
|
|
42
|
-
|
|
42
|
+
/** **`onLine` forces `pipe` the same way a live region does.** Left to the default, a caller at
|
|
43
|
+
* `info` would get `inherit` - the child writes straight to the terminal, `child.stdout` is
|
|
44
|
+
* null, and the callback is never called at all. An option that silently does nothing is worse
|
|
45
|
+
* than one that does not exist. */
|
|
46
|
+
const stdio = options.stdio ?? (options.onLine || region ? 'pipe' : atLeast('info') ? 'inherit' : 'pipe');
|
|
43
47
|
if (atLeast('verbose'))
|
|
44
48
|
console.log(colors.magenta('verbose'), colors.gray('$'), bin, argv.join(' '));
|
|
45
49
|
const child = spawn(process.platform === 'win32' ? `${bin}.cmd` : bin, argv, {
|
|
@@ -74,6 +78,14 @@ export async function runBin(bin, argv, options = {}) {
|
|
|
74
78
|
const collect = (d) => {
|
|
75
79
|
const text = d.toString();
|
|
76
80
|
output += text;
|
|
81
|
+
/** A caller taking the lines owns them - it is showing them somewhere of its own, and writing
|
|
82
|
+
* them here as well would both double them and scroll whatever it is drawing. */
|
|
83
|
+
if (options.onLine) {
|
|
84
|
+
for (const line of text.split(/\r?\n/))
|
|
85
|
+
if (line)
|
|
86
|
+
options.onLine(line);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
77
89
|
if (region && atLeast('info'))
|
|
78
90
|
region.passThrough(text);
|
|
79
91
|
};
|
|
@@ -89,7 +101,7 @@ export async function runBin(bin, argv, options = {}) {
|
|
|
89
101
|
/** Captured output has to be surfaced here or it is lost with the process - the one thing
|
|
90
102
|
* worse than a noisy failure is a silent one. 'silent' is the caller saying otherwise. */
|
|
91
103
|
/** Already streamed through the region, so printing it again would double it. */
|
|
92
|
-
if (stdio === 'pipe' && output && logLevel !== 'silent' && !(region && atLeast('info'))) {
|
|
104
|
+
if (stdio === 'pipe' && output && logLevel !== 'silent' && !options.onLine && !(region && atLeast('info'))) {
|
|
93
105
|
process.stderr.write(output);
|
|
94
106
|
}
|
|
95
107
|
const err = new Error(`"${bin} ${argv.join(' ')}" exited with code ${code}`);
|
package/utils/status-region.d.ts
CHANGED
|
@@ -55,7 +55,15 @@ export declare class StatusRegion implements TerminalRegion {
|
|
|
55
55
|
* screen. The answer to *which* region is the forwarding above.
|
|
56
56
|
*/
|
|
57
57
|
suspend(takeover?: TerminalRegion): void;
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Takes the terminal back - a no-op when it was never suspended.
|
|
60
|
+
*
|
|
61
|
+
* **The first frame waits for the timer rather than being drawn now**, which `start` does. A
|
|
62
|
+
* command is usually seconds from finishing when its panel comes down, and drawing immediately
|
|
63
|
+
* put one spinner frame on screen between the panel's recap and the result line that replaces it
|
|
64
|
+
* - a single flash, which is the thing this whole mechanism exists to remove. Anything still
|
|
65
|
+
* working is drawn 80ms later and nobody waits for it.
|
|
66
|
+
*/
|
|
59
67
|
resume(): void;
|
|
60
68
|
/** Writes `text` without corrupting the block: erase, write, redraw underneath. */
|
|
61
69
|
passThrough(text: string): void;
|
package/utils/status-region.js
CHANGED
|
@@ -57,12 +57,26 @@ export class StatusRegion {
|
|
|
57
57
|
this.timer = undefined;
|
|
58
58
|
this.region.clear();
|
|
59
59
|
}
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Takes the terminal back - a no-op when it was never suspended.
|
|
62
|
+
*
|
|
63
|
+
* **The first frame waits for the timer rather than being drawn now**, which `start` does. A
|
|
64
|
+
* command is usually seconds from finishing when its panel comes down, and drawing immediately
|
|
65
|
+
* put one spinner frame on screen between the panel's recap and the result line that replaces it
|
|
66
|
+
* - a single flash, which is the thing this whole mechanism exists to remove. Anything still
|
|
67
|
+
* working is drawn 80ms later and nobody waits for it.
|
|
68
|
+
*/
|
|
61
69
|
resume() {
|
|
62
70
|
if (!this.takeover && this.timer)
|
|
63
71
|
return;
|
|
64
72
|
this.takeover = undefined;
|
|
65
|
-
this.
|
|
73
|
+
if (!this.region.enabled || this.timer)
|
|
74
|
+
return;
|
|
75
|
+
this.timer = setInterval(() => {
|
|
76
|
+
this.frame++;
|
|
77
|
+
this.draw();
|
|
78
|
+
}, FRAME_MS);
|
|
79
|
+
this.timer.unref();
|
|
66
80
|
}
|
|
67
81
|
/** Writes `text` without corrupting the block: erase, write, redraw underneath. */
|
|
68
82
|
passThrough(text) {
|