runset 0.0.0 → 0.0.1

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 CHANGED
@@ -2,9 +2,10 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import path from "node:path";
5
+ import { WriteStream } from "node:tty";
5
6
  import fs from "node:fs";
6
- import os from "node:os";
7
7
  import { inspect, styleText } from "node:util";
8
+ import os from "node:os";
8
9
  import cp from "node:child_process";
9
10
  //#region src/types.ts
10
11
  const EXIT_ACTIONS = /* @__PURE__ */ new Set([
@@ -24,10 +25,13 @@ function isStreamDestination(destination) {
24
25
  /** Any failure runset reports itself; `exitCode` is what the CLI exits with. */
25
26
  var RunsetError = class extends Error {
26
27
  exitCode;
27
- constructor(message, exitCode = 1) {
28
+ /** What the CLI prints in place of `message`; empty when it already said so. */
29
+ report;
30
+ constructor(message, exitCode = 1, report) {
28
31
  super(message);
29
32
  this.name = new.target.name;
30
33
  this.exitCode = exitCode;
34
+ this.report = report;
31
35
  }
32
36
  };
33
37
  /** A command line runset cannot read. */
@@ -37,6 +41,109 @@ var ConfigError = class extends RunsetError {};
37
41
  /** A command that cannot be resolved. */
38
42
  var NormalizeError = class extends RunsetError {};
39
43
  //#endregion
44
+ //#region src/utils/terminal.ts
45
+ /**
46
+ * Measuring and wrapping text the way a terminal lays it out: escape sequences
47
+ * take no room, wide characters (CJK, emoji) take two columns.
48
+ */
49
+ const ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)?|[@-Z\\-_])/g;
50
+ const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
51
+ function isTerminal(stream) {
52
+ return stream.isTTY === true;
53
+ }
54
+ /** The width a terminal stream reports; `undefined` for anything else. */
55
+ function terminalColumns(stream) {
56
+ const { columns } = stream;
57
+ return isTerminal(stream) && columns > 0 ? columns : void 0;
58
+ }
59
+ /** `COLUMNS` as a positive integer, or `undefined`. */
60
+ function parseColumns(value) {
61
+ const columns = Number(value);
62
+ return Number.isInteger(columns) && columns > 0 ? columns : void 0;
63
+ }
64
+ /** The columns `text` takes on screen. */
65
+ function visibleWidth(text) {
66
+ let width = 0;
67
+ for (const { segment } of segmenter.segment(text.replace(ESCAPE, ""))) width += columnWidth(segment, width);
68
+ return width;
69
+ }
70
+ /**
71
+ * Hard-wraps one line to `width` columns, the way the terminal would have, so
72
+ * each piece can carry a label of its own. A style still open at a break is
73
+ * reset at the end of the piece and re-applied at the start of the next, so a
74
+ * label never inherits it. `offset` is the column the line starts at, for tab
75
+ * stops. A line that moves the cursor — `\r`, or any escape but a color — is
76
+ * left whole: where it lands is not something runset can know.
77
+ */
78
+ function wrapLine(line, width, offset = 0) {
79
+ const fits = line.length * 2 <= width && !line.includes(" ");
80
+ if (width < 1 || fits || line.includes("\r")) return [line];
81
+ const pieces = [];
82
+ let current = "";
83
+ let column = 0;
84
+ let style = [];
85
+ for (const token of tokenize(line)) {
86
+ if (token.escape) {
87
+ if (token.text.startsWith("\x1B[") && !isSgr(token.text)) return [line];
88
+ current += token.text;
89
+ style = applySgr(style, token.text);
90
+ continue;
91
+ }
92
+ for (const { segment } of segmenter.segment(token.text)) {
93
+ let size = columnWidth(segment, offset + column);
94
+ if (column > 0 && column + size > width) {
95
+ pieces.push(style.length > 0 ? `${current}\u001B[0m` : current);
96
+ current = style.join("");
97
+ column = 0;
98
+ size = columnWidth(segment, offset);
99
+ }
100
+ current += segment;
101
+ column += size;
102
+ }
103
+ }
104
+ pieces.push(current);
105
+ return pieces;
106
+ }
107
+ function tokenize(text) {
108
+ const tokens = [];
109
+ let last = 0;
110
+ for (const match of text.matchAll(ESCAPE)) {
111
+ if (match.index > last) tokens.push({
112
+ escape: false,
113
+ text: text.slice(last, match.index)
114
+ });
115
+ tokens.push({
116
+ escape: true,
117
+ text: match[0]
118
+ });
119
+ last = match.index + match[0].length;
120
+ }
121
+ if (last < text.length) tokens.push({
122
+ escape: false,
123
+ text: text.slice(last)
124
+ });
125
+ return tokens;
126
+ }
127
+ /** A color or style (`ESC [ … m`); any other CSI moves the cursor. */
128
+ function isSgr(sequence) {
129
+ return sequence.startsWith("\x1B[") && sequence.endsWith("m");
130
+ }
131
+ /** The SGR sequences in force after `sequence`; a reset clears them. */
132
+ function applySgr(style, sequence) {
133
+ if (!isSgr(sequence)) return style;
134
+ const params = sequence.slice(2, -1);
135
+ if (params === "" || params === "0") return [];
136
+ return params.startsWith("0;") ? [sequence] : [...style, sequence];
137
+ }
138
+ /** The columns one grapheme takes when it starts at `column`. */
139
+ function columnWidth(grapheme, column) {
140
+ if (grapheme === " ") return 8 - column % 8;
141
+ if (/^[ -~]$/.test(grapheme)) return 1;
142
+ if (/^[\p{Cc}\p{Cf}\p{Mn}\p{Me}]+$/u.test(grapheme)) return 0;
143
+ if (/\p{Extended_Pictographic}|\p{Regional_Indicator}/u.test(grapheme) && /\p{Emoji_Presentation}|\uFE0F/u.test(grapheme)) return 2;
144
+ return /^[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]/u.test(grapheme) ? 2 : 1;
145
+ }
146
+ //#endregion
40
147
  //#region src/utils/fs.ts
41
148
  const require = createRequire(import.meta.url);
42
149
  /** The sync counterpart of `import()`: CJS, JSON, and ESM/TS via `require(esm)`. */
@@ -67,17 +174,19 @@ function readPackageJson(cwd) {
67
174
  //#region src/config/loadConfig.ts
68
175
  /**
69
176
  * Loads `runset.config.*` — the `explicitPath`, or the first one found walking
70
- * up from `cwd` — and unwraps a default export or a factory function.
177
+ * up from `cwd`, where a `package.json` `runset` section counts as one too —
178
+ * and unwraps a default export or a factory function.
71
179
  */
72
180
  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), {
181
+ const context = {
77
182
  argv,
78
183
  cwd,
79
184
  env
80
- });
185
+ };
186
+ if (explicitPath === void 0) return unwrapConfigJs(findConfigJs(cwd) ?? {}, context);
187
+ const filePath = path.resolve(cwd, explicitPath);
188
+ if (!fs.existsSync(filePath)) throw new ConfigError(`Config file not found: ${explicitPath}`);
189
+ return unwrapConfigJs(importSync(filePath), context);
81
190
  }
