rman 2.8.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.
@@ -42,24 +42,133 @@ export class RunService extends Service {
42
42
  const status = await repository.listStatus({ hash: changedSince });
43
43
  packages = packages.filter(p => status[p.name] !== 'clean');
44
44
  }
45
- const concurrency = options.parallel === false
46
- ? 1
47
- : typeof options.parallel === 'number'
48
- ? options.parallel
49
- : options.parallel === true
50
- ? os.cpus().length
51
- : resolveNumber(undefined, repository.rootPackage, script, 'concurrency', os.cpus().length);
45
+ const concurrency = resolveConcurrency(options.parallel, resolveNumber(undefined, repository.rootPackage, script, 'concurrency', os.cpus().length));
52
46
  const progress = resolveBool(options.progress, repository.rootPackage, script, 'progress', true);
53
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;
54
150
  /** Set once the aggregate Task exists, so a package's own failure can trigger a manual
55
151
  * abort using *its own* resolved bail setting (see `runSteps` below) - power-tasks' own
56
152
  * `bail` is a single blanket policy for the whole batch, it can't vary per package. */
57
153
  let rootTask;
58
- const runSteps = async (ctx, pkg, pkgLabel, steps, cwd, pkgBail, pkgLogLevel) => {
59
- ctx.status = 'running';
60
- 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
+ }
61
170
  try {
62
- for (let i = 0; i < steps.length; i++) {
171
+ for (let i = from; i < steps.length; i++) {
63
172
  const step = steps[i];
64
173
  ctx.currentStep = step.name;
65
174
  /** The command for a shell step, the function's own name for a JS one - and a bare
@@ -120,6 +229,15 @@ export class RunService extends Service {
120
229
  throw stepError;
121
230
  }
122
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
+ }
123
241
  ctx.status = 'success';
124
242
  }
125
243
  catch (e) {
@@ -129,114 +247,79 @@ export class RunService extends Service {
129
247
  throw e;
130
248
  }
131
249
  finally {
132
- ctx.finishedAt = Date.now();
250
+ if (!part?.more || ctx.status === 'failed')
251
+ ctx.finishedAt = Date.now();
133
252
  }
134
253
  };
135
254
  const children = [];
136
- /** Shared across all `if: changed[ = hash]` evaluations so the same reference is only `git`-queried once. */
137
- const ifStatusCache = new Map();
138
- /** Repo-wide bookend: root's own pre/post hooks run once each, exclusively, around every package
139
- * (unless root itself opts out via `run.<script>.skip`, fails its own `run.<script>.if`, or the
140
- * run is scoped to a single package by `cwdScope` - a repo-wide bookend has no place there).
141
- *
142
- * Only in a monorepo. Without one the root *is* the single package, already in the loop below
143
- * with the same hooks and the same directory - a bookend would simply run each of them a
144
- * second time. */
145
- /** Short-circuited deliberately: a root already out of the run for a structural reason must not
146
- * have its `if` evaluated, now that evaluating one can mean calling the repository's own code. */
147
- const rootSkipped = !!cwdScope ||
148
- !repository.monorepo ||
149
- rootCfg.skip === true ||
150
- !(await passesIf(repository, repository.rootPackage, rootCfg.if, repository.dirname, ifStatusCache));
151
- const rootSteps = rootSkipped ? [] : getScriptSteps(repository.rootPackage, script);
152
- /** Filtered on the slot, not on `'pre' + script`: the step labels are `before`/`exec`/`after`
153
- * now - the same words the config uses - rather than npm's `pre<script>` naming, which moved
154
- * out with the package.json source. */
155
- const rootPre = rootSteps.filter(s => s.name === 'before');
156
- const rootPost = rootSteps.filter(s => s.name === 'after');
157
- const rootPreName = 'root (pre)';
158
- const rootPostName = 'root (post)';
159
- if (rootPre.length) {
160
- const ctx = panel.addItem(rootPreName, rootPre.length);
161
- const pkgBail = resolveBail(options.bail, repository.rootPackage, script, true);
162
- const pkgLogLevel = resolveLogLevel(options.logLevel, repository.rootPackage, script, logLevelDefault);
163
- children.push(new Task(() => runSteps(ctx, repository.rootPackage, 'root', rootPre, repository.dirname, pkgBail, pkgLogLevel), {
164
- name: ctx.name,
165
- exclusive: true,
166
- }));
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 }));
167
263
  }
