rman 2.7.0 → 2.9.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.
@@ -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';
@@ -41,24 +42,133 @@ export class RunService extends Service {
41
42
  const status = await repository.listStatus({ hash: changedSince });
42
43
  packages = packages.filter(p => status[p.name] !== 'clean');
43
44
  }
44
- const concurrency = options.parallel === false
45
- ? 1
46
- : typeof options.parallel === 'number'
47
- ? options.parallel
48
- : options.parallel === true
49
- ? os.cpus().length
50
- : resolveNumber(undefined, repository.rootPackage, script, 'concurrency', os.cpus().length);
45
+ const concurrency = resolveConcurrency(options.parallel, resolveNumber(undefined, repository.rootPackage, script, 'concurrency', os.cpus().length));
51
46
  const progress = resolveBool(options.progress, repository.rootPackage, script, 'progress', true);
52
47
  const panel = new ProgressPanel(`RUN ${script}`, !!process.stdout.isTTY && progress);
48
+ /** Shared across all `if: changed[ = hash]` evaluations so the same reference is only `git`-queried once. */
49
+ const ifStatusCache = new Map();
50
+ /** Repo-wide bookend: root's own pre/post hooks run once each, exclusively, around every package
51
+ * (unless root itself opts out via `run.<script>.skip`, fails its own `run.<script>.if`, or the
52
+ * run is scoped to a single package by `cwdScope` - a repo-wide bookend has no place there).
53
+ *
54
+ * Only in a monorepo. Without one the root *is* the single package, already in the loop below
55
+ * with the same hooks and the same directory - a bookend would simply run each of them a
56
+ * second time. */
57
+ /** Short-circuited deliberately: a root already out of the run for a structural reason must not
58
+ * have its `if` evaluated, now that evaluating one can mean calling the repository's own code. */
59
+ const rootSkipped = !!cwdScope ||
60
+ !repository.monorepo ||
61
+ rootCfg.skip === true ||
62
+ !(await passesIf(repository, repository.rootPackage, rootCfg.if, repository.dirname, ifStatusCache));
63
+ const rootSteps = rootSkipped ? [] : getScriptSteps(repository.rootPackage, script);
64
+ /** Filtered on the slot, not on `'pre' + script`: the step labels are `before`/`exec`/`after`
65
+ * now - the same words the config uses - rather than npm's `pre<script>` naming, which moved
66
+ * out with the package.json source. */
67
+ const bookend = (slot, name) => {
68
+ const steps = rootSteps.filter(s => s.name === slot);
69
+ if (!steps.length)
70
+ return undefined;
71
+ return {
72
+ pkg: repository.rootPackage,
73
+ name,
74
+ label: 'root',
75
+ steps,
76
+ cwd: repository.dirname,
77
+ topo: false,
78
+ bail: resolveBail(options.bail, repository.rootPackage, script, true),
79
+ logLevel: resolveLogLevel(options.logLevel, repository.rootPackage, script, logLevelDefault),
80
+ };
81
+ };
82
+ const before = bookend('before', 'root (pre)');
83
+ const after = bookend('after', 'root (post)');
84
+ const plans = [];
85
+ for (const pkg of packages) {
86
+ const pkgCfg = RunService.getConfig(pkg, script);
87
+ if (pkgCfg.skip === true)
88
+ continue;
89
+ if (!(await passesIf(repository, pkg, pkgCfg.if, pkg.dirname, ifStatusCache)))
90
+ continue;
91
+ const steps = getScriptSteps(pkg, script);
92
+ if (!steps.length)
93
+ continue;
94
+ plans.push({
95
+ pkg,
96
+ name: pkg.name,
97
+ label: pkg.name,
98
+ steps,
99
+ cwd: pkg.dirname,
100
+ topo: resolveBool(options.topo, pkg, script, 'topo', topo),
101
+ bail: resolveBail(options.bail, pkg, script, true),
102
+ logLevel: resolveLogLevel(options.logLevel, pkg, script, logLevelDefault),
103
+ });
104
+ }
105
+ if (!plans.length && !before && !after) {
106
+ /**
107
+ * Two different nothings, and only one of them is fine.
108
+ *
109
+ * Nobody in the repository defines this script at all: the name is a mistake - a typo, or a
110
+ * script that used to exist - and `npm run` fails on exactly this. Staying silent is how
111
+ * `rman run qc` sat in a CI pipeline for months reporting success while running nothing, with
112
+ * `qc` defined only on the root (whose own scripts a monorepo never runs, only its
113
+ * `pre`/`post` bookends).
114
+ *
115
+ * Everything was filtered out instead - `--scope`, `--changed`, `run.<script>.skip`, an
116
+ * `if:` that didn't match: zero is the correct answer to what was asked, and asking "build
117
+ * only what changed" when nothing changed must not fail a pipeline.
118
+ */
119
+ /** `getPackages()` and nothing else - in a monorepo that excludes the root, which is the
120
+ * point: the root contributes only `pre`/`post` bookends, never the script itself, so a
121
+ * `qc` defined *only* there is exactly the mistake above rather than an excuse for it. (And
122
+ * had the root contributed a bookend, there would be a plan.) In a single-package
123
+ * repository the root *is* the one package, and is covered. */
124
+ const definedSomewhere = repository.getPackages().some(pkg => getScriptSteps(pkg, script).length > 0);
125
+ if (definedSomewhere) {
126
+ console.log(colors.gray(`Nothing to run - every package was filtered out of "${script}".`));
127
+ return;
128
+ }
129
+ const message = `No package defines a "${script}" script.`;
130
+ console.log(colors.red(message));
131
+ const err = new Error(message);
132
+ err.logged = true;
133
+ throw err;
134
+ }
135
+ await this.schedule({ panel, concurrency, commandName, failureLabel: `"${script}"`, before, plans, after });
136
+ }
137
+ /**
138
+ * **The scheduler, shared by every driver that runs work per package.**
139
+ *
140
+ * `runScript` is one driver and `forEachPackage` is the other; both hand over a plan per package
141
+ * and get the same machinery, which is the point of this being a method rather than a shape each
142
+ * one builds for itself. Four things have had to be fixed in it this month alone - the status
143
+ * region handover, the depth-counted console patch, the per-row command, the failed list - and a
144
+ * second copy would have needed each fix twice, with nothing reporting the one that was missed.
145
+ *
146
+ * Throws when any package failed, with `logged` set: the panel has already printed the recap.
147
+ */
148
+ async schedule(options) {
149
+ const { panel, concurrency, commandName } = options;
53
150
  /** Set once the aggregate Task exists, so a package's own failure can trigger a manual
54
151
  * abort using *its own* resolved bail setting (see `runSteps` below) - power-tasks' own
55
152
  * `bail` is a single blanket policy for the whole batch, it can't vary per package. */
56
153
  let rootTask;
57
- const runSteps = async (ctx, pkg, pkgLabel, steps, cwd, pkgBail, pkgLogLevel) => {
58
- ctx.status = 'running';
59
- ctx.startedAt = Date.now();
154
+ const runSteps = async (ctx, plan, steps,
155
+ /**
156
+ * Which slice of the package's steps this call runs, when a `topo` step split it in two.
157
+ * `from` keeps `stepIndex` counting across the pair, so one panel row spans both tasks and
158
+ * still reports `(4/9)` rather than restarting; `more` says another call is coming, so this
159
+ * one must not mark the package finished.
160
+ */
161
+ part) => {
162
+ const { pkg, cwd, bail: pkgBail, logLevel: pkgLogLevel, label: pkgLabel } = plan;
163
+ const from = part?.from ?? 0;
164
+ /** Only the first part starts the clock: the row's elapsed time is the package's, and a
165
+ * package waiting at a `topo` barrier is waiting as part of its own run. */
166
+ if (!from) {
167
+ ctx.status = 'running';
168
+ ctx.startedAt = Date.now();
169
+ }
60
170
  try {
61
- for (let i = 0; i < steps.length; i++) {
171
+ for (let i = from; i < steps.length; i++) {
62
172
  const step = steps[i];
63
173
  ctx.currentStep = step.name;
64
174
  /** The command for a shell step, the function's own name for a JS one - and a bare
@@ -119,6 +229,15 @@ export class RunService extends Service {
119
229
  throw stepError;
120
230
  }
121
231
  }
232
+ /** Another part is coming, so the package is not done - and it is about to sit at the
233
+ * barrier, which the row says out loud rather than leaving the last step's command on
234
+ * screen as though it were still running. (The setter clears the row's last output line,
235
+ * which would otherwise read as that step's.) */
236
+ if (part?.more) {
237
+ ctx.currentStep = 'waiting';
238
+ ctx.currentCommand = 'waiting for dependencies';
239
+ return;
240
+ }
122
241
  ctx.status = 'success';
123
242
  }
124
243
  catch (e) {
@@ -128,114 +247,79 @@ export class RunService extends Service {
128
247
  throw e;
129
248
  }
130
249
  finally {
131
- ctx.finishedAt = Date.now();
250
+ if (!part?.more || ctx.status === 'failed')
251
+ ctx.finishedAt = Date.now();
132
252
  }
133
253
  };
134
254
  const children = [];
135
- /** Shared across all `if: changed[ = hash]` evaluations so the same reference is only `git`-queried once. */
136
- const ifStatusCache = new Map();
137
- /** Repo-wide bookend: root's own pre/post hooks run once each, exclusively, around every package
138
- * (unless root itself opts out via `run.<script>.skip`, fails its own `run.<script>.if`, or the
139
- * run is scoped to a single package by `cwdScope` - a repo-wide bookend has no place there).
140
- *
141
- * Only in a monorepo. Without one the root *is* the single package, already in the loop below
142
- * with the same hooks and the same directory - a bookend would simply run each of them a
143
- * second time. */
144
- /** Short-circuited deliberately: a root already out of the run for a structural reason must not
145
- * have its `if` evaluated, now that evaluating one can mean calling the repository's own code. */
146
- const rootSkipped = !!cwdScope ||
147
- !repository.monorepo ||
148
- rootCfg.skip === true ||
149
- !(await passesIf(repository, repository.rootPackage, rootCfg.if, repository.dirname, ifStatusCache));
150
- const rootSteps = rootSkipped ? [] : getScriptSteps(repository.rootPackage, script);
151
- /** Filtered on the slot, not on `'pre' + script`: the step labels are `before`/`exec`/`after`
152
- * now - the same words the config uses - rather than npm's `pre<script>` naming, which moved
153
- * out with the package.json source. */
154
- const rootPre = rootSteps.filter(s => s.name === 'before');
155
- const rootPost = rootSteps.filter(s => s.name === 'after');
156
- const rootPreName = 'root (pre)';
157
- const rootPostName = 'root (post)';
158
- if (rootPre.length) {
159
- const ctx = panel.addItem(rootPreName, rootPre.length);
160
- const pkgBail = resolveBail(options.bail, repository.rootPackage, script, true);
161
- const pkgLogLevel = resolveLogLevel(options.logLevel, repository.rootPackage, script, logLevelDefault);
162
- children.push(new Task(() => runSteps(ctx, repository.rootPackage, 'root', rootPre, repository.dirname, pkgBail, pkgLogLevel), {
163
- name: ctx.name,
164
- exclusive: true,
165
- }));
255
+ /** Row and task names are the plan's, and a package's is its package name - which is what lets
256
+ * the dependency edges below be handed to power-tasks as names. Mapped rather than assumed, so
257
+ * a driver labelling its rows differently does not silently lose its ordering. */
258
+ const taskOf = new Map(options.plans.map(plan => [plan.pkg.name, plan.name]));
259
+ if (options.before) {
260
+ const plan = options.before;
261
+ const ctx = panel.addItem(plan.name, plan.steps.length);
262
+ children.push(new Task(() => runSteps(ctx, plan, plan.steps), { name: plan.name, exclusive: true }));
166
263
  }
167
- const stepsByPackage = new Map();
168
- for (const pkg of packages) {
169
- const pkgCfg = RunService.getConfig(pkg, script);
170
- if (pkgCfg.skip === true)
171
- continue;
172
- if (!(await passesIf(repository, pkg, pkgCfg.if, pkg.dirname, ifStatusCache)))
173
- continue;
174
- const steps = getScriptSteps(pkg, script);
175
- if (steps.length)
176
- stepsByPackage.set(pkg.name, steps);
177
- }
178
- for (const pkg of packages) {
179
- const steps = stepsByPackage.get(pkg.name);
180
- if (!steps)
181
- continue;
182
- const ctx = panel.addItem(pkg.name, steps.length);
183
- const pkgTopo = resolveBool(options.topo, pkg, script, 'topo', topo);
184
- const pkgBail = resolveBail(options.bail, pkg, script, true);
185
- const pkgLogLevel = resolveLogLevel(options.logLevel, pkg, script, logLevelDefault);
264
+ for (const plan of options.plans) {
265
+ const ctx = panel.addItem(plan.name, plan.steps.length);
266
+ const steps = plan.steps;
186
267
  /** power-tasks identifies a task by its name string, so the graph is handed over as names -
187
268
  * the references are what rman reasons with, the names are what the scheduler wants. */
188
- const dependencies = pkgTopo ? pkg.dependencies.filter(d => stepsByPackage.has(d.name)).map(d => d.name) : [];
189
- if (rootPre.length)
190
- dependencies.push(rootPreName);
191
- children.push(new Task(() => runSteps(ctx, pkg, pkg.name, steps, pkg.dirname, pkgBail, pkgLogLevel), {
192
- name: ctx.name,
193
- dependencies,
194
- }));
195
- }
196
- if (rootPost.length) {
197
- const ctx = panel.addItem(rootPostName, rootPost.length);
198
- const pkgBail = resolveBail(options.bail, repository.rootPackage, script, true);
199
- const pkgLogLevel = resolveLogLevel(options.logLevel, repository.rootPackage, script, logLevelDefault);
200
- children.push(new Task(() => runSteps(ctx, repository.rootPackage, 'root', rootPost, repository.dirname, pkgBail, pkgLogLevel), {
201
- name: ctx.name,
202
- exclusive: true,
203
- /** Must wait for every package task to finish, not just be "exclusive" once it starts. */
204
- dependencies: [...stepsByPackage.keys()],
205
- }));
206
- }
207
- if (!children.length) {
269
+ const waitsFor = plan.topo
270
+ ? plan.pkg.dependencies.map(d => taskOf.get(d.name)).filter((name) => !!name)
271
+ : [];
208
272
  /**
209
- * Two different nothings, and only one of them is fine.
273
+ * **Where this package starts waiting for its dependencies**, or `-1` for not at all.
210
274
  *
211
- * Nobody in the repository defines this script at all: the name is a mistake - a typo, or a
212
- * script that used to exist - and `npm run` fails on exactly this. Staying silent is how
213
- * `rman run qc` sat in a CI pipeline for months reporting success while running nothing, with
214
- * `qc` defined only on the root (whose own scripts a monorepo never runs, only its
215
- * `pre`/`post` bookends).
275
+ * A script whose steps mention `topo` at all is taken as the whole statement: the first step
276
+ * saying `true` is where the package blocks, and a script where every step says `false` does
277
+ * not wait. Only where **no** step mentions it does `run.<script>.topo` decide, and then the
278
+ * wait is before the first step - where it has always been.
216
279
  *
217
- * Everything was filtered out instead - `--scope`, `--changed`, `run.<script>.skip`, an
218
- * `if:` that didn't match: zero is the correct answer to what was asked, and asking "build
219
- * only what changed" when nothing changed must not fail a pipeline.
280
+ * The two halves cannot be collapsed into one `findIndex`: "no step marked true" and "no step
281
+ * marked at all" are different answers (no wait against the old wait at step 0), and reading
282
+ * `-1` as either one alone gets the other wrong.
220
283
  */
221
- /** `getPackages()` and nothing else - in a monorepo that excludes the root, which is the
222
- * point: the root contributes only `pre`/`post` bookends, never the script itself, so a
223
- * `qc` defined *only* there is exactly the mistake above rather than an excuse for it. (And
224
- * had the root contributed a bookend, `children` wouldn't be empty.) In a single-package
225
- * repository the root *is* the one package, and is covered. */
226
- const definedSomewhere = repository.getPackages().some(pkg => getScriptSteps(pkg, script).length > 0);
227
- if (definedSomewhere) {
228
- console.log(colors.gray(`Nothing to run - every package was filtered out of "${script}".`));
229
- return;
284
+ const barrier = !plan.topo ? -1 : steps.some(s => s.topo !== undefined) ? steps.findIndex(s => s.topo) : 0;
285
+ /**
286
+ * **Split only when the wait is in the middle of the package and there is something to wait
287
+ * for.** A barrier at step 0 is one task, as it always was; a package with no in-run
288
+ * dependencies has nothing to wait for wherever its barrier sits, and a second task would buy
289
+ * an empty wait and a second panel transition for it.
290
+ */
291
+ if (barrier > 0 && waitsFor.length) {
292
+ /** A name of its own, because power-tasks addresses tasks by name and the plan's name has to
293
+ * stay the *whole* package: a dependency waiting on it, and the root's post bookend
294
+ * waiting on all of them, both mean "everything this package does". */
295
+ const freeName = `${plan.name} (pre-topo)`;
296
+ children.push(new Task(() => runSteps(ctx, plan, steps.slice(0, barrier), { from: 0, more: true }), {
297
+ name: freeName,
298
+ dependencies: options.before ? [options.before.name] : [],
299
+ }));
300
+ children.push(new Task(() => runSteps(ctx, plan, steps, { from: barrier }), {
301
+ name: plan.name,
302
+ dependencies: [freeName, ...waitsFor],
303
+ }));
304
+ continue;
230
305
  }
231
- const message = `No package defines a "${script}" script.`;
232
- console.log(colors.red(message));
233
- const err = new Error(message);
234
- err.logged = true;
235
- throw err;
306
+ const dependencies = barrier < 0 ? [] : [...waitsFor];
307
+ if (options.before)
308
+ dependencies.push(options.before.name);
309
+ children.push(new Task(() => runSteps(ctx, plan, steps), { name: plan.name, dependencies }));
310
+ }
311
+ if (options.after) {
312
+ const plan = options.after;
313
+ const ctx = panel.addItem(plan.name, plan.steps.length);
314
+ children.push(new Task(() => runSteps(ctx, plan, plan.steps), {
315
+ name: plan.name,
316
+ exclusive: true,
317
+ /** Must wait for every package task to finish, not just be "exclusive" once it starts. */
318
+ dependencies: [...taskOf.values()],
319
+ }));
236
320
  }
237
- panel.detail = repository.name;
238
- panel.start(repository.app.statusRegion);
321
+ panel.detail = this.repository.name;
322
+ panel.start(this.repository.app.statusRegion);
239
323
  try {
240
324
  /** bail:false here - each package's own resolved bail setting decides whether to call
241
325
  * `rootTask.abort()` itself (see `runSteps`), since power-tasks' own `bail` can't vary per package. */
@@ -256,17 +340,121 @@ export class RunService extends Service {
256
340
  panel.stop();
257
341
  }
258
342
  const summary = panel.printSummary();
343
+ /** The tallies, never `rootTask.toPromise()`'s own outcome: with a sibling still in flight at
344
+ * the moment one package failed, that promise *resolves*, and this command used to exit 0 on a
345
+ * run it had just reported as failed - non-deterministically, since it came down to which
346
+ * packages happened to still be running (measured: `1 0 1 1 0` across five identical runs).
347
+ * A single failed package must fail the command, every time. */
259
348
  /** The tallies, never `rootTask.toPromise()`'s own outcome: with a sibling still in flight at
260
349
  * the moment one package failed, that promise *resolves*, and this command used to exit 0 on a
261
350
  * run it had just reported as failed - non-deterministically, since it came down to which
262
351
  * packages happened to still be running (measured: `1 0 1 1 0` across five identical runs).
263
352
  * A single failed package must fail the command, every time. */
264
353
  if (summary.failedCount > 0) {
265
- const err = new Error(`"${script}" failed`);
354
+ const err = new Error(`${options.failureLabel} failed`);
266
355
  err.logged = true;
267
356
  throw err;
268
357
  }
269
358
  }
359
+ /**
360
+ * Runs `fn` once per package, under the same scheduler `run` uses - concurrency, `--bail`,
361
+ * dependency order, the progress panel, and `console` routed to the right row.
362
+ *
363
+ * **This is what a command reaches for instead of a `for` loop.** `check` was that loop: `await
364
+ * runBin(...)` per package, which ignores `--parallel` outright, draws no panel, and had
365
+ * re-implemented `--bail`, the package filters, the two-endings rule and a summary line - about
366
+ * sixty lines of this service, already drifted (its own tally counted packages a bail had skipped
367
+ * as successes, a bug fixed here separately). Measured on `panates/opra`: nineteen packages at
368
+ * 0.4-0.9s each, so eleven seconds of wall clock where the work is entirely independent.
369
+ *
370
+ * `fn` is handed exactly what a **function step** gets - `pkg`, `cwd`, a `runBin` already bound to
371
+ * that package's directory and this run's log level, and a `logger`. That is the answer to the
372
+ * complaint this exists for: an imported `runBin` knows neither, and a command reaching for one
373
+ * had to thread `cwd`, `app` and `logLevel` through by hand at every call.
374
+ *
375
+ * Ordering is **off** unless `topo` asks for it: a command sweeping packages with an independent
376
+ * tool is the common case, and a wait nobody asked for is the more expensive mistake here.
377
+ */
378
+ async forEachPackage(packages, fn, options = {}) {
379
+ const repository = this.repository;
380
+ const label = options.label ?? 'each';
381
+ const logLevel = options.logLevel ?? resolveRootLogLevel(repository);
382
+ const concurrency = resolveConcurrency(options.parallel, os.cpus().length);
383
+ const panel = new ProgressPanel(label.toUpperCase(), !!process.stdout.isTTY && options.progress !== false);
384
+ /** Nothing to do is not a failure, and the caller is the one that can tell the two endings
385
+ * apart - it knows why its list is empty. `check` says so itself and returns. */
386
+ if (!packages.length)
387
+ return;
388
+ /**
389
+ * **The step's label is the function's own name, and empty for an anonymous one** - never the
390
+ * run's label repeated. A sweep has one step whose slot name is already the label, so falling
391
+ * back to it printed the same word three times on the panel-off line:
392
+ * `info check @opra/testing ┆ check success ┆ check`. The panel path never had it - a row shows
393
+ * whatever `runBin` spawned (`dpdm -T ...`) as soon as there is one.
394
+ */
395
+ const steps = [{ name: label, label: fn.name, run: fn }];
396
+ const plans = packages.map(pkg => ({
397
+ pkg,
398
+ name: pkg.name,
399
+ label: pkg.name,
400
+ /** One step object per package, not one shared: `ScriptStep` is read for `topo` and the row's
401
+ * own labels, and a shared object would be fine today and a race the day a driver writes to
402
+ * it. */
403
+ steps: [...steps],
404
+ cwd: pkg.dirname,
405
+ topo: options.topo === true,
406
+ bail: options.bail !== false,
407
+ logLevel,
408
+ }));
409
+ await this.schedule({
410
+ panel,
411
+ concurrency,
412
+ commandName: label,
413
+ failureLabel: `"${label}"`,
414
+ plans,
415
+ });
416
+ }
417
+ /**
418
+ * Runs `tasks` with at most `parallel` of them in flight - `Promise.all` under the repository's
419
+ * own concurrency rule.
420
+ *
421
+ * **The low-level half of `forEachPackage`, for work that is not per package** - sharding a file
422
+ * list across cores, say. It buys the concurrency limit and nothing else: a bare list of functions
423
+ * carries no name, so there is no panel row to label, nothing to name in a failure, and no
424
+ * dependency to order by. That is why it is the second thing to reach for, not the first.
425
+ *
426
+ * **Settles the tasks already in flight before rejecting.** A plain `Promise.all` rejects on the
427
+ * first failure while its siblings keep running - with child processes behind them, that leaves
428
+ * output arriving after the command has reported and exited.
429
+ */
430
+ async parallel(tasks, options = {}) {
431
+ const limit = Math.max(1, resolveConcurrency(options.parallel, os.cpus().length));
432
+ const results = new Array(tasks.length);
433
+ const running = new Set();
434
+ let failure;
435
+ let failed = false;
436
+ for (let i = 0; i < tasks.length; i++) {
437
+ if (failed)
438
+ break;
439
+ const index = i;
440
+ const started = (async () => {
441
+ results[index] = await tasks[index]();
442
+ })().catch(error => {
443
+ if (!failed) {
444
+ failed = true;
445
+ failure = error;
446
+ }
447
+ });
448
+ running.add(started);
449
+ void started.finally(() => running.delete(started));
450
+ if (running.size >= limit)
451
+ await Promise.race(running);
452
+ }
453
+ await Promise.allSettled(running);
454
+ if (failed)
455
+ throw failure;
456
+ return results;
457
+ }
270
458
  }
271
459
  (function (RunService) {
272
460
  /**
@@ -310,11 +498,15 @@ export class RunService extends Service {
310
498
  async function runLifecycleSlot(pkg, script, slot, fallback) {
311
499
  const values = slotValues(slot, contributedSlots(pkg, script)?.[slot] ?? [], fallback ?? [], false);
312
500
  for (const value of values) {
313
- if (typeof value === 'function') {
314
- await value(createStepContext(pkg, pkg.dirname));
501
+ /** Through `toStep` rather than reading the value's own shape, so the object form
502
+ * (`{ command }` / `{ run }`) reaches here too and only one place knows the three spellings.
503
+ * `topo` cannot be set on this path - `normalizeScriptValue` refuses it for a version hook. */
504
+ const step = toStep(slot, value);
505
+ if (step.run) {
506
+ await step.run(createStepContext(pkg, pkg.dirname));
315
507
  continue;
316
508
  }
317
- await exec(value, { cwd: pkg.dirname, stdio: 'inherit', app: pkg.repository.app });
509
+ await exec(step.command, { cwd: pkg.dirname, stdio: 'inherit', app: pkg.repository.app });
318
510
  }
319
511
  }
320
512
  RunService.runLifecycleSlot = runLifecycleSlot;
@@ -348,7 +540,7 @@ export class RunService extends Service {
348
540
  * Exported, and the only implementation: `VersionService` used to carry a second one that behaved
349
541
  * differently, which is how `version.<slot>` came to join its array with `' && '`.
350
542
  */
351
- function normalizeScriptValue(value, at) {
543
+ function normalizeScriptValue(value, at, options) {
352
544
  const items = Array.isArray(value) ? value : [value];
353
545
  const steps = [];
354
546
  for (let i = 0; i < items.length; i++) {
@@ -357,17 +549,75 @@ export class RunService extends Service {
357
549
  * declaring a slot some packages don't use has always behaved. */
358
550
  if (item === undefined || item === null || item === '')
359
551
  continue;
552
+ const where = Array.isArray(value) ? `${at}[${i}]` : at;
360
553
  if (typeof item === 'string' || typeof item === 'function') {
361
554
  steps.push(item);
362
555
  continue;
363
556
  }
364
- const where = Array.isArray(value) ? `${at}[${i}]` : at;
365
- throw new Error(`"${where}" must be a shell command or a function, but it is ${describeValue(item)}.\n` +
366
- ` A list of either (or both) runs them in sequence.`);
557
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
558
+ steps.push(assertStepObject(item, where, options?.topo !== false));
559
+ continue;
560
+ }
561
+ throw new Error(`"${where}" must be a shell command, a function, or { command | run, topo }, ` +
562
+ `but it is ${describeValue(item)}.\n` +
563
+ ` A list of any of them runs them in sequence.`);
367
564
  }
368
565
  return steps;
369
566
  }
370
567
  RunService.normalizeScriptValue = normalizeScriptValue;
568
+ /**
569
+ * Runs `fn` with `console` captured into `onLine`, and leaves the real `console` behind when the
570
+ * last concurrent step is done.
571
+ */
572
+ /* **One patch for the whole run, routed per step - and the obvious alternative is broken under
573
+ * concurrency.** Patching per step and restoring "the original" works for one step and corrupts the
574
+ * console for every run with two:
575
+ *
576
+ * A patches -> A's "original" is the real console
577
+ * B patches -> B's "original" is *A's patch*
578
+ * A restores -> console is real again
579
+ * B restores -> console is A's patch, for the rest of the process
580
+ *
581
+ * Measured on a twenty-package build with sixteen running at once: afterwards `printSummary`'s own
582
+ * `console.log` calls went into a finished package's log array, so the run printed **no recap and
583
+ * no failure logs at all** - every error silently swallowed, which is the opposite of what the
584
+ * capture is for. The depth counter is what restores the pristine console rather than whatever was
585
+ * installed when this step started.
586
+ *
587
+ * `AsyncLocalStorage` is what makes one shared patch route to the right step: the steps interleave
588
+ * on the event loop, so the call stack cannot say which package a `console.log` belongs to, and
589
+ * `await` inside an author's function is exactly what it propagates through. A log from outside any
590
+ * step - a timer a step left running - finds no store and goes to the real console, which is right.
591
+ */
592
+ function withCapturedConsole(onLine, fn) {
593
+ const console_ = globalThis.console;
594
+ if (consoleDepth++ === 0) {
595
+ const pristine = {};
596
+ for (const method of CAPTURED_CONSOLE) {
597
+ pristine[method] = console_[method];
598
+ console_[method] = (...args) => {
599
+ const sink = consoleSink.getStore();
600
+ if (!sink)
601
+ return pristine[method](...args);
602
+ /** Split, because one `console.log` may carry several lines and the panel's log is a list
603
+ * of them - a multi-line entry would render as one unreadable row. */
604
+ for (const line of format(args).split('\n'))
605
+ sink(line);
606
+ };
607
+ }
608
+ pristineConsole = pristine;
609
+ }
610
+ /** `Promise.resolve`, because a step may be a plain function - `RunStepFn` returns
611
+ * `void | Promise<void>` and only the promise form can be awaited. */
612
+ return Promise.resolve(consoleSink.run(onLine, fn)).finally(() => {
613
+ if (--consoleDepth > 0 || !pristineConsole)
614
+ return;
615
+ for (const method of CAPTURED_CONSOLE)
616
+ console_[method] = pristineConsole[method];
617
+ pristineConsole = undefined;
618
+ });
619
+ }
620
+ RunService.withCapturedConsole = withCapturedConsole;
371
621
  /**
372
622
  * `onCommand` is told what the step is spawning, for as long as it runs - the argv while a child
373
623
  * is up, `undefined` when it returns.
@@ -376,7 +626,7 @@ export class RunService extends Service {
376
626
  * whole of a build, because that is all the slot knows; what a reader wants is the `tsc -b
377
627
  * <tsconfig>` it is sitting in. A function step is the shape every shared config uses, so this is
378
628
  * the common case rather than an edge one. */
379
- function createStepContext(pkg, cwd, onCommand) {
629
+ function createStepContext(pkg, cwd, onCommand, onLine) {
380
630
  const logLevel = resolveRootLogLevel(pkg.repository);
381
631
  return {
382
632
  pkg,
@@ -384,14 +634,17 @@ export class RunService extends Service {
384
634
  cwd,
385
635
  runBin: async (bin, argv, opts) => {
386
636
  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
- }
637
+ const result = await runBin(bin, argv, { cwd, logLevel, app: pkg.repository.app, onLine, ...opts });
638
+ /**
639
+ * **Handed back on success only, so a failure leaves the command that failed on the row.**
640
+ */
641
+ /* A rejection skips this line, which is the whole point: the step is over, its row becomes
642
+ * a failed one, and `buildWithTsc()` is a worse thing to read there than the
643
+ * `tsc -b <tsconfig>` that actually exited non-zero. On the way out of a *successful* call
644
+ * the name does have to come back, or a step that spawns once would wear that command for
645
+ * the rest of its run. */
646
+ onCommand?.(undefined);
647
+ return result;
395
648
  },
396
649
  logger: new Logger(logLevel),
397
650
  };
@@ -483,7 +736,7 @@ function printLegacyExecutingLine(commandName, pkgLabel, step, level) {
483
736
  if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf('verbose'))
484
737
  return;
485
738
  const sep = colors.gray('┆');
486
- console.log(colors.magenta('verbose'), commandName, colors.cyan(pkgLabel), sep, colors.cyanBright.bold(step.name), colors.cyanBright.bold('executing'), sep, step.label);
739
+ console.log(colors.magenta('verbose'), commandName, colors.cyan(pkgLabel), sep, colors.cyanBright.bold(step.name), colors.cyanBright.bold('executing'), ...describing(step));
487
740
  }
488
741
  function printLegacyStepLine(commandName, pkgLabel, step, durationMs, level, error) {
489
742
  if (!error && LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf('info'))
@@ -493,7 +746,13 @@ function printLegacyStepLine(commandName, pkgLabel, step, durationMs, level, err
493
746
  const sep = colors.gray('┆');
494
747
  const levelLabel = error ? colors.red('error') : colors.green('info');
495
748
  const status = error ? colors.red.bold('failed') : colors.green.bold('success');
496
- console.log(levelLabel, commandName, colors.cyan(pkgLabel), sep, colors.cyanBright.bold(step.name), status, sep, step.label, colors.yellow(` (${durationMs} ms)`));
749
+ console.log(levelLabel, commandName, colors.cyan(pkgLabel), sep, colors.cyanBright.bold(step.name), status, ...describing(step), colors.yellow(` (${durationMs} ms)`));
750
+ }
751
+ /** The `┆ <what it runs>` tail of a legacy log line, **or nothing at all** when the step has no
752
+ * label of its own - an anonymous function handed to `forEachPackage`, where the slot name already
753
+ * said everything there is to say and a separator with nothing after it is just noise. */
754
+ function describing(step) {
755
+ return step.label ? [colors.gray('┆'), step.label] : [];
497
756
  }
498
757
  function describeValue(value) {
499
758
  if (Array.isArray(value))
@@ -506,10 +765,54 @@ function describeValue(value) {
506
765
  * `function copyDocs()` and `const copyDocs = () => {}` both read as `copyDocs` - falling back to
507
766
  * the slot's own word for one passed inline, which has no name at all. */
508
767
  function toStep(slot, value) {
509
- if (typeof value === 'function')
510
- return { name: slot, label: value.name || `${slot} (js)`, run: value };
511
- return { name: slot, label: value, command: value };
768
+ const topo = typeof value === 'object' ? value.topo : undefined;
769
+ const step = typeof value === 'object' ? value.command : value;
770
+ if (typeof step === 'function')
771
+ return { name: slot, label: step.name || `${slot} (js)`, run: step, topo };
772
+ return { name: slot, label: step, command: step, topo };
512
773
  }
774
+ /**
775
+ * Checks a step written as an object and hands it back - a `command` that is a shell command or a
776
+ * function, and a boolean `topo` where ordering means anything.
777
+ *
778
+ * **An unknown key is refused, which is most of what this is for.** The object form exists to carry
779
+ * one marker beside the step, so anything else in it is a mistake with no effect - and the mistake
780
+ * is a plausible one: `{ topo: true, script: 'tsc -b' }` reads perfectly well and names the key rman
781
+ * uses for the *lifecycle* (`run.<script>`) rather than the step. Ignored, that step would silently
782
+ * run nothing while the run reported success, which is the failure `normalizeScriptValue` was
783
+ * already rewritten once to stop.
784
+ *
785
+ * **`command` takes a function as well as a string**, because `run.<script>.exec` already does: a
786
+ * second key for the function case would be two spellings of one thing plus a rule about which to
787
+ * use. The first draft of this did have one (`run`), and the config that drove the feature -
788
+ * `@panates/rman-preset`'s build hook - reached for `command` without being asked.
789
+ *
790
+ * `allowTopo` is false for `version`'s hooks: they belong to one package's version write, with no
791
+ * package graph to wait on, so the key means nothing there and says so rather than being dropped.
792
+ */
793
+ function assertStepObject(item, where, allowTopo) {
794
+ for (const key of Object.keys(item)) {
795
+ if (!STEP_OBJECT_KEYS.includes(key)) {
796
+ throw new Error(`"${where}" has an unknown key "${key}". A step object takes ${STEP_OBJECT_KEYS.join(', ')}.\n` +
797
+ ` The step itself goes in "command" - "script" is the name of the lifecycle, not the step.`);
798
+ }
799
+ }
800
+ if (typeof item.command !== 'function' && (typeof item.command !== 'string' || item.command === '')) {
801
+ throw new Error(`"${where}" must set "command" to a shell command or a function, but it is ` + `${describeValue(item.command)}.`);
802
+ }
803
+ if (item.topo !== undefined) {
804
+ if (!allowTopo) {
805
+ throw new Error(`"${where}" cannot set "topo": it is a version hook, which runs for one package and has no\n` +
806
+ ` dependency order to wait for. The key belongs to run.<script> steps.`);
807
+ }
808
+ if (typeof item.topo !== 'boolean') {
809
+ throw new Error(`"${where}" must set "topo" to true or false, but it is ${describeValue(item.topo)}.`);
810
+ }
811
+ }
812
+ return { command: item.command, topo: item.topo };
813
+ }
814
+ /** What a step object may hold - the step itself, and the marker beside it. */
815
+ const STEP_OBJECT_KEYS = ['command', 'topo'];
513
816
  /**
514
817
  * Runs a function step.
515
818
  *
@@ -536,7 +839,7 @@ function toStep(slot, value) {
536
839
  * stderr with it off, ahead of the `failed` line - the order a shell step already produces.
537
840
  */
538
841
  async function runFunctionStep(run, pkg, cwd, onLine, onCommand) {
539
- const context = RunService.createStepContext(pkg, cwd, onCommand);
842
+ const context = RunService.createStepContext(pkg, cwd, onCommand, onLine);
540
843
  if (!onLine) {
541
844
  try {
542
845
  await run(context);
@@ -547,32 +850,21 @@ async function runFunctionStep(run, pkg, cwd, onLine, onCommand) {
547
850
  }
548
851
  return;
549
852
  }
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
853
  try {
562
- await run(context);
854
+ await RunService.withCapturedConsole(onLine, () => run(context));
563
855
  }
564
856
  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. */
857
+ /** Straight to `onLine`, so the message lands in this step's log rather than over the panel it
858
+ * is drawn inside. */
567
859
  for (const line of messageOf(e).split('\n'))
568
860
  onLine(line);
569
861
  throw e;
570
862
  }
571
- finally {
572
- for (const method of CAPTURED_CONSOLE)
573
- console_[method] = original[method];
574
- }
575
863
  }
864
+ /** Which step a captured `console` call belongs to - see `withCapturedConsole`. */
865
+ const consoleSink = new AsyncLocalStorage();
866
+ let consoleDepth = 0;
867
+ let pristineConsole;
576
868
  /** What a thrown value has to say for itself. A step may throw anything, and `String(undefined)`
577
869
  * reading as `undefined` in a run log is worse than saying nothing was said. */
578
870
  function messageOf(error) {
@@ -756,6 +1048,28 @@ export function resolveNumber(cliValue, pkg, script, key, fallback) {
756
1048
  const v = RunService.getConfig(pkg, script)[key];
757
1049
  return typeof v === 'number' ? v : fallback;
758
1050
  }
1051
+ /**
1052
+ * How many packages run at once: what the flag said, or `whenUnset` where it said nothing.
1053
+ *
1054
+ * **`--parallel` and `concurrency` are deliberately different names**, so this is where the two
1055
+ * meet: the flag is `boolean | number` (omit/`true` = CPU count, `false` = serially), the key is the
1056
+ * number it resolves to. It was written out at `runScript`'s top and two more callers need the same
1057
+ * answer - three copies of a four-branch ternary is how a flag comes to mean something slightly
1058
+ * different depending on which command you typed.
1059
+ *
1060
+ * **Where the unset case comes from is the caller's**, and deliberately not read here: `run`'s is
1061
+ * `run.<script>.concurrency`, while a command calling `forEachPackage` owns a key of its own
1062
+ * (`check.concurrency`) that this function has no business guessing the name of.
1063
+ */
1064
+ export function resolveConcurrency(parallel, whenUnset) {
1065
+ if (parallel === false)
1066
+ return 1;
1067
+ if (typeof parallel === 'number')
1068
+ return parallel;
1069
+ if (parallel === true)
1070
+ return os.cpus().length;
1071
+ return whenUnset;
1072
+ }
759
1073
  export function resolveLogLevel(cliValue, pkg, script, fallback) {
760
1074
  if (cliValue !== undefined)
761
1075
  return cliValue;