82
191
  function unwrapConfigJs(source, context) {
83
192
  const unwrapped = source.default ?? source;
@@ -93,18 +202,33 @@ function ancestors(dir) {
93
202
  dirs.push(parent);
94
203
  }
95
204
  }
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;
205
+ /**
206
+ * The nearest config walking up from `cwd`. Within one directory a
207
+ * `runset.config.*` file wins over the `package.json` `runset` section.
208
+ */
209
+ function findConfigJs(cwd) {
210
+ for (const dir of ancestors(cwd)) {
211
+ for (const extension of [
212
+ "ts",
213
+ "js",
214
+ "mjs",
215
+ "cjs",
216
+ "json"
217
+ ]) {
218
+ const candidate = path.join(dir, `runset.config.${extension}`);
219
+ if (fs.existsSync(candidate)) return importSync(candidate);
220
+ }
221
+ const section = readPackageJsonSection(path.join(dir, "package.json"));
222
+ if (section !== void 0) return section;
106
223
  }
107
224
  }
225
+ function readPackageJsonSection(filePath) {
226
+ if (!fs.existsSync(filePath)) return;
227
+ const { runset } = JSON.parse(fs.readFileSync(filePath, "utf8"));
228
+ if (runset === void 0) return;
229
+ if (runset === null || typeof runset !== "object" || Array.isArray(runset)) throw new ConfigError(`${filePath}: "runset" must be an object`);
230
+ return runset;
231
+ }
108
232
  //#endregion
109
233
  //#region src/utils/string.ts
