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.
- package/builtins/platforms/node/node-manifest.provider.d.ts +10 -0
- package/builtins/platforms/node/node-manifest.provider.js +32 -5
- package/builtins/platforms/node/services/clean.service.js +104 -23
- package/cli.js +31 -13
- package/constants.js +1 -1
- package/core/classes/repository.d.ts +48 -0
- package/core/classes/repository.js +139 -8
- package/core/command-builder.d.ts +9 -1
- package/core/command-builder.js +24 -11
- package/core/config/config-paths.js +24 -0
- package/core/interfaces/custom-command.d.ts +39 -0
- package/core/interfaces/run-step.d.ts +50 -3
- package/index.d.ts +7 -1
- package/index.js +11 -0
- package/interfaces/rman-config.interface.d.ts +21 -3
- package/package.json +1 -1
- package/services/run.service.d.ts +125 -1
- package/services/run.service.js +388 -120
- package/services/version.service.d.ts +2 -0
- package/services/version.service.js +7 -2
- package/utils/run-options.d.ts +44 -0
- package/utils/run-options.js +55 -0
package/services/run.service.js
CHANGED
|
@@ -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
|
|
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,
|
|
59
|
-
|
|
60
|
-
|
|
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 =
|
|
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.
|
|
250
|
+
if (!part?.more || ctx.status === 'failed')
|
|
251
|
+
ctx.finishedAt = Date.now();
|
|
133
252
|
}
|
|
134
253
|
};
|
|
135
254
|
const children = [];
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
const
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
*
|
|
273
|
+
* **Where this package starts waiting for its dependencies**, or `-1` for not at all.
|
|
211
274
|
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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(
|
|
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
|
-
|
|
315
|
-
|
|
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(
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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'),
|
|
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,
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|