168
- const stepsByPackage = new Map();
169
- for (const pkg of packages) {
170
- const pkgCfg = RunService.getConfig(pkg, script);
171
- if (pkgCfg.skip === true)
172
- continue;
173
- if (!(await passesIf(repository, pkg, pkgCfg.if, pkg.dirname, ifStatusCache)))
174
- continue;
175
- const steps = getScriptSteps(pkg, script);
176
- if (steps.length)
177
- stepsByPackage.set(pkg.name, steps);
178
- }
179
- for (const pkg of packages) {
180
- const steps = stepsByPackage.get(pkg.name);
181
- if (!steps)
182
- continue;
183
- const ctx = panel.addItem(pkg.name, steps.length);
184
- const pkgTopo = resolveBool(options.topo, pkg, script, 'topo', topo);
185
- const pkgBail = resolveBail(options.bail, pkg, script, true);
186
- 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;
187
267
  /** power-tasks identifies a task by its name string, so the graph is handed over as names -
188
268
  * the references are what rman reasons with, the names are what the scheduler wants. */
189
- const dependencies = pkgTopo ? pkg.dependencies.filter(d => stepsByPackage.has(d.name)).map(d => d.name) : [];
190
- if (rootPre.length)
191
- dependencies.push(rootPreName);
192
- children.push(new Task(() => runSteps(ctx, pkg, pkg.name, steps, pkg.dirname, pkgBail, pkgLogLevel), {
193
- name: ctx.name,
194
- dependencies,
195
- }));
196
- }
197
- if (rootPost.length) {
198
- const ctx = panel.addItem(rootPostName, rootPost.length);
199
- const pkgBail = resolveBail(options.bail, repository.rootPackage, script, true);
200
- const pkgLogLevel = resolveLogLevel(options.logLevel, repository.rootPackage, script, logLevelDefault);
201
- children.push(new Task(() => runSteps(ctx, repository.rootPackage, 'root', rootPost, repository.dirname, pkgBail, pkgLogLevel), {
202
- name: ctx.name,
203
- exclusive: true,
204
- /** Must wait for every package task to finish, not just be "exclusive" once it starts. */
205
- dependencies: [...stepsByPackage.keys()],
206
- }));
207
- }
208
- if (!children.length) {
269
+ const waitsFor = plan.topo
270
+ ? plan.pkg.dependencies.map(d => taskOf.get(d.name)).filter((name) => !!name)
271
+ : [];
209
272
  /**
210
- * 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.
211
274
  *
212
- * Nobody in the repository defines this script at all: the name is a mistake - a typo, or a
213
- * script that used to exist - and `npm run` fails on exactly this. Staying silent is how
214
- * `rman run qc` sat in a CI pipeline for months reporting success while running nothing, with
215
- * `qc` defined only on the root (whose own scripts a monorepo never runs, only its
216
- * `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.
217
279
  *
218
- * Everything was filtered out instead - `--scope`, `--changed`, `run.<script>.skip`, an
219
- * `if:` that didn't match: zero is the correct answer to what was asked, and asking "build
220
- * 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.
221
283
  */
222
- /** `getPackages()` and nothing else - in a monorepo that excludes the root, which is the
223
- * point: the root contributes only `pre`/`post` bookends, never the script itself, so a
224
- * `qc` defined *only* there is exactly the mistake above rather than an excuse for it. (And
225
- * had the root contributed a bookend, `children` wouldn't be empty.) In a single-package
226
- * repository the root *is* the one package, and is covered. */
227
- const definedSomewhere = repository.getPackages().some(pkg => getScriptSteps(pkg, script).length > 0);
228
- if (definedSomewhere) {
229
- console.log(colors.gray(`Nothing to run - every package was filtered out of "${script}".`));
230
- 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;
231
305
  }
232
- const message = `No package defines a "${script}" script.`;
233
- console.log(colors.red(message));
234
- const err = new Error(message);
235
- err.logged = true;
236
- 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
+ }));
237
320
  }
238
- panel.detail = repository.name;
239
- panel.start(repository.app.statusRegion);
321
+ panel.detail = this.repository.name;
322
+ panel.start(this.repository.app.statusRegion);
240
323
  try {
241
324
  /** bail:false here - each package's own resolved bail setting decides whether to call
242
325
  * `rootTask.abort()` itself (see `runSteps`), since power-tasks' own `bail` can't vary per package. */
@@ -257,17 +340,121 @@ export class RunService extends Service {
257
340
  panel.stop();
258
341
  }
259
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. */
260
348
  /** The tallies, never `rootTask.toPromise()`'s own outcome: with a sibling still in flight at
261
349
  * the moment one package failed, that promise *resolves*, and this command used to exit 0 on a
262
350
  * run it had just reported as failed - non-deterministically, since it came down to which
263
351
  * packages happened to still be running (measured: `1 0 1 1 0` across five identical runs).
264
352
  * A single failed package must fail the command, every time. */
265
353
  if (summary.failedCount > 0) {
266
- const err = new Error(`"${script}" failed`);
354
+ const err = new Error(`${options.failureLabel} failed`);
267
355
  err.logged = true;
268
356
  throw err;
269
357
  }
270
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
+ }
271
458
  }