110
234
  function toCamelCase(name) {
@@ -113,6 +237,11 @@ function toCamelCase(name) {
113
237
  function toKebabCase(name) {
114
238
  return name.replaceAll(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
115
239
  }
240
+ /** `850ms` under a second, `2.1s` from one up. */
241
+ function formatDuration(ms) {
242
+ const rounded = Math.round(ms);
243
+ return rounded < 1e3 ? `${rounded}ms` : `${(ms / 1e3).toFixed(1)}s`;
244
+ }
116
245
  //#endregion
117
246
  //#region src/config/winArgv.ts
118
247
  /**
@@ -152,6 +281,11 @@ const FLAGS = [
152
281
  short: "s",
153
282
  takesValue: false
154
283
  },
284
+ {
285
+ name: "recursive",
286
+ short: "r",
287
+ takesValue: false
288
+ },
155
289
  {
156
290
  name: "jobs",
157
291
  short: "j",
@@ -178,9 +312,15 @@ const FLAGS = [
178
312
  name: "cwd",
179
313
  takesValue: true
180
314
  },
315
+ {
316
+ name: "env",
317
+ short: "e",
318
+ takesValue: true
319
+ },
181
320
  {
182
321
  name: "color",
183
- takesValue: false
322
+ negated: "none",
323
+ takesValue: true
184
324
  },
185
325
  {
186
326
  name: "logLevel",
@@ -207,6 +347,19 @@ const FLAGS = [
207
347
  name: "labels",
208
348
  takesValue: true
209
349
  },
350
+ {
351
+ name: "showCommand",
352
+ takesValue: false
353
+ },
354
+ {
355
+ name: "showExitCode",
356
+ takesValue: false
357
+ },
358
+ {
359
+ name: "wrap",
360
+ short: "w",
361
+ takesValue: false
362
+ },
210
363
  {
211
364
  name: "help",
212
365
  short: "h",
@@ -234,6 +387,13 @@ function parseCli(rawArgv) {
234
387
  let cursor = 0;
235
388
  function setOption(spec, raw) {
236
389
  switch (spec.name) {
390
+ case "env": {
391
+ const text = String(raw);
392
+ const eq = text.indexOf("=");
393
+ if (eq < 1) throw new CliError(`Option "--env" takes NAME=value, not "${text}".`);
394
+ (options.env ??= {})[text.slice(0, eq)] = text.slice(eq + 1);
395
+ break;
396
+ }
237
397
  case "help":
238
398
  help = true;
239
399
  break;
@@ -265,8 +425,8 @@ function parseCli(rawArgv) {
265
425
  const flag = findLongFlag(name);
266
426
  if (flag === void 0 && /^no-?[A-Za-z]/.test(name)) {
267
427
  const negated = findLongFlag(name.replace(/^no-?/, ""));
268
- if (negated && !negated.takesValue) {
269
- setOption(negated, false);
428
+ if (negated && (!negated.takesValue || negated.negated !== void 0)) {
429
+ setOption(negated, negated.negated ?? false);
270
430
  return;
271
431
  }
272
432
  }
@@ -359,6 +519,11 @@ function booleanError(name, value) {
359
519
  function stringError(name, value) {
360
520
  return typeof value === "string" ? void 0 : `${name} must be a string, got ${formatValue(value)}.`;
361
521
  }
522
+ function envError(name, value) {
523
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return `${name} must be an object.`;
524
+ const bad = Object.entries(value).find(([, entry]) => typeof entry !== "string");
525
+ return bad === void 0 ? void 0 : `${name}.${bad[0]} must be a string, got ${formatValue(bad[1])}.`;
526
+ }
362
527
  function oneOfError(name, value, allowed) {
363
528
  return allowed.has(value) ? void 0 : `${name} is one of ${[...allowed].join(", ")}, got ${formatValue(value)}.`;
364
529
  }
@@ -389,12 +554,20 @@ const LABEL_MODES = /* @__PURE__ */ new Set([
389
554
  "custom",
390
555
  "all"
391
556
  ]);
557
+ const COLOR_MODES = /* @__PURE__ */ new Set([
558
+ "auto",
559
+ "none",
560
+ "basic",
561
+ "soft",
562
+ "all"
563
+ ]);
392
564
  const KNOWN_KEYS = /* @__PURE__ */ new Set([
393
565
  "color",
394
566
  "commandDictionary",
395
567
  "commands",
396
568
  "cwd",
397
569
  "dryRun",
570
+ "env",
398
571
  "formatLabel",
399
572
  "jobs",
400
573
  "killTimeout",
@@ -402,9 +575,14 @@ const KNOWN_KEYS = /* @__PURE__ */ new Set([
402
575
  "logLevel",
403
576
  "onFailure",
404
577
  "onSuccess",
578
+ "output",
405
579
  "parallel",
580
+ "recursive",
581
+ "showCommand",
582
+ "showExitCode",
406
583
  "stderr",
407
- "stdout"
584
+ "stdout",
585
+ "wrap"
408
586
  ]);
409
587
  /** Parses, loads and validates; anything left out comes from the process. */
410
588
  function createConfig({ cli = parseCli(process.argv.slice(2)), configJs, cwd = process.cwd(), destinations = process, env = process.env } = {}) {
@@ -432,6 +610,12 @@ var Config = class {
432
610
  onFailure;
433
611
  color;
434
612
  parallel;
613
+ recursive;
614
+ showCommand;
615
+ showExitCode;
616
+ wrap;
617
+ /** `COLUMNS` as runset was started with it: the width where no TTY says. */
618
+ envColumns;
435
619
  labels;
436
620
  logLevel;
437
621
  dryRun;
@@ -445,7 +629,6 @@ var Config = class {
445
629
  const { options } = cli;
446
630
  this.cwd = path.resolve(cwd, options.cwd ?? ".");
447
631
  this.destinations = destinations;
448
- this.env = env;
449
632
  const context = {
450
633
  argv: cli.argv,
451
634
  cwd: this.cwd,
@@ -457,12 +640,23 @@ var Config = class {
457
640
  }) : unwrapConfigJs(configJs, context);
458
641
  for (const key of Object.keys(file)) if (!KNOWN_KEYS.has(key)) this.warnings.push(`Unknown config key "${key}" was ignored.`);
459
642
  if (file.cwd !== void 0 && options.cwd === void 0) this.cwd = path.resolve(cwd, file.cwd);
643
+ check(envError("env", file.env ?? {}), ConfigError);
644
+ this.env = {
645
+ ...env,
646
+ ...file.env,
647
+ ...options.env
648
+ };
460
649
  this.commands = [...file.commands ?? [], ...cli.commands].filter(Boolean);
461
650
  this.commandDictionary = file.commandDictionary ?? {};
462
651
  this.args = cli.positional;
463
652
  this.jobs = options.jobs ?? file.jobs ?? Number.POSITIVE_INFINITY;
464
653
  this.killTimeout = options.killTimeout ?? file.killTimeout ?? DEFAULT_KILL_TIMEOUT;
465
654
  this.parallel = file.parallel ?? false;
655
+ this.recursive = options.recursive ?? file.recursive ?? false;
656
+ this.showCommand = options.showCommand ?? file.showCommand ?? false;
657
+ this.showExitCode = options.showExitCode ?? file.showExitCode ?? false;
658
+ this.wrap = options.wrap ?? file.wrap ?? false;
659
+ this.envColumns = parseColumns(env.COLUMNS);
466
660
  this.labels = options.labels ?? file.labels ?? "auto";
467
661
  this.onSuccess = options.onSuccess ?? file.onSuccess ?? "continue";
468
662
  this.onFailure = options.onFailure ?? file.onFailure ?? "stop";
@@ -470,7 +664,16 @@ var Config = class {
470
664
  this.logLevel = options.logLevel ?? file.logLevel ?? "info";
471
665
  this.color = resolveColor(options.color ?? file.color, env, destinations);
472
666
  this.formatLabel = file.formatLabel;
473
- const std = (stream) => mergeStd(mergeStd(defaultStd(stream), file[stream] ?? options.output), options[stream] ?? options.output);
667
+ const std = (stream) => {
668
+ let result = defaultStd(stream);
669
+ for (const value of [
670
+ file.output,
671
+ file[stream],
672
+ options.output,
673
+ options[stream]
674
+ ]) result = mergeStd(result, value);
675
+ return result;
676
+ };
474
677
  this.stdout = std("stdout");
475
678
  this.stderr = std("stderr");
476
679
  }
@@ -480,8 +683,12 @@ var Config = class {
480
683
  oneOfError("logLevel", this.logLevel, LOG_LEVELS),
481
684
  numberError("jobs", this.jobs, 1),
482
685
  numberError("killTimeout", this.killTimeout, 0),
483
- booleanError("color", this.color),
686
+ oneOfError("color", this.color, COLOR_MODES),
484
687
  booleanError("parallel", this.parallel),
688
+ booleanError("recursive", this.recursive),
689
+ booleanError("showCommand", this.showCommand),
690
+ booleanError("showExitCode", this.showExitCode),
691
+ booleanError("wrap", this.wrap),
485
692
  booleanError("dryRun", this.dryRun),
486
693
  oneOfError("labels", this.labels, LABEL_MODES),
487
694
  stdError("stdout", this.stdout),
@@ -497,17 +704,291 @@ var Config = class {
497
704
  return this.commands.some((command) => typeof command === "object" && command !== null ? !isCommandSettings(command) : Boolean(command));
498
705
  }
499
706
  };
500
- function isTerminal(stream) {
501
- return stream.isTTY === true;
707
+ /**
708
+ * A mode as given; under `auto`, whether to color at all, then `soft` where
709
+ * the terminal shows 256 colors. `all` is only ever asked for. Anything else is
710
+ * left for `validate` to refuse.
711
+ */
712
+ function resolveColor(mode, env, destinations) {
713
+ if (mode !== void 0 && mode !== "auto") return mode;
714
+ if (!detectColor(env, destinations)) return "none";
715
+ return WriteStream.prototype.getColorDepth(env) >= 8 ? "soft" : "basic";
502
716
  }
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;
717
+ /** `NO_COLOR`/`FORCE_COLOR`, then whether both streams are TTYs. */
718
+ function detectColor(env, destinations) {
506
719
  if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
507
720
  if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
508
721
  return isTerminal(destinations.stdout) && isTerminal(destinations.stderr);
509
722
  }
510
723
  //#endregion
724
+ //#region src/utils/colors.ts
725
+ const KNOWN = new Set(Object.keys(inspect.colors));
726
+ const SHADES = {
727
+ mint: {
728
+ index: 115,
729
+ fallback: "greenBright"
730
+ },
731
+ sky: {
732
+ index: 111,
733
+ fallback: "blueBright"
734
+ },
735
+ rose: {
736
+ index: 211,
737
+ fallback: "magentaBright"
738
+ },
739
+ amber: {
740
+ index: 179,
741
+ fallback: "yellowBright"
742
+ },
743
+ lavender: {
744
+ index: 183,
745
+ fallback: "magentaBright"
746
+ },
747
+ aqua: {
748
+ index: 80,
749
+ fallback: "cyanBright"
750
+ },
751
+ coral: {
752
+ index: 210,
753
+ fallback: "redBright"
754
+ },
755
+ sage: {
756
+ index: 151,
757
+ fallback: "greenBright"
758
+ },
759
+ periwinkle: {
760
+ index: 147,
761
+ fallback: "blueBright"
762
+ },
763
+ peach: {
764
+ index: 216,
765
+ fallback: "yellowBright"
766
+ },
767
+ teal: {
768
+ index: 79,
769
+ fallback: "cyanBright"
770
+ },
771
+ lilac: {
772
+ index: 177,
773
+ fallback: "magentaBright"
774
+ },
775
+ lime: {
776
+ index: 149,
777
+ fallback: "greenBright"
778
+ },
779
+ steel: {
780
+ index: 110,
781
+ fallback: "blueBright"
782
+ },
783
+ pink: {
784
+ index: 218,
785
+ fallback: "magentaBright"
786
+ },
787
+ seafoam: {
788
+ index: 121,
789
+ fallback: "cyanBright"
790
+ },
791
+ apricot: {
792
+ index: 215,
793
+ fallback: "yellowBright"
794
+ },
795
+ mauve: {
796
+ index: 139,
797
+ fallback: "magentaBright"
798
+ },
799
+ jade: {
800
+ index: 114,
801
+ fallback: "greenBright"
802
+ },
803
+ sand: {
804
+ index: 180,
805
+ fallback: "yellowBright"
806
+ },
807
+ iris: {
808
+ index: 141,
809
+ fallback: "blueBright"
810
+ },
811
+ ice: {
812
+ index: 117,
813
+ fallback: "cyanBright"
814
+ },
815
+ salmon: {
816
+ index: 174,
817
+ fallback: "redBright"
818
+ },
819
+ olive: {
820
+ index: 150,
821
+ fallback: "greenBright"
822
+ },
823
+ navy: {
824
+ index: 24,
825
+ fallback: "blue",
826
+ foreground: "paper"
827
+ },
828
+ plum: {
829
+ index: 96,
830
+ fallback: "magenta",
831
+ foreground: "paper"
832
+ },
833
+ forest: {
834
+ index: 22,
835
+ fallback: "green",
836
+ foreground: "paper"
837
+ },
838
+ wine: {
839
+ index: 89,
840
+ fallback: "red",
841
+ foreground: "paper"
842
+ },
843
+ ocean: {
844
+ index: 25,
845
+ fallback: "blue",
846
+ foreground: "paper"
847
+ },
848
+ indigo: {
849
+ index: 61,
850
+ fallback: "blue",
851
+ foreground: "paper"
852
+ },
853
+ copper: {
854
+ index: 94,
855
+ fallback: "red",
856
+ foreground: "paper"
857
+ },
858
+ pine: {
859
+ index: 23,
860
+ fallback: "cyan",
861
+ foreground: "paper"
862
+ },
863
+ violet: {
864
+ index: 55,
865
+ fallback: "magenta",
866
+ foreground: "paper"
867
+ },
868
+ brick: {
869
+ index: 88,
870
+ fallback: "red",
871
+ foreground: "paper"
872
+ },
873
+ slate: {
874
+ index: 60,
875
+ fallback: "blue",
876
+ foreground: "paper"
877
+ },
878
+ cocoa: {
879
+ index: 95,
880
+ fallback: "red",
881
+ foreground: "paper"
882
+ },
883
+ ink: {
884
+ index: 235,
885
+ fallback: "black"
886
+ },
887
+ paper: {
888
+ index: 255,
889
+ fallback: "whiteBright"
890
+ }
891
+ };
892
+ /** Every shade by its foreground and its background name, e.g. `mint`, `bgMint`. */
893
+ const SHADE_NAMES = new Map(Object.entries(SHADES).flatMap(([name, custom]) => [[name, custom], [toCamelCase(`bg-${name}`), custom]]));
894
+ const BASIC_PALETTE = [
895
+ {
896
+ bgColor: "bgGreen",
897
+ color: "black"
898
+ },
899
+ {
900
+ bgColor: "bgBlue",
901
+ color: "white"
902
+ },
903
+ {
904
+ bgColor: "bgMagenta",
905
+ color: "white"
906
+ },
907
+ {
908
+ bgColor: "bgCyan",
909
+ color: "black"
910
+ },
911
+ {
912
+ bgColor: "bgYellow",
913
+ color: "black"
914
+ },
915
+ {
916
+ bgColor: "bgRed",
917
+ color: "white"
918
+ },
919
+ {
920
+ bgColor: "bgGray",
921
+ color: "white"
922
+ },
923
+ {
924
+ bgColor: "bgGreenBright",
925
+ color: "black"
926
+ },
927
+ {
928
+ bgColor: "bgBlueBright",
929
+ color: "black"
930
+ },
931
+ {
932
+ bgColor: "bgMagentaBright",
933
+ color: "black"
934
+ },
935
+ {
936
+ bgColor: "bgCyanBright",
937
+ color: "black"
938
+ },
939
+ {
940
+ bgColor: "bgYellowBright",
941
+ color: "black"
942
+ }
943
+ ];
944
+ /** Interleave hue families so adjacent assignments remain easy to tell apart. */
945
+ const PALETTE = Object.entries(SHADES).filter(([name]) => name !== "ink" && name !== "paper").map(([name, { foreground }]) => ({
946
+ bgColor: toCamelCase(`bg-${name}`),
947
+ color: foreground ?? "ink"
948
+ }));
949
+ /** What `colorLabels` hands out under each mode; `none` still picks, unseen. */
950
+ const PALETTES = {
951
+ none: BASIC_PALETTE,
952
+ basic: BASIC_PALETTE,
953
+ soft: PALETTE.filter((pair) => pair.color === "ink"),
954
+ all: PALETTE
955
+ };
956
+ /** A built-in style or one of runset's foreground/background shades. */
957
+ function isColorName(name) {
958
+ return KNOWN.has(name) || shade(name) !== void 0;
959
+ }
960
+ /** Styles text; a shade is painted from the 256-color table. */
961
+ function paint(text, colors) {
962
+ let result = text;
963
+ for (const name of colors.toReversed()) {
964
+ const custom = shade(name);
965
+ if (custom !== void 0) {
966
+ const background = name.startsWith("bg");
967
+ const open = `\u001B[${background ? 48 : 38};5;${custom.index}m`;
968
+ const close = `\u001B[${background ? 49 : 39}m`;
969
+ result = open + result.replaceAll(close, open) + close;
970
+ } else if (KNOWN.has(name)) result = styleText(name, result, { validateStream: false });
971
+ }
972
+ return result;
973
+ }
974
+ /** The theme color used for a shade on a basic terminal. */
975
+ function fallbackColor(name) {
976
+ const custom = shade(name);
977
+ if (custom === void 0) return name;
978
+ return name.startsWith("bg") ? toCamelCase(`bg-${custom.fallback}`) : custom.fallback;
979
+ }
980
+ /** A stable palette index for a label. */
981
+ function paletteIndex(label, palette = PALETTE) {
982
+ /** The largest modulus that keeps `hash * 31 + code` an exact integer. */
983
+ const HASH_MODULUS = 2147483647;
984
+ let hash = 0;
985
+ for (const character of label) hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % HASH_MODULUS;
986
+ return hash % palette.length;
987
+ }
988
+ function shade(name) {
989
+ return SHADE_NAMES.get(name);
990
+ }
991
+ //#endregion
511
992
  //#region src/plan/stages.ts
512
993
  const EMPTY_SEGMENT = {
513
994
  commands: [],
@@ -564,60 +1045,11 @@ function groupByStage(items, stageOf) {
564
1045
  }
565
1046
  //#endregion
566
1047
  //#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
1048
  /**
618
1049
  * 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.
1050
+ * sharing a stage is named after itself, whether or not someone in its stage
1051
+ * has a label of their own. Under `custom` it gets a blank label instead,
1052
+ * holding the column when someone in its stage has one.
621
1053
  */
622
1054
  function assignAutoLabels({ commands, config }) {
623
1055
  const mode = config.labels;
@@ -631,7 +1063,7 @@ function assignAutoLabels({ commands, config }) {
631
1063
  const labeled = stage.some((command) => command.label !== "");
632
1064
  for (const command of stage) {
633
1065
  if (command.label !== "") continue;
634
- if (mode === "all" || mode === "auto" && shared && !labeled) {
1066
+ if (mode === "all" || mode === "auto" && shared) {
635
1067
  command.label = nameOf(command);
636
1068
  named.push(command);
637
1069
  } else if (shared && labeled) command.label = " ";
@@ -641,16 +1073,23 @@ function assignAutoLabels({ commands, config }) {
641
1073
  }
642
1074
  /**
643
1075
  * 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.
1076
+ * one its label hashes to, or the next free when that is taken, from the
1077
+ * palette of `config.color`. Without shades every one is first swapped for its
1078
+ * theme color, so nothing past the plan has to know how many colors there are.
645
1079
  */
646
- function colorLabels({ commands }) {
1080
+ function colorLabels({ commands, config }) {
1081
+ const palette = PALETTES[config.color];
1082
+ if (config.color === "none" || config.color === "basic") for (const command of commands) {
1083
+ command.bgColor = fallbackColor(command.bgColor);
1084
+ command.color = fallbackColor(command.color);
1085
+ }
647
1086
  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));
1087
+ const taken = new Set(labeled.map((command) => palette.findIndex((pair) => pair.bgColor === command.bgColor)).filter((index) => index !== -1));
649
1088
  for (const command of labeled) {
650
1089
  if (command.color !== "" || command.bgColor !== "" || command.label.trim() === "") continue;
651
- const index = claim(paletteIndex(command.label), taken);
1090
+ const index = claim(paletteIndex(command.label, palette), taken, palette.length);
652
1091
  taken.add(index);
653
- const pair = PALETTE[index];
1092
+ const pair = palette[index];
654
1093
  command.bgColor = pair.bgColor;
655
1094
  command.color = pair.color;
656
1095
  }
@@ -661,17 +1100,9 @@ function alignLabels({ commands }) {
661
1100
  const width = Math.max(0, ...labeled.map((command) => command.label.length));
662
1101
  for (const command of labeled) command.label = command.label.padEnd(width);
663
1102
  }
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
1103
  /** The script name, or the program a shell command runs. */
673
1104
  function nameOf(command) {
674
- return command.scriptName ?? command.command.split(/\s/, 1)[0] ?? "";
1105
+ return command.packageName ?? command.scriptName ?? command.command.split(/\s/, 1)[0] ?? "";
675
1106
  }
676
1107
  /** `serve`, `serve` → `serve#1`, `serve#2`. */
677
1108
  function numberRepeats(named) {
@@ -685,10 +1116,10 @@ function numberRepeats(named) {
685
1116
  command.label = `${command.label}#${index}`;
686
1117
  }
687
1118
  }
688
- function claim(preferred, taken) {
689
- if (taken.size >= PALETTE.length) return preferred;
1119
+ function claim(preferred, taken, size) {
1120
+ if (taken.size >= size) return preferred;
690
1121
  let index = preferred;
691
- while (taken.has(index)) index = (index + 1) % PALETTE.length;
1122
+ while (taken.has(index)) index = (index + 1) % size;
692
1123
  return index;
693
1124
  }
694
1125
  //#endregion
@@ -719,6 +1150,44 @@ function toRegExp(pattern) {
719
1150
  return new RegExp(`^${source.join(":")}$`);
720
1151
  }
721
1152
  //#endregion
1153
+ //#region src/utils/workspace.ts
1154
+ /**
1155
+ * The packages of the workspace at or above `cwd`, from `pnpm-workspace.yaml`
1156
+ * or `package.json` `workspaces`, sorted by path; the root is not one of them.
1157
+ */
1158
+ function readWorkspacePackages(cwd) {
1159
+ for (const dir of ancestors(cwd)) {
1160
+ const patterns = readWorkspacePatterns(dir);
1161
+ if (patterns === void 0) continue;
1162
+ const include = patterns.filter((pattern) => !pattern.startsWith("!"));
1163
+ const exclude = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
1164
+ return fs.globSync(include, {
1165
+ cwd: dir,
1166
+ exclude: [...exclude, "**/node_modules"]
1167
+ }).map((match) => path.join(dir, match, "package.json")).filter((filePath) => fs.existsSync(filePath)).toSorted().map((filePath) => readPackageJson(path.dirname(filePath)));
1168
+ }
1169
+ return [];
1170
+ }
1171
+ function readWorkspacePatterns(dir) {
1172
+ const yamlPath = path.join(dir, "pnpm-workspace.yaml");
1173
+ if (fs.existsSync(yamlPath)) return parsePnpmPackages(fs.readFileSync(yamlPath, "utf8"));
1174
+ const jsonPath = path.join(dir, "package.json");
1175
+ if (!fs.existsSync(jsonPath)) return;
1176
+ const { workspaces } = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
1177
+ return Array.isArray(workspaces) ? workspaces : workspaces?.packages;
1178
+ }
1179
+ /** The `packages:` list of a `pnpm-workspace.yaml`, read without a YAML parser. */
1180
+ function parsePnpmPackages(text) {
1181
+ const patterns = [];
1182
+ let inPackages = false;
1183
+ for (const raw of text.split(/\r?\n/)) {
1184
+ const line = raw.replace(/\s#.*$/, "").trimEnd();
1185
+ if (/^\S/.test(line)) inPackages = /^packages\s*:/.test(line);
1186
+ else if (inPackages && /^\s+-/.test(line)) patterns.push(line.replace(/^\s+-\s*/, "").replaceAll(/^['"]|['"]$/g, ""));
1187
+ }
1188
+ return patterns;
1189
+ }
1190
+ //#endregion
722
1191
  //#region src/utils/os.ts
723
1192
  /** 128 + the signal's number, the way a shell reports it: 130, 143, … */
724
1193
  function signalExitCode(signal) {
@@ -783,7 +1252,11 @@ function namedValue(key, args) {
783
1252
  }
784
1253
  //#endregion
785
1254
  //#region src/plan/token.ts
786
- const BOOLEAN_OPTIONS = /* @__PURE__ */ new Set(["disabled", "parallel"]);
1255
+ const BOOLEAN_OPTIONS = /* @__PURE__ */ new Set([
1256
+ "disabled",
1257
+ "parallel",
1258
+ "recursive"
1259
+ ]);
787
1260
  const ACTION_OPTIONS = /* @__PURE__ */ new Set(["onFailure", "onSuccess"]);
788
1261
  /** Keys a `::` suffix may set. */
789
1262
  const INLINE_OPTIONS = /* @__PURE__ */ new Set([
@@ -791,6 +1264,7 @@ const INLINE_OPTIONS = /* @__PURE__ */ new Set([
791
1264
  "color",
792
1265
  "cwd",
793
1266
  "label",
1267
+ "output",
794
1268
  "stderr",
795
1269
  "stdout",
796
1270
  ...BOOLEAN_OPTIONS,
@@ -824,7 +1298,7 @@ function parseInlineOptions(text, args) {
824
1298
  const written = eq === -1 ? "true" : entry.slice(eq + 1);
825
1299
  const value = substitutePlaceholders(key === "label" ? written : written.trim(), args, true);
826
1300
  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);
1301
+ if (key === "output" || key === "stderr" || key === "stdout") options[key] = parsePartialStd(value);
828
1302
  else if (BOOLEAN_OPTIONS.has(key)) options[key] = value !== "false" && value !== "";
829
1303
  else if (ACTION_OPTIONS.has(key)) {
830
1304
  check(actionError(`"${toKebabCase(key)}"`, value), NormalizeError);
@@ -847,7 +1321,7 @@ const SETTINGS_OPTIONS = /* @__PURE__ */ new Set([
847
1321
  */
848
1322
  function normalize(config, packageInfo) {
849
1323
  const scriptNames = Object.keys(packageInfo.scripts);
850
- const packageRoot = packageInfo.filePath === "" ? config.cwd : path.dirname(packageInfo.filePath);
1324
+ let workspacePackages;
851
1325
  const defaults = {
852
1326
  bgColor: "",
853
1327
  color: "",
@@ -860,12 +1334,13 @@ function normalize(config, packageInfo) {
860
1334
  onFailure: config.onFailure,
861
1335
  onSuccess: config.onSuccess,
862
1336
  parallel: config.parallel,
1337
+ recursive: config.recursive,
863
1338
  stage: 0,
864
1339
  stderr: config.stderr,
865
1340
  stdout: config.stdout,
866
1341
  type: "shell"
867
1342
  };
868
- function makeLeaf(token, rawOptions, script) {
1343
+ function makeLeaf(token, rawOptions, script, pkg = packageInfo) {
869
1344
  const options = {
870
1345
  ...rawOptions,
871
1346
  stderr: mergeStd(defaults.stderr, rawOptions.stderr),
@@ -873,7 +1348,7 @@ function normalize(config, packageInfo) {
873
1348
  };
874
1349
  const args = substitutePlaceholders(token.args, config.args);
875
1350
  const command = [token.name, args].filter((part) => part !== "").join(" ");
876
- const line = script === void 0 ? command : [packageInfo.scripts[script], args].filter((part) => part !== "").join(" ");
1351
+ const line = script === void 0 ? command : [pkg.scripts[script], args].filter((part) => part !== "").join(" ");
877
1352
  const leaf = {
878
1353
  ...defaults,
879
1354
  ...options,
@@ -881,9 +1356,10 @@ function normalize(config, packageInfo) {
881
1356
  line,
882
1357
  stage: 0,
883
1358
  type: script === void 0 ? "shell" : "npm",
884
- ...script === void 0 ? {} : { scriptName: script }
1359
+ ...script === void 0 ? {} : { scriptName: script },
1360
+ ...pkg === packageInfo ? {} : { packageName: packageNameOf(pkg) }
885
1361
  };
886
- if (script !== void 0 && options.cwd === void 0) leaf.cwd = packageRoot;
1362
+ if (script !== void 0 && options.cwd === void 0) leaf.cwd = pkg.filePath === "" ? config.cwd : path.dirname(pkg.filePath);
887
1363
  validateCommand(leaf);
888
1364
  return leaf;
889
1365
  }
@@ -906,6 +1382,14 @@ function normalize(config, packageInfo) {
906
1382
  return token.args === "" ? resolved : appendArgs(resolved, token);
907
1383
  }
908
1384
  const settled = layer(inherited, inline, overrides);
1385
+ if (settled.recursive ?? defaults.recursive) {
1386
+ workspacePackages ??= readWorkspacePackages(config.cwd);
1387
+ const leaves = workspacePackages.flatMap((pkg) => matchScripts(token.name, pkg).map((name) => makeLeaf({
1388
+ ...token,
1389
+ name
1390
+ }, settled, name, pkg)));
1391
+ if (leaves.length > 0) return toSegment(leaves, settled);
1392
+ }
909
1393
  if (isGlob(token.name)) return toSegment(matchGlob(token.name, scriptNames).map((name) => makeLeaf({
910
1394
  ...token,
911
1395
  name
@@ -969,6 +1453,15 @@ function normalize(config, packageInfo) {
969
1453
  commands.sort((first, second) => first.stage - second.stage);
970
1454
  return commands;
971
1455
  }
1456
+ /** The scripts of `pkg` a token name matches, glob or not. */
1457
+ function matchScripts(name, pkg) {
1458
+ const scriptNames = Object.keys(pkg.scripts);
1459
+ if (isGlob(name)) return matchGlob(name, scriptNames);
1460
+ return Object.hasOwn(pkg.scripts, name) ? [name] : [];
1461
+ }
1462
+ function packageNameOf(pkg) {
1463
+ return pkg.name || path.basename(path.dirname(pkg.filePath));
1464
+ }
972
1465
  function isSettingsEntry(definition) {
973
1466
  return typeof definition === "object" && definition !== null && !Array.isArray(definition) && isCommandSettings(definition);
974
1467
  }
@@ -998,10 +1491,9 @@ function readSettings(entry, base) {
998
1491
  /** Layers option bags, later over earlier; stream settings merge per axis. */
999
1492
  function layer(...bags) {
1000
1493
  const result = {};
1001
- for (const { stderr, stdout, ...rest } of bags) {
1494
+ for (const { output, stderr, stdout, ...rest } of bags) {
1002
1495
  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);
1496
+ for (const [key, own] of [["stderr", stderr], ["stdout", stdout]]) for (const value of [output, own]) if (value !== void 0) result[key] = mergeStd(toPartialStd(result[key] ?? {}), value);
1005
1497
  }
1006
1498
  return result;
1007
1499
  }
@@ -1010,7 +1502,11 @@ function validateCommand(command) {
1010
1502
  const where = (key) => `"${toKebabCase(key)}" on "${command.command}"`;
1011
1503
  check([
1012
1504
  ...["onFailure", "onSuccess"].map((key) => actionError(where(key), command[key])),
1013
- ...["disabled", "parallel"].map((key) => booleanError(where(key), command[key])),
1505
+ ...[
1506
+ "disabled",
1507
+ "parallel",
1508
+ "recursive"
1509
+ ].map((key) => booleanError(where(key), command[key])),
1014
1510
  ...[
1015
1511
  "bgColor",
1016
1512
  "color",
@@ -1018,7 +1514,7 @@ function validateCommand(command) {
1018
1514
  "label"
1019
1515
  ].map((key) => stringError(where(key), command[key])),
1020
1516
  ...["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.`
1517
+ envError(where("env"), command.env)
1022
1518
  ].find((error) => error !== void 0), NormalizeError);
1023
1519
  }
1024
1520
  //#endregion
@@ -1064,13 +1560,17 @@ function describeOptions(config) {
1064
1560
  ["cwd", config.cwd],
1065
1561
  ["jobs", amount(config.jobs)],
1066
1562
  ["parallel", String(config.parallel)],
1563
+ ["recursive", String(config.recursive)],
1067
1564
  ["on-success", config.onSuccess],
1068
1565
  ["on-failure", config.onFailure],
1069
1566
  ["kill-timeout", `${config.killTimeout}ms`],
1070
1567
  ["stdout", describeStd(config.stdout)],
1071
1568
  ["stderr", describeStd(config.stderr)],
1072
1569
  ["labels", config.labels],
1073
- ["color", String(config.color)],
1570
+ ["show-command", String(config.showCommand)],
1571
+ ["show-exit-code", String(config.showExitCode)],
1572
+ ["wrap", String(config.wrap)],
1573
+ ["color", config.color],
1074
1574
  ["log-level", config.logLevel]
1075
1575
  ]).map((line) => ` ${line}`)];
1076
1576
  }
@@ -1145,18 +1645,6 @@ function closed(stream) {
1145
1645
  });
1146
1646
  }
1147
1647
  //#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
1648
  //#region src/run/Logger.ts
1161
1649
  const RANK = {
1162
1650
  debug: 3,
@@ -1196,6 +1684,34 @@ var Logger = class {
1196
1684
  }
1197
1685
  };
1198
1686
  //#endregion
1687
+ //#region src/run/prefix.ts
1688
+ /**
1689
+ * The prefix of a command's output lines: `''` without a label, a function
1690
+ * when `formatLabel` may answer differently per line.
1691
+ */
1692
+ function makePrefix(command, color, formatLabel, stream) {
1693
+ if (command.label === "") return "";
1694
+ const defaultPrefix = defaultLabel(command, color);
1695
+ if (formatLabel === void 0) return defaultPrefix;
1696
+ return () => String(formatLabel({
1697
+ color,
1698
+ command,
1699
+ defaultPrefix,
1700
+ stream
1701
+ }));
1702
+ }
1703
+ /** The columns runset's own prefix takes; `0` without a label. */
1704
+ function prefixWidth(command, color) {
1705
+ return command.label === "" ? 0 : visibleWidth(defaultLabel(command, color));
1706
+ }
1707
+ /** A colored block with color; `[label]` without. */
1708
+ function defaultLabel(command, color) {
1709
+ if (color && command.label.trim() === "") return `${command.label} `;
1710
+ const formats = [command.bgColor, command.color].filter((name) => isColorName(name));
1711
+ if (!color || formats.length === 0) return `[${command.label}] `;
1712
+ return `${paint(command.label, formats)} `;
1713
+ }
1714
+ //#endregion
1199
1715
  //#region src/run/env.ts
1200
1716
  /**
1201
1717
  * The environment a command runs in: every `node_modules/.bin` up from `cwd`
@@ -1228,15 +1744,22 @@ var OutputSink = class {
1228
1744
  /** A prefixed stream's unfinished line, held until its newline. */
1229
1745
  pending = "";
1230
1746
  opened;
1747
+ /** Whether everything written so far ended with a newline. */
1748
+ atLineStart = true;
1231
1749
  std;
1232
1750
  prefix;
1233
1751
  destinations;
1234
1752
  files;
1235
- constructor(std, prefix, destinations, files) {
1753
+ wrap;
1754
+ /** `COLUMNS`, for a stream that is not a terminal. */
1755
+ envColumns;
1756
+ constructor(std, prefix, destinations, files, wrap = false, envColumns) {
1236
1757
  this.std = std;
1237
1758
  this.prefix = prefix;
1238
1759
  this.destinations = destinations;
1239
1760
  this.files = files;
1761
+ this.wrap = wrap;
1762
+ this.envColumns = envColumns;
1240
1763
  }
1241
1764
  /** Opened on first use, so a command that never writes truncates nothing. */
1242
1765
  get target() {
@@ -1244,8 +1767,17 @@ var OutputSink = class {
1244
1767
  this.opened ??= this.files.open(this.std.destination);
1245
1768
  return this.opened;
1246
1769
  }
1770
+ /**
1771
+ * The width labelled lines are wrapped to under `wrap`: the terminal's own,
1772
+ * else `COLUMNS`. A file has none, and neither has a run without `wrap`.
1773
+ */
1774
+ columns() {
1775
+ if (!this.wrap || !isStreamDestination(this.std.destination)) return;
1776
+ return terminalColumns(this.target) ?? this.envColumns;
1777
+ }
1247
1778
  write(chunk) {
1248
- if (this.std.destination === "none") return;
1779
+ if (this.std.destination === "none" || chunk === "") return;
1780
+ this.atLineStart = chunk.endsWith("\n");
1249
1781
  if (this.std.timing === "grouped") {
1250
1782
  this.buffered += chunk;
1251
1783
  return;
@@ -1261,6 +1793,10 @@ var OutputSink = class {
1261
1793
  this.pending = this.pending.slice(end + 1);
1262
1794
  this.target.write(this.applyPrefix(complete));
1263
1795
  }
1796
+ /** A line of runset's own, on a line of its own: after any unfinished one. */
1797
+ writeLine(text) {
1798
+ this.write(`${this.atLineStart ? "" : "\n"}${text}\n`);
1799
+ }
1264
1800
  /** Emits what is held back: a `grouped` stream, or a partial line. */
1265
1801
  flush() {
1266
1802
  const held = [this.pending, this.buffered].filter((text) => text !== "");
@@ -1272,34 +1808,19 @@ var OutputSink = class {
1272
1808
  const { prefix } = this;
1273
1809
  if (prefix === "") return chunk;
1274
1810
  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" : "");
1811
+ const body = endsWithNewline ? chunk.slice(0, -1) : chunk;
1812
+ const columns = this.columns();
1813
+ return body.split("\n").map((line) => this.label(line, prefix, columns)).join("\n") + (endsWithNewline ? "\n" : "");
1814
+ }
1815
+ /** One line, prefixed; wrapped first when it would overflow the terminal. */
1816
+ label(line, prefix, columns) {
1817
+ const text = typeof prefix === "string" ? prefix : prefix();
1818
+ if (columns === void 0) return text + line;
1819
+ const width = visibleWidth(text);
1820
+ return wrapLine(line, columns - width, width).map((piece) => text + piece).join("\n");
1276
1821
  }
1277
1822
  };
1278
1823
  //#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
1824
  //#region src/run/Process.ts
1304
1825
  const USE_PROCESS_GROUPS = process.platform !== "win32";
1305
1826
  /** One command, running in one shell, restarted as its exit policy says. */
@@ -1313,14 +1834,17 @@ var Process = class {
1313
1834
  finished = false;
1314
1835
  command;
1315
1836
  child;
1837
+ /** When the current attempt was spawned, from `performance.now()`. */
1838
+ attemptStart = 0;
1316
1839
  context;
1317
1840
  stdout;
1318
1841
  stderr;
1319
1842
  constructor(command, context) {
1320
1843
  this.command = command;
1321
1844
  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);
1845
+ const { destinations, envColumns, formatLabel, wrap } = context.config;
1846
+ const color = context.config.color !== "none";
1847
+ const sink = (stream) => new OutputSink(command[stream], makePrefix(command, color, formatLabel, stream), destinations, context.files, wrap, envColumns);
1324
1848
  this.stdout = sink("stdout");
1325
1849
  this.stderr = sink("stderr");
1326
1850
  }
@@ -1338,10 +1862,15 @@ var Process = class {
1338
1862
  let stopTheRun = false;
1339
1863
  for (;;) {
1340
1864
  this.started = true;
1865
+ if (this.context.config.showCommand) {
1866
+ const run = this.context.config.color === "none" ? "run" : paint("run", ["blue"]);
1867
+ this.stdout.writeLine(`${run} ${this.command.command}`);
1868
+ }
1341
1869
  await this.spawn();
1870
+ if (this.context.config.showExitCode) this.stdout.writeLine(this.exitLine());
1342
1871
  this.stdout.flush();
1343
1872
  this.stderr.flush();
1344
- this.context.logger.debug(`< ${this.command.line} ${this.report()}`);
1873
+ this.context.logger.debug(`< ${this.command.line} ${this.describeExit()}`);
1345
1874
  if (this.terminated) break;
1346
1875
  const action = this.isFailed() ? this.command.onFailure : this.command.onSuccess;
1347
1876
  if (action !== "restart") {
@@ -1360,14 +1889,45 @@ var Process = class {
1360
1889
  if (this.child?.pid !== void 0) this.context.logger.debug(`! ${sent} ${this.command.line}`);
1361
1890
  this.kill(sent);
1362
1891
  }
1363
- report() {
1364
- return this.signal === void 0 ? `exited with ${this.exitCode ?? 0}` : `was killed by ${this.signal}`;
1892
+ /**
1893
+ * `✓ 2.1s` when clean; `– stopped · 2.1s typecheck` in yellow when runset
1894
+ * stopped it; otherwise `✗ code 1 · 2.1s typecheck` in red. The command is
1895
+ * in gray, so it can be found without its label.
1896
+ */
1897
+ exitLine() {
1898
+ const color = this.context.config.color !== "none";
1899
+ const duration = formatDuration(performance.now() - this.attemptStart);
1900
+ if (this.exitCode === 0 && this.signal === void 0 && !this.terminated) return `${color ? paint("✓", ["green"]) : "✓"} ${duration}`;
1901
+ const [status, tint] = this.terminated ? ["– stopped", "yellow"] : [`✗ ${this.shortExit()}`, "red"];
1902
+ const { command } = this.command;
1903
+ return color ? `${paint(status, [tint])} · ${duration} ${paint(command, ["gray"])}` : `${status} · ${duration} ${command}`;
1904
+ }
1905
+ /** `code N`, the signal's name, or `stopped`. */
1906
+ shortExit() {
1907
+ if (this.signal !== void 0) return this.signal;
1908
+ return this.exitCode === void 0 ? "stopped" : `code ${this.exitCode}`;
1909
+ }
1910
+ /** `exited with code N`, `was killed by SIGTERM`, or `was stopped`. */
1911
+ describeExit() {
1912
+ if (this.signal !== void 0) return `was killed by ${this.signal}`;
1913
+ return this.exitCode === void 0 ? "was stopped" : `exited with code ${this.exitCode}`;
1914
+ }
1915
+ /**
1916
+ * Under `wrap`, `COLUMNS` narrowed by the label: a child writing to a pipe
1917
+ * has no width of its own, and this is what is left of the parent's for it.
1918
+ */
1919
+ columnsEnv() {
1920
+ const columns = this.stdout.columns();
1921
+ if (columns === void 0) return {};
1922
+ const width = prefixWidth(this.command, this.context.config.color !== "none");
1923
+ return { COLUMNS: String(Math.max(1, columns - width)) };
1365
1924
  }
1366
1925
  /** One attempt: resolves when the child is gone. */
1367
1926
  async spawn() {
1368
1927
  const { command, context } = this;
1369
1928
  this.exitCode = void 0;
1370
1929
  this.signal = void 0;
1930
+ this.attemptStart = performance.now();
1371
1931
  context.logger.debug(`> ${command.line}`);
1372
1932
  await new Promise((resolve) => {
1373
1933
  let settled = false;
@@ -1377,7 +1937,8 @@ var Process = class {
1377
1937
  env: createEnv({
1378
1938
  base: {
1379
1939
  ...context.config.env,
1380
- ...command.env
1940
+ ...command.env,
1941
+ ...this.columnsEnv()
1381
1942
  },
1382
1943
  cwd: command.cwd,
1383
1944
  packageInfo: context.packageInfo,
@@ -1465,7 +2026,7 @@ var Run = class Run {
1465
2026
  constructor({ commands, config, packageInfo }) {
1466
2027
  this.config = config;
1467
2028
  this.files = new FileRegistry(config.cwd);
1468
- this.logger = new Logger(config.logLevel, config.destinations.stderr, config.color);
2029
+ this.logger = new Logger(config.logLevel, config.destinations.stderr, config.color !== "none");
1469
2030
  for (const warning of config.warnings) this.logger.warn(`runset: ${warning}`);
1470
2031
  const context = {
1471
2032
  config,
@@ -1517,14 +2078,41 @@ var Run = class Run {
1517
2078
  const exitCode = failed[0]?.getReportedExitCode();
1518
2079
  const { stopping } = this;
1519
2080
  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));
2081
+ if (stopping?.signal) throw new RunsetError(`the run was stopped by ${stopping.signal}`, signalExitCode(stopping.signal), "");
1521
2082
  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);
2083
+ if (exitCode !== void 0) throw new RunsetError(`${this.tally()}: ${failed.map((process) => describeFailure(process)).join(", ")}`, exitCode, this.failureReport());
1523
2084
  return this;
1524
2085
  }
1525
2086
  terminate(options = {}) {
1526
2087
  this.stopEverything({ reason: "terminate" }, options);
1527
2088
  }
2089
+ /** `1 of 5 commands failed, 2 stopped, 1 not started`. */
2090
+ tally() {
2091
+ const { processes } = this;
2092
+ const total = processes.length;
2093
+ const stopped = processes.filter((process) => process.started && process.terminated).length;
2094
+ const notStarted = processes.filter((process) => !process.started).length;
2095
+ return [
2096
+ `${this.failed().length} of ${total} command${total === 1 ? "" : "s"} failed`,
2097
+ stopped > 0 && `${stopped} stopped`,
2098
+ notStarted > 0 && `${notStarted} not started`
2099
+ ].filter(Boolean).join(", ");
2100
+ }
2101
+ /** The tally, then a row per failure, labelled as its output was. */
2102
+ failureReport() {
2103
+ const color = this.config.color !== "none";
2104
+ const red = (text) => color ? paint(text, ["red"]) : text;
2105
+ const rows = this.failed().map((process) => {
2106
+ const { command } = process;
2107
+ const label = command.label.trim();
2108
+ const prefix = makePrefix({
2109
+ ...command,
2110
+ label
2111
+ }, color, void 0, "stderr");
2112
+ return ` ${red("✖")} ${prefix}${command.command} ${red(process.describeExit())}`;
2113
+ });
2114
+ return [this.tally(), ...rows].join("\n");
2115
+ }
1528
2116
  failed() {
1529
2117
  return this.processes.filter((process) => process.isFailed());
1530
2118
  }
@@ -1568,6 +2156,12 @@ var Run = class Run {
1568
2156
  this.stopping.killTimer = void 0;
1569
2157
  }
1570
2158
  };
2159
+ /** `typecheck (api) exited with code 2`; the label only where it adds something. */
2160
+ function describeFailure(process) {
2161
+ const { command } = process;
2162
+ const label = command.label.trim();
2163
+ return `${label === "" || label === command.command ? command.command : `${command.command} (${label})`} ${process.describeExit()}`;
2164
+ }
1571
2165
  //#endregion
1572
2166
  //#region src/cli.ts
1573
2167
  const HELP = `Usage: runset [options] <command...>
@@ -1578,6 +2172,7 @@ Run mode:
1578
2172
  -p, --parallel <commands> run the following commands together
1579
2173
  -s, --serial <commands> run the following commands one after another
1580
2174
  -j, --jobs <n> max commands running at once (default: unlimited)
2175
+ -r, --recursive run npm scripts in every workspace package having them
1581
2176
 
1582
2177
  On exit:
1583
2178
  --on-success <action> what a clean exit does (default: continue)
@@ -1593,11 +2188,15 @@ Output:
1593
2188
  destination: stdout | stderr | none | <file path>
1594
2189
  --labels <mode> label each output line with its command:
1595
2190
  none | auto | custom | all (default: auto)
2191
+ --show-command print each command as it starts
2192
+ --show-exit-code mark each exit and its duration: ✓ 2.1s, ✗ code 1 · 2.1s
2193
+ -w, --wrap wrap labelled lines to the terminal, a label on each
1596
2194
 
1597
2195
  General:
1598
2196
  -c, --config <path> load a config file, skipping the default lookup
1599
2197
  --cwd <dir> working directory for the commands
1600
- --color, --no-color force color on or off
2198
+ -e, --env <NAME=value> set an environment variable for every command
2199
+ --color <mode> auto | none | basic | soft | all (default: auto)
1601
2200
  --log-level <level> error | warn | info | debug (default: info)
1602
2201
  --dry-run print the resolved stages and run nothing
1603
2202
  -h, --help show this help
@@ -1608,12 +2207,14 @@ Command syntax:
1608
2207
  "npm start::on-failure=restart"
1609
2208
  "serve --port 80::label=api,color=cyan"
1610
2209
  "build:*" glob over package.json script names
2210
+ "build::recursive" build in every workspace package that has it
1611
2211
  "serve -- --port {1}" placeholders drawn from arguments after --
1612
2212
 
1613
2213
  Examples:
1614
2214
  runset clean lint "build:**"
1615
2215
  runset -p "watch:**"
1616
2216
  runset clean -p lint test -s deploy
2217
+ runset -e FORCE_COLOR=1 -e 'DEBUG=*' start
1617
2218
  `;
1618
2219
  /** Runs the CLI and returns its exit code. */
1619
2220
  async function main(rawArgv) {
@@ -1636,7 +2237,8 @@ async function main(rawArgv) {
1636
2237
  return 0;
1637
2238
  } catch (error) {
1638
2239
  if (!(error instanceof RunsetError)) throw error;
1639
- process.stderr.write(`runset: ${error.message}\n`);
2240
+ const report = error.report ?? error.message;
2241
+ if (report !== "") process.stderr.write(`runset: ${report}\n`);
1640
2242
  return error.exitCode;
1641
2243
  }
1642
2244
  }