runset 0.0.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/dist/index.mjs ADDED
@@ -0,0 +1,1665 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { fileURLToPath } from "node:url";
4
+ import path from "node:path";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import { inspect, styleText } from "node:util";
8
+ import cp from "node:child_process";
9
+ //#region src/types.ts
10
+ const EXIT_ACTIONS = /* @__PURE__ */ new Set([
11
+ "continue",
12
+ "restart",
13
+ "stop"
14
+ ]);
15
+ /** True for an object written without a `command` — a settings entry. */
16
+ function isCommandSettings(definition) {
17
+ return !("command" in definition);
18
+ }
19
+ function isStreamDestination(destination) {
20
+ return destination === "stdout" || destination === "stderr";
21
+ }
22
+ //#endregion
23
+ //#region src/utils/errors.ts
24
+ /** Any failure runset reports itself; `exitCode` is what the CLI exits with. */
25
+ var RunsetError = class extends Error {
26
+ exitCode;
27
+ constructor(message, exitCode = 1) {
28
+ super(message);
29
+ this.name = new.target.name;
30
+ this.exitCode = exitCode;
31
+ }
32
+ };
33
+ /** A command line runset cannot read. */
34
+ var CliError = class extends RunsetError {};
35
+ /** A run-wide option or config file runset cannot use. */
36
+ var ConfigError = class extends RunsetError {};
37
+ /** A command that cannot be resolved. */
38
+ var NormalizeError = class extends RunsetError {};
39
+ //#endregion
40
+ //#region src/utils/fs.ts
41
+ const require = createRequire(import.meta.url);
42
+ /** The sync counterpart of `import()`: CJS, JSON, and ESM/TS via `require(esm)`. */
43
+ function importSync(modulePath) {
44
+ return require(modulePath);
45
+ }
46
+ /** The nearest `package.json` at or above `cwd`. */
47
+ function readPackageJson(cwd) {
48
+ for (const dir of ancestors(cwd)) {
49
+ const filePath = path.join(dir, "package.json");
50
+ if (!fs.existsSync(filePath)) continue;
51
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
52
+ return {
53
+ filePath,
54
+ name: raw.name ?? "",
55
+ scripts: raw.scripts ?? {},
56
+ version: raw.version ?? ""
57
+ };
58
+ }
59
+ return {
60
+ filePath: "",
61
+ name: "",
62
+ scripts: {},
63
+ version: ""
64
+ };
65
+ }
66
+ //#endregion
67
+ //#region src/config/loadConfig.ts
68
+ /**
69
+ * Loads `runset.config.*` — the `explicitPath`, or the first one found walking
70
+ * up from `cwd` — and unwraps a default export or a factory function.
71
+ */
72
+ function loadConfigJs({ argv, cwd, env, explicitPath }) {
73
+ const filePath = explicitPath === void 0 ? findConfigFile(cwd) : path.resolve(cwd, explicitPath);
74
+ if (filePath === void 0) return {};
75
+ if (!fs.existsSync(filePath)) throw new ConfigError(`Config file not found: ${explicitPath}`);
76
+ return unwrapConfigJs(importSync(filePath), {
77
+ argv,
78
+ cwd,
79
+ env
80
+ });
81
+ }
82
+ function unwrapConfigJs(source, context) {
83
+ const unwrapped = source.default ?? source;
84
+ return typeof unwrapped === "function" ? unwrapped(context) : unwrapped;
85
+ }
86
+ /** `dir` and every directory above it, up to the root. */
87
+ function ancestors(dir) {
88
+ const dirs = [path.resolve(dir)];
89
+ for (;;) {
90
+ const current = dirs.at(-1);
91
+ const parent = path.dirname(current);
92
+ if (parent === current) return dirs;
93
+ dirs.push(parent);
94
+ }
95
+ }
96
+ function findConfigFile(cwd) {
97
+ for (const dir of ancestors(cwd)) for (const extension of [
98
+ "ts",
99
+ "js",
100
+ "mjs",
101
+ "cjs",
102
+ "json"
103
+ ]) {
104
+ const candidate = path.join(dir, `runset.config.${extension}`);
105
+ if (fs.existsSync(candidate)) return candidate;
106
+ }
107
+ }
108
+ //#endregion
109
+ //#region src/utils/string.ts
110
+ function toCamelCase(name) {
111
+ return name.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
112
+ }
113
+ function toKebabCase(name) {
114
+ return name.replaceAll(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
115
+ }
116
+ //#endregion
117
+ //#region src/config/winArgv.ts
118
+ /**
119
+ * Windows only: rejoins `'single quoted'` tokens that `cmd.exe` leaves split,
120
+ * joined by single spaces. The identity everywhere else.
121
+ */
122
+ function regroupQuoted(argv, platform = process.platform) {
123
+ if (platform !== "win32") return argv;
124
+ const result = [];
125
+ let open;
126
+ for (const token of argv) {
127
+ if (open === void 0) {
128
+ if (token.startsWith("'") && token.endsWith("'") && token.length > 1) result.push(token.slice(1, -1));
129
+ else if (token.startsWith("'")) open = [token.slice(1)];
130
+ else result.push(token);
131
+ continue;
132
+ }
133
+ if (token.endsWith("'")) {
134
+ open.push(token.slice(0, -1));
135
+ result.push(open.join(" "));
136
+ open = void 0;
137
+ } else open.push(token);
138
+ }
139
+ if (open !== void 0) throw new CliError(`Unterminated quote in "'${open.join(" ")}".`);
140
+ return result;
141
+ }
142
+ //#endregion
143
+ //#region src/config/parseCli.ts
144
+ const FLAGS = [
145
+ {
146
+ name: "parallel",
147
+ short: "p",
148
+ takesValue: false
149
+ },
150
+ {
151
+ name: "serial",
152
+ short: "s",
153
+ takesValue: false
154
+ },
155
+ {
156
+ name: "jobs",
157
+ short: "j",
158
+ takesValue: true
159
+ },
160
+ {
161
+ name: "killTimeout",
162
+ takesValue: true
163
+ },
164
+ {
165
+ name: "onSuccess",
166
+ takesValue: true
167
+ },
168
+ {
169
+ name: "onFailure",
170
+ takesValue: true
171
+ },
172
+ {
173
+ name: "config",
174
+ short: "c",
175
+ takesValue: true
176
+ },
177
+ {
178
+ name: "cwd",
179
+ takesValue: true
180
+ },
181
+ {
182
+ name: "color",
183
+ takesValue: false
184
+ },
185
+ {
186
+ name: "logLevel",
187
+ takesValue: true
188
+ },
189
+ {
190
+ name: "dryRun",
191
+ takesValue: false
192
+ },
193
+ {
194
+ name: "stdout",
195
+ takesValue: true
196
+ },
197
+ {
198
+ name: "stderr",
199
+ takesValue: true
200
+ },
201
+ {
202
+ name: "output",
203
+ short: "o",
204
+ takesValue: true
205
+ },
206
+ {
207
+ name: "labels",
208
+ takesValue: true
209
+ },
210
+ {
211
+ name: "help",
212
+ short: "h",
213
+ takesValue: false
214
+ },
215
+ {
216
+ name: "version",
217
+ short: "v",
218
+ takesValue: false
219
+ }
220
+ ];
221
+ /**
222
+ * Splits argv into options, commands, and the literal arguments after `--`.
223
+ * Each `-p`/`-s` becomes a settings entry where it was typed, the same object
224
+ * a config file would write.
225
+ */
226
+ function parseCli(rawArgv) {
227
+ const argv = regroupQuoted(rawArgv);
228
+ const options = {};
229
+ const commands = [];
230
+ const positional = [];
231
+ let help = false;
232
+ let version = false;
233
+ let afterDoubleDash = false;
234
+ let cursor = 0;
235
+ function setOption(spec, raw) {
236
+ switch (spec.name) {
237
+ case "help":
238
+ help = true;
239
+ break;
240
+ case "jobs":
241
+ case "killTimeout":
242
+ options[spec.name] = Number(raw);
243
+ break;
244
+ case "parallel":
245
+ case "serial":
246
+ commands.push({ parallel: spec.name === "parallel" });
247
+ break;
248
+ case "version":
249
+ version = true;
250
+ break;
251
+ default: if (typeof raw === "boolean") options[spec.name] = raw;
252
+ else options[spec.name] = raw;
253
+ }
254
+ }
255
+ function takeValue(label) {
256
+ const value = argv[cursor + 1];
257
+ if (value === void 0) throw new CliError(`Option "${label}" needs a value.`);
258
+ cursor += 1;
259
+ return value;
260
+ }
261
+ function readLong(body) {
262
+ const eq = body.indexOf("=");
263
+ const name = eq === -1 ? body : body.slice(0, eq);
264
+ const inlineValue = eq === -1 ? void 0 : body.slice(eq + 1);
265
+ const flag = findLongFlag(name);
266
+ if (flag === void 0 && /^no-?[A-Za-z]/.test(name)) {
267
+ const negated = findLongFlag(name.replace(/^no-?/, ""));
268
+ if (negated && !negated.takesValue) {
269
+ setOption(negated, false);
270
+ return;
271
+ }
272
+ }
273
+ if (flag === void 0) throw new CliError(`Unknown option "--${name}".`);
274
+ if (flag.takesValue) setOption(flag, inlineValue ?? takeValue(`--${name}`));
275
+ else setOption(flag, true);
276
+ }
277
+ function readShortCluster(body) {
278
+ for (let position = 0; position < body.length; position += 1) {
279
+ const letter = body[position];
280
+ const flag = findShortFlag(letter);
281
+ if (flag === void 0) throw new CliError(`Unknown option "-${letter}".`);
282
+ if (!flag.takesValue) {
283
+ setOption(flag, true);
284
+ continue;
285
+ }
286
+ const rest = body.slice(position + 1);
287
+ setOption(flag, rest === "" ? takeValue(`-${letter}`) : rest);
288
+ return;
289
+ }
290
+ }
291
+ for (; cursor < argv.length; cursor += 1) {
292
+ const token = argv[cursor];
293
+ if (afterDoubleDash) positional.push(token);
294
+ else if (token === "--") afterDoubleDash = true;
295
+ else if (token.startsWith("--") && token.length > 2) readLong(token.slice(2));
296
+ else if (token.startsWith("-") && token.length > 1) readShortCluster(token.slice(1));
297
+ else commands.push(token);
298
+ }
299
+ return {
300
+ argv,
301
+ commands,
302
+ help,
303
+ options,
304
+ positional,
305
+ version
306
+ };
307
+ }
308
+ function findLongFlag(name) {
309
+ const camel = toCamelCase(name);
310
+ return FLAGS.find((flag) => flag.name === camel);
311
+ }
312
+ function findShortFlag(letter) {
313
+ return FLAGS.find((flag) => flag.short === letter);
314
+ }
315
+ //#endregion
316
+ //#region src/config/std.ts
317
+ const TIMINGS = /* @__PURE__ */ new Set(["realtime", "grouped"]);
318
+ /**
319
+ * Parses `<timing>+<destination>` (either order, either optional) into the
320
+ * axes it names. Unnamed axes are left out so settings can be layered.
321
+ */
322
+ function parsePartialStd(value) {
323
+ const result = {};
324
+ for (const part of value.split("+")) {
325
+ const token = part.trim();
326
+ if (token === "") continue;
327
+ if (TIMINGS.has(token)) result.timing = token;
328
+ else result.destination = token;
329
+ }
330
+ return result;
331
+ }
332
+ function toPartialStd(value) {
333
+ return typeof value === "string" ? parsePartialStd(value) : value;
334
+ }
335
+ /** Layers a stream setting, in either form, over `base` one axis at a time. */
336
+ function mergeStd(base, value) {
337
+ return value === void 0 ? base : {
338
+ ...base,
339
+ ...toPartialStd(value)
340
+ };
341
+ }
342
+ function defaultStd(destination) {
343
+ return {
344
+ destination,
345
+ timing: "realtime"
346
+ };
347
+ }
348
+ //#endregion
349
+ //#region src/config/validate.ts
350
+ function formatValue(value) {
351
+ return typeof value === "string" ? `"${value}"` : String(value);
352
+ }
353
+ function numberError(name, value, minimum) {
354
+ if (typeof value !== "number" || Number.isNaN(value) || value < minimum) return `${name} must be a number of ${minimum} or more, got ${formatValue(value)}.`;
355
+ }
356
+ function booleanError(name, value) {
357
+ return typeof value === "boolean" ? void 0 : `${name} must be true or false, got ${formatValue(value)}.`;
358
+ }
359
+ function stringError(name, value) {
360
+ return typeof value === "string" ? void 0 : `${name} must be a string, got ${formatValue(value)}.`;
361
+ }
362
+ function oneOfError(name, value, allowed) {
363
+ return allowed.has(value) ? void 0 : `${name} is one of ${[...allowed].join(", ")}, got ${formatValue(value)}.`;
364
+ }
365
+ function actionError(name, value) {
366
+ return oneOfError(name, value, EXIT_ACTIONS);
367
+ }
368
+ function stdError(name, value) {
369
+ if (typeof value !== "object" || value === null) return `${name} must be a stream setting.`;
370
+ if (!TIMINGS.has(value.timing)) return `Invalid timing ${formatValue(value.timing)} for ${name}; one of ${[...TIMINGS].join(", ")}.`;
371
+ if (typeof value.destination !== "string" || value.destination === "") return `${name} needs a destination to write to.`;
372
+ }
373
+ /** Throws `message` as an `ErrorClass`, if there is one. */
374
+ function check(message, ErrorClass) {
375
+ if (message !== void 0) throw new ErrorClass(message);
376
+ }
377
+ //#endregion
378
+ //#region src/config/Config.ts
379
+ const DEFAULT_KILL_TIMEOUT = 5e3;
380
+ const LOG_LEVELS = /* @__PURE__ */ new Set([
381
+ "error",
382
+ "warn",
383
+ "info",
384
+ "debug"
385
+ ]);
386
+ const LABEL_MODES = /* @__PURE__ */ new Set([
387
+ "none",
388
+ "auto",
389
+ "custom",
390
+ "all"
391
+ ]);
392
+ const KNOWN_KEYS = /* @__PURE__ */ new Set([
393
+ "color",
394
+ "commandDictionary",
395
+ "commands",
396
+ "cwd",
397
+ "dryRun",
398
+ "formatLabel",
399
+ "jobs",
400
+ "killTimeout",
401
+ "labels",
402
+ "logLevel",
403
+ "onFailure",
404
+ "onSuccess",
405
+ "parallel",
406
+ "stderr",
407
+ "stdout"
408
+ ]);
409
+ /** Parses, loads and validates; anything left out comes from the process. */
410
+ function createConfig({ cli = parseCli(process.argv.slice(2)), configJs, cwd = process.cwd(), destinations = process, env = process.env } = {}) {
411
+ const config = new Config({
412
+ cli,
413
+ configJs,
414
+ cwd,
415
+ destinations,
416
+ env
417
+ });
418
+ config.validate();
419
+ return config;
420
+ }
421
+ /** The resolved run-wide configuration: CLI flag → config file → default. */
422
+ var Config = class {
423
+ commands;
424
+ commandDictionary;
425
+ /** Everything after `--`, for placeholders. */
426
+ args;
427
+ cwd;
428
+ env;
429
+ jobs;
430
+ killTimeout;
431
+ onSuccess;
432
+ onFailure;
433
+ color;
434
+ parallel;
435
+ labels;
436
+ logLevel;
437
+ dryRun;
438
+ formatLabel;
439
+ stdout;
440
+ stderr;
441
+ destinations;
442
+ /** Printed by the run once it is built. */
443
+ warnings = [];
444
+ constructor({ cli, configJs, cwd, destinations, env }) {
445
+ const { options } = cli;
446
+ this.cwd = path.resolve(cwd, options.cwd ?? ".");
447
+ this.destinations = destinations;
448
+ this.env = env;
449
+ const context = {
450
+ argv: cli.argv,
451
+ cwd: this.cwd,
452
+ env
453
+ };
454
+ const file = configJs === void 0 ? loadConfigJs({
455
+ ...context,
456
+ explicitPath: options.config
457
+ }) : unwrapConfigJs(configJs, context);
458
+ for (const key of Object.keys(file)) if (!KNOWN_KEYS.has(key)) this.warnings.push(`Unknown config key "${key}" was ignored.`);
459
+ if (file.cwd !== void 0 && options.cwd === void 0) this.cwd = path.resolve(cwd, file.cwd);
460
+ this.commands = [...file.commands ?? [], ...cli.commands].filter(Boolean);
461
+ this.commandDictionary = file.commandDictionary ?? {};
462
+ this.args = cli.positional;
463
+ this.jobs = options.jobs ?? file.jobs ?? Number.POSITIVE_INFINITY;
464
+ this.killTimeout = options.killTimeout ?? file.killTimeout ?? DEFAULT_KILL_TIMEOUT;
465
+ this.parallel = file.parallel ?? false;
466
+ this.labels = options.labels ?? file.labels ?? "auto";
467
+ this.onSuccess = options.onSuccess ?? file.onSuccess ?? "continue";
468
+ this.onFailure = options.onFailure ?? file.onFailure ?? "stop";
469
+ this.dryRun = options.dryRun ?? file.dryRun ?? false;
470
+ this.logLevel = options.logLevel ?? file.logLevel ?? "info";
471
+ this.color = resolveColor(options.color ?? file.color, env, destinations);
472
+ this.formatLabel = file.formatLabel;
473
+ const std = (stream) => mergeStd(mergeStd(defaultStd(stream), file[stream] ?? options.output), options[stream] ?? options.output);
474
+ this.stdout = std("stdout");
475
+ this.stderr = std("stderr");
476
+ }
477
+ /** Throws on the first invalid value; types too, as config files are arbitrary JS. */
478
+ validate() {
479
+ check([
480
+ oneOfError("logLevel", this.logLevel, LOG_LEVELS),
481
+ numberError("jobs", this.jobs, 1),
482
+ numberError("killTimeout", this.killTimeout, 0),
483
+ booleanError("color", this.color),
484
+ booleanError("parallel", this.parallel),
485
+ booleanError("dryRun", this.dryRun),
486
+ oneOfError("labels", this.labels, LABEL_MODES),
487
+ stdError("stdout", this.stdout),
488
+ stdError("stderr", this.stderr),
489
+ this.formatLabel !== void 0 && typeof this.formatLabel !== "function" ? "formatLabel must be a function." : void 0,
490
+ typeof this.commandDictionary !== "object" || this.commandDictionary === null || Array.isArray(this.commandDictionary) ? "commandDictionary must be an object." : void 0,
491
+ actionError("onSuccess", this.onSuccess),
492
+ actionError("onFailure", this.onFailure)
493
+ ].find((error) => error !== void 0), ConfigError);
494
+ }
495
+ /** True when `commands` holds something to run, not just settings entries. */
496
+ hasCommands() {
497
+ return this.commands.some((command) => typeof command === "object" && command !== null ? !isCommandSettings(command) : Boolean(command));
498
+ }
499
+ };
500
+ function isTerminal(stream) {
501
+ return stream.isTTY === true;
502
+ }
503
+ /** Flag, then `NO_COLOR`/`FORCE_COLOR`, then whether both streams are TTYs. */
504
+ function resolveColor(flag, env, destinations) {
505
+ if (flag !== void 0) return flag;
506
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
507
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
508
+ return isTerminal(destinations.stdout) && isTerminal(destinations.stderr);
509
+ }
510
+ //#endregion
511
+ //#region src/plan/stages.ts
512
+ const EMPTY_SEGMENT = {
513
+ commands: [],
514
+ parallel: false
515
+ };
516
+ /** A `-p`/`-s`: what follows starts a new group past everything before it. */
517
+ const BOUNDARY = {
518
+ boundary: true,
519
+ commands: [],
520
+ parallel: false
521
+ };
522
+ /**
523
+ * Lays segments out into stages. A parallel segment joins the open stage;
524
+ * anything else starts past every stage in use, and so does what follows it.
525
+ * A segment's commands keep their relative stages, shifted onto its own.
526
+ */
527
+ function link(segments) {
528
+ const commands = [];
529
+ /** The stage a parallel segment joins. */
530
+ let open = 0;
531
+ /** The last stage in use; -1 while none is. */
532
+ let used = -1;
533
+ for (const segment of segments) {
534
+ if (segment.boundary) {
535
+ open = used + 1;
536
+ continue;
537
+ }
538
+ if (segment.commands.length === 0) continue;
539
+ const base = segment.parallel ? open : used + 1;
540
+ for (const command of segment.commands) {
541
+ command.stage += base;
542
+ used = Math.max(used, command.stage);
543
+ commands.push(command);
544
+ }
545
+ open = segment.parallel ? open : used + 1;
546
+ }
547
+ return commands;
548
+ }
549
+ /** Splits a stage-sorted list into its stages. */
550
+ function groupByStage(items, stageOf) {
551
+ const stages = [];
552
+ let current;
553
+ let stage;
554
+ for (const item of items) {
555
+ const own = stageOf(item);
556
+ if (current === void 0 || own !== stage) {
557
+ current = [];
558
+ stage = own;
559
+ stages.push(current);
560
+ }
561
+ current.push(item);
562
+ }
563
+ return stages;
564
+ }
565
+ //#endregion
566
+ //#region src/plan/labels.ts
567
+ const PALETTE = [
568
+ {
569
+ bgColor: "bgGreen",
570
+ color: "black"
571
+ },
572
+ {
573
+ bgColor: "bgBlue",
574
+ color: "white"
575
+ },
576
+ {
577
+ bgColor: "bgMagenta",
578
+ color: "white"
579
+ },
580
+ {
581
+ bgColor: "bgCyan",
582
+ color: "black"
583
+ },
584
+ {
585
+ bgColor: "bgYellow",
586
+ color: "black"
587
+ },
588
+ {
589
+ bgColor: "bgRed",
590
+ color: "white"
591
+ },
592
+ {
593
+ bgColor: "bgGray",
594
+ color: "white"
595
+ },
596
+ {
597
+ bgColor: "bgGreenBright",
598
+ color: "black"
599
+ },
600
+ {
601
+ bgColor: "bgBlueBright",
602
+ color: "black"
603
+ },
604
+ {
605
+ bgColor: "bgMagentaBright",
606
+ color: "black"
607
+ },
608
+ {
609
+ bgColor: "bgCyanBright",
610
+ color: "black"
611
+ },
612
+ {
613
+ bgColor: "bgYellowBright",
614
+ color: "black"
615
+ }
616
+ ];
617
+ /**
618
+ * Settles who is labelled, per `config.labels`. Under `auto`, a command
619
+ * sharing a stage is named after itself — or gets a blank label holding the
620
+ * column when someone in its stage has a label of their own.
621
+ */
622
+ function assignAutoLabels({ commands, config }) {
623
+ const mode = config.labels;
624
+ if (mode === "none") {
625
+ for (const command of commands) command.label = "";
626
+ return;
627
+ }
628
+ const named = [];
629
+ for (const stage of groupByStage(commands, (command) => command.stage)) {
630
+ const shared = stage.length > 1;
631
+ const labeled = stage.some((command) => command.label !== "");
632
+ for (const command of stage) {
633
+ if (command.label !== "") continue;
634
+ if (mode === "all" || mode === "auto" && shared && !labeled) {
635
+ command.label = nameOf(command);
636
+ named.push(command);
637
+ } else if (shared && labeled) command.label = " ";
638
+ }
639
+ }
640
+ numberRepeats(named);
641
+ }
642
+ /**
643
+ * Gives each labelled command without colors of its own a palette pair: the
644
+ * one its label hashes to, or the next free when that is taken.
645
+ */
646
+ function colorLabels({ commands }) {
647
+ const labeled = commands.filter((command) => command.label !== "");
648
+ const taken = new Set(labeled.map((command) => PALETTE.findIndex((pair) => pair.bgColor === command.bgColor)).filter((index) => index !== -1));
649
+ for (const command of labeled) {
650
+ if (command.color !== "" || command.bgColor !== "" || command.label.trim() === "") continue;
651
+ const index = claim(paletteIndex(command.label), taken);
652
+ taken.add(index);
653
+ const pair = PALETTE[index];
654
+ command.bgColor = pair.bgColor;
655
+ command.color = pair.color;
656
+ }
657
+ }
658
+ /** Pads every label to the widest one. */
659
+ function alignLabels({ commands }) {
660
+ const labeled = commands.filter((command) => command.label !== "");
661
+ const width = Math.max(0, ...labeled.map((command) => command.label.length));
662
+ for (const command of labeled) command.label = command.label.padEnd(width);
663
+ }
664
+ /** A stable palette index for a label. */
665
+ function paletteIndex(label) {
666
+ /** The largest modulus that keeps `hash * 31 + code` an exact integer. */
667
+ const HASH_MODULUS = 2147483647;
668
+ let hash = 0;
669
+ for (const character of label) hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % HASH_MODULUS;
670
+ return hash % PALETTE.length;
671
+ }
672
+ /** The script name, or the program a shell command runs. */
673
+ function nameOf(command) {
674
+ return command.scriptName ?? command.command.split(/\s/, 1)[0] ?? "";
675
+ }
676
+ /** `serve`, `serve` → `serve#1`, `serve#2`. */
677
+ function numberRepeats(named) {
678
+ const totals = /* @__PURE__ */ new Map();
679
+ for (const command of named) totals.set(command.label, (totals.get(command.label) ?? 0) + 1);
680
+ const counters = /* @__PURE__ */ new Map();
681
+ for (const command of named) {
682
+ if ((totals.get(command.label) ?? 0) < 2) continue;
683
+ const index = (counters.get(command.label) ?? 0) + 1;
684
+ counters.set(command.label, index);
685
+ command.label = `${command.label}#${index}`;
686
+ }
687
+ }
688
+ function claim(preferred, taken) {
689
+ if (taken.size >= PALETTE.length) return preferred;
690
+ let index = preferred;
691
+ while (taken.has(index)) index = (index + 1) % PALETTE.length;
692
+ return index;
693
+ }
694
+ //#endregion
695
+ //#region src/utils/glob.ts
696
+ function isGlob(pattern) {
697
+ return pattern.includes("*");
698
+ }
699
+ /** The `names` a glob matches, in declared order; none is not an error. */
700
+ function matchGlob(pattern, names) {
701
+ const regexp = toRegExp(pattern);
702
+ return names.filter((name) => regexp.test(name));
703
+ }
704
+ function escape(literal) {
705
+ return literal.replaceAll(/[$()+.?[\\\]^{|}]/g, String.raw`\$&`);
706
+ }
707
+ /**
708
+ * `*` matches within one `:`-separated segment, `**` spans one or more.
709
+ * `build:**:*` means `build:**`.
710
+ */
711
+ function toRegExp(pattern) {
712
+ const segments = pattern.split(":");
713
+ const source = [];
714
+ for (const [index, segment] of segments.entries()) {
715
+ if (segment === "*" && segments[index - 1] === "**") continue;
716
+ if (segment === "**") source.push(String.raw`[^:]+(?::[^:]+)*`);
717
+ else source.push(segment.split("*").map((part) => escape(part)).join("[^:]*"));
718
+ }
719
+ return new RegExp(`^${source.join(":")}$`);
720
+ }
721
+ //#endregion
722
+ //#region src/utils/os.ts
723
+ /** 128 + the signal's number, the way a shell reports it: 130, 143, … */
724
+ function signalExitCode(signal) {
725
+ return 128 + (os.constants.signals[signal] ?? 15);
726
+ }
727
+ /**
728
+ * Quotes a value so the platform's shell passes it on as literal text —
729
+ * double quotes are not enough, since `$(…)` still runs inside them.
730
+ */
731
+ function quoteForShell(value, platform = process.platform) {
732
+ return platform === "win32" ? quoteForCmd(value) : quoteForPosix(value);
733
+ }
734
+ /** Single quotes are fully literal; each `'` inside closes, escapes, reopens. */
735
+ function quoteForPosix(value) {
736
+ return `'${value.replaceAll("'", String.raw`'\''`)}'`;
737
+ }
738
+ /**
739
+ * Quoted for the C runtime first, then every `cmd.exe` metacharacter
740
+ * `^`-escaped (see <https://qntm.org/cmd>). Newlines cannot survive `cmd.exe`,
741
+ * so they become spaces.
742
+ */
743
+ function quoteForCmd(value) {
744
+ return `"${value.replaceAll("\r\n", " ").replaceAll(/[\n\r]/g, " ").replaceAll(/(\\*)"/g, String.raw`$1$1\"`).replace(/(\\*)$/, "$1$1")}"`.replaceAll(/["%&()<>^|]/g, (character) => `^${character}`);
745
+ }
746
+ //#endregion
747
+ //#region src/plan/placeholders.ts
748
+ /**
749
+ * Fills `{1}`, `{@}`, `{*}` and `{name}` from the arguments after `--`.
750
+ *
751
+ * In a command line values are shell-quoted and unknown names left as written
752
+ * (`${HOME}`); in an option (`forOption`) values are raw and unknown names
753
+ * are empty, so `disabled={noTest}` is false when nothing was passed.
754
+ */
755
+ function substitutePlaceholders(text, args, forOption = false) {
756
+ const PLACEHOLDER = /\{([^\s{}]*)\}/g;
757
+ const wrap = forOption ? (value) => value : quoteForShell;
758
+ return text.replaceAll(PLACEHOLDER, (match, key) => {
759
+ if (key === "@") return args.map((arg) => wrap(arg)).join(" ");
760
+ if (key === "*") return args.length === 0 ? "" : wrap(args.join(" "));
761
+ if (/^[1-9]\d*$/.test(key)) {
762
+ const value = args[Number(key) - 1];
763
+ return value === void 0 ? "" : wrap(value);
764
+ }
765
+ const named = namedValue(key, args);
766
+ if (named !== void 0) return wrap(named);
767
+ return forOption ? "" : match;
768
+ });
769
+ }
770
+ /**
771
+ * The value of `--name=value` or `--name value` (camel or kebab); a bare
772
+ * `--name` is `'true'`.
773
+ */
774
+ function namedValue(key, args) {
775
+ for (const [index, token] of args.entries()) {
776
+ if (!token.startsWith("--") || token.length === 2) continue;
777
+ const equals = token.indexOf("=");
778
+ if (toCamelCase(equals === -1 ? token.slice(2) : token.slice(2, equals)) !== key) continue;
779
+ if (equals !== -1) return token.slice(equals + 1);
780
+ const next = args[index + 1];
781
+ return next !== void 0 && !next.startsWith("-") ? next : "true";
782
+ }
783
+ }
784
+ //#endregion
785
+ //#region src/plan/token.ts
786
+ const BOOLEAN_OPTIONS = /* @__PURE__ */ new Set(["disabled", "parallel"]);
787
+ const ACTION_OPTIONS = /* @__PURE__ */ new Set(["onFailure", "onSuccess"]);
788
+ /** Keys a `::` suffix may set. */
789
+ const INLINE_OPTIONS = /* @__PURE__ */ new Set([
790
+ "bgColor",
791
+ "color",
792
+ "cwd",
793
+ "label",
794
+ "stderr",
795
+ "stdout",
796
+ ...BOOLEAN_OPTIONS,
797
+ ...ACTION_OPTIONS
798
+ ]);
799
+ /**
800
+ * Splits `"name args::options"` at the last `::`. The options run to the end
801
+ * of the string, so a trailing space stays part of the last value.
802
+ */
803
+ function splitToken(raw) {
804
+ const trimmed = raw.trimStart();
805
+ const marker = trimmed.lastIndexOf("::");
806
+ const command = (marker === -1 ? trimmed : trimmed.slice(0, marker)).trim();
807
+ const space = command.search(/\s/);
808
+ return {
809
+ args: space === -1 ? "" : command.slice(space + 1).trim(),
810
+ name: space === -1 ? command : command.slice(0, space),
811
+ options: marker === -1 ? "" : trimmed.slice(marker + 2)
812
+ };
813
+ }
814
+ /**
815
+ * Parses `key=value,flag`. Split before placeholders are filled, so a `,` or
816
+ * `=` arriving in a value cannot inject another option.
817
+ */
818
+ function parseInlineOptions(text, args) {
819
+ const options = {};
820
+ for (const entry of text.split(",")) {
821
+ if (entry.trim() === "") continue;
822
+ const eq = entry.indexOf("=");
823
+ const key = toCamelCase((eq === -1 ? entry : entry.slice(0, eq)).trim());
824
+ const written = eq === -1 ? "true" : entry.slice(eq + 1);
825
+ const value = substitutePlaceholders(key === "label" ? written : written.trim(), args, true);
826
+ if (!INLINE_OPTIONS.has(key)) throw new NormalizeError(`Unknown command option "${toKebabCase(key)}" in "::${text.trim()}".`);
827
+ if (key === "stderr" || key === "stdout") options[key] = parsePartialStd(value);
828
+ else if (BOOLEAN_OPTIONS.has(key)) options[key] = value !== "false" && value !== "";
829
+ else if (ACTION_OPTIONS.has(key)) {
830
+ check(actionError(`"${toKebabCase(key)}"`, value), NormalizeError);
831
+ options[key] = value;
832
+ } else options[key] = value;
833
+ }
834
+ return options;
835
+ }
836
+ //#endregion
837
+ //#region src/plan/normalize.ts
838
+ /** Keys a settings entry may set. */
839
+ const SETTINGS_OPTIONS = /* @__PURE__ */ new Set([
840
+ ...INLINE_OPTIONS,
841
+ "env",
842
+ "serial"
843
+ ]);
844
+ /**
845
+ * Turns `config.commands` into the run's commands: flat, validated, numbered
846
+ * by stage and sorted by it.
847
+ */
848
+ function normalize(config, packageInfo) {
849
+ const scriptNames = Object.keys(packageInfo.scripts);
850
+ const packageRoot = packageInfo.filePath === "" ? config.cwd : path.dirname(packageInfo.filePath);
851
+ const defaults = {
852
+ bgColor: "",
853
+ color: "",
854
+ command: "",
855
+ cwd: config.cwd,
856
+ disabled: false,
857
+ env: {},
858
+ label: "",
859
+ line: "",
860
+ onFailure: config.onFailure,
861
+ onSuccess: config.onSuccess,
862
+ parallel: config.parallel,
863
+ stage: 0,
864
+ stderr: config.stderr,
865
+ stdout: config.stdout,
866
+ type: "shell"
867
+ };
868
+ function makeLeaf(token, rawOptions, script) {
869
+ const options = {
870
+ ...rawOptions,
871
+ stderr: mergeStd(defaults.stderr, rawOptions.stderr),
872
+ stdout: mergeStd(defaults.stdout, rawOptions.stdout)
873
+ };
874
+ const args = substitutePlaceholders(token.args, config.args);
875
+ const command = [token.name, args].filter((part) => part !== "").join(" ");
876
+ const line = script === void 0 ? command : [packageInfo.scripts[script], args].filter((part) => part !== "").join(" ");
877
+ const leaf = {
878
+ ...defaults,
879
+ ...options,
880
+ command,
881
+ line,
882
+ stage: 0,
883
+ type: script === void 0 ? "shell" : "npm",
884
+ ...script === void 0 ? {} : { scriptName: script }
885
+ };
886
+ if (script !== void 0 && options.cwd === void 0) leaf.cwd = packageRoot;
887
+ validateCommand(leaf);
888
+ return leaf;
889
+ }
890
+ /** Links one token's commands (e.g. glob matches) by their own `parallel`. */
891
+ function toSegment(leaves, settled) {
892
+ return {
893
+ commands: link(leaves.filter((leaf) => !leaf.disabled).map((leaf) => ({
894
+ commands: [leaf],
895
+ parallel: leaf.parallel
896
+ }))),
897
+ parallel: settled.parallel ?? defaults.parallel
898
+ };
899
+ }
900
+ function resolveToken(raw, inherited, overrides, seen) {
901
+ const token = splitToken(raw);
902
+ const inline = resolveCwd(parseInlineOptions(token.options, config.args), inherited.cwd ?? config.cwd);
903
+ if (Object.hasOwn(config.commandDictionary, token.name)) {
904
+ if (seen.has(token.name)) throw new NormalizeError(`Command "${token.name}" refers to itself.`);
905
+ const resolved = resolveEntry(config.commandDictionary[token.name], inherited, layer(inline, overrides), /* @__PURE__ */ new Set([...seen, token.name]));
906
+ return token.args === "" ? resolved : appendArgs(resolved, token);
907
+ }
908
+ const settled = layer(inherited, inline, overrides);
909
+ if (isGlob(token.name)) return toSegment(matchGlob(token.name, scriptNames).map((name) => makeLeaf({
910
+ ...token,
911
+ name
912
+ }, settled, name)), settled);
913
+ return toSegment([makeLeaf(token, settled, Object.hasOwn(packageInfo.scripts, token.name) ? token.name : void 0)], settled);
914
+ }
915
+ function appendArgs(segment, token) {
916
+ const [leaf] = segment.commands;
917
+ if (segment.commands.length !== 1 || leaf === void 0) throw new NormalizeError(`Cannot pass arguments to "${token.name}": it expands to more than one command.`);
918
+ const args = substitutePlaceholders(token.args, config.args);
919
+ const extended = {
920
+ ...leaf,
921
+ command: `${leaf.command} ${args}`,
922
+ line: `${leaf.line} ${args}`
923
+ };
924
+ return {
925
+ ...segment,
926
+ commands: [extended]
927
+ };
928
+ }
929
+ /** One list entry other than a settings entry. */
930
+ function resolve(definition, inherited, overrides, seen) {
931
+ if (!definition) return EMPTY_SEGMENT;
932
+ if (typeof definition === "string") return resolveToken(definition, inherited, overrides, seen);
933
+ if (Array.isArray(definition)) throw new NormalizeError("A list of commands belongs in \"commands\" or in a \"commandDictionary\" entry, not inside one.");
934
+ if (isCommandSettings(definition)) throw new NormalizeError("A command definition needs a \"command\" field.");
935
+ const entry = definition;
936
+ if (typeof entry.command !== "string") throw new NormalizeError("A command's \"command\" must be a string.");
937
+ const { command, ...raw } = entry;
938
+ return resolveToken(command, layer(inherited, resolveCwd(raw, inherited.cwd ?? config.cwd)), overrides, seen);
939
+ }
940
+ /**
941
+ * `commands`, or a dictionary entry that is a list. The list's own
942
+ * `parallel` places the list as a whole and is not passed to its members.
943
+ */
944
+ function resolveList(definitions, inherited, overrides, seen) {
945
+ const { parallel: inheritedParallel, ...passedDown } = inherited;
946
+ const { parallel: overriddenParallel, ...passedOver } = overrides;
947
+ const parallel = overriddenParallel ?? inheritedParallel ?? defaults.parallel;
948
+ check(booleanError("\"parallel\" on a list of commands", parallel), NormalizeError);
949
+ const segments = [];
950
+ let settled = passedDown;
951
+ for (const definition of definitions) {
952
+ if (isSettingsEntry(definition)) {
953
+ const written = readSettings(definition, settled.cwd ?? config.cwd);
954
+ settled = layer(settled, written);
955
+ if (written.parallel !== void 0) segments.push(BOUNDARY);
956
+ continue;
957
+ }
958
+ segments.push(resolve(definition, settled, passedOver, seen));
959
+ }
960
+ return {
961
+ commands: link(segments),
962
+ parallel
963
+ };
964
+ }
965
+ function resolveEntry(entry, inherited, overrides, seen) {
966
+ return Array.isArray(entry) ? resolveList(entry, inherited, overrides, seen) : resolve(entry, inherited, overrides, seen);
967
+ }
968
+ const { commands } = resolveList(config.commands, {}, {}, /* @__PURE__ */ new Set());
969
+ commands.sort((first, second) => first.stage - second.stage);
970
+ return commands;
971
+ }
972
+ function isSettingsEntry(definition) {
973
+ return typeof definition === "object" && definition !== null && !Array.isArray(definition) && isCommandSettings(definition);
974
+ }
975
+ /** Resolves a relative `cwd` against the inherited one, so directories nest. */
976
+ function resolveCwd(options, base) {
977
+ return options.cwd === void 0 ? options : {
978
+ ...options,
979
+ cwd: path.resolve(base, options.cwd)
980
+ };
981
+ }
982
+ /**
983
+ * Reads a settings entry into the options the rest of its list inherits.
984
+ * Keys are checked strictly: `{ paralel: true }` would otherwise do nothing.
985
+ */
986
+ function readSettings(entry, base) {
987
+ const keys = Object.keys(entry);
988
+ if (keys.length === 0) throw new NormalizeError("A settings entry must set at least one option.");
989
+ for (const key of keys) if (!SETTINGS_OPTIONS.has(key)) throw new NormalizeError(`Unknown option "${toKebabCase(key)}" in a settings entry.`);
990
+ const { serial, ...options } = entry;
991
+ if (serial !== void 0) {
992
+ check(booleanError("\"serial\"", serial), NormalizeError);
993
+ if (options.parallel !== void 0) throw new NormalizeError("A settings entry takes \"parallel\" or \"serial\", not both.");
994
+ options.parallel = !serial;
995
+ }
996
+ return resolveCwd(options, base);
997
+ }
998
+ /** Layers option bags, later over earlier; stream settings merge per axis. */
999
+ function layer(...bags) {
1000
+ const result = {};
1001
+ for (const { stderr, stdout, ...rest } of bags) {
1002
+ Object.assign(result, rest);
1003
+ if (stdout !== void 0) result.stdout = mergeStd(toPartialStd(result.stdout ?? {}), stdout);
1004
+ if (stderr !== void 0) result.stderr = mergeStd(toPartialStd(result.stderr ?? {}), stderr);
1005
+ }
1006
+ return result;
1007
+ }
1008
+ /** Checks what a config file could have written by hand. */
1009
+ function validateCommand(command) {
1010
+ const where = (key) => `"${toKebabCase(key)}" on "${command.command}"`;
1011
+ check([
1012
+ ...["onFailure", "onSuccess"].map((key) => actionError(where(key), command[key])),
1013
+ ...["disabled", "parallel"].map((key) => booleanError(where(key), command[key])),
1014
+ ...[
1015
+ "bgColor",
1016
+ "color",
1017
+ "cwd",
1018
+ "label"
1019
+ ].map((key) => stringError(where(key), command[key])),
1020
+ ...["stderr", "stdout"].map((key) => stdError(where(key), command[key])),
1021
+ typeof command.env === "object" && command.env !== null ? void 0 : `${where("env")} must be an object.`
1022
+ ].find((error) => error !== void 0), NormalizeError);
1023
+ }
1024
+ //#endregion
1025
+ //#region src/plan/plan.ts
1026
+ /** Resolves a config into the commands a run will spawn. */
1027
+ function createPlan({ config }) {
1028
+ if (!config.hasCommands()) throw new ConfigError("No commands to run.");
1029
+ const packageInfo = readPackageJson(config.cwd);
1030
+ const plan = {
1031
+ commands: normalize(config, packageInfo),
1032
+ config,
1033
+ packageInfo
1034
+ };
1035
+ assignAutoLabels(plan);
1036
+ colorLabels(plan);
1037
+ alignLabels(plan);
1038
+ return plan;
1039
+ }
1040
+ //#endregion
1041
+ //#region src/plan/describe.ts
1042
+ /** The resolved run and every option in force, as `--dry-run` prints it. */
1043
+ function describeRun(config, commands) {
1044
+ return [
1045
+ ...describeOptions(config),
1046
+ "",
1047
+ "Commands:",
1048
+ ...describeCommands(commands)
1049
+ ].join("\n");
1050
+ }
1051
+ function describeStd(std) {
1052
+ return `${std.timing} -> ${std.destination}`;
1053
+ }
1054
+ function amount(value) {
1055
+ return Number.isFinite(value) ? String(value) : "unlimited";
1056
+ }
1057
+ /** `name value` lines, names padded into a column. */
1058
+ function settings(rows) {
1059
+ const width = Math.max(...rows.map(([name]) => name.length));
1060
+ return rows.map(([name, value]) => `${name.padEnd(width)} ${value}`);
1061
+ }
1062
+ function describeOptions(config) {
1063
+ return ["Options:", ...settings([
1064
+ ["cwd", config.cwd],
1065
+ ["jobs", amount(config.jobs)],
1066
+ ["parallel", String(config.parallel)],
1067
+ ["on-success", config.onSuccess],
1068
+ ["on-failure", config.onFailure],
1069
+ ["kill-timeout", `${config.killTimeout}ms`],
1070
+ ["stdout", describeStd(config.stdout)],
1071
+ ["stderr", describeStd(config.stderr)],
1072
+ ["labels", config.labels],
1073
+ ["color", String(config.color)],
1074
+ ["log-level", config.logLevel]
1075
+ ]).map((line) => ` ${line}`)];
1076
+ }
1077
+ function describeCommand(command) {
1078
+ const rows = [];
1079
+ if (command.line !== command.command) rows.push(["line", command.line]);
1080
+ rows.push(["cwd", command.cwd], ["on-success", command.onSuccess], ["on-failure", command.onFailure], ["stdout", describeStd(command.stdout)], ["stderr", describeStd(command.stderr)]);
1081
+ if (command.label !== "") {
1082
+ rows.push(["label", `"${command.label}"`]);
1083
+ const colors = [command.bgColor, command.color].filter((name) => name !== "");
1084
+ if (colors.length > 0) rows.push(["color", colors.join(" on ")]);
1085
+ }
1086
+ for (const [name, value] of Object.entries(command.env)) rows.push([`env ${name}`, String(value)]);
1087
+ return rows;
1088
+ }
1089
+ function describeCommands(commands) {
1090
+ const lines = [];
1091
+ for (const [index, stage] of groupByStage(commands, (command) => command.stage).entries()) {
1092
+ lines.push(` stage ${index + 1}:`);
1093
+ for (const command of stage) lines.push(` - ${command.command} (${command.type})`, ...settings(describeCommand(command)).map((line) => ` ${line}`));
1094
+ }
1095
+ return lines;
1096
+ }
1097
+ //#endregion
1098
+ //#region src/run/FileRegistry.ts
1099
+ /** One shared write stream per file path, for the lifetime of a run. */
1100
+ var FileRegistry = class {
1101
+ /** The first failure any file reported. */
1102
+ error;
1103
+ streams = /* @__PURE__ */ new Map();
1104
+ cwd;
1105
+ handler;
1106
+ constructor(cwd) {
1107
+ this.cwd = cwd;
1108
+ }
1109
+ /** Called once, with the first failure. */
1110
+ onFailure(handler) {
1111
+ this.handler = handler;
1112
+ }
1113
+ open(destination) {
1114
+ const filePath = path.resolve(this.cwd, destination);
1115
+ let stream = this.streams.get(filePath);
1116
+ if (stream === void 0) {
1117
+ stream = fs.createWriteStream(filePath);
1118
+ stream.on("error", (error) => {
1119
+ this.fail(destination, error);
1120
+ });
1121
+ this.streams.set(filePath, stream);
1122
+ }
1123
+ return stream;
1124
+ }
1125
+ /** Ends every file and waits for `close`, which follows errors too. */
1126
+ async closeAll() {
1127
+ const streams = [...this.streams.values()];
1128
+ this.streams.clear();
1129
+ await Promise.all(streams.map(async (stream) => closed(stream)));
1130
+ }
1131
+ fail(destination, error) {
1132
+ if (this.error !== void 0) return;
1133
+ this.error = /* @__PURE__ */ new Error(`cannot write to "${destination}": ${error.message}`);
1134
+ this.handler?.(this.error);
1135
+ }
1136
+ };
1137
+ function closed(stream) {
1138
+ return new Promise((resolve) => {
1139
+ if (stream.closed) {
1140
+ resolve();
1141
+ return;
1142
+ }
1143
+ stream.once("close", resolve);
1144
+ stream.end();
1145
+ });
1146
+ }
1147
+ //#endregion
1148
+ //#region src/utils/colors.ts
1149
+ const KNOWN = new Set(Object.keys(inspect.colors));
1150
+ /** A color name `node:util`'s `styleText` knows. */
1151
+ function isColorName(name) {
1152
+ return KNOWN.has(name);
1153
+ }
1154
+ /** Styles `text` with the known names among `colors`, if any. */
1155
+ function paint(text, colors) {
1156
+ const formats = colors.filter((name) => isColorName(name));
1157
+ return formats.length === 0 ? text : styleText(formats, text, { validateStream: false });
1158
+ }
1159
+ //#endregion
1160
+ //#region src/run/Logger.ts
1161
+ const RANK = {
1162
+ debug: 3,
1163
+ error: 0,
1164
+ info: 2,
1165
+ warn: 1
1166
+ };
1167
+ const COLORS = {
1168
+ debug: ["gray"],
1169
+ error: ["red"],
1170
+ info: [],
1171
+ warn: ["yellow"]
1172
+ };
1173
+ /** runset's own messages — never the commands' output. */
1174
+ var Logger = class {
1175
+ level;
1176
+ stream;
1177
+ color;
1178
+ constructor(level, stream, color = false) {
1179
+ this.level = level;
1180
+ this.stream = stream;
1181
+ this.color = color;
1182
+ }
1183
+ warn(message) {
1184
+ this.write("warn", message);
1185
+ }
1186
+ info(message) {
1187
+ this.write("info", message);
1188
+ }
1189
+ debug(message) {
1190
+ this.write("debug", message);
1191
+ }
1192
+ write(level, message) {
1193
+ if (RANK[level] > RANK[this.level]) return;
1194
+ const text = this.color ? paint(message, COLORS[level]) : message;
1195
+ this.stream.write(`${text}\n`);
1196
+ }
1197
+ };
1198
+ //#endregion
1199
+ //#region src/run/env.ts
1200
+ /**
1201
+ * The environment a command runs in: every `node_modules/.bin` up from `cwd`
1202
+ * on `PATH`, plus the `npm_*` variables scripts most often read.
1203
+ */
1204
+ function createEnv({ base, cwd, packageInfo, scriptName }) {
1205
+ const PATH_KEY = process.platform === "win32" ? "Path" : "PATH";
1206
+ const pathKey = Object.keys(base).find((key) => key.toUpperCase() === "PATH") ?? PATH_KEY;
1207
+ const binDirs = ancestors(cwd).map((dir) => path.join(dir, "node_modules", ".bin"));
1208
+ const env = {
1209
+ ...base,
1210
+ [pathKey]: [...binDirs, base[pathKey]].filter(Boolean).join(path.delimiter)
1211
+ };
1212
+ if (packageInfo.name !== "") env.npm_package_name = packageInfo.name;
1213
+ if (packageInfo.version !== "") env.npm_package_version = packageInfo.version;
1214
+ if (scriptName !== void 0) {
1215
+ env.npm_lifecycle_event = scriptName;
1216
+ env.npm_lifecycle_script = packageInfo.scripts[scriptName] ?? "";
1217
+ }
1218
+ return env;
1219
+ }
1220
+ //#endregion
1221
+ //#region src/run/OutputSink.ts
1222
+ /**
1223
+ * Routes one command stream by its {@link Std}: `realtime` writes through,
1224
+ * `grouped` holds everything until {@link flush}, `none` discards.
1225
+ */
1226
+ var OutputSink = class {
1227
+ buffered = "";
1228
+ /** A prefixed stream's unfinished line, held until its newline. */
1229
+ pending = "";
1230
+ opened;
1231
+ std;
1232
+ prefix;
1233
+ destinations;
1234
+ files;
1235
+ constructor(std, prefix, destinations, files) {
1236
+ this.std = std;
1237
+ this.prefix = prefix;
1238
+ this.destinations = destinations;
1239
+ this.files = files;
1240
+ }
1241
+ /** Opened on first use, so a command that never writes truncates nothing. */
1242
+ get target() {
1243
+ if (isStreamDestination(this.std.destination)) return this.destinations[this.std.destination];
1244
+ this.opened ??= this.files.open(this.std.destination);
1245
+ return this.opened;
1246
+ }
1247
+ write(chunk) {
1248
+ if (this.std.destination === "none") return;
1249
+ if (this.std.timing === "grouped") {
1250
+ this.buffered += chunk;
1251
+ return;
1252
+ }
1253
+ if (this.prefix === "") {
1254
+ this.target.write(chunk);
1255
+ return;
1256
+ }
1257
+ this.pending += chunk;
1258
+ const end = this.pending.lastIndexOf("\n");
1259
+ if (end === -1) return;
1260
+ const complete = this.pending.slice(0, end + 1);
1261
+ this.pending = this.pending.slice(end + 1);
1262
+ this.target.write(this.applyPrefix(complete));
1263
+ }
1264
+ /** Emits what is held back: a `grouped` stream, or a partial line. */
1265
+ flush() {
1266
+ const held = [this.pending, this.buffered].filter((text) => text !== "");
1267
+ this.pending = "";
1268
+ this.buffered = "";
1269
+ for (const text of held) this.target.write(this.applyPrefix(text));
1270
+ }
1271
+ applyPrefix(chunk) {
1272
+ const { prefix } = this;
1273
+ if (prefix === "") return chunk;
1274
+ const endsWithNewline = chunk.endsWith("\n");
1275
+ return (endsWithNewline ? chunk.slice(0, -1) : chunk).split("\n").map((line) => (typeof prefix === "string" ? prefix : prefix()) + line).join("\n") + (endsWithNewline ? "\n" : "");
1276
+ }
1277
+ };
1278
+ //#endregion
1279
+ //#region src/run/prefix.ts
1280
+ /**
1281
+ * The prefix of a command's output lines: `''` without a label, a function
1282
+ * when `formatLabel` may answer differently per line.
1283
+ */
1284
+ function makePrefix(command, color, formatLabel, stream) {
1285
+ if (command.label === "") return "";
1286
+ const defaultPrefix = defaultLabel(command, color);
1287
+ if (formatLabel === void 0) return defaultPrefix;
1288
+ return () => String(formatLabel({
1289
+ color,
1290
+ command,
1291
+ defaultPrefix,
1292
+ stream
1293
+ }));
1294
+ }
1295
+ /** A colored block with color; `[label]` without. */
1296
+ function defaultLabel(command, color) {
1297
+ if (color && command.label.trim() === "") return `${command.label} `;
1298
+ const formats = [command.bgColor, command.color].filter((name) => isColorName(name));
1299
+ if (!color || formats.length === 0) return `[${command.label}] `;
1300
+ return `${paint(command.label, formats)} `;
1301
+ }
1302
+ //#endregion
1303
+ //#region src/run/Process.ts
1304
+ const USE_PROCESS_GROUPS = process.platform !== "win32";
1305
+ /** One command, running in one shell, restarted as its exit policy says. */
1306
+ var Process = class {
1307
+ /** `undefined` when killed by a signal. */
1308
+ exitCode;
1309
+ signal;
1310
+ started = false;
1311
+ /** True when runset stopped this command rather than letting it finish. */
1312
+ terminated = false;
1313
+ finished = false;
1314
+ command;
1315
+ child;
1316
+ context;
1317
+ stdout;
1318
+ stderr;
1319
+ constructor(command, context) {
1320
+ this.command = command;
1321
+ this.context = context;
1322
+ const { color, destinations, formatLabel } = context.config;
1323
+ const sink = (stream) => new OutputSink(command[stream], makePrefix(command, color, formatLabel, stream), destinations, context.files);
1324
+ this.stdout = sink("stdout");
1325
+ this.stderr = sink("stderr");
1326
+ }
1327
+ /** Ran and did not end cleanly; a command runset killed doesn't count. */
1328
+ isFailed() {
1329
+ return this.started && !this.terminated && (this.exitCode !== 0 || this.signal !== void 0);
1330
+ }
1331
+ /** What runset reports for this command: a signal as 128 + its number. */
1332
+ getReportedExitCode() {
1333
+ return this.signal === void 0 ? this.exitCode ?? 0 : signalExitCode(this.signal);
1334
+ }
1335
+ /** Runs the command, again and again while its exit policy says `restart`. */
1336
+ async start() {
1337
+ if (this.terminated) return;
1338
+ let stopTheRun = false;
1339
+ for (;;) {
1340
+ this.started = true;
1341
+ await this.spawn();
1342
+ this.stdout.flush();
1343
+ this.stderr.flush();
1344
+ this.context.logger.debug(`< ${this.command.line} ${this.report()}`);
1345
+ if (this.terminated) break;
1346
+ const action = this.isFailed() ? this.command.onFailure : this.command.onSuccess;
1347
+ if (action !== "restart") {
1348
+ stopTheRun = action === "stop";
1349
+ break;
1350
+ }
1351
+ this.context.logger.info(`runset: restarting "${this.command.command}"`);
1352
+ }
1353
+ this.finished = true;
1354
+ if (stopTheRun) this.context.requestStop();
1355
+ }
1356
+ terminate({ force = false, signal = "SIGTERM" } = {}) {
1357
+ if (this.finished) return;
1358
+ this.terminated = true;
1359
+ const sent = force ? "SIGKILL" : signal;
1360
+ if (this.child?.pid !== void 0) this.context.logger.debug(`! ${sent} ${this.command.line}`);
1361
+ this.kill(sent);
1362
+ }
1363
+ report() {
1364
+ return this.signal === void 0 ? `exited with ${this.exitCode ?? 0}` : `was killed by ${this.signal}`;
1365
+ }
1366
+ /** One attempt: resolves when the child is gone. */
1367
+ async spawn() {
1368
+ const { command, context } = this;
1369
+ this.exitCode = void 0;
1370
+ this.signal = void 0;
1371
+ context.logger.debug(`> ${command.line}`);
1372
+ await new Promise((resolve) => {
1373
+ let settled = false;
1374
+ const child = cp.spawn(command.line, {
1375
+ cwd: command.cwd,
1376
+ detached: USE_PROCESS_GROUPS,
1377
+ env: createEnv({
1378
+ base: {
1379
+ ...context.config.env,
1380
+ ...command.env
1381
+ },
1382
+ cwd: command.cwd,
1383
+ packageInfo: context.packageInfo,
1384
+ scriptName: command.scriptName
1385
+ }),
1386
+ shell: true,
1387
+ stdio: [
1388
+ "inherit",
1389
+ "pipe",
1390
+ "pipe"
1391
+ ]
1392
+ });
1393
+ this.child = child;
1394
+ child.stdout?.setEncoding("utf8").on("data", (chunk) => this.stdout.write(chunk));
1395
+ child.stderr?.setEncoding("utf8").on("data", (chunk) => this.stderr.write(chunk));
1396
+ child.on("error", (error) => {
1397
+ if (settled) return;
1398
+ settled = true;
1399
+ this.stderr.write(`${error.message}\n`);
1400
+ this.exitCode = 1;
1401
+ resolve();
1402
+ });
1403
+ child.on("close", (code, signal) => {
1404
+ if (settled) return;
1405
+ settled = true;
1406
+ this.exitCode = this.terminated || signal !== null ? void 0 : code ?? 0;
1407
+ this.signal = signal ?? void 0;
1408
+ resolve();
1409
+ });
1410
+ if (this.terminated) this.kill("SIGTERM");
1411
+ });
1412
+ this.child = void 0;
1413
+ }
1414
+ kill(signal) {
1415
+ const pid = this.child?.pid;
1416
+ if (pid === void 0) return;
1417
+ try {
1418
+ if (USE_PROCESS_GROUPS) process.kill(-pid, signal);
1419
+ else killTree(pid, signal);
1420
+ } catch {}
1421
+ }
1422
+ };
1423
+ /**
1424
+ * Windows has no process groups, and `child.kill` would reach only `cmd.exe`;
1425
+ * `taskkill /T /F` ends the tree (always forcefully). Falls back to the shell.
1426
+ */
1427
+ function killTree(pid, signal) {
1428
+ const killed = cp.spawnSync("taskkill", [
1429
+ "/pid",
1430
+ String(pid),
1431
+ "/T",
1432
+ "/F"
1433
+ ], {
1434
+ stdio: "ignore",
1435
+ windowsHide: true
1436
+ });
1437
+ if (killed.error !== void 0 || killed.status !== 0) process.kill(pid, signal);
1438
+ }
1439
+ //#endregion
1440
+ //#region src/run/schedule.ts
1441
+ /**
1442
+ * Runs the stages in order, everything in a stage at once — or, when `jobs`
1443
+ * is narrower, in batches of that many. A stopped run starts no new batch.
1444
+ */
1445
+ async function schedule(processes, stopped, jobs) {
1446
+ for (const stage of groupByStage(processes, (p) => p.command.stage)) {
1447
+ const size = Math.min(jobs, stage.length);
1448
+ for (let start = 0; start < stage.length; start += size) {
1449
+ if (stopped()) return;
1450
+ const batch = stage.slice(start, start + size);
1451
+ await Promise.all(batch.map((process) => process.start()));
1452
+ }
1453
+ }
1454
+ }
1455
+ //#endregion
1456
+ //#region src/run/Run.ts
1457
+ /** One run: the processes built from a plan, and what stops them. */
1458
+ var Run = class Run {
1459
+ config;
1460
+ /** @internal */
1461
+ processes;
1462
+ logger;
1463
+ files;
1464
+ stopping;
1465
+ constructor({ commands, config, packageInfo }) {
1466
+ this.config = config;
1467
+ this.files = new FileRegistry(config.cwd);
1468
+ this.logger = new Logger(config.logLevel, config.destinations.stderr, config.color);
1469
+ for (const warning of config.warnings) this.logger.warn(`runset: ${warning}`);
1470
+ const context = {
1471
+ config,
1472
+ files: this.files,
1473
+ logger: this.logger,
1474
+ packageInfo,
1475
+ requestStop: () => this.stopEverything({ reason: "policy" })
1476
+ };
1477
+ this.processes = commands.map((command) => new Process(command, context));
1478
+ this.files.onFailure(() => this.stopEverything({ reason: "policy" }));
1479
+ }
1480
+ /** A library caller's config object, through the same pipeline as the CLI. */
1481
+ static fromConfigJs(configJs) {
1482
+ const config = createConfig({
1483
+ cli: parseCli([]),
1484
+ configJs
1485
+ });
1486
+ return new Run(createPlan({ config }));
1487
+ }
1488
+ isFailed() {
1489
+ return this.failed().length > 0;
1490
+ }
1491
+ getExitCode() {
1492
+ return this.failed()[0]?.getReportedExitCode() ?? 0;
1493
+ }
1494
+ /** The resolved run, and every option in force, as `--dry-run` prints it. */
1495
+ describe() {
1496
+ return describeRun(this.config, this.processes.map((process) => process.command));
1497
+ }
1498
+ /** Rejects with a {@link RunsetError} when the run did not succeed. */
1499
+ async start() {
1500
+ if (this.config.dryRun) {
1501
+ this.config.destinations.stdout.write(`${this.describe()}\n`);
1502
+ return this;
1503
+ }
1504
+ const onSigint = this.onSignal("SIGINT");
1505
+ const onSigterm = this.onSignal("SIGTERM");
1506
+ process.on("SIGINT", onSigint);
1507
+ process.on("SIGTERM", onSigterm);
1508
+ try {
1509
+ await schedule(this.processes, () => this.stopping !== void 0, this.config.jobs);
1510
+ } finally {
1511
+ process.off("SIGINT", onSigint);
1512
+ process.off("SIGTERM", onSigterm);
1513
+ this.clearKillTimer();
1514
+ await this.files.closeAll();
1515
+ }
1516
+ const failed = this.failed();
1517
+ const exitCode = failed[0]?.getReportedExitCode();
1518
+ const { stopping } = this;
1519
+ if (this.files.error) throw new RunsetError(this.files.error.message, exitCode ?? 1);
1520
+ if (stopping?.signal) throw new RunsetError(`the run was stopped by ${stopping.signal}`, signalExitCode(stopping.signal));
1521
+ if (stopping?.reason === "terminate") throw new RunsetError("the run was terminated", signalExitCode("SIGTERM"));
1522
+ if (exitCode !== void 0) throw new RunsetError(`${failed.length} command${failed.length === 1 ? "" : "s"} failed: ${failed.map((item) => `"${item.command.command}"`).join(", ")}`, exitCode);
1523
+ return this;
1524
+ }
1525
+ terminate(options = {}) {
1526
+ this.stopEverything({ reason: "terminate" }, options);
1527
+ }
1528
+ failed() {
1529
+ return this.processes.filter((process) => process.isFailed());
1530
+ }
1531
+ /** Says a signal arrived before any command's parting words, then stops. */
1532
+ onSignal(signal) {
1533
+ return () => {
1534
+ const { stderr } = this.config.destinations;
1535
+ if (this.stopping !== void 0) {
1536
+ this.logger.warn(`runset: ${signal} again, killing what is left`);
1537
+ this.forceKill();
1538
+ return;
1539
+ }
1540
+ if (signal === "SIGINT" && isTerminal(stderr)) stderr.write(" ");
1541
+ this.logger.warn(`runset: ${signal} received, stopping the run`);
1542
+ this.stopEverything({
1543
+ reason: "signal",
1544
+ signal
1545
+ }, { signal });
1546
+ };
1547
+ }
1548
+ stopEverything(state, options = {}) {
1549
+ this.stopping ??= state;
1550
+ for (const process of this.processes) process.terminate(options);
1551
+ if (this.stopping.killTimer !== void 0) return;
1552
+ const { killTimeout } = this.config;
1553
+ if (killTimeout <= 0) {
1554
+ this.forceKill();
1555
+ return;
1556
+ }
1557
+ const killTimer = setTimeout(() => this.forceKill(), killTimeout);
1558
+ killTimer.unref();
1559
+ this.stopping.killTimer = killTimer;
1560
+ }
1561
+ forceKill() {
1562
+ this.clearKillTimer();
1563
+ for (const process of this.processes) process.terminate({ force: true });
1564
+ }
1565
+ clearKillTimer() {
1566
+ if (this.stopping === void 0) return;
1567
+ clearTimeout(this.stopping.killTimer);
1568
+ this.stopping.killTimer = void 0;
1569
+ }
1570
+ };
1571
+ //#endregion
1572
+ //#region src/cli.ts
1573
+ const HELP = `Usage: runset [options] <command...>
1574
+
1575
+ Runs npm scripts and shell commands in sequence or in parallel.
1576
+
1577
+ Run mode:
1578
+ -p, --parallel <commands> run the following commands together
1579
+ -s, --serial <commands> run the following commands one after another
1580
+ -j, --jobs <n> max commands running at once (default: unlimited)
1581
+
1582
+ On exit:
1583
+ --on-success <action> what a clean exit does (default: continue)
1584
+ --on-failure <action> what a non-zero exit does (default: stop)
1585
+ action: continue | restart | stop
1586
+ --kill-timeout <ms> grace period before a SIGKILL (default: 5000)
1587
+
1588
+ Output:
1589
+ --stdout <value> configure stdout: <timing>[+<destination>]
1590
+ --stderr <value> configure stderr
1591
+ -o, --output <value> apply <value> to both streams
1592
+ timing: realtime | grouped
1593
+ destination: stdout | stderr | none | <file path>
1594
+ --labels <mode> label each output line with its command:
1595
+ none | auto | custom | all (default: auto)
1596
+
1597
+ General:
1598
+ -c, --config <path> load a config file, skipping the default lookup
1599
+ --cwd <dir> working directory for the commands
1600
+ --color, --no-color force color on or off
1601
+ --log-level <level> error | warn | info | debug (default: info)
1602
+ --dry-run print the resolved stages and run nothing
1603
+ -h, --help show this help
1604
+ -v, --version show the version
1605
+
1606
+ Command syntax:
1607
+ cmd args::opt=value,flag per-command options, after the last "::":
1608
+ "npm start::on-failure=restart"
1609
+ "serve --port 80::label=api,color=cyan"
1610
+ "build:*" glob over package.json script names
1611
+ "serve -- --port {1}" placeholders drawn from arguments after --
1612
+
1613
+ Examples:
1614
+ runset clean lint "build:**"
1615
+ runset -p "watch:**"
1616
+ runset clean -p lint test -s deploy
1617
+ `;
1618
+ /** Runs the CLI and returns its exit code. */
1619
+ async function main(rawArgv) {
1620
+ try {
1621
+ const cli = parseCli(rawArgv);
1622
+ if (cli.version) {
1623
+ process.stdout.write(`${version()}\n`);
1624
+ return 0;
1625
+ }
1626
+ if (cli.help) {
1627
+ process.stdout.write(HELP);
1628
+ return 0;
1629
+ }
1630
+ const config = createConfig({ cli });
1631
+ if (cli.argv.length === 0 && !config.hasCommands()) {
1632
+ process.stdout.write(HELP);
1633
+ return 0;
1634
+ }
1635
+ await new Run(createPlan({ config })).start();
1636
+ return 0;
1637
+ } catch (error) {
1638
+ if (!(error instanceof RunsetError)) throw error;
1639
+ process.stderr.write(`runset: ${error.message}\n`);
1640
+ return error.exitCode;
1641
+ }
1642
+ }
1643
+ function version() {
1644
+ return `v${readPackageJson(fileURLToPath(new URL("..", import.meta.url))).version}`;
1645
+ }
1646
+ //#endregion
1647
+ //#region src/index.ts
1648
+ async function runset(first, second) {
1649
+ return Run.fromConfigJs(toConfigJs(first, second)).start();
1650
+ }
1651
+ if (import.meta.main) process.exitCode = await main(process.argv.slice(2));
1652
+ /** A lone object carrying `commands` is a config; anything else is commands. */
1653
+ function toConfigJs(first, second) {
1654
+ if (second === void 0 && isConfigObject(first)) return first;
1655
+ const commands = Array.isArray(first) ? first : [first];
1656
+ return {
1657
+ ...second,
1658
+ commands
1659
+ };
1660
+ }
1661
+ function isConfigObject(value) {
1662
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Array.isArray(value.commands);
1663
+ }
1664
+ //#endregion
1665
+ export { CliError, ConfigError, EXIT_ACTIONS, NormalizeError, Run, RunsetError, runset as default, runset, isCommandSettings, isStreamDestination };