272
459
  (function (RunService) {
273
460
  /**
@@ -311,11 +498,15 @@ export class RunService extends Service {
311
498
  async function runLifecycleSlot(pkg, script, slot, fallback) {
312
499
  const values = slotValues(slot, contributedSlots(pkg, script)?.[slot] ?? [], fallback ?? [], false);
313
500
  for (const value of values) {
314
- if (typeof value === 'function') {
315
- 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));
316
507
  continue;
317
508
  }
318
- 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 });
319
510
  }
320
511
  }
321
512
  RunService.runLifecycleSlot = runLifecycleSlot;
@@ -349,7 +540,7 @@ export class RunService extends Service {
349
540
  * Exported, and the only implementation: `VersionService` used to carry a second one that behaved
350
541
  * differently, which is how `version.<slot>` came to join its array with `' && '`.
351
542
  */
352
- function normalizeScriptValue(value, at) {
543
+ function normalizeScriptValue(value, at, options) {
353
544
  const items = Array.isArray(value) ? value : [value];
354
545
  const steps = [];
355
546
  for (let i = 0; i < items.length; i++) {
@@ -358,13 +549,18 @@ export class RunService extends Service {
358
549
  * declaring a slot some packages don't use has always behaved. */
359
550
  if (item === undefined || item === null || item === '')
360
551
  continue;
552
+ const where = Array.isArray(value) ? `${at}[${i}]` : at;
361
553
  if (typeof item === 'string' || typeof item === 'function') {
362
554
  steps.push(item);
363
555
  continue;
364
556
  }
365
- const where = Array.isArray(value) ? `${at}[${i}]` : at;
366
- throw new Error(`"${where}" must be a shell command or a function, but it is ${describeValue(item)}.\n` +
367
- ` 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.`);
368
564
  }
369
565
  return steps;
370
566
  }
@@ -540,7 +736,7 @@ function printLegacyExecutingLine(commandName, pkgLabel, step, level) {
540
736
  if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf('verbose'))
541
737
  return;
542
738
  const sep = colors.gray('┆');
543
- 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));
544
740
  }
545
741
  function printLegacyStepLine(commandName, pkgLabel, step, durationMs, level, error) {
546
742
  if (!error && LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf('info'))
@@ -550,7 +746,13 @@ function printLegacyStepLine(commandName, pkgLabel, step, durationMs, level, err
550
746
  const sep = colors.gray('┆');
551
747
  const levelLabel = error ? colors.red('error') : colors.green('info');
552
748
  const status = error ? colors.red.bold('failed') : colors.green.bold('success');
553
- 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] : [];
554
756
  }
555
757
  function describeValue(value) {
556
758
  if (Array.isArray(value))
@@ -563,10 +765,54 @@ function describeValue(value) {
563
765
  * `function copyDocs()` and `const copyDocs = () => {}` both read as `copyDocs` - falling back to
564
766
  * the slot's own word for one passed inline, which has no name at all. */
565
767
  function toStep(slot, value) {
566
- if (typeof value === 'function')
567
- return { name: slot, label: value.name || `${slot} (js)`, run: value };
568
- 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 };
569
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'];
570
816
  /**
571
817
  * Runs a function step.
572
818
  *
@@ -802,6 +1048,28 @@ export function resolveNumber(cliValue, pkg, script, key, fallback) {
802
1048
  const v = RunService.getConfig(pkg, script)[key];
803
1049
  return typeof v === 'number' ? v : fallback;
804
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
+ }
805
1073
  export function resolveLogLevel(cliValue, pkg, script, fallback) {
806
1074
  if (cliValue !== undefined)
807
1075
  return cliValue;
@@ -101,6 +101,8 @@ export declare namespace VersionService {
101
101
  * never run. (The doc comment also still named `.script`/`.preScript`/`.postScript`, three keys
102
102
  * that have been `before`/`exec`/`after` for a long time.)
103
103
  */
104
+ /** `topo` is refused by default here, which is the one way this differs from `RunService`'s: a
105
+ * version hook has no package ordering to join. */
104
106
  function normalizeScriptValue(value: unknown, at: string): RunStepValue[];
105
107
  /**
106
108
  * Keeps a package's Dockerfile `org.opencontainers.image.version` label in step with the version