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.
@@ -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 {
@@ -225,6 +225,7 @@ const changelogCommand = registerCommand(app => {
225
225
  progress: panelReporter(panel),
226
226
  };
227
227
  const changelog = app.getService('changelog');
228
+ panel.detail = repository.name;
228
229
  panel.start(app.statusRegion);
229
230
  let entries;
230
231
  try {
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.6.1';
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.6.1",
4
+ "version": "2.8.0",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -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
- export function createStepContext(pkg: Package, cwd: string): RunStepContext;
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) -
@@ -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
- ctx.currentCommand = step.command ?? (step.run?.name ? `${step.run.name}()` : '(js function)');
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
- function createStepContext(pkg, cwd) {
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) => runBin(bin, argv, { cwd, logLevel, app: pkg.repository.app, ...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
- /** Through the patched `console` deliberately - `onLine` is still installed at this point, so
541
- * 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. */
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
  /**
@@ -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 = { name, status: 'pending', stepsTotal, log: [] };
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
- /** Leave room for the header, a safety margin, and a possible "N more running" line - a
119
- * block taller than the terminal breaks cursor-up math (the terminal scrolls instead of
120
- * 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). */
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 shown = 0;
124
- for (const item of runningList) {
125
- 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) => {
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 below is wrapped in escape sequences, and
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
- const command = truncate(item.currentCommand ?? '', (process.stdout.columns || 80) - fixed - 2);
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
- 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++;
145
199
  }
146
- const remaining = runningList.length - shown;
147
- if (remaining > 0)
148
- 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(', ')}`));
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
+ }
@@ -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}`);
@@ -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
- /** Takes the terminal back and starts drawing again - a no-op when it was never suspended. */
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;
@@ -57,12 +57,26 @@ export class StatusRegion {
57
57
  this.timer = undefined;
58
58
  this.region.clear();
59
59
  }
60
- /** Takes the terminal back and starts drawing again - a no-op when it was never suspended. */
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.start();
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) {