rman 2.7.0 → 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/constants.js +1 -1
- package/package.json +1 -1
- package/services/run.service.d.ts +6 -1
- package/services/run.service.js +74 -28
- package/utils/progress-panel.js +98 -16
- package/utils/run-bin.d.ts +4 -0
- package/utils/run-bin.js +14 -2
package/constants.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '2.
|
|
1
|
+
export const version = '2.8.0';
|
package/package.json
CHANGED
|
@@ -168,11 +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
|
+
/**
|
|
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>;
|
|
171
176
|
/**
|
|
172
177
|
* `onCommand` is told what the step is spawning, for as long as it runs - the argv while a child
|
|
173
178
|
* is up, `undefined` when it returns.
|
|
174
179
|
*/
|
|
175
|
-
export function createStepContext(pkg: Package, cwd: string, onCommand?: (command: string | undefined) => void): RunStepContext;
|
|
180
|
+
export function createStepContext(pkg: Package, cwd: string, onCommand?: (command: string | undefined) => void, onLine?: (line: string) => void): RunStepContext;
|
|
176
181
|
/**
|
|
177
182
|
* `.rmanrc` conditional execution, GitHub Actions-`if`-flavored but a small closed grammar
|
|
178
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';
|
|
@@ -368,6 +369,59 @@ export class RunService extends Service {
|
|
|
368
369
|
return steps;
|
|
369
370
|
}
|
|
370
371
|
RunService.normalizeScriptValue = normalizeScriptValue;
|
|
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;
|
|
371
425
|
/**
|
|
372
426
|
* `onCommand` is told what the step is spawning, for as long as it runs - the argv while a child
|
|
373
427
|
* is up, `undefined` when it returns.
|
|
@@ -376,7 +430,7 @@ export class RunService extends Service {
|
|
|
376
430
|
* whole of a build, because that is all the slot knows; what a reader wants is the `tsc -b
|
|
377
431
|
* <tsconfig>` it is sitting in. A function step is the shape every shared config uses, so this is
|
|
378
432
|
* the common case rather than an edge one. */
|
|
379
|
-
function createStepContext(pkg, cwd, onCommand) {
|
|
433
|
+
function createStepContext(pkg, cwd, onCommand, onLine) {
|
|
380
434
|
const logLevel = resolveRootLogLevel(pkg.repository);
|
|
381
435
|
return {
|
|
382
436
|
pkg,
|
|
@@ -384,14 +438,17 @@ export class RunService extends Service {
|
|
|
384
438
|
cwd,
|
|
385
439
|
runBin: async (bin, argv, opts) => {
|
|
386
440
|
onCommand?.([bin, ...argv].join(' '));
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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;
|
|
395
452
|
},
|
|
396
453
|
logger: new Logger(logLevel),
|
|
397
454
|
};
|
|
@@ -536,7 +593,7 @@ function toStep(slot, value) {
|
|
|
536
593
|
* stderr with it off, ahead of the `failed` line - the order a shell step already produces.
|
|
537
594
|
*/
|
|
538
595
|
async function runFunctionStep(run, pkg, cwd, onLine, onCommand) {
|
|
539
|
-
const context = RunService.createStepContext(pkg, cwd, onCommand);
|
|
596
|
+
const context = RunService.createStepContext(pkg, cwd, onCommand, onLine);
|
|
540
597
|
if (!onLine) {
|
|
541
598
|
try {
|
|
542
599
|
await run(context);
|
|
@@ -547,32 +604,21 @@ async function runFunctionStep(run, pkg, cwd, onLine, onCommand) {
|
|
|
547
604
|
}
|
|
548
605
|
return;
|
|
549
606
|
}
|
|
550
|
-
const console_ = globalThis.console;
|
|
551
|
-
const original = {};
|
|
552
|
-
for (const method of CAPTURED_CONSOLE) {
|
|
553
|
-
original[method] = console_[method];
|
|
554
|
-
console_[method] = (...args) => {
|
|
555
|
-
/** Split, because one `console.log` may carry several lines and the panel's log is a list of
|
|
556
|
-
* them - a multi-line entry would render as one unreadable row. */
|
|
557
|
-
for (const line of format(args).split('\n'))
|
|
558
|
-
onLine(line);
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
607
|
try {
|
|
562
|
-
await run(context);
|
|
608
|
+
await RunService.withCapturedConsole(onLine, () => run(context));
|
|
563
609
|
}
|
|
564
610
|
catch (e) {
|
|
565
|
-
/**
|
|
566
|
-
*
|
|
611
|
+
/** Straight to `onLine`, so the message lands in this step's log rather than over the panel it
|
|
612
|
+
* is drawn inside. */
|
|
567
613
|
for (const line of messageOf(e).split('\n'))
|
|
568
614
|
onLine(line);
|
|
569
615
|
throw e;
|
|
570
616
|
}
|
|
571
|
-
finally {
|
|
572
|
-
for (const method of CAPTURED_CONSOLE)
|
|
573
|
-
console_[method] = original[method];
|
|
574
|
-
}
|
|
575
617
|
}
|
|
618
|
+
/** Which step a captured `console` call belongs to - see `withCapturedConsole`. */
|
|
619
|
+
const consoleSink = new AsyncLocalStorage();
|
|
620
|
+
let consoleDepth = 0;
|
|
621
|
+
let pristineConsole;
|
|
576
622
|
/** What a thrown value has to say for itself. A step may throw anything, and `String(undefined)`
|
|
577
623
|
* reading as `undefined` in a run log is worse than saying nothing was said. */
|
|
578
624
|
function messageOf(error) {
|
package/utils/progress-panel.js
CHANGED
|
@@ -45,7 +45,7 @@ export class ProgressPanel {
|
|
|
45
45
|
/** Registers an item and returns it - callers mutate the returned object directly (`status`,
|
|
46
46
|
* `currentStep`, `lastLine`, ...) to drive the next render tick. */
|
|
47
47
|
addItem(name, stepsTotal) {
|
|
48
|
-
const item =
|
|
48
|
+
const item = new PanelItem(name, stepsTotal);
|
|
49
49
|
this.items.set(name, item);
|
|
50
50
|
return item;
|
|
51
51
|
}
|
|
@@ -134,28 +134,46 @@ export class ProgressPanel {
|
|
|
134
134
|
`${header} ${bar} ${colors.bold(`${done}/${total}`)} ${colors.cyan(`${running} running`)} ${failedText} ${colors.yellow(totalElapsed)}${detail}`,
|
|
135
135
|
];
|
|
136
136
|
const runningList = [...this.items.values()].filter(i => i.status === 'running');
|
|
137
|
-
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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). */
|
|
140
152
|
const budget = Math.max(0, (process.stdout.rows || 24) - 3);
|
|
153
|
+
const width = process.stdout.columns || 80;
|
|
141
154
|
let used = 0;
|
|
142
|
-
let
|
|
143
|
-
|
|
144
|
-
|
|
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) => {
|
|
145
159
|
const step = item.stepsTotal && item.stepsTotal > 1 && item.stepIndex != null
|
|
146
160
|
? `${item.currentStep} (${item.stepIndex + 1}/${item.stepsTotal})`
|
|
147
161
|
: item.currentStep || '';
|
|
148
|
-
/** Measured against the *plain* text: every piece
|
|
162
|
+
/** Measured against the *plain* text: every piece is wrapped in escape sequences, and
|
|
149
163
|
* `String.length` counts those, so budgeting on the rendered string wraps a row that fits. */
|
|
150
164
|
const fixed = ` ${item.name} ${step} ${elapsed} | `.length;
|
|
151
|
-
|
|
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);
|
|
152
170
|
/**
|
|
153
171
|
* **The command last, after a `|`, and that is not only layout.** It is the one field with no
|
|
154
172
|
* bound on its length - a `tsc -b` line carries a path, a `run` step carries whatever the
|
|
155
173
|
* author wrote - so between the step and the clock it pushed the elapsed time to a different
|
|
156
|
-
* column on every row, and off the end entirely once a command was long. Everything
|
|
157
|
-
* now reads down a straight edge and the variable part runs off to the right,
|
|
158
|
-
* cuts it.
|
|
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.
|
|
159
177
|
*/
|
|
160
178
|
const group = [
|
|
161
179
|
`${spinner} ${colors.bold(item.name)} ${colors.gray(step)} ${colors.yellow(elapsed)}` +
|
|
@@ -167,11 +185,27 @@ export class ProgressPanel {
|
|
|
167
185
|
break;
|
|
168
186
|
lines.push(...group);
|
|
169
187
|
used += group.length;
|
|
170
|
-
|
|
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++;
|
|
171
199
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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(', ')}`));
|
|
175
209
|
this.live.render(lines);
|
|
176
210
|
}
|
|
177
211
|
/** Stops the redraw loop and erases the panel, leaving the cursor where it started. */
|
|
@@ -267,3 +301,51 @@ function renderProgressBar(done, total, width = 24) {
|
|
|
267
301
|
const filled = Math.max(0, Math.min(width, cells));
|
|
268
302
|
return colors.green('█'.repeat(filled)) + colors.gray('░'.repeat(width - filled));
|
|
269
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}`);
|