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 CHANGED
@@ -1 +1 @@
1
- export const version = '2.7.0';
1
+ export const version = '2.8.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.7.0",
4
+ "version": "2.8.0",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -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) -
@@ -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
- try {
388
- return await runBin(bin, argv, { cwd, logLevel, app: pkg.repository.app, ...opts });
389
- }
390
- finally {
391
- /** Back to whatever the caller had, or a step that spawns once would wear that command
392
- * for the rest of its run. */
393
- onCommand?.(undefined);
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
- /** Through the patched `console` deliberately - `onLine` is still installed at this point, so
566
- * the message lands in this step's log rather than over the panel it is drawn inside. */
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) {
@@ -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 = { name, status: 'pending', stepsTotal, log: [] };
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
- /** Leave room for the header, a safety margin, and a possible "N more running" line - a
138
- * block taller than the terminal breaks cursor-up math (the terminal scrolls instead of
139
- * the cursor moving, so redraws land in the wrong place and pile up). */
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 shown = 0;
143
- for (const item of runningList) {
144
- const elapsed = item.startedAt ? formatDuration(Date.now() - item.startedAt) : '';
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 below is wrapped in escape sequences, and
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
- const command = truncate(item.currentCommand ?? '', (process.stdout.columns || 80) - fixed - 2);
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 fixed-width
157
- * now reads down a straight edge and the variable part runs off to the right, where `truncate`
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
- shown++;
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
- const remaining = runningList.length - shown;
173
- if (remaining > 0)
174
- lines.push(colors.gray(`… and ${remaining} more running`));
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
+ }
@@ -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
- const stdio = options.stdio ?? (region ? 'pipe' : atLeast('info') ? 'inherit' : 'pipe');
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}`);