runset 0.0.1 → 0.0.3

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
@@ -1,31 +1,107 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
+ import fs from "node:fs";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import path from "node:path";
5
6
  import { WriteStream } from "node:tty";
6
- import fs from "node:fs";
7
- import { inspect, styleText } from "node:util";
8
7
  import os from "node:os";
8
+ import { inspect, styleText } from "node:util";
9
9
  import cp from "node:child_process";
10
- //#region src/types.ts
10
+ //#region src/config/std.ts
11
+ const TIMINGS = /* @__PURE__ */ new Set(["realtime", "grouped"]);
12
+ /**
13
+ * Parses `<timing>+<destination>` (either order, either optional) into the
14
+ * axes it names. Unnamed axes are left out so settings can be layered.
15
+ */
16
+ function parsePartialStd(value) {
17
+ const result = {};
18
+ for (const part of value.split("+")) {
19
+ const token = part.trim();
20
+ if (token === "") continue;
21
+ if (TIMINGS.has(token)) result.timing = token;
22
+ else result.destination = token;
23
+ }
24
+ return result;
25
+ }
26
+ function toPartialStd(value) {
27
+ return typeof value === "string" ? parsePartialStd(value) : value;
28
+ }
29
+ /** Layers a stream setting, in either form, over `base` one axis at a time. */
30
+ function mergeStd(base, value) {
31
+ return value === void 0 ? base : {
32
+ ...base,
33
+ ...toPartialStd(value)
34
+ };
35
+ }
36
+ function defaultStd(destination) {
37
+ return {
38
+ destination,
39
+ timing: "realtime"
40
+ };
41
+ }
42
+ /**
43
+ * Layers bags' stream settings over `base`, later bags over earlier, one axis
44
+ * at a time. `output` names both streams; a stream's own setting in the same
45
+ * bag outranks it.
46
+ */
47
+ function layerStreams(base, bags) {
48
+ const result = { ...base };
49
+ for (const bag of bags) for (const key of ["stderr", "stdout"]) result[key] = mergeStd(mergeStd(result[key], bag.output), bag[key]);
50
+ return result;
51
+ }
52
+ //#endregion
53
+ //#region src/utils/object.ts
54
+ function isPlainObject(value) {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
57
+ //#endregion
58
+ //#region src/config/validate.ts
11
59
  const EXIT_ACTIONS = /* @__PURE__ */ new Set([
12
60
  "continue",
13
61
  "restart",
14
62
  "stop"
15
63
  ]);
16
- /** True for an object written without a `command` — a settings entry. */
17
- function isCommandSettings(definition) {
18
- return !("command" in definition);
64
+ function formatValue(value) {
65
+ return typeof value === "string" ? `"${value}"` : String(value);
19
66
  }
20
- function isStreamDestination(destination) {
21
- return destination === "stdout" || destination === "stderr";
67
+ function numberError(name, value, minimum) {
68
+ if (typeof value !== "number" || Number.isNaN(value) || value < minimum) return `${name} must be a number of ${minimum} or more, got ${formatValue(value)}.`;
69
+ }
70
+ function booleanError(name, value) {
71
+ return typeof value === "boolean" ? void 0 : `${name} must be true or false, got ${formatValue(value)}.`;
72
+ }
73
+ function stringError(name, value) {
74
+ return typeof value === "string" ? void 0 : `${name} must be a string, got ${formatValue(value)}.`;
75
+ }
76
+ function envError(name, value) {
77
+ if (!isPlainObject(value)) return `${name} must be an object.`;
78
+ const bad = Object.entries(value).find(([, entry]) => typeof entry !== "string");
79
+ return bad === void 0 ? void 0 : `${name}.${bad[0]} must be a string, got ${formatValue(bad[1])}.`;
80
+ }
81
+ function oneOfError(name, value, allowed) {
82
+ return allowed.has(value) ? void 0 : `${name} is one of ${[...allowed].join(", ")}, got ${formatValue(value)}.`;
83
+ }
84
+ function actionError(name, value) {
85
+ return oneOfError(name, value, EXIT_ACTIONS);
86
+ }
87
+ function stdError(name, value) {
88
+ if (typeof value !== "object" || value === null) return `${name} must be a stream setting.`;
89
+ if (!TIMINGS.has(value.timing)) return `Invalid timing ${formatValue(value.timing)} for ${name}; one of ${[...TIMINGS].join(", ")}.`;
90
+ if (typeof value.destination !== "string" || value.destination === "") return `${name} needs a destination to write to.`;
91
+ }
92
+ /** Throws `message` as an `ErrorClass`, if there is one. */
93
+ function check(message, ErrorClass) {
94
+ if (message !== void 0) throw new ErrorClass(message);
22
95
  }
23
96
  //#endregion
24
97
  //#region src/utils/errors.ts
25
98
  /** Any failure runset reports itself; `exitCode` is what the CLI exits with. */
26
99
  var RunsetError = class extends Error {
27
100
  exitCode;
28
- /** What the CLI prints in place of `message`; empty when it already said so. */
101
+ /**
102
+ * What the CLI prints in place of `message`; empty when it already said so.
103
+ * @internal
104
+ */
29
105
  report;
30
106
  constructor(message, exitCode = 1, report) {
31
107
  super(message);
@@ -41,107 +117,44 @@ var ConfigError = class extends RunsetError {};
41
117
  /** A command that cannot be resolved. */
42
118
  var NormalizeError = class extends RunsetError {};
43
119
  //#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;
120
+ //#region src/utils/glob.ts
121
+ function isGlob(pattern) {
122
+ return pattern.includes("*");
58
123
  }
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;
124
+ /** The `names` a glob matches, in declared order; none is not an error. */
125
+ function matchGlob(pattern, names) {
126
+ const regexp = toRegExp(pattern);
127
+ return names.filter((name) => regexp.test(name));
63
128
  }
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;
129
+ function escape(literal) {
130
+ return literal.replaceAll(/[$()+.?[\\\]^{|}]/g, String.raw`\$&`);
69
131
  }
70
132
  /**
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.
133
+ * `*` matches within one `:`-separated segment, `**` spans one or more.
134
+ * `build:**:*` means `build:**`.
77
135
  */
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;
136
+ function toRegExp(pattern) {
137
+ const segments = pattern.split(":");
138
+ const source = [];
139
+ for (const [index, segment] of segments.entries()) {
140
+ if (segment === "*" && segments[index - 1] === "**") continue;
141
+ if (segment === "**") source.push(String.raw`[^:]+(?::[^:]+)*`);
142
+ else source.push(segment.split("*").map((part) => escape(part)).join("[^:]*"));
120
143
  }
121
- if (last < text.length) tokens.push({
122
- escape: false,
123
- text: text.slice(last)
124
- });
125
- return tokens;
144
+ return new RegExp(`^${source.join(":")}$`);
126
145
  }
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");
146
+ //#endregion
147
+ //#region src/utils/string.ts
148
+ function toCamelCase(name) {
149
+ return name.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
130
150
  }
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];
151
+ function toKebabCase(name) {
152
+ return name.replaceAll(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
137
153
  }
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;
154
+ /** `850ms` under a second, `2.1s` from one up. */
155
+ function formatDuration(ms) {
156
+ const rounded = Math.round(ms);
157
+ return rounded < 1e3 ? `${rounded}ms` : `${(ms / 1e3).toFixed(1)}s`;
145
158
  }
146
159
  //#endregion
147
160
  //#region src/utils/fs.ts
@@ -150,13 +163,26 @@ const require = createRequire(import.meta.url);
150
163
  function importSync(modulePath) {
151
164
  return require(modulePath);
152
165
  }
166
+ /** `dir` and every directory above it, up to the root. */
167
+ function ancestors(dir) {
168
+ const dirs = [path.resolve(dir)];
169
+ for (;;) {
170
+ const current = dirs.at(-1);
171
+ const parent = path.dirname(current);
172
+ if (parent === current) return dirs;
173
+ dirs.push(parent);
174
+ }
175
+ }
176
+ /** A parsed JSON file, or `undefined` when there is none. */
177
+ function readJson(filePath) {
178
+ return fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, "utf8")) : void 0;
179
+ }
153
180
  /** The nearest `package.json` at or above `cwd`. */
154
181
  function readPackageJson(cwd) {
155
182
  for (const dir of ancestors(cwd)) {
156
183
  const filePath = path.join(dir, "package.json");
157
- if (!fs.existsSync(filePath)) continue;
158
- const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
159
- return {
184
+ const raw = readJson(filePath);
185
+ if (raw !== void 0) return {
160
186
  filePath,
161
187
  name: raw.name ?? "",
162
188
  scripts: raw.scripts ?? {},
@@ -171,1351 +197,1325 @@ function readPackageJson(cwd) {
171
197
  };
172
198
  }
173
199
  //#endregion
174
- //#region src/config/loadConfig.ts
200
+ //#region src/utils/workspace.ts
175
201
  /**
176
- * Loads `runset.config.*` — the `explicitPath`, or the first one found walking
177
- * up from `cwd`, where a `package.json` `runset` section counts as one too —
178
- * and unwraps a default export or a factory function.
202
+ * The packages of the workspace at or above `cwd`, from `pnpm-workspace.yaml`
203
+ * or `package.json` `workspaces`, sorted by path; the root is not one of them.
179
204
  */
180
- function loadConfigJs({ argv, cwd, env, explicitPath }) {
181
- const context = {
182
- argv,
183
- cwd,
184
- env
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);
190
- }
191
- function unwrapConfigJs(source, context) {
192
- const unwrapped = source.default ?? source;
193
- return typeof unwrapped === "function" ? unwrapped(context) : unwrapped;
194
- }
195
- /** `dir` and every directory above it, up to the root. */
196
- function ancestors(dir) {
197
- const dirs = [path.resolve(dir)];
198
- for (;;) {
199
- const current = dirs.at(-1);
200
- const parent = path.dirname(current);
201
- if (parent === current) return dirs;
202
- dirs.push(parent);
205
+ function readWorkspacePackages(cwd) {
206
+ for (const dir of ancestors(cwd)) {
207
+ const patterns = readWorkspacePatterns(dir);
208
+ if (patterns === void 0) continue;
209
+ const include = patterns.filter((pattern) => !pattern.startsWith("!"));
210
+ const exclude = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
211
+ return fs.globSync(include, {
212
+ cwd: dir,
213
+ exclude: [...exclude, "**/node_modules"]
214
+ }).map((match) => path.join(dir, match, "package.json")).filter((filePath) => fs.existsSync(filePath)).toSorted().map((filePath) => readPackageJson(path.dirname(filePath)));
203
215
  }
216
+ return [];
204
217
  }
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;
223
- }
218
+ function readWorkspacePatterns(dir) {
219
+ const yamlPath = path.join(dir, "pnpm-workspace.yaml");
220
+ if (fs.existsSync(yamlPath)) return parsePnpmPackages(fs.readFileSync(yamlPath, "utf8"));
221
+ const { workspaces } = readJson(path.join(dir, "package.json")) ?? {};
222
+ return Array.isArray(workspaces) ? workspaces : workspaces?.packages;
224
223
  }
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;
224
+ /** The `packages:` list of a `pnpm-workspace.yaml`, read without a YAML parser. */
225
+ function parsePnpmPackages(text) {
226
+ const patterns = [];
227
+ let inPackages = false;
228
+ for (const raw of text.split(/\r?\n/)) {
229
+ const line = raw.replace(/\s#.*$/, "").trimEnd();
230
+ if (/^\S/.test(line)) inPackages = /^packages\s*:/.test(line);
231
+ else if (inPackages && /^\s+-/.test(line)) patterns.push(line.replace(/^\s+-\s*/, "").replaceAll(/^['"]|['"]$/g, ""));
232
+ }
233
+ return patterns;
231
234
  }
232
235
  //#endregion
233
- //#region src/utils/string.ts
234
- function toCamelCase(name) {
235
- return name.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
236
+ //#region src/utils/os.ts
237
+ /** 128 + the signal's number, the way a shell reports it: 130, 143, … */
238
+ function signalExitCode(signal) {
239
+ return 128 + (os.constants.signals[signal] ?? 15);
236
240
  }
237
- function toKebabCase(name) {
238
- return name.replaceAll(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
241
+ /**
242
+ * Quotes a value so the platform's shell passes it on as literal text —
243
+ * double quotes are not enough, since `$(…)` still runs inside them.
244
+ */
245
+ function quoteForShell(value, platform = process.platform) {
246
+ return platform === "win32" ? quoteForCmd(value) : quoteForPosix(value);
239
247
  }
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`;
248
+ /** Single quotes are fully literal; each `'` inside closes, escapes, reopens. */
249
+ function quoteForPosix(value) {
250
+ return `'${value.replaceAll("'", String.raw`'\''`)}'`;
251
+ }
252
+ /**
253
+ * Quoted for the C runtime first, then every `cmd.exe` metacharacter
254
+ * `^`-escaped (see <https://qntm.org/cmd>). Newlines cannot survive `cmd.exe`,
255
+ * so they become spaces.
256
+ */
257
+ function quoteForCmd(value) {
258
+ return `"${value.replaceAll("\r\n", " ").replaceAll(/[\n\r]/g, " ").replaceAll(/(\\*)"/g, String.raw`$1$1\"`).replace(/(\\*)$/, "$1$1")}"`.replaceAll(/["%&()<>^|]/g, (character) => `^${character}`);
244
259
  }
245
260
  //#endregion
246
- //#region src/config/winArgv.ts
261
+ //#region src/plan/placeholders.ts
247
262
  /**
248
- * Windows only: rejoins `'single quoted'` tokens that `cmd.exe` leaves split,
249
- * joined by single spaces. The identity everywhere else.
263
+ * Fills `{1}`, `{@}`, `{*}` and `{name}` from the arguments after `--`.
264
+ *
265
+ * In a command line values are shell-quoted and unknown names left as written
266
+ * (`${HOME}`); in an option (`forOption`) values are raw and unknown names
267
+ * are empty, so `disabled={noTest}` is false when nothing was passed.
250
268
  */
251
- function regroupQuoted(argv, platform = process.platform) {
252
- if (platform !== "win32") return argv;
253
- const result = [];
254
- let open;
255
- for (const token of argv) {
256
- if (open === void 0) {
257
- if (token.startsWith("'") && token.endsWith("'") && token.length > 1) result.push(token.slice(1, -1));
258
- else if (token.startsWith("'")) open = [token.slice(1)];
259
- else result.push(token);
260
- continue;
269
+ function substitutePlaceholders(text, args, forOption = false) {
270
+ const PLACEHOLDER = /\{([^\s{}]*)\}/g;
271
+ const wrap = forOption ? (value) => value : quoteForShell;
272
+ return text.replaceAll(PLACEHOLDER, (match, key) => {
273
+ if (key === "@") return args.map((arg) => wrap(arg)).join(" ");
274
+ if (key === "*") return args.length === 0 ? "" : wrap(args.join(" "));
275
+ if (/^[1-9]\d*$/.test(key)) {
276
+ const value = args[Number(key) - 1];
277
+ return value === void 0 ? "" : wrap(value);
261
278
  }
262
- if (token.endsWith("'")) {
263
- open.push(token.slice(0, -1));
264
- result.push(open.join(" "));
265
- open = void 0;
266
- } else open.push(token);
279
+ const named = namedValue(key, args);
280
+ if (named !== void 0) return wrap(named);
281
+ return forOption ? "" : match;
282
+ });
283
+ }
284
+ /**
285
+ * The value of `--name=value` or `--name value` (camel or kebab); a bare
286
+ * `--name` is `'true'`.
287
+ */
288
+ function namedValue(key, args) {
289
+ for (const [index, token] of args.entries()) {
290
+ if (!token.startsWith("--") || token.length === 2) continue;
291
+ const equals = token.indexOf("=");
292
+ if (toCamelCase(equals === -1 ? token.slice(2) : token.slice(2, equals)) !== key) continue;
293
+ if (equals !== -1) return token.slice(equals + 1);
294
+ const next = args[index + 1];
295
+ return next !== void 0 && !next.startsWith("-") ? next : "true";
267
296
  }
268
- if (open !== void 0) throw new CliError(`Unterminated quote in "'${open.join(" ")}".`);
269
- return result;
270
297
  }
271
298
  //#endregion
272
- //#region src/config/parseCli.ts
273
- const FLAGS = [
274
- {
275
- name: "parallel",
276
- short: "p",
277
- takesValue: false
278
- },
279
- {
280
- name: "serial",
281
- short: "s",
282
- takesValue: false
283
- },
284
- {
285
- name: "recursive",
286
- short: "r",
287
- takesValue: false
288
- },
289
- {
290
- name: "jobs",
291
- short: "j",
292
- takesValue: true
293
- },
294
- {
295
- name: "killTimeout",
296
- takesValue: true
297
- },
298
- {
299
- name: "onSuccess",
300
- takesValue: true
301
- },
302
- {
303
- name: "onFailure",
304
- takesValue: true
305
- },
306
- {
307
- name: "config",
308
- short: "c",
309
- takesValue: true
310
- },
311
- {
312
- name: "cwd",
313
- takesValue: true
314
- },
315
- {
316
- name: "env",
317
- short: "e",
318
- takesValue: true
319
- },
320
- {
321
- name: "color",
322
- negated: "none",
323
- takesValue: true
324
- },
325
- {
326
- name: "logLevel",
327
- takesValue: true
328
- },
329
- {
330
- name: "dryRun",
331
- takesValue: false
332
- },
333
- {
334
- name: "stdout",
335
- takesValue: true
336
- },
337
- {
338
- name: "stderr",
339
- takesValue: true
340
- },
341
- {
342
- name: "output",
343
- short: "o",
344
- takesValue: true
345
- },
346
- {
347
- name: "labels",
348
- takesValue: true
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
- },
363
- {
364
- name: "help",
365
- short: "h",
366
- takesValue: false
367
- },
368
- {
369
- name: "version",
370
- short: "v",
371
- takesValue: false
372
- }
373
- ];
299
+ //#region src/plan/stages.ts
300
+ const EMPTY_SEGMENT = {
301
+ commands: [],
302
+ parallel: false
303
+ };
304
+ /** A `-p`/`-s`: what follows starts a new group past everything before it. */
305
+ const BOUNDARY = {
306
+ boundary: true,
307
+ commands: [],
308
+ parallel: false
309
+ };
374
310
  /**
375
- * Splits argv into options, commands, and the literal arguments after `--`.
376
- * Each `-p`/`-s` becomes a settings entry where it was typed, the same object
377
- * a config file would write.
311
+ * Lays segments out into stages. A parallel segment joins the open stage;
312
+ * anything else starts past every stage in use, and so does what follows it.
313
+ * A segment's commands keep their relative stages, shifted onto its own.
378
314
  */
379
- function parseCli(rawArgv) {
380
- const argv = regroupQuoted(rawArgv);
381
- const options = {};
315
+ function link(segments) {
382
316
  const commands = [];
383
- const positional = [];
384
- let help = false;
385
- let version = false;
386
- let afterDoubleDash = false;
387
- let cursor = 0;
388
- function setOption(spec, raw) {
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
- }
397
- case "help":
398
- help = true;
399
- break;
400
- case "jobs":
401
- case "killTimeout":
402
- options[spec.name] = Number(raw);
403
- break;
404
- case "parallel":
405
- case "serial":
406
- commands.push({ parallel: spec.name === "parallel" });
407
- break;
408
- case "version":
409
- version = true;
410
- break;
411
- default: if (typeof raw === "boolean") options[spec.name] = raw;
412
- else options[spec.name] = raw;
413
- }
414
- }
415
- function takeValue(label) {
416
- const value = argv[cursor + 1];
417
- if (value === void 0) throw new CliError(`Option "${label}" needs a value.`);
418
- cursor += 1;
419
- return value;
420
- }
421
- function readLong(body) {
422
- const eq = body.indexOf("=");
423
- const name = eq === -1 ? body : body.slice(0, eq);
424
- const inlineValue = eq === -1 ? void 0 : body.slice(eq + 1);
425
- const flag = findLongFlag(name);
426
- if (flag === void 0 && /^no-?[A-Za-z]/.test(name)) {
427
- const negated = findLongFlag(name.replace(/^no-?/, ""));
428
- if (negated && (!negated.takesValue || negated.negated !== void 0)) {
429
- setOption(negated, negated.negated ?? false);
430
- return;
431
- }
317
+ /** The stage a parallel segment joins. */
318
+ let open = 0;
319
+ /** The last stage in use; -1 while none is. */
320
+ let used = -1;
321
+ for (const segment of segments) {
322
+ if (segment.boundary) {
323
+ open = used + 1;
324
+ continue;
432
325
  }
433
- if (flag === void 0) throw new CliError(`Unknown option "--${name}".`);
434
- if (flag.takesValue) setOption(flag, inlineValue ?? takeValue(`--${name}`));
435
- else setOption(flag, true);
436
- }
437
- function readShortCluster(body) {
438
- for (let position = 0; position < body.length; position += 1) {
439
- const letter = body[position];
440
- const flag = findShortFlag(letter);
441
- if (flag === void 0) throw new CliError(`Unknown option "-${letter}".`);
442
- if (!flag.takesValue) {
443
- setOption(flag, true);
444
- continue;
445
- }
446
- const rest = body.slice(position + 1);
447
- setOption(flag, rest === "" ? takeValue(`-${letter}`) : rest);
448
- return;
326
+ if (segment.commands.length === 0) continue;
327
+ const base = segment.parallel ? open : used + 1;
328
+ for (const command of segment.commands) {
329
+ command.stage += base;
330
+ used = Math.max(used, command.stage);
331
+ commands.push(command);
449
332
  }
333
+ open = segment.parallel ? open : used + 1;
450
334
  }
451
- for (; cursor < argv.length; cursor += 1) {
452
- const token = argv[cursor];
453
- if (afterDoubleDash) positional.push(token);
454
- else if (token === "--") afterDoubleDash = true;
455
- else if (token.startsWith("--") && token.length > 2) readLong(token.slice(2));
456
- else if (token.startsWith("-") && token.length > 1) readShortCluster(token.slice(1));
457
- else commands.push(token);
458
- }
459
- return {
460
- argv,
461
- commands,
462
- help,
463
- options,
464
- positional,
465
- version
466
- };
467
- }
468
- function findLongFlag(name) {
469
- const camel = toCamelCase(name);
470
- return FLAGS.find((flag) => flag.name === camel);
335
+ return commands;
471
336
  }
472
- function findShortFlag(letter) {
473
- return FLAGS.find((flag) => flag.short === letter);
337
+ /** Splits a stage-sorted list into its stages, in order. */
338
+ function groupByStage(items, stageOf) {
339
+ return [...Map.groupBy(items, stageOf).values()];
474
340
  }
475
341
  //#endregion
476
- //#region src/config/std.ts
477
- const TIMINGS = /* @__PURE__ */ new Set(["realtime", "grouped"]);
342
+ //#region src/plan/token.ts
343
+ const BOOLEAN_OPTIONS = [
344
+ "disabled",
345
+ "parallel",
346
+ "recursive"
347
+ ];
348
+ const ACTION_OPTIONS = ["onFailure", "onSuccess"];
349
+ /** Keys a `::` suffix may set. */
350
+ const INLINE_OPTIONS = /* @__PURE__ */ new Set([
351
+ "bgColor",
352
+ "color",
353
+ "cwd",
354
+ "label",
355
+ "output",
356
+ "stderr",
357
+ "stdout",
358
+ ...BOOLEAN_OPTIONS,
359
+ ...ACTION_OPTIONS
360
+ ]);
478
361
  /**
479
- * Parses `<timing>+<destination>` (either order, either optional) into the
480
- * axes it names. Unnamed axes are left out so settings can be layered.
362
+ * Splits `"name args::options"` at the last `::`. The options run to the end
363
+ * of the string, so a trailing space stays part of the last value.
481
364
  */
482
- function parsePartialStd(value) {
483
- const result = {};
484
- for (const part of value.split("+")) {
485
- const token = part.trim();
486
- if (token === "") continue;
487
- if (TIMINGS.has(token)) result.timing = token;
488
- else result.destination = token;
489
- }
490
- return result;
491
- }
492
- function toPartialStd(value) {
493
- return typeof value === "string" ? parsePartialStd(value) : value;
494
- }
495
- /** Layers a stream setting, in either form, over `base` one axis at a time. */
496
- function mergeStd(base, value) {
497
- return value === void 0 ? base : {
498
- ...base,
499
- ...toPartialStd(value)
500
- };
501
- }
502
- function defaultStd(destination) {
365
+ function splitToken(raw) {
366
+ const trimmed = raw.trimStart();
367
+ const marker = trimmed.lastIndexOf("::");
368
+ const command = (marker === -1 ? trimmed : trimmed.slice(0, marker)).trim();
369
+ const space = command.search(/\s/);
503
370
  return {
504
- destination,
505
- timing: "realtime"
371
+ args: space === -1 ? "" : command.slice(space + 1).trim(),
372
+ name: space === -1 ? command : command.slice(0, space),
373
+ options: marker === -1 ? "" : trimmed.slice(marker + 2)
506
374
  };
507
375
  }
508
- //#endregion
509
- //#region src/config/validate.ts
510
- function formatValue(value) {
511
- return typeof value === "string" ? `"${value}"` : String(value);
512
- }
513
- function numberError(name, value, minimum) {
514
- if (typeof value !== "number" || Number.isNaN(value) || value < minimum) return `${name} must be a number of ${minimum} or more, got ${formatValue(value)}.`;
515
- }
516
- function booleanError(name, value) {
517
- return typeof value === "boolean" ? void 0 : `${name} must be true or false, got ${formatValue(value)}.`;
518
- }
519
- function stringError(name, value) {
520
- return typeof value === "string" ? void 0 : `${name} must be a string, got ${formatValue(value)}.`;
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
- }
527
- function oneOfError(name, value, allowed) {
528
- return allowed.has(value) ? void 0 : `${name} is one of ${[...allowed].join(", ")}, got ${formatValue(value)}.`;
529
- }
530
- function actionError(name, value) {
531
- return oneOfError(name, value, EXIT_ACTIONS);
532
- }
533
- function stdError(name, value) {
534
- if (typeof value !== "object" || value === null) return `${name} must be a stream setting.`;
535
- if (!TIMINGS.has(value.timing)) return `Invalid timing ${formatValue(value.timing)} for ${name}; one of ${[...TIMINGS].join(", ")}.`;
536
- if (typeof value.destination !== "string" || value.destination === "") return `${name} needs a destination to write to.`;
537
- }
538
- /** Throws `message` as an `ErrorClass`, if there is one. */
539
- function check(message, ErrorClass) {
540
- if (message !== void 0) throw new ErrorClass(message);
376
+ /**
377
+ * Parses `key=value,flag`. Split before placeholders are filled, so a `,` or
378
+ * `=` arriving in a value cannot inject another option.
379
+ */
380
+ function parseInlineOptions(text, args) {
381
+ const options = {};
382
+ for (const entry of text.split(",")) {
383
+ if (entry.trim() === "") continue;
384
+ const eq = entry.indexOf("=");
385
+ const key = toCamelCase((eq === -1 ? entry : entry.slice(0, eq)).trim());
386
+ const written = eq === -1 ? "true" : entry.slice(eq + 1);
387
+ const value = substitutePlaceholders(key === "label" ? written : written.trim(), args, true);
388
+ if (!INLINE_OPTIONS.has(key)) throw new NormalizeError(`Unknown command option "${toKebabCase(key)}" in "::${text.trim()}".`);
389
+ if (key === "output" || key === "stderr" || key === "stdout") options[key] = parsePartialStd(value);
390
+ else if (BOOLEAN_OPTIONS.includes(key)) options[key] = value !== "false" && value !== "";
391
+ else if (ACTION_OPTIONS.includes(key)) {
392
+ check(actionError(`"${toKebabCase(key)}"`, value), NormalizeError);
393
+ options[key] = value;
394
+ } else options[key] = value;
395
+ }
396
+ return options;
541
397
  }
542
398
  //#endregion
543
- //#region src/config/Config.ts
544
- const DEFAULT_KILL_TIMEOUT = 5e3;
545
- const LOG_LEVELS = /* @__PURE__ */ new Set([
546
- "error",
547
- "warn",
548
- "info",
549
- "debug"
550
- ]);
551
- const LABEL_MODES = /* @__PURE__ */ new Set([
552
- "none",
553
- "auto",
554
- "custom",
555
- "all"
556
- ]);
557
- const COLOR_MODES = /* @__PURE__ */ new Set([
558
- "auto",
559
- "none",
560
- "basic",
561
- "soft",
562
- "all"
563
- ]);
564
- const KNOWN_KEYS = /* @__PURE__ */ new Set([
565
- "color",
566
- "commandDictionary",
567
- "commands",
568
- "cwd",
569
- "dryRun",
399
+ //#region src/plan/normalize.ts
400
+ /** Keys a settings entry may set. */
401
+ const SETTINGS_OPTIONS = /* @__PURE__ */ new Set([
402
+ ...INLINE_OPTIONS,
570
403
  "env",
571
- "formatLabel",
572
- "jobs",
573
- "killTimeout",
574
- "labels",
575
- "logLevel",
576
- "onFailure",
577
- "onSuccess",
578
- "output",
579
- "parallel",
580
- "recursive",
581
- "showCommand",
582
- "showExitCode",
583
- "stderr",
584
- "stdout",
585
- "wrap"
404
+ "serial"
586
405
  ]);
587
- /** Parses, loads and validates; anything left out comes from the process. */
588
- function createConfig({ cli = parseCli(process.argv.slice(2)), configJs, cwd = process.cwd(), destinations = process, env = process.env } = {}) {
589
- const config = new Config({
590
- cli,
591
- configJs,
592
- cwd,
593
- destinations,
594
- env
595
- });
596
- config.validate();
597
- return config;
598
- }
599
- /** The resolved run-wide configuration: CLI flag → config file → default. */
600
- var Config = class {
601
- commands;
602
- commandDictionary;
603
- /** Everything after `--`, for placeholders. */
604
- args;
605
- cwd;
606
- env;
607
- jobs;
608
- killTimeout;
609
- onSuccess;
610
- onFailure;
611
- color;
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;
619
- labels;
620
- logLevel;
621
- dryRun;
622
- formatLabel;
623
- stdout;
624
- stderr;
625
- destinations;
626
- /** Printed by the run once it is built. */
627
- warnings = [];
628
- constructor({ cli, configJs, cwd, destinations, env }) {
629
- const { options } = cli;
630
- this.cwd = path.resolve(cwd, options.cwd ?? ".");
631
- this.destinations = destinations;
632
- const context = {
633
- argv: cli.argv,
634
- cwd: this.cwd,
635
- env
406
+ /**
407
+ * Turns `config.commands` into the run's commands: flat, validated, numbered
408
+ * by stage and sorted by it.
409
+ */
410
+ function normalize(config, packageInfo) {
411
+ let workspacePackages;
412
+ const defaults = {
413
+ bgColor: "",
414
+ color: "",
415
+ command: "",
416
+ cwd: config.cwd,
417
+ env: {},
418
+ label: "",
419
+ line: "",
420
+ onFailure: config.onFailure,
421
+ onSuccess: config.onSuccess,
422
+ stage: 0,
423
+ stderr: config.stderr,
424
+ stdout: config.stdout,
425
+ type: "shell"
426
+ };
427
+ function makeLeaf(token, rawOptions, script, pkg = packageInfo) {
428
+ const { disabled, parallel, recursive, ...own } = rawOptions;
429
+ const options = {
430
+ ...own,
431
+ stderr: mergeStd(defaults.stderr, own.stderr),
432
+ stdout: mergeStd(defaults.stdout, own.stdout)
636
433
  };
637
- const file = configJs === void 0 ? loadConfigJs({
638
- ...context,
639
- explicitPath: options.config
640
- }) : unwrapConfigJs(configJs, context);
641
- for (const key of Object.keys(file)) if (!KNOWN_KEYS.has(key)) this.warnings.push(`Unknown config key "${key}" was ignored.`);
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
434
+ const args = substitutePlaceholders(token.args, config.args);
435
+ const command = [token.name, args].filter((part) => part !== "").join(" ");
436
+ const line = script === void 0 ? command : [pkg.scripts[script], args].filter((part) => part !== "").join(" ");
437
+ const leaf = {
438
+ ...defaults,
439
+ ...options,
440
+ command,
441
+ line,
442
+ stage: 0,
443
+ type: script === void 0 ? "shell" : "npm",
444
+ ...script === void 0 ? {} : { scriptName: script },
445
+ ...pkg === packageInfo ? {} : { packageName: packageNameOf(pkg) }
648
446
  };
649
- this.commands = [...file.commands ?? [], ...cli.commands].filter(Boolean);
650
- this.commandDictionary = file.commandDictionary ?? {};
651
- this.args = cli.positional;
652
- this.jobs = options.jobs ?? file.jobs ?? Number.POSITIVE_INFINITY;
653
- this.killTimeout = options.killTimeout ?? file.killTimeout ?? DEFAULT_KILL_TIMEOUT;
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);
660
- this.labels = options.labels ?? file.labels ?? "auto";
661
- this.onSuccess = options.onSuccess ?? file.onSuccess ?? "continue";
662
- this.onFailure = options.onFailure ?? file.onFailure ?? "stop";
663
- this.dryRun = options.dryRun ?? file.dryRun ?? false;
664
- this.logLevel = options.logLevel ?? file.logLevel ?? "info";
665
- this.color = resolveColor(options.color ?? file.color, env, destinations);
666
- this.formatLabel = file.formatLabel;
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;
447
+ if (script !== void 0 && options.cwd === void 0) leaf.cwd = pkg.filePath === "" ? config.cwd : path.dirname(pkg.filePath);
448
+ validateCommand(leaf, {
449
+ disabled,
450
+ parallel,
451
+ recursive
452
+ });
453
+ return leaf;
454
+ }
455
+ /** Links one token's commands (e.g. glob matches) by their `parallel`. */
456
+ function toSegment(leaves, settled) {
457
+ const parallel = settled.parallel ?? config.parallel;
458
+ return {
459
+ commands: settled.disabled ? [] : link(leaves.map((leaf) => ({
460
+ commands: [leaf],
461
+ parallel
462
+ }))),
463
+ parallel
676
464
  };
677
- this.stdout = std("stdout");
678
- this.stderr = std("stderr");
679
465
  }
680
- /** Throws on the first invalid value; types too, as config files are arbitrary JS. */
681
- validate() {
682
- check([
683
- oneOfError("logLevel", this.logLevel, LOG_LEVELS),
684
- numberError("jobs", this.jobs, 1),
685
- numberError("killTimeout", this.killTimeout, 0),
686
- oneOfError("color", this.color, COLOR_MODES),
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),
692
- booleanError("dryRun", this.dryRun),
693
- oneOfError("labels", this.labels, LABEL_MODES),
694
- stdError("stdout", this.stdout),
695
- stdError("stderr", this.stderr),
696
- this.formatLabel !== void 0 && typeof this.formatLabel !== "function" ? "formatLabel must be a function." : void 0,
697
- typeof this.commandDictionary !== "object" || this.commandDictionary === null || Array.isArray(this.commandDictionary) ? "commandDictionary must be an object." : void 0,
698
- actionError("onSuccess", this.onSuccess),
699
- actionError("onFailure", this.onFailure)
700
- ].find((error) => error !== void 0), ConfigError);
466
+ function resolveToken(raw, inherited, overrides, seen) {
467
+ const token = splitToken(raw);
468
+ const inline = resolveCwd(parseInlineOptions(token.options, config.args), inherited.cwd ?? config.cwd);
469
+ if (Object.hasOwn(config.scripts, token.name)) {
470
+ if (seen.has(token.name)) throw new NormalizeError(`Command "${token.name}" refers to itself.`);
471
+ const resolved = resolveEntry(config.scripts[token.name], inherited, layer(inline, overrides), /* @__PURE__ */ new Set([...seen, token.name]));
472
+ return token.args === "" ? resolved : appendArgs(resolved, token);
473
+ }
474
+ const settled = layer(inherited, inline, overrides);
475
+ if (settled.recursive ?? config.recursive) {
476
+ workspacePackages ??= readWorkspacePackages(config.cwd);
477
+ const leaves = workspacePackages.flatMap((pkg) => matchScripts(token.name, pkg).map((name) => makeLeaf({
478
+ ...token,
479
+ name
480
+ }, settled, name, pkg)));
481
+ if (leaves.length > 0) return toSegment(leaves, settled);
482
+ }
483
+ const names = matchScripts(token.name, packageInfo);
484
+ return toSegment(names.length > 0 || isGlob(token.name) ? names.map((name) => makeLeaf({
485
+ ...token,
486
+ name
487
+ }, settled, name)) : [makeLeaf(token, settled, void 0)], settled);
701
488
  }
702
- /** True when `commands` holds something to run, not just settings entries. */
703
- hasCommands() {
704
- return this.commands.some((command) => typeof command === "object" && command !== null ? !isCommandSettings(command) : Boolean(command));
489
+ function appendArgs(segment, token) {
490
+ const [leaf] = segment.commands;
491
+ if (segment.commands.length !== 1 || leaf === void 0) throw new NormalizeError(`Cannot pass arguments to "${token.name}": it expands to more than one command.`);
492
+ const args = substitutePlaceholders(token.args, config.args);
493
+ const extended = {
494
+ ...leaf,
495
+ command: `${leaf.command} ${args}`,
496
+ line: `${leaf.line} ${args}`
497
+ };
498
+ return {
499
+ ...segment,
500
+ commands: [extended]
501
+ };
705
502
  }
706
- };
503
+ /** One list entry other than a settings entry. */
504
+ function resolve(definition, inherited, overrides, seen) {
505
+ if (!definition) return EMPTY_SEGMENT;
506
+ if (typeof definition === "string") return resolveToken(definition, inherited, overrides, seen);
507
+ if (Array.isArray(definition)) throw new NormalizeError("A list of commands belongs in \"commands\" or in a \"scripts\" entry, not inside one.");
508
+ if (isSettingsEntry(definition)) throw new NormalizeError("A command definition needs a \"command\" field.");
509
+ const entry = definition;
510
+ if (typeof entry.command !== "string") throw new NormalizeError("A command's \"command\" must be a string.");
511
+ const { command, ...raw } = entry;
512
+ return resolveToken(command, layer(inherited, resolveCwd(raw, inherited.cwd ?? config.cwd)), overrides, seen);
513
+ }
514
+ /**
515
+ * `commands`, or a `scripts` entry that is a list. The list's own
516
+ * `parallel` places the list as a whole and is not passed to its members.
517
+ */
518
+ function resolveList(definitions, inherited, overrides, seen) {
519
+ const { parallel: inheritedParallel, ...passedDown } = inherited;
520
+ const { parallel: overriddenParallel, ...passedOver } = overrides;
521
+ const parallel = overriddenParallel ?? inheritedParallel ?? config.parallel;
522
+ check(booleanError("\"parallel\" on a list of commands", parallel), NormalizeError);
523
+ const segments = [];
524
+ let settled = passedDown;
525
+ for (const definition of definitions) {
526
+ if (isSettingsEntry(definition)) {
527
+ const written = readSettings(definition, settled.cwd ?? config.cwd);
528
+ settled = layer(settled, written);
529
+ if (written.parallel !== void 0) segments.push(BOUNDARY);
530
+ continue;
531
+ }
532
+ segments.push(resolve(definition, settled, passedOver, seen));
533
+ }
534
+ return {
535
+ commands: link(segments),
536
+ parallel
537
+ };
538
+ }
539
+ function resolveEntry(entry, inherited, overrides, seen) {
540
+ return Array.isArray(entry) ? resolveList(entry, inherited, overrides, seen) : resolve(entry, inherited, overrides, seen);
541
+ }
542
+ const { commands } = resolveList(config.commands, {}, {}, /* @__PURE__ */ new Set());
543
+ commands.sort((first, second) => first.stage - second.stage);
544
+ return commands;
545
+ }
546
+ /** True for an object written without a `command` — a settings entry. */
547
+ function isSettingsEntry(definition) {
548
+ return typeof definition === "object" && definition !== null && !Array.isArray(definition) && !("command" in definition);
549
+ }
550
+ /** The scripts of `pkg` a token name matches, glob or not. */
551
+ function matchScripts(name, pkg) {
552
+ if (isGlob(name)) return matchGlob(name, Object.keys(pkg.scripts));
553
+ return Object.hasOwn(pkg.scripts, name) ? [name] : [];
554
+ }
555
+ function packageNameOf(pkg) {
556
+ return pkg.name || path.basename(path.dirname(pkg.filePath));
557
+ }
558
+ /** Resolves a relative `cwd` against the inherited one, so directories nest. */
559
+ function resolveCwd(options, base) {
560
+ return options.cwd === void 0 ? options : {
561
+ ...options,
562
+ cwd: path.resolve(base, options.cwd)
563
+ };
564
+ }
707
565
  /**
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.
566
+ * Reads a settings entry into the options the rest of its list inherits.
567
+ * Keys are checked strictly: `{ paralel: true }` would otherwise do nothing.
711
568
  */
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";
569
+ function readSettings(entry, base) {
570
+ const keys = Object.keys(entry);
571
+ if (keys.length === 0) throw new NormalizeError("A settings entry must set at least one option.");
572
+ for (const key of keys) if (!SETTINGS_OPTIONS.has(key)) throw new NormalizeError(`Unknown option "${toKebabCase(key)}" in a settings entry.`);
573
+ const { serial, ...options } = entry;
574
+ if (serial !== void 0) {
575
+ check(booleanError("\"serial\"", serial), NormalizeError);
576
+ if (options.parallel !== void 0) throw new NormalizeError("A settings entry takes \"parallel\" or \"serial\", not both.");
577
+ options.parallel = !serial;
578
+ }
579
+ return resolveCwd(options, base);
716
580
  }
717
- /** `NO_COLOR`/`FORCE_COLOR`, then whether both streams are TTYs. */
718
- function detectColor(env, destinations) {
719
- if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
720
- if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
721
- return isTerminal(destinations.stdout) && isTerminal(destinations.stderr);
581
+ /** Layers option bags, later over earlier; stream settings merge per axis. */
582
+ function layer(...bags) {
583
+ const result = {};
584
+ for (const { output, stderr, stdout, ...rest } of bags) Object.assign(result, rest);
585
+ return {
586
+ ...result,
587
+ ...layerStreams({
588
+ stderr: {},
589
+ stdout: {}
590
+ }, bags)
591
+ };
592
+ }
593
+ /**
594
+ * Checks what a config file could have written by hand: the leaf, and the
595
+ * options that were read while laying it out.
596
+ */
597
+ function validateCommand(command, read) {
598
+ const where = (key) => `"${toKebabCase(key)}" on "${command.command}"`;
599
+ check([
600
+ ...ACTION_OPTIONS.map((key) => actionError(where(key), command[key])),
601
+ ...BOOLEAN_OPTIONS.map((key) => read[key] === void 0 ? void 0 : booleanError(where(key), read[key])),
602
+ ...[
603
+ "bgColor",
604
+ "color",
605
+ "cwd",
606
+ "label"
607
+ ].map((key) => stringError(where(key), command[key])),
608
+ ...["stderr", "stdout"].map((key) => stdError(where(key), command[key])),
609
+ envError(where("env"), command.env)
610
+ ].find((error) => error !== void 0), NormalizeError);
722
611
  }
723
612
  //#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"
613
+ //#region src/utils/terminal.ts
614
+ /**
615
+ * Measuring and wrapping text the way a terminal lays it out: escape sequences
616
+ * take no room, wide characters (CJK, emoji) take two columns.
617
+ */
618
+ const ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)?|[@-Z\\-_])/g;
619
+ const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
620
+ /** Printable ASCII only: one column per character, nothing to segment. */
621
+ const PLAIN = /^[ -~]*$/;
622
+ function isTerminal(stream) {
623
+ return stream.isTTY === true;
624
+ }
625
+ /** The width a terminal stream reports; `undefined` for anything else. */
626
+ function terminalColumns(stream) {
627
+ const { columns } = stream;
628
+ return isTerminal(stream) && columns > 0 ? columns : void 0;
629
+ }
630
+ /** `COLUMNS` as a positive integer, or `undefined`. */
631
+ function parseColumns(value) {
632
+ const columns = Number(value);
633
+ return Number.isInteger(columns) && columns > 0 ? columns : void 0;
634
+ }
635
+ /** The columns `text` takes on screen. */
636
+ function visibleWidth(text) {
637
+ if (PLAIN.test(text)) return text.length;
638
+ let width = 0;
639
+ for (const { segment } of segmenter.segment(text.replace(ESCAPE, ""))) width += columnWidth(segment, width);
640
+ return width;
641
+ }
642
+ /**
643
+ * Hard-wraps one line to `width` columns, the way the terminal would have, so
644
+ * each piece can carry a label of its own. A style still open at a break is
645
+ * reset at the end of the piece and re-applied at the start of the next, so a
646
+ * label never inherits it. `offset` is the column the line starts at, for tab
647
+ * stops. A line that moves the cursor — `\r`, or any escape but a color — is
648
+ * left whole: where it lands is not something runset can know.
649
+ */
650
+ function wrapLine(line, width, offset = 0) {
651
+ const fits = line.length * 2 <= width && !line.includes(" ");
652
+ if (width < 1 || fits || line.includes("\r")) return [line];
653
+ if (PLAIN.test(line)) {
654
+ const pieces = [];
655
+ for (let start = 0; start < line.length; start += width) pieces.push(line.slice(start, start + width));
656
+ return pieces;
657
+ }
658
+ const pieces = [];
659
+ let current = "";
660
+ let column = 0;
661
+ let style = [];
662
+ for (const token of tokenize(line)) {
663
+ if (token.escape) {
664
+ if (token.text.startsWith("\x1B[") && !isSgr(token.text)) return [line];
665
+ current += token.text;
666
+ style = applySgr(style, token.text);
667
+ continue;
668
+ }
669
+ for (const { segment } of segmenter.segment(token.text)) {
670
+ let size = columnWidth(segment, offset + column);
671
+ if (column > 0 && column + size > width) {
672
+ pieces.push(style.length > 0 ? `${current}\u001B[0m` : current);
673
+ current = style.join("");
674
+ column = 0;
675
+ size = columnWidth(segment, offset);
676
+ }
677
+ current += segment;
678
+ column += size;
679
+ }
680
+ }
681
+ pieces.push(current);
682
+ return pieces;
683
+ }
684
+ function tokenize(text) {
685
+ const tokens = [];
686
+ let last = 0;
687
+ for (const match of text.matchAll(ESCAPE)) {
688
+ if (match.index > last) tokens.push({
689
+ escape: false,
690
+ text: text.slice(last, match.index)
691
+ });
692
+ tokens.push({
693
+ escape: true,
694
+ text: match[0]
695
+ });
696
+ last = match.index + match[0].length;
697
+ }
698
+ if (last < text.length) tokens.push({
699
+ escape: false,
700
+ text: text.slice(last)
701
+ });
702
+ return tokens;
703
+ }
704
+ /** A color or style (`ESC [ … m`); any other CSI moves the cursor. */
705
+ function isSgr(sequence) {
706
+ return sequence.startsWith("\x1B[") && sequence.endsWith("m");
707
+ }
708
+ /** The SGR sequences in force after `sequence`; a reset clears them. */
709
+ function applySgr(style, sequence) {
710
+ if (!isSgr(sequence)) return style;
711
+ const params = sequence.slice(2, -1);
712
+ if (params === "" || params === "0") return [];
713
+ return params.startsWith("0;") ? [sequence] : [...style, sequence];
714
+ }
715
+ /** The columns one grapheme takes when it starts at `column`. */
716
+ function columnWidth(grapheme, column) {
717
+ if (grapheme === " ") return 8 - column % 8;
718
+ if (/^[ -~]$/.test(grapheme)) return 1;
719
+ if (/^[\p{Cc}\p{Cf}\p{Mn}\p{Me}]+$/u.test(grapheme)) return 0;
720
+ if (/\p{Extended_Pictographic}|\p{Regional_Indicator}/u.test(grapheme) && /\p{Emoji_Presentation}|\uFE0F/u.test(grapheme)) return 2;
721
+ 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;
722
+ }
723
+ //#endregion
724
+ //#region src/config/loadConfig.ts
725
+ /**
726
+ * Loads `runset.config.*` — the `explicitPath`, or the first one found walking
727
+ * up from `cwd`, where a `package.json` `runset` section counts as one too —
728
+ * and unwraps a default export or a factory function.
729
+ */
730
+ function loadConfigJs(context, explicitPath) {
731
+ if (explicitPath === void 0) return unwrapConfigJs(findConfigJs(context.cwd) ?? {}, context);
732
+ const filePath = path.resolve(context.cwd, explicitPath);
733
+ if (!fs.existsSync(filePath)) throw new ConfigError(`Config file not found: ${explicitPath}`);
734
+ return unwrapConfigJs(importSync(filePath), context);
735
+ }
736
+ function unwrapConfigJs(source, context) {
737
+ const unwrapped = source.default ?? source;
738
+ return typeof unwrapped === "function" ? unwrapped(context) : unwrapped;
739
+ }
740
+ /**
741
+ * The nearest config walking up from `cwd`. Within one directory a
742
+ * `runset.config.*` file wins over the `package.json` `runset` section.
743
+ */
744
+ function findConfigJs(cwd) {
745
+ for (const dir of ancestors(cwd)) {
746
+ for (const extension of [
747
+ "ts",
748
+ "js",
749
+ "mjs",
750
+ "cjs",
751
+ "json"
752
+ ]) {
753
+ const candidate = path.join(dir, `runset.config.${extension}`);
754
+ if (fs.existsSync(candidate)) return importSync(candidate);
755
+ }
756
+ const section = readPackageJsonSection(path.join(dir, "package.json"));
757
+ if (section !== void 0) return section;
758
+ }
759
+ }
760
+ function readPackageJsonSection(filePath) {
761
+ const runset = readJson(filePath)?.runset;
762
+ if (runset === void 0) return;
763
+ if (!isPlainObject(runset)) throw new ConfigError(`${filePath}: "runset" must be an object`);
764
+ return runset;
765
+ }
766
+ //#endregion
767
+ //#region src/config/winArgv.ts
768
+ /**
769
+ * Windows only: rejoins `'single quoted'` tokens that `cmd.exe` leaves split,
770
+ * joined by single spaces. The identity everywhere else.
771
+ */
772
+ function regroupQuoted(argv, platform = process.platform) {
773
+ if (platform !== "win32") return argv;
774
+ const result = [];
775
+ let open;
776
+ for (const token of argv) {
777
+ if (open === void 0) {
778
+ if (token.startsWith("'") && token.endsWith("'") && token.length > 1) result.push(token.slice(1, -1));
779
+ else if (token.startsWith("'")) open = [token.slice(1)];
780
+ else result.push(token);
781
+ continue;
782
+ }
783
+ if (token.endsWith("'")) {
784
+ open.push(token.slice(0, -1));
785
+ result.push(open.join(" "));
786
+ open = void 0;
787
+ } else open.push(token);
788
+ }
789
+ if (open !== void 0) throw new CliError(`Unterminated quote in "'${open.join(" ")}".`);
790
+ return result;
791
+ }
792
+ //#endregion
793
+ //#region src/config/parseCli.ts
794
+ const FLAGS = [
795
+ {
796
+ name: "parallel",
797
+ short: "p",
798
+ takesValue: false
762
799
  },
763
- peach: {
764
- index: 216,
765
- fallback: "yellowBright"
800
+ {
801
+ name: "serial",
802
+ short: "s",
803
+ takesValue: false
766
804
  },
767
- teal: {
768
- index: 79,
769
- fallback: "cyanBright"
805
+ {
806
+ name: "recursive",
807
+ short: "r",
808
+ takesValue: false
770
809
  },
771
- lilac: {
772
- index: 177,
773
- fallback: "magentaBright"
810
+ {
811
+ name: "jobs",
812
+ short: "j",
813
+ takesValue: true
774
814
  },
775
- lime: {
776
- index: 149,
777
- fallback: "greenBright"
815
+ {
816
+ name: "killTimeout",
817
+ takesValue: true
778
818
  },
779
- steel: {
780
- index: 110,
781
- fallback: "blueBright"
819
+ {
820
+ name: "onSuccess",
821
+ takesValue: true
782
822
  },
783
- pink: {
784
- index: 218,
785
- fallback: "magentaBright"
823
+ {
824
+ name: "onFailure",
825
+ takesValue: true
786
826
  },
787
- seafoam: {
788
- index: 121,
789
- fallback: "cyanBright"
827
+ {
828
+ name: "config",
829
+ short: "c",
830
+ takesValue: true
790
831
  },
791
- apricot: {
792
- index: 215,
793
- fallback: "yellowBright"
832
+ {
833
+ name: "cwd",
834
+ takesValue: true
794
835
  },
795
- mauve: {
796
- index: 139,
797
- fallback: "magentaBright"
836
+ {
837
+ name: "env",
838
+ short: "e",
839
+ takesValue: true
798
840
  },
799
- jade: {
800
- index: 114,
801
- fallback: "greenBright"
841
+ {
842
+ name: "color",
843
+ negated: "none",
844
+ takesValue: true
802
845
  },
803
- sand: {
804
- index: 180,
805
- fallback: "yellowBright"
846
+ {
847
+ name: "logLevel",
848
+ takesValue: true
806
849
  },
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"
850
+ {
851
+ name: "dryRun",
852
+ takesValue: false
906
853
  },
907
854
  {
908
- bgColor: "bgCyan",
909
- color: "black"
855
+ name: "stdout",
856
+ takesValue: true
910
857
  },
911
858
  {
912
- bgColor: "bgYellow",
913
- color: "black"
859
+ name: "stderr",
860
+ takesValue: true
914
861
  },
915
862
  {
916
- bgColor: "bgRed",
917
- color: "white"
863
+ name: "output",
864
+ short: "o",
865
+ takesValue: true
918
866
  },
919
867
  {
920
- bgColor: "bgGray",
921
- color: "white"
868
+ name: "labels",
869
+ takesValue: true
922
870
  },
923
871
  {
924
- bgColor: "bgGreenBright",
925
- color: "black"
872
+ name: "showCommand",
873
+ takesValue: false
926
874
  },
927
875
  {
928
- bgColor: "bgBlueBright",
929
- color: "black"
876
+ name: "showExitCode",
877
+ takesValue: false
930
878
  },
931
879
  {
932
- bgColor: "bgMagentaBright",
933
- color: "black"
880
+ name: "wrap",
881
+ short: "w",
882
+ takesValue: false
934
883
  },
935
884
  {
936
- bgColor: "bgCyanBright",
937
- color: "black"
885
+ name: "help",
886
+ short: "h",
887
+ takesValue: false
938
888
  },
939
889
  {
940
- bgColor: "bgYellowBright",
941
- color: "black"
890
+ name: "version",
891
+ short: "v",
892
+ takesValue: false
942
893
  }
943
894
  ];
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
992
- //#region src/plan/stages.ts
993
- const EMPTY_SEGMENT = {
994
- commands: [],
995
- parallel: false
996
- };
997
- /** A `-p`/`-s`: what follows starts a new group past everything before it. */
998
- const BOUNDARY = {
999
- boundary: true,
1000
- commands: [],
1001
- parallel: false
1002
- };
1003
895
  /**
1004
- * Lays segments out into stages. A parallel segment joins the open stage;
1005
- * anything else starts past every stage in use, and so does what follows it.
1006
- * A segment's commands keep their relative stages, shifted onto its own.
896
+ * Splits argv into options, commands, and the literal arguments after `--`.
897
+ * Each `-p`/`-s` becomes a settings entry where it was typed, the same object
898
+ * a config file would write.
1007
899
  */
1008
- function link(segments) {
900
+ function parseCli(rawArgv) {
901
+ const argv = regroupQuoted(rawArgv);
902
+ const options = {};
1009
903
  const commands = [];
1010
- /** The stage a parallel segment joins. */
1011
- let open = 0;
1012
- /** The last stage in use; -1 while none is. */
1013
- let used = -1;
1014
- for (const segment of segments) {
1015
- if (segment.boundary) {
1016
- open = used + 1;
1017
- continue;
1018
- }
1019
- if (segment.commands.length === 0) continue;
1020
- const base = segment.parallel ? open : used + 1;
1021
- for (const command of segment.commands) {
1022
- command.stage += base;
1023
- used = Math.max(used, command.stage);
1024
- commands.push(command);
904
+ let positional = [];
905
+ let help = false;
906
+ let version = false;
907
+ let cursor = 0;
908
+ function setOption(spec, raw) {
909
+ switch (spec.name) {
910
+ case "env": {
911
+ const text = String(raw);
912
+ const eq = text.indexOf("=");
913
+ if (eq < 1) throw new CliError(`Option "--env" takes NAME=value, not "${text}".`);
914
+ (options.env ??= {})[text.slice(0, eq)] = text.slice(eq + 1);
915
+ break;
916
+ }
917
+ case "help":
918
+ help = true;
919
+ break;
920
+ case "jobs":
921
+ case "killTimeout":
922
+ options[spec.name] = Number(raw);
923
+ break;
924
+ case "parallel":
925
+ case "serial":
926
+ commands.push({ parallel: spec.name === "parallel" });
927
+ break;
928
+ case "version":
929
+ version = true;
930
+ break;
931
+ default: options[spec.name] = raw;
1025
932
  }
1026
- open = segment.parallel ? open : used + 1;
1027
933
  }
1028
- return commands;
1029
- }
1030
- /** Splits a stage-sorted list into its stages. */
1031
- function groupByStage(items, stageOf) {
1032
- const stages = [];
1033
- let current;
1034
- let stage;
1035
- for (const item of items) {
1036
- const own = stageOf(item);
1037
- if (current === void 0 || own !== stage) {
1038
- current = [];
1039
- stage = own;
1040
- stages.push(current);
1041
- }
1042
- current.push(item);
934
+ function takeValue(label) {
935
+ const value = argv[cursor + 1];
936
+ if (value === void 0) throw new CliError(`Option "${label}" needs a value.`);
937
+ cursor += 1;
938
+ return value;
1043
939
  }
1044
- return stages;
1045
- }
1046
- //#endregion
1047
- //#region src/plan/labels.ts
1048
- /**
1049
- * Settles who is labelled, per `config.labels`. Under `auto`, a command
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.
1053
- */
1054
- function assignAutoLabels({ commands, config }) {
1055
- const mode = config.labels;
1056
- if (mode === "none") {
1057
- for (const command of commands) command.label = "";
1058
- return;
1059
- }
1060
- const named = [];
1061
- for (const stage of groupByStage(commands, (command) => command.stage)) {
1062
- const shared = stage.length > 1;
1063
- const labeled = stage.some((command) => command.label !== "");
1064
- for (const command of stage) {
1065
- if (command.label !== "") continue;
1066
- if (mode === "all" || mode === "auto" && shared) {
1067
- command.label = nameOf(command);
1068
- named.push(command);
1069
- } else if (shared && labeled) command.label = " ";
940
+ function readLong(body) {
941
+ const eq = body.indexOf("=");
942
+ const name = eq === -1 ? body : body.slice(0, eq);
943
+ const inlineValue = eq === -1 ? void 0 : body.slice(eq + 1);
944
+ const flag = findLongFlag(name);
945
+ if (flag === void 0 && /^no-?[A-Za-z]/.test(name)) {
946
+ const negated = findLongFlag(name.replace(/^no-?/, ""));
947
+ if (negated && (!negated.takesValue || negated.negated !== void 0)) {
948
+ setOption(negated, negated.negated ?? false);
949
+ return;
950
+ }
1070
951
  }
952
+ if (flag === void 0) throw new CliError(`Unknown option "--${name}".`);
953
+ if (flag.takesValue) setOption(flag, inlineValue ?? takeValue(`--${name}`));
954
+ else setOption(flag, true);
1071
955
  }
1072
- numberRepeats(named);
1073
- }
1074
- /**
1075
- * Gives each labelled command without colors of its own a palette pair: the
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.
1079
- */
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);
956
+ function readShortCluster(body) {
957
+ for (let position = 0; position < body.length; position += 1) {
958
+ const letter = body[position];
959
+ const flag = findShortFlag(letter);
960
+ if (flag === void 0) throw new CliError(`Unknown option "-${letter}".`);
961
+ if (!flag.takesValue) {
962
+ setOption(flag, true);
963
+ continue;
964
+ }
965
+ const rest = body.slice(position + 1);
966
+ setOption(flag, rest === "" ? takeValue(`-${letter}`) : rest);
967
+ return;
968
+ }
1085
969
  }
1086
- const labeled = commands.filter((command) => command.label !== "");
1087
- const taken = new Set(labeled.map((command) => palette.findIndex((pair) => pair.bgColor === command.bgColor)).filter((index) => index !== -1));
1088
- for (const command of labeled) {
1089
- if (command.color !== "" || command.bgColor !== "" || command.label.trim() === "") continue;
1090
- const index = claim(paletteIndex(command.label, palette), taken, palette.length);
1091
- taken.add(index);
1092
- const pair = palette[index];
1093
- command.bgColor = pair.bgColor;
1094
- command.color = pair.color;
970
+ for (; cursor < argv.length; cursor += 1) {
971
+ const token = argv[cursor];
972
+ if (token === "--") {
973
+ positional = argv.slice(cursor + 1);
974
+ break;
975
+ }
976
+ if (token.startsWith("--") && token.length > 2) readLong(token.slice(2));
977
+ else if (token.startsWith("-") && token.length > 1) readShortCluster(token.slice(1));
978
+ else commands.push(token);
1095
979
  }
980
+ return {
981
+ argv,
982
+ commands,
983
+ help,
984
+ options,
985
+ positional,
986
+ version
987
+ };
1096
988
  }
1097
- /** Pads every label to the widest one. */
1098
- function alignLabels({ commands }) {
1099
- const labeled = commands.filter((command) => command.label !== "");
1100
- const width = Math.max(0, ...labeled.map((command) => command.label.length));
1101
- for (const command of labeled) command.label = command.label.padEnd(width);
1102
- }
1103
- /** The script name, or the program a shell command runs. */
1104
- function nameOf(command) {
1105
- return command.packageName ?? command.scriptName ?? command.command.split(/\s/, 1)[0] ?? "";
1106
- }
1107
- /** `serve`, `serve` → `serve#1`, `serve#2`. */
1108
- function numberRepeats(named) {
1109
- const totals = /* @__PURE__ */ new Map();
1110
- for (const command of named) totals.set(command.label, (totals.get(command.label) ?? 0) + 1);
1111
- const counters = /* @__PURE__ */ new Map();
1112
- for (const command of named) {
1113
- if ((totals.get(command.label) ?? 0) < 2) continue;
1114
- const index = (counters.get(command.label) ?? 0) + 1;
1115
- counters.set(command.label, index);
1116
- command.label = `${command.label}#${index}`;
1117
- }
989
+ function findLongFlag(name) {
990
+ const camel = toCamelCase(name);
991
+ return FLAGS.find((flag) => flag.name === camel);
1118
992
  }
1119
- function claim(preferred, taken, size) {
1120
- if (taken.size >= size) return preferred;
1121
- let index = preferred;
1122
- while (taken.has(index)) index = (index + 1) % size;
1123
- return index;
993
+ function findShortFlag(letter) {
994
+ return FLAGS.find((flag) => flag.short === letter);
1124
995
  }
1125
996
  //#endregion
1126
- //#region src/utils/glob.ts
1127
- function isGlob(pattern) {
1128
- return pattern.includes("*");
1129
- }
1130
- /** The `names` a glob matches, in declared order; none is not an error. */
1131
- function matchGlob(pattern, names) {
1132
- const regexp = toRegExp(pattern);
1133
- return names.filter((name) => regexp.test(name));
1134
- }
1135
- function escape(literal) {
1136
- return literal.replaceAll(/[$()+.?[\\\]^{|}]/g, String.raw`\$&`);
997
+ //#region src/config/Config.ts
998
+ const DEFAULT_KILL_TIMEOUT = 5e3;
999
+ const LOG_LEVELS = /* @__PURE__ */ new Set([
1000
+ "error",
1001
+ "warn",
1002
+ "info",
1003
+ "debug"
1004
+ ]);
1005
+ const LABEL_MODES = /* @__PURE__ */ new Set([
1006
+ "none",
1007
+ "auto",
1008
+ "custom",
1009
+ "all"
1010
+ ]);
1011
+ const COLOR_MODES = /* @__PURE__ */ new Set([
1012
+ "auto",
1013
+ "none",
1014
+ "basic",
1015
+ "soft",
1016
+ "all"
1017
+ ]);
1018
+ /** Every key a config may set; a record, so a new one cannot be missed. */
1019
+ const KNOWN_KEYS = {
1020
+ color: true,
1021
+ commands: true,
1022
+ cwd: true,
1023
+ dryRun: true,
1024
+ env: true,
1025
+ formatLabel: true,
1026
+ jobs: true,
1027
+ killTimeout: true,
1028
+ labels: true,
1029
+ logLevel: true,
1030
+ onFailure: true,
1031
+ onSuccess: true,
1032
+ output: true,
1033
+ parallel: true,
1034
+ recursive: true,
1035
+ scripts: true,
1036
+ showCommand: true,
1037
+ showExitCode: true,
1038
+ stderr: true,
1039
+ stdout: true,
1040
+ wrap: true
1041
+ };
1042
+ /** Parses, loads and validates; anything left out comes from the process. */
1043
+ function createConfig({ cli = parseCli(process.argv.slice(2)), configJs, cwd = process.cwd(), destinations = process, env = process.env } = {}) {
1044
+ const config = new Config({
1045
+ cli,
1046
+ configJs,
1047
+ cwd,
1048
+ destinations,
1049
+ env
1050
+ });
1051
+ config.validate();
1052
+ return config;
1137
1053
  }
1054
+ /** The resolved run-wide configuration: CLI flag → config file → default. */
1055
+ var Config = class {
1056
+ commands;
1057
+ scripts;
1058
+ /** Everything after `--`, for placeholders. */
1059
+ args;
1060
+ cwd;
1061
+ env;
1062
+ jobs;
1063
+ killTimeout;
1064
+ onSuccess;
1065
+ onFailure;
1066
+ color;
1067
+ parallel;
1068
+ recursive;
1069
+ showCommand;
1070
+ showExitCode;
1071
+ wrap;
1072
+ /** `COLUMNS` as runset was started with it: the width where no TTY says. */
1073
+ envColumns;
1074
+ labels;
1075
+ logLevel;
1076
+ dryRun;
1077
+ formatLabel;
1078
+ stdout;
1079
+ stderr;
1080
+ destinations;
1081
+ /** Printed by the run once it is built. */
1082
+ warnings = [];
1083
+ constructor({ cli, configJs, cwd, destinations, env }) {
1084
+ const { options } = cli;
1085
+ this.cwd = path.resolve(cwd, options.cwd ?? ".");
1086
+ this.destinations = destinations;
1087
+ const context = {
1088
+ argv: cli.argv,
1089
+ cwd: this.cwd,
1090
+ env
1091
+ };
1092
+ const file = configJs === void 0 ? loadConfigJs(context, options.config) : unwrapConfigJs(configJs, context);
1093
+ for (const key of Object.keys(file)) if (!Object.hasOwn(KNOWN_KEYS, key)) this.warnings.push(`Unknown config key "${key}" was ignored.`);
1094
+ if (file.cwd !== void 0 && options.cwd === void 0) this.cwd = path.resolve(cwd, file.cwd);
1095
+ check(envError("env", file.env ?? {}), ConfigError);
1096
+ this.env = {
1097
+ ...env,
1098
+ ...file.env,
1099
+ ...options.env
1100
+ };
1101
+ this.commands = [...file.commands ?? [], ...cli.commands].filter(Boolean);
1102
+ this.scripts = file.scripts ?? {};
1103
+ this.args = cli.positional;
1104
+ this.jobs = options.jobs ?? file.jobs ?? Number.POSITIVE_INFINITY;
1105
+ this.killTimeout = options.killTimeout ?? file.killTimeout ?? DEFAULT_KILL_TIMEOUT;
1106
+ this.parallel = file.parallel ?? false;
1107
+ this.recursive = options.recursive ?? file.recursive ?? false;
1108
+ this.showCommand = options.showCommand ?? file.showCommand ?? false;
1109
+ this.showExitCode = options.showExitCode ?? file.showExitCode ?? false;
1110
+ this.wrap = options.wrap ?? file.wrap ?? false;
1111
+ this.envColumns = parseColumns(env.COLUMNS);
1112
+ this.labels = options.labels ?? file.labels ?? "auto";
1113
+ this.onSuccess = options.onSuccess ?? file.onSuccess ?? "continue";
1114
+ this.onFailure = options.onFailure ?? file.onFailure ?? "stop";
1115
+ this.dryRun = options.dryRun ?? file.dryRun ?? false;
1116
+ this.logLevel = options.logLevel ?? file.logLevel ?? "info";
1117
+ this.color = resolveColor(options.color ?? file.color, env, destinations);
1118
+ this.formatLabel = file.formatLabel;
1119
+ const streams = layerStreams({
1120
+ stderr: defaultStd("stderr"),
1121
+ stdout: defaultStd("stdout")
1122
+ }, [file, options]);
1123
+ this.stdout = streams.stdout;
1124
+ this.stderr = streams.stderr;
1125
+ }
1126
+ /** Throws on the first invalid value; types too, as config files are arbitrary JS. */
1127
+ validate() {
1128
+ check([
1129
+ oneOfError("logLevel", this.logLevel, LOG_LEVELS),
1130
+ numberError("jobs", this.jobs, 1),
1131
+ numberError("killTimeout", this.killTimeout, 0),
1132
+ oneOfError("color", this.color, COLOR_MODES),
1133
+ booleanError("parallel", this.parallel),
1134
+ booleanError("recursive", this.recursive),
1135
+ booleanError("showCommand", this.showCommand),
1136
+ booleanError("showExitCode", this.showExitCode),
1137
+ booleanError("wrap", this.wrap),
1138
+ booleanError("dryRun", this.dryRun),
1139
+ oneOfError("labels", this.labels, LABEL_MODES),
1140
+ stdError("stdout", this.stdout),
1141
+ stdError("stderr", this.stderr),
1142
+ this.formatLabel !== void 0 && typeof this.formatLabel !== "function" ? "formatLabel must be a function." : void 0,
1143
+ isPlainObject(this.scripts) ? void 0 : "scripts must be an object.",
1144
+ actionError("onSuccess", this.onSuccess),
1145
+ actionError("onFailure", this.onFailure)
1146
+ ].find((error) => error !== void 0), ConfigError);
1147
+ }
1148
+ /** True when `commands` holds something to run, not just settings entries. */
1149
+ hasCommands() {
1150
+ return this.commands.some((command) => Boolean(command) && !isSettingsEntry(command));
1151
+ }
1152
+ };
1138
1153
  /**
1139
- * `*` matches within one `:`-separated segment, `**` spans one or more.
1140
- * `build:**:*` means `build:**`.
1154
+ * A mode as given; under `auto`, whether to color at all, then `soft` where
1155
+ * the terminal shows 256 colors. `all` is only ever asked for. Anything else is
1156
+ * left for `validate` to refuse.
1141
1157
  */
1142
- function toRegExp(pattern) {
1143
- const segments = pattern.split(":");
1144
- const source = [];
1145
- for (const [index, segment] of segments.entries()) {
1146
- if (segment === "*" && segments[index - 1] === "**") continue;
1147
- if (segment === "**") source.push(String.raw`[^:]+(?::[^:]+)*`);
1148
- else source.push(segment.split("*").map((part) => escape(part)).join("[^:]*"));
1149
- }
1150
- return new RegExp(`^${source.join(":")}$`);
1151
- }
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
1191
- //#region src/utils/os.ts
1192
- /** 128 + the signal's number, the way a shell reports it: 130, 143, … */
1193
- function signalExitCode(signal) {
1194
- return 128 + (os.constants.signals[signal] ?? 15);
1195
- }
1196
- /**
1197
- * Quotes a value so the platform's shell passes it on as literal text —
1198
- * double quotes are not enough, since `$(…)` still runs inside them.
1199
- */
1200
- function quoteForShell(value, platform = process.platform) {
1201
- return platform === "win32" ? quoteForCmd(value) : quoteForPosix(value);
1202
- }
1203
- /** Single quotes are fully literal; each `'` inside closes, escapes, reopens. */
1204
- function quoteForPosix(value) {
1205
- return `'${value.replaceAll("'", String.raw`'\''`)}'`;
1206
- }
1207
- /**
1208
- * Quoted for the C runtime first, then every `cmd.exe` metacharacter
1209
- * `^`-escaped (see <https://qntm.org/cmd>). Newlines cannot survive `cmd.exe`,
1210
- * so they become spaces.
1211
- */
1212
- function quoteForCmd(value) {
1213
- return `"${value.replaceAll("\r\n", " ").replaceAll(/[\n\r]/g, " ").replaceAll(/(\\*)"/g, String.raw`$1$1\"`).replace(/(\\*)$/, "$1$1")}"`.replaceAll(/["%&()<>^|]/g, (character) => `^${character}`);
1214
- }
1215
- //#endregion
1216
- //#region src/plan/placeholders.ts
1217
- /**
1218
- * Fills `{1}`, `{@}`, `{*}` and `{name}` from the arguments after `--`.
1219
- *
1220
- * In a command line values are shell-quoted and unknown names left as written
1221
- * (`${HOME}`); in an option (`forOption`) values are raw and unknown names
1222
- * are empty, so `disabled={noTest}` is false when nothing was passed.
1223
- */
1224
- function substitutePlaceholders(text, args, forOption = false) {
1225
- const PLACEHOLDER = /\{([^\s{}]*)\}/g;
1226
- const wrap = forOption ? (value) => value : quoteForShell;
1227
- return text.replaceAll(PLACEHOLDER, (match, key) => {
1228
- if (key === "@") return args.map((arg) => wrap(arg)).join(" ");
1229
- if (key === "*") return args.length === 0 ? "" : wrap(args.join(" "));
1230
- if (/^[1-9]\d*$/.test(key)) {
1231
- const value = args[Number(key) - 1];
1232
- return value === void 0 ? "" : wrap(value);
1233
- }
1234
- const named = namedValue(key, args);
1235
- if (named !== void 0) return wrap(named);
1236
- return forOption ? "" : match;
1237
- });
1238
- }
1239
- /**
1240
- * The value of `--name=value` or `--name value` (camel or kebab); a bare
1241
- * `--name` is `'true'`.
1242
- */
1243
- function namedValue(key, args) {
1244
- for (const [index, token] of args.entries()) {
1245
- if (!token.startsWith("--") || token.length === 2) continue;
1246
- const equals = token.indexOf("=");
1247
- if (toCamelCase(equals === -1 ? token.slice(2) : token.slice(2, equals)) !== key) continue;
1248
- if (equals !== -1) return token.slice(equals + 1);
1249
- const next = args[index + 1];
1250
- return next !== void 0 && !next.startsWith("-") ? next : "true";
1251
- }
1252
- }
1253
- //#endregion
1254
- //#region src/plan/token.ts
1255
- const BOOLEAN_OPTIONS = /* @__PURE__ */ new Set([
1256
- "disabled",
1257
- "parallel",
1258
- "recursive"
1259
- ]);
1260
- const ACTION_OPTIONS = /* @__PURE__ */ new Set(["onFailure", "onSuccess"]);
1261
- /** Keys a `::` suffix may set. */
1262
- const INLINE_OPTIONS = /* @__PURE__ */ new Set([
1263
- "bgColor",
1264
- "color",
1265
- "cwd",
1266
- "label",
1267
- "output",
1268
- "stderr",
1269
- "stdout",
1270
- ...BOOLEAN_OPTIONS,
1271
- ...ACTION_OPTIONS
1272
- ]);
1273
- /**
1274
- * Splits `"name args::options"` at the last `::`. The options run to the end
1275
- * of the string, so a trailing space stays part of the last value.
1276
- */
1277
- function splitToken(raw) {
1278
- const trimmed = raw.trimStart();
1279
- const marker = trimmed.lastIndexOf("::");
1280
- const command = (marker === -1 ? trimmed : trimmed.slice(0, marker)).trim();
1281
- const space = command.search(/\s/);
1282
- return {
1283
- args: space === -1 ? "" : command.slice(space + 1).trim(),
1284
- name: space === -1 ? command : command.slice(0, space),
1285
- options: marker === -1 ? "" : trimmed.slice(marker + 2)
1286
- };
1158
+ function resolveColor(mode, env, destinations) {
1159
+ if (mode !== void 0 && mode !== "auto") return mode;
1160
+ if (!detectColor(env, destinations)) return "none";
1161
+ return WriteStream.prototype.getColorDepth(env) >= 8 ? "soft" : "basic";
1287
1162
  }
1288
- /**
1289
- * Parses `key=value,flag`. Split before placeholders are filled, so a `,` or
1290
- * `=` arriving in a value cannot inject another option.
1291
- */
1292
- function parseInlineOptions(text, args) {
1293
- const options = {};
1294
- for (const entry of text.split(",")) {
1295
- if (entry.trim() === "") continue;
1296
- const eq = entry.indexOf("=");
1297
- const key = toCamelCase((eq === -1 ? entry : entry.slice(0, eq)).trim());
1298
- const written = eq === -1 ? "true" : entry.slice(eq + 1);
1299
- const value = substitutePlaceholders(key === "label" ? written : written.trim(), args, true);
1300
- if (!INLINE_OPTIONS.has(key)) throw new NormalizeError(`Unknown command option "${toKebabCase(key)}" in "::${text.trim()}".`);
1301
- if (key === "output" || key === "stderr" || key === "stdout") options[key] = parsePartialStd(value);
1302
- else if (BOOLEAN_OPTIONS.has(key)) options[key] = value !== "false" && value !== "";
1303
- else if (ACTION_OPTIONS.has(key)) {
1304
- check(actionError(`"${toKebabCase(key)}"`, value), NormalizeError);
1305
- options[key] = value;
1306
- } else options[key] = value;
1307
- }
1308
- return options;
1163
+ /** `NO_COLOR`/`FORCE_COLOR`, then whether both streams are TTYs. */
1164
+ function detectColor(env, destinations) {
1165
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
1166
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
1167
+ return isTerminal(destinations.stdout) && isTerminal(destinations.stderr);
1309
1168
  }
1310
1169
  //#endregion
1311
- //#region src/plan/normalize.ts
1312
- /** Keys a settings entry may set. */
1313
- const SETTINGS_OPTIONS = /* @__PURE__ */ new Set([
1314
- ...INLINE_OPTIONS,
1315
- "env",
1316
- "serial"
1317
- ]);
1318
- /**
1319
- * Turns `config.commands` into the run's commands: flat, validated, numbered
1320
- * by stage and sorted by it.
1321
- */
1322
- function normalize(config, packageInfo) {
1323
- const scriptNames = Object.keys(packageInfo.scripts);
1324
- let workspacePackages;
1325
- const defaults = {
1326
- bgColor: "",
1327
- color: "",
1328
- command: "",
1329
- cwd: config.cwd,
1330
- disabled: false,
1331
- env: {},
1332
- label: "",
1333
- line: "",
1334
- onFailure: config.onFailure,
1335
- onSuccess: config.onSuccess,
1336
- parallel: config.parallel,
1337
- recursive: config.recursive,
1338
- stage: 0,
1339
- stderr: config.stderr,
1340
- stdout: config.stdout,
1341
- type: "shell"
1342
- };
1343
- function makeLeaf(token, rawOptions, script, pkg = packageInfo) {
1344
- const options = {
1345
- ...rawOptions,
1346
- stderr: mergeStd(defaults.stderr, rawOptions.stderr),
1347
- stdout: mergeStd(defaults.stdout, rawOptions.stdout)
1348
- };
1349
- const args = substitutePlaceholders(token.args, config.args);
1350
- const command = [token.name, args].filter((part) => part !== "").join(" ");
1351
- const line = script === void 0 ? command : [pkg.scripts[script], args].filter((part) => part !== "").join(" ");
1352
- const leaf = {
1353
- ...defaults,
1354
- ...options,
1355
- command,
1356
- line,
1357
- stage: 0,
1358
- type: script === void 0 ? "shell" : "npm",
1359
- ...script === void 0 ? {} : { scriptName: script },
1360
- ...pkg === packageInfo ? {} : { packageName: packageNameOf(pkg) }
1361
- };
1362
- if (script !== void 0 && options.cwd === void 0) leaf.cwd = pkg.filePath === "" ? config.cwd : path.dirname(pkg.filePath);
1363
- validateCommand(leaf);
1364
- return leaf;
1365
- }
1366
- /** Links one token's commands (e.g. glob matches) by their own `parallel`. */
1367
- function toSegment(leaves, settled) {
1368
- return {
1369
- commands: link(leaves.filter((leaf) => !leaf.disabled).map((leaf) => ({
1370
- commands: [leaf],
1371
- parallel: leaf.parallel
1372
- }))),
1373
- parallel: settled.parallel ?? defaults.parallel
1374
- };
1375
- }
1376
- function resolveToken(raw, inherited, overrides, seen) {
1377
- const token = splitToken(raw);
1378
- const inline = resolveCwd(parseInlineOptions(token.options, config.args), inherited.cwd ?? config.cwd);
1379
- if (Object.hasOwn(config.commandDictionary, token.name)) {
1380
- if (seen.has(token.name)) throw new NormalizeError(`Command "${token.name}" refers to itself.`);
1381
- const resolved = resolveEntry(config.commandDictionary[token.name], inherited, layer(inline, overrides), /* @__PURE__ */ new Set([...seen, token.name]));
1382
- return token.args === "" ? resolved : appendArgs(resolved, token);
1383
- }
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
- }
1393
- if (isGlob(token.name)) return toSegment(matchGlob(token.name, scriptNames).map((name) => makeLeaf({
1394
- ...token,
1395
- name
1396
- }, settled, name)), settled);
1397
- return toSegment([makeLeaf(token, settled, Object.hasOwn(packageInfo.scripts, token.name) ? token.name : void 0)], settled);
1398
- }
1399
- function appendArgs(segment, token) {
1400
- const [leaf] = segment.commands;
1401
- if (segment.commands.length !== 1 || leaf === void 0) throw new NormalizeError(`Cannot pass arguments to "${token.name}": it expands to more than one command.`);
1402
- const args = substitutePlaceholders(token.args, config.args);
1403
- const extended = {
1404
- ...leaf,
1405
- command: `${leaf.command} ${args}`,
1406
- line: `${leaf.line} ${args}`
1407
- };
1408
- return {
1409
- ...segment,
1410
- commands: [extended]
1411
- };
1412
- }
1413
- /** One list entry other than a settings entry. */
1414
- function resolve(definition, inherited, overrides, seen) {
1415
- if (!definition) return EMPTY_SEGMENT;
1416
- if (typeof definition === "string") return resolveToken(definition, inherited, overrides, seen);
1417
- if (Array.isArray(definition)) throw new NormalizeError("A list of commands belongs in \"commands\" or in a \"commandDictionary\" entry, not inside one.");
1418
- if (isCommandSettings(definition)) throw new NormalizeError("A command definition needs a \"command\" field.");
1419
- const entry = definition;
1420
- if (typeof entry.command !== "string") throw new NormalizeError("A command's \"command\" must be a string.");
1421
- const { command, ...raw } = entry;
1422
- return resolveToken(command, layer(inherited, resolveCwd(raw, inherited.cwd ?? config.cwd)), overrides, seen);
1423
- }
1424
- /**
1425
- * `commands`, or a dictionary entry that is a list. The list's own
1426
- * `parallel` places the list as a whole and is not passed to its members.
1427
- */
1428
- function resolveList(definitions, inherited, overrides, seen) {
1429
- const { parallel: inheritedParallel, ...passedDown } = inherited;
1430
- const { parallel: overriddenParallel, ...passedOver } = overrides;
1431
- const parallel = overriddenParallel ?? inheritedParallel ?? defaults.parallel;
1432
- check(booleanError("\"parallel\" on a list of commands", parallel), NormalizeError);
1433
- const segments = [];
1434
- let settled = passedDown;
1435
- for (const definition of definitions) {
1436
- if (isSettingsEntry(definition)) {
1437
- const written = readSettings(definition, settled.cwd ?? config.cwd);
1438
- settled = layer(settled, written);
1439
- if (written.parallel !== void 0) segments.push(BOUNDARY);
1440
- continue;
1441
- }
1442
- segments.push(resolve(definition, settled, passedOver, seen));
1443
- }
1444
- return {
1445
- commands: link(segments),
1446
- parallel
1447
- };
1170
+ //#region src/utils/colors.ts
1171
+ const KNOWN = new Set(Object.keys(inspect.colors));
1172
+ const SHADES = {
1173
+ mint: {
1174
+ index: 115,
1175
+ fallback: "greenBright"
1176
+ },
1177
+ sky: {
1178
+ index: 111,
1179
+ fallback: "blueBright"
1180
+ },
1181
+ rose: {
1182
+ index: 211,
1183
+ fallback: "magentaBright"
1184
+ },
1185
+ amber: {
1186
+ index: 179,
1187
+ fallback: "yellowBright"
1188
+ },
1189
+ lavender: {
1190
+ index: 183,
1191
+ fallback: "magentaBright"
1192
+ },
1193
+ aqua: {
1194
+ index: 80,
1195
+ fallback: "cyanBright"
1196
+ },
1197
+ coral: {
1198
+ index: 210,
1199
+ fallback: "redBright"
1200
+ },
1201
+ sage: {
1202
+ index: 151,
1203
+ fallback: "greenBright"
1204
+ },
1205
+ periwinkle: {
1206
+ index: 147,
1207
+ fallback: "blueBright"
1208
+ },
1209
+ peach: {
1210
+ index: 216,
1211
+ fallback: "yellowBright"
1212
+ },
1213
+ teal: {
1214
+ index: 79,
1215
+ fallback: "cyanBright"
1216
+ },
1217
+ lilac: {
1218
+ index: 177,
1219
+ fallback: "magentaBright"
1220
+ },
1221
+ lime: {
1222
+ index: 149,
1223
+ fallback: "greenBright"
1224
+ },
1225
+ steel: {
1226
+ index: 110,
1227
+ fallback: "blueBright"
1228
+ },
1229
+ pink: {
1230
+ index: 218,
1231
+ fallback: "magentaBright"
1232
+ },
1233
+ seafoam: {
1234
+ index: 121,
1235
+ fallback: "cyanBright"
1236
+ },
1237
+ apricot: {
1238
+ index: 215,
1239
+ fallback: "yellowBright"
1240
+ },
1241
+ mauve: {
1242
+ index: 139,
1243
+ fallback: "magentaBright"
1244
+ },
1245
+ jade: {
1246
+ index: 114,
1247
+ fallback: "greenBright"
1248
+ },
1249
+ sand: {
1250
+ index: 180,
1251
+ fallback: "yellowBright"
1252
+ },
1253
+ iris: {
1254
+ index: 141,
1255
+ fallback: "blueBright"
1256
+ },
1257
+ ice: {
1258
+ index: 117,
1259
+ fallback: "cyanBright"
1260
+ },
1261
+ salmon: {
1262
+ index: 174,
1263
+ fallback: "redBright"
1264
+ },
1265
+ olive: {
1266
+ index: 150,
1267
+ fallback: "greenBright"
1268
+ },
1269
+ navy: {
1270
+ index: 24,
1271
+ fallback: "blue",
1272
+ foreground: "paper"
1273
+ },
1274
+ plum: {
1275
+ index: 96,
1276
+ fallback: "magenta",
1277
+ foreground: "paper"
1278
+ },
1279
+ forest: {
1280
+ index: 22,
1281
+ fallback: "green",
1282
+ foreground: "paper"
1283
+ },
1284
+ wine: {
1285
+ index: 89,
1286
+ fallback: "red",
1287
+ foreground: "paper"
1288
+ },
1289
+ ocean: {
1290
+ index: 25,
1291
+ fallback: "blue",
1292
+ foreground: "paper"
1293
+ },
1294
+ indigo: {
1295
+ index: 61,
1296
+ fallback: "blue",
1297
+ foreground: "paper"
1298
+ },
1299
+ copper: {
1300
+ index: 94,
1301
+ fallback: "red",
1302
+ foreground: "paper"
1303
+ },
1304
+ pine: {
1305
+ index: 23,
1306
+ fallback: "cyan",
1307
+ foreground: "paper"
1308
+ },
1309
+ violet: {
1310
+ index: 55,
1311
+ fallback: "magenta",
1312
+ foreground: "paper"
1313
+ },
1314
+ brick: {
1315
+ index: 88,
1316
+ fallback: "red",
1317
+ foreground: "paper"
1318
+ },
1319
+ slate: {
1320
+ index: 60,
1321
+ fallback: "blue",
1322
+ foreground: "paper"
1323
+ },
1324
+ cocoa: {
1325
+ index: 95,
1326
+ fallback: "red",
1327
+ foreground: "paper"
1328
+ },
1329
+ ink: {
1330
+ index: 235,
1331
+ fallback: "black"
1332
+ },
1333
+ paper: {
1334
+ index: 255,
1335
+ fallback: "whiteBright"
1448
1336
  }
1449
- function resolveEntry(entry, inherited, overrides, seen) {
1450
- return Array.isArray(entry) ? resolveList(entry, inherited, overrides, seen) : resolve(entry, inherited, overrides, seen);
1337
+ };
1338
+ /** Every shade by its foreground and its background name, e.g. `mint`, `bgMint`. */
1339
+ const SHADE_NAMES = new Map(Object.entries(SHADES).flatMap(([name, custom]) => [[name, custom], [toCamelCase(`bg-${name}`), custom]]));
1340
+ const BASIC_PALETTE = [
1341
+ {
1342
+ bgColor: "bgGreen",
1343
+ color: "black"
1344
+ },
1345
+ {
1346
+ bgColor: "bgBlue",
1347
+ color: "white"
1348
+ },
1349
+ {
1350
+ bgColor: "bgMagenta",
1351
+ color: "white"
1352
+ },
1353
+ {
1354
+ bgColor: "bgCyan",
1355
+ color: "black"
1356
+ },
1357
+ {
1358
+ bgColor: "bgYellow",
1359
+ color: "black"
1360
+ },
1361
+ {
1362
+ bgColor: "bgRed",
1363
+ color: "white"
1364
+ },
1365
+ {
1366
+ bgColor: "bgGray",
1367
+ color: "white"
1368
+ },
1369
+ {
1370
+ bgColor: "bgGreenBright",
1371
+ color: "black"
1372
+ },
1373
+ {
1374
+ bgColor: "bgBlueBright",
1375
+ color: "black"
1376
+ },
1377
+ {
1378
+ bgColor: "bgMagentaBright",
1379
+ color: "black"
1380
+ },
1381
+ {
1382
+ bgColor: "bgCyanBright",
1383
+ color: "black"
1384
+ },
1385
+ {
1386
+ bgColor: "bgYellowBright",
1387
+ color: "black"
1451
1388
  }
1452
- const { commands } = resolveList(config.commands, {}, {}, /* @__PURE__ */ new Set());
1453
- commands.sort((first, second) => first.stage - second.stage);
1454
- return commands;
1389
+ ];
1390
+ /** Interleave hue families so adjacent assignments remain easy to tell apart. */
1391
+ const PALETTE = Object.entries(SHADES).filter(([name]) => name !== "ink" && name !== "paper").map(([name, { foreground }]) => ({
1392
+ bgColor: toCamelCase(`bg-${name}`),
1393
+ color: foreground ?? "ink"
1394
+ }));
1395
+ /** What `colorLabels` hands out under each mode; `none` still picks, unseen. */
1396
+ const PALETTES = {
1397
+ none: BASIC_PALETTE,
1398
+ basic: BASIC_PALETTE,
1399
+ soft: PALETTE.filter((pair) => pair.color === "ink"),
1400
+ all: PALETTE
1401
+ };
1402
+ /** A built-in style or one of runset's foreground/background shades. */
1403
+ function isColorName(name) {
1404
+ return KNOWN.has(name) || shade(name) !== void 0;
1455
1405
  }
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] : [];
1406
+ /**
1407
+ * Styles text; a shade is painted from the 256-color table. Plain text when
1408
+ * not `enabled`.
1409
+ */
1410
+ function paint(text, colors, enabled = true) {
1411
+ if (!enabled) return text;
1412
+ let result = text;
1413
+ for (const name of colors.toReversed()) {
1414
+ const custom = shade(name);
1415
+ if (custom !== void 0) {
1416
+ const background = name.startsWith("bg");
1417
+ const open = `\u001B[${background ? 48 : 38};5;${custom.index}m`;
1418
+ const close = `\u001B[${background ? 49 : 39}m`;
1419
+ result = open + result.replaceAll(close, open) + close;
1420
+ } else if (KNOWN.has(name)) result = styleText(name, result, { validateStream: false });
1421
+ }
1422
+ return result;
1461
1423
  }
1462
- function packageNameOf(pkg) {
1463
- return pkg.name || path.basename(path.dirname(pkg.filePath));
1424
+ /** The theme color used for a shade on a basic terminal. */
1425
+ function fallbackColor(name) {
1426
+ const custom = shade(name);
1427
+ if (custom === void 0) return name;
1428
+ return name.startsWith("bg") ? toCamelCase(`bg-${custom.fallback}`) : custom.fallback;
1464
1429
  }
1465
- function isSettingsEntry(definition) {
1466
- return typeof definition === "object" && definition !== null && !Array.isArray(definition) && isCommandSettings(definition);
1430
+ /** A stable palette index for a label. */
1431
+ function paletteIndex(label, palette = PALETTE) {
1432
+ /** The largest modulus that keeps `hash * 31 + code` an exact integer. */
1433
+ const HASH_MODULUS = 2147483647;
1434
+ let hash = 0;
1435
+ for (const character of label) hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % HASH_MODULUS;
1436
+ return hash % palette.length;
1467
1437
  }
1468
- /** Resolves a relative `cwd` against the inherited one, so directories nest. */
1469
- function resolveCwd(options, base) {
1470
- return options.cwd === void 0 ? options : {
1471
- ...options,
1472
- cwd: path.resolve(base, options.cwd)
1473
- };
1438
+ function shade(name) {
1439
+ return SHADE_NAMES.get(name);
1474
1440
  }
1441
+ //#endregion
1442
+ //#region src/plan/labels.ts
1475
1443
  /**
1476
- * Reads a settings entry into the options the rest of its list inherits.
1477
- * Keys are checked strictly: `{ paralel: true }` would otherwise do nothing.
1444
+ * Settles who is labelled, per `config.labels`. Under `auto`, a command
1445
+ * sharing a stage is named after itself, whether or not someone in its stage
1446
+ * has a label of their own. Under `custom` it gets a blank label instead,
1447
+ * holding the column when someone in its stage has one.
1478
1448
  */
1479
- function readSettings(entry, base) {
1480
- const keys = Object.keys(entry);
1481
- if (keys.length === 0) throw new NormalizeError("A settings entry must set at least one option.");
1482
- for (const key of keys) if (!SETTINGS_OPTIONS.has(key)) throw new NormalizeError(`Unknown option "${toKebabCase(key)}" in a settings entry.`);
1483
- const { serial, ...options } = entry;
1484
- if (serial !== void 0) {
1485
- check(booleanError("\"serial\"", serial), NormalizeError);
1486
- if (options.parallel !== void 0) throw new NormalizeError("A settings entry takes \"parallel\" or \"serial\", not both.");
1487
- options.parallel = !serial;
1449
+ function assignAutoLabels({ commands, config }) {
1450
+ const mode = config.labels;
1451
+ if (mode === "none") {
1452
+ for (const command of commands) command.label = "";
1453
+ return;
1488
1454
  }
1489
- return resolveCwd(options, base);
1455
+ const named = [];
1456
+ for (const stage of groupByStage(commands, (command) => command.stage)) {
1457
+ const shared = stage.length > 1;
1458
+ const labeled = stage.some((command) => command.label !== "");
1459
+ for (const command of stage) {
1460
+ if (command.label !== "") continue;
1461
+ if (mode === "all" || mode === "auto" && shared) {
1462
+ command.label = nameOf(command);
1463
+ named.push(command);
1464
+ } else if (shared && labeled) command.label = " ";
1465
+ }
1466
+ }
1467
+ numberRepeats(named);
1490
1468
  }
1491
- /** Layers option bags, later over earlier; stream settings merge per axis. */
1492
- function layer(...bags) {
1493
- const result = {};
1494
- for (const { output, stderr, stdout, ...rest } of bags) {
1495
- Object.assign(result, rest);
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);
1469
+ /**
1470
+ * Gives each labelled command without colors of its own a palette pair: the
1471
+ * one its label hashes to, or the next free when that is taken, from the
1472
+ * palette of `config.color`. Without shades every one is first swapped for its
1473
+ * theme color, so nothing past the plan has to know how many colors there are.
1474
+ */
1475
+ function colorLabels({ commands, config }) {
1476
+ const palette = PALETTES[config.color];
1477
+ if (config.color === "none" || config.color === "basic") for (const command of commands) {
1478
+ command.bgColor = fallbackColor(command.bgColor);
1479
+ command.color = fallbackColor(command.color);
1480
+ }
1481
+ const labeled = commands.filter((command) => command.label !== "");
1482
+ const taken = new Set(labeled.map((command) => palette.findIndex((pair) => pair.bgColor === command.bgColor)).filter((index) => index !== -1));
1483
+ for (const command of labeled) {
1484
+ if (command.color !== "" || command.bgColor !== "" || command.label.trim() === "") continue;
1485
+ const index = claim(paletteIndex(command.label, palette), taken, palette.length);
1486
+ taken.add(index);
1487
+ const pair = palette[index];
1488
+ command.bgColor = pair.bgColor;
1489
+ command.color = pair.color;
1497
1490
  }
1498
- return result;
1499
1491
  }
1500
- /** Checks what a config file could have written by hand. */
1501
- function validateCommand(command) {
1502
- const where = (key) => `"${toKebabCase(key)}" on "${command.command}"`;
1503
- check([
1504
- ...["onFailure", "onSuccess"].map((key) => actionError(where(key), command[key])),
1505
- ...[
1506
- "disabled",
1507
- "parallel",
1508
- "recursive"
1509
- ].map((key) => booleanError(where(key), command[key])),
1510
- ...[
1511
- "bgColor",
1512
- "color",
1513
- "cwd",
1514
- "label"
1515
- ].map((key) => stringError(where(key), command[key])),
1516
- ...["stderr", "stdout"].map((key) => stdError(where(key), command[key])),
1517
- envError(where("env"), command.env)
1518
- ].find((error) => error !== void 0), NormalizeError);
1492
+ /** Pads every label to the widest one. */
1493
+ function alignLabels({ commands }) {
1494
+ const labeled = commands.filter((command) => command.label !== "");
1495
+ const width = Math.max(0, ...labeled.map((command) => command.label.length));
1496
+ for (const command of labeled) command.label = command.label.padEnd(width);
1497
+ }
1498
+ /** The script name, or the program a shell command runs. */
1499
+ function nameOf(command) {
1500
+ return command.packageName ?? command.scriptName ?? command.command.split(/\s/, 1)[0] ?? "";
1501
+ }
1502
+ /** `serve`, `serve` → `serve#1`, `serve#2`. */
1503
+ function numberRepeats(named) {
1504
+ const totals = /* @__PURE__ */ new Map();
1505
+ for (const command of named) totals.set(command.label, (totals.get(command.label) ?? 0) + 1);
1506
+ const counters = /* @__PURE__ */ new Map();
1507
+ for (const command of named) {
1508
+ if ((totals.get(command.label) ?? 0) < 2) continue;
1509
+ const index = (counters.get(command.label) ?? 0) + 1;
1510
+ counters.set(command.label, index);
1511
+ command.label = `${command.label}#${index}`;
1512
+ }
1513
+ }
1514
+ function claim(preferred, taken, size) {
1515
+ if (taken.size >= size) return preferred;
1516
+ let index = preferred;
1517
+ while (taken.has(index)) index = (index + 1) % size;
1518
+ return index;
1519
1519
  }
1520
1520
  //#endregion
1521
1521
  //#region src/plan/plan.ts
@@ -1602,13 +1602,11 @@ var FileRegistry = class {
1602
1602
  error;
1603
1603
  streams = /* @__PURE__ */ new Map();
1604
1604
  cwd;
1605
- handler;
1606
- constructor(cwd) {
1605
+ onFailure;
1606
+ /** `onFailure` is called once, with the first failure. */
1607
+ constructor(cwd, onFailure) {
1607
1608
  this.cwd = cwd;
1608
- }
1609
- /** Called once, with the first failure. */
1610
- onFailure(handler) {
1611
- this.handler = handler;
1609
+ this.onFailure = onFailure;
1612
1610
  }
1613
1611
  open(destination) {
1614
1612
  const filePath = path.resolve(this.cwd, destination);
@@ -1626,12 +1624,12 @@ var FileRegistry = class {
1626
1624
  async closeAll() {
1627
1625
  const streams = [...this.streams.values()];
1628
1626
  this.streams.clear();
1629
- await Promise.all(streams.map(async (stream) => closed(stream)));
1627
+ await Promise.all(streams.map((stream) => closed(stream)));
1630
1628
  }
1631
1629
  fail(destination, error) {
1632
1630
  if (this.error !== void 0) return;
1633
1631
  this.error = /* @__PURE__ */ new Error(`cannot write to "${destination}": ${error.message}`);
1634
- this.handler?.(this.error);
1632
+ this.onFailure(this.error);
1635
1633
  }
1636
1634
  };
1637
1635
  function closed(stream) {
@@ -1679,8 +1677,7 @@ var Logger = class {
1679
1677
  }
1680
1678
  write(level, message) {
1681
1679
  if (RANK[level] > RANK[this.level]) return;
1682
- const text = this.color ? paint(message, COLORS[level]) : message;
1683
- this.stream.write(`${text}\n`);
1680
+ this.stream.write(`${paint(message, COLORS[level], this.color)}\n`);
1684
1681
  }
1685
1682
  };
1686
1683
  //#endregion
@@ -1721,17 +1718,28 @@ function createEnv({ base, cwd, packageInfo, scriptName }) {
1721
1718
  const PATH_KEY = process.platform === "win32" ? "Path" : "PATH";
1722
1719
  const pathKey = Object.keys(base).find((key) => key.toUpperCase() === "PATH") ?? PATH_KEY;
1723
1720
  const binDirs = ancestors(cwd).map((dir) => path.join(dir, "node_modules", ".bin"));
1724
- const env = {
1725
- ...base,
1726
- [pathKey]: [...binDirs, base[pathKey]].filter(Boolean).join(path.delimiter)
1727
- };
1728
- if (packageInfo.name !== "") env.npm_package_name = packageInfo.name;
1729
- if (packageInfo.version !== "") env.npm_package_version = packageInfo.version;
1721
+ const own = {};
1722
+ if (packageInfo.name !== "") own.npm_package_name = packageInfo.name;
1723
+ if (packageInfo.version !== "") own.npm_package_version = packageInfo.version;
1730
1724
  if (scriptName !== void 0) {
1731
- env.npm_lifecycle_event = scriptName;
1732
- env.npm_lifecycle_script = packageInfo.scripts[scriptName] ?? "";
1725
+ own.npm_lifecycle_event = scriptName;
1726
+ own.npm_lifecycle_script = packageInfo.scripts[scriptName] ?? "";
1733
1727
  }
1734
- return env;
1728
+ return {
1729
+ ...withoutSpellingsOf(base, Object.keys(own)),
1730
+ ...own,
1731
+ [pathKey]: [...binDirs, base[pathKey]].filter(Boolean).join(path.delimiter)
1732
+ };
1733
+ }
1734
+ /**
1735
+ * Windows names are case-insensitive, and of two spellings a child is given
1736
+ * the one that sorts first — an outer npm's `NPM_…` over ours — so on Windows
1737
+ * every other spelling of a name about to be set is dropped.
1738
+ */
1739
+ function withoutSpellingsOf(env, names) {
1740
+ if (process.platform !== "win32") return env;
1741
+ const replaced = new Set(names.map((name) => name.toUpperCase()));
1742
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !replaced.has(key.toUpperCase())));
1735
1743
  }
1736
1744
  //#endregion
1737
1745
  //#region src/run/OutputSink.ts
@@ -1740,31 +1748,26 @@ function createEnv({ base, cwd, packageInfo, scriptName }) {
1740
1748
  * `grouped` holds everything until {@link flush}, `none` discards.
1741
1749
  */
1742
1750
  var OutputSink = class {
1751
+ /** A grouped stream's output, or a prefixed stream's unfinished line. */
1743
1752
  buffered = "";
1744
- /** A prefixed stream's unfinished line, held until its newline. */
1745
- pending = "";
1746
1753
  opened;
1747
1754
  /** Whether everything written so far ended with a newline. */
1748
1755
  atLineStart = true;
1749
1756
  std;
1750
1757
  prefix;
1751
- destinations;
1752
- files;
1753
- wrap;
1754
- /** `COLUMNS`, for a stream that is not a terminal. */
1755
- envColumns;
1756
- constructor(std, prefix, destinations, files, wrap = false, envColumns) {
1758
+ /** The columns a fixed prefix takes; measured per line otherwise. */
1759
+ prefixWidth;
1760
+ context;
1761
+ constructor(std, prefix, context) {
1757
1762
  this.std = std;
1758
1763
  this.prefix = prefix;
1759
- this.destinations = destinations;
1760
- this.files = files;
1761
- this.wrap = wrap;
1762
- this.envColumns = envColumns;
1764
+ this.prefixWidth = typeof prefix === "string" ? visibleWidth(prefix) : void 0;
1765
+ this.context = context;
1763
1766
  }
1764
1767
  /** Opened on first use, so a command that never writes truncates nothing. */
1765
1768
  get target() {
1766
- if (isStreamDestination(this.std.destination)) return this.destinations[this.std.destination];
1767
- this.opened ??= this.files.open(this.std.destination);
1769
+ if (isStreamDestination(this.std.destination)) return this.context.config.destinations[this.std.destination];
1770
+ this.opened ??= this.context.files.open(this.std.destination);
1768
1771
  return this.opened;
1769
1772
  }
1770
1773
  /**
@@ -1772,8 +1775,9 @@ var OutputSink = class {
1772
1775
  * else `COLUMNS`. A file has none, and neither has a run without `wrap`.
1773
1776
  */
1774
1777
  columns() {
1775
- if (!this.wrap || !isStreamDestination(this.std.destination)) return;
1776
- return terminalColumns(this.target) ?? this.envColumns;
1778
+ const { envColumns, wrap } = this.context.config;
1779
+ if (!wrap || !isStreamDestination(this.std.destination)) return;
1780
+ return terminalColumns(this.target) ?? envColumns;
1777
1781
  }
1778
1782
  write(chunk) {
1779
1783
  if (this.std.destination === "none" || chunk === "") return;
@@ -1786,11 +1790,13 @@ var OutputSink = class {
1786
1790
  this.target.write(chunk);
1787
1791
  return;
1788
1792
  }
1789
- this.pending += chunk;
1790
- const end = this.pending.lastIndexOf("\n");
1791
- if (end === -1) return;
1792
- const complete = this.pending.slice(0, end + 1);
1793
- this.pending = this.pending.slice(end + 1);
1793
+ const end = chunk.lastIndexOf("\n");
1794
+ if (end === -1) {
1795
+ this.buffered += chunk;
1796
+ return;
1797
+ }
1798
+ const complete = this.buffered + chunk.slice(0, end + 1);
1799
+ this.buffered = chunk.slice(end + 1);
1794
1800
  this.target.write(this.applyPrefix(complete));
1795
1801
  }
1796
1802
  /** A line of runset's own, on a line of its own: after any unfinished one. */
@@ -1799,27 +1805,32 @@ var OutputSink = class {
1799
1805
  }
1800
1806
  /** Emits what is held back: a `grouped` stream, or a partial line. */
1801
1807
  flush() {
1802
- const held = [this.pending, this.buffered].filter((text) => text !== "");
1803
- this.pending = "";
1808
+ const held = this.buffered;
1804
1809
  this.buffered = "";
1805
- for (const text of held) this.target.write(this.applyPrefix(text));
1810
+ if (held !== "") this.target.write(this.applyPrefix(held));
1806
1811
  }
1807
1812
  applyPrefix(chunk) {
1808
1813
  const { prefix } = this;
1809
1814
  if (prefix === "") return chunk;
1810
1815
  const endsWithNewline = chunk.endsWith("\n");
1811
1816
  const body = endsWithNewline ? chunk.slice(0, -1) : chunk;
1817
+ const end = endsWithNewline ? "\n" : "";
1812
1818
  const columns = this.columns();
1813
- return body.split("\n").map((line) => this.label(line, prefix, columns)).join("\n") + (endsWithNewline ? "\n" : "");
1819
+ if (typeof prefix === "string" && columns === void 0) return prefix + body.replaceAll("\n", `\n${prefix}`) + end;
1820
+ return body.split("\n").map((line) => this.label(line, prefix, columns)).join("\n") + end;
1814
1821
  }
1815
1822
  /** One line, prefixed; wrapped first when it would overflow the terminal. */
1816
1823
  label(line, prefix, columns) {
1817
1824
  const text = typeof prefix === "string" ? prefix : prefix();
1818
1825
  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");
1826
+ const cr = line.endsWith("\r") ? "\r" : "";
1827
+ const width = this.prefixWidth ?? visibleWidth(text);
1828
+ return wrapLine(line.slice(0, line.length - cr.length), columns - width, width).map((piece) => text + piece).join("\n") + cr;
1821
1829
  }
1822
1830
  };
1831
+ function isStreamDestination(destination) {
1832
+ return destination === "stdout" || destination === "stderr";
1833
+ }
1823
1834
  //#endregion
1824
1835
  //#region src/run/Process.ts
1825
1836
  const USE_PROCESS_GROUPS = process.platform !== "win32";
@@ -1842,9 +1853,9 @@ var Process = class {
1842
1853
  constructor(command, context) {
1843
1854
  this.command = command;
1844
1855
  this.context = context;
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);
1856
+ const { color, formatLabel } = context.config;
1857
+ const colored = color !== "none";
1858
+ const sink = (stream) => new OutputSink(command[stream], makePrefix(command, colored, formatLabel, stream), context);
1848
1859
  this.stdout = sink("stdout");
1849
1860
  this.stderr = sink("stderr");
1850
1861
  }
@@ -1862,12 +1873,13 @@ var Process = class {
1862
1873
  let stopTheRun = false;
1863
1874
  for (;;) {
1864
1875
  this.started = true;
1865
- if (this.context.config.showCommand) {
1866
- const run = this.context.config.color === "none" ? "run" : paint("run", ["blue"]);
1876
+ const { color, showCommand, showExitCode } = this.context.config;
1877
+ if (showCommand) {
1878
+ const run = paint("run", ["blue"], color !== "none");
1867
1879
  this.stdout.writeLine(`${run} ${this.command.command}`);
1868
1880
  }
1869
1881
  await this.spawn();
1870
- if (this.context.config.showExitCode) this.stdout.writeLine(this.exitLine());
1882
+ if (showExitCode) this.stdout.writeLine(this.exitLine());
1871
1883
  this.stdout.flush();
1872
1884
  this.stderr.flush();
1873
1885
  this.context.logger.debug(`< ${this.command.line} ${this.describeExit()}`);
@@ -1895,12 +1907,12 @@ var Process = class {
1895
1907
  * in gray, so it can be found without its label.
1896
1908
  */
1897
1909
  exitLine() {
1898
- const color = this.context.config.color !== "none";
1910
+ const colored = this.context.config.color !== "none";
1899
1911
  const duration = formatDuration(performance.now() - this.attemptStart);
1900
- if (this.exitCode === 0 && this.signal === void 0 && !this.terminated) return `${color ? paint("✓", ["green"]) : "✓"} ${duration}`;
1912
+ if (this.exitCode === 0 && this.signal === void 0 && !this.terminated) return `${paint("✓", ["green"], colored)} ${duration}`;
1901
1913
  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}`;
1914
+ const command = paint(this.command.command, ["gray"], colored);
1915
+ return `${paint(status, [tint], colored)} · ${duration} ${command}`;
1904
1916
  }
1905
1917
  /** `code N`, the signal's name, or `stopped`. */
1906
1918
  shortExit() {
@@ -2017,6 +2029,7 @@ async function schedule(processes, stopped, jobs) {
2017
2029
  //#region src/run/Run.ts
2018
2030
  /** One run: the processes built from a plan, and what stops them. */
2019
2031
  var Run = class Run {
2032
+ /** @internal */
2020
2033
  config;
2021
2034
  /** @internal */
2022
2035
  processes;
@@ -2025,9 +2038,8 @@ var Run = class Run {
2025
2038
  stopping;
2026
2039
  constructor({ commands, config, packageInfo }) {
2027
2040
  this.config = config;
2028
- this.files = new FileRegistry(config.cwd);
2041
+ this.files = new FileRegistry(config.cwd, () => this.stopEverything({ reason: "policy" }));
2029
2042
  this.logger = new Logger(config.logLevel, config.destinations.stderr, config.color !== "none");
2030
- for (const warning of config.warnings) this.logger.warn(`runset: ${warning}`);
2031
2043
  const context = {
2032
2044
  config,
2033
2045
  files: this.files,
@@ -2036,7 +2048,6 @@ var Run = class Run {
2036
2048
  requestStop: () => this.stopEverything({ reason: "policy" })
2037
2049
  };
2038
2050
  this.processes = commands.map((command) => new Process(command, context));
2039
- this.files.onFailure(() => this.stopEverything({ reason: "policy" }));
2040
2051
  }
2041
2052
  /** A library caller's config object, through the same pipeline as the CLI. */
2042
2053
  static fromConfigJs(configJs) {
@@ -2058,6 +2069,7 @@ var Run = class Run {
2058
2069
  }
2059
2070
  /** Rejects with a {@link RunsetError} when the run did not succeed. */
2060
2071
  async start() {
2072
+ for (const warning of this.config.warnings) this.logger.warn(`runset: ${warning}`);
2061
2073
  if (this.config.dryRun) {
2062
2074
  this.config.destinations.stdout.write(`${this.describe()}\n`);
2063
2075
  return this;
@@ -2075,43 +2087,45 @@ var Run = class Run {
2075
2087
  await this.files.closeAll();
2076
2088
  }
2077
2089
  const failed = this.failed();
2078
- const exitCode = failed[0]?.getReportedExitCode();
2090
+ const exitCode = this.getExitCode();
2079
2091
  const { stopping } = this;
2080
- if (this.files.error) throw new RunsetError(this.files.error.message, exitCode ?? 1);
2092
+ if (this.files.error) throw new RunsetError(this.files.error.message, exitCode || 1);
2081
2093
  if (stopping?.signal) throw new RunsetError(`the run was stopped by ${stopping.signal}`, signalExitCode(stopping.signal), "");
2082
2094
  if (stopping?.reason === "terminate") throw new RunsetError("the run was terminated", signalExitCode("SIGTERM"));
2083
- if (exitCode !== void 0) throw new RunsetError(`${this.tally()}: ${failed.map((process) => describeFailure(process)).join(", ")}`, exitCode, this.failureReport());
2095
+ if (failed.length > 0) {
2096
+ const tally = this.tally(failed);
2097
+ throw new RunsetError(`${tally}: ${failed.map((process) => describeFailure(process)).join(", ")}`, exitCode, this.failureReport(tally, failed));
2098
+ }
2084
2099
  return this;
2085
2100
  }
2086
2101
  terminate(options = {}) {
2087
2102
  this.stopEverything({ reason: "terminate" }, options);
2088
2103
  }
2089
2104
  /** `1 of 5 commands failed, 2 stopped, 1 not started`. */
2090
- tally() {
2105
+ tally(failed) {
2091
2106
  const { processes } = this;
2092
2107
  const total = processes.length;
2093
2108
  const stopped = processes.filter((process) => process.started && process.terminated).length;
2094
2109
  const notStarted = processes.filter((process) => !process.started).length;
2095
2110
  return [
2096
- `${this.failed().length} of ${total} command${total === 1 ? "" : "s"} failed`,
2111
+ `${failed.length} of ${total} command${total === 1 ? "" : "s"} failed`,
2097
2112
  stopped > 0 && `${stopped} stopped`,
2098
2113
  notStarted > 0 && `${notStarted} not started`
2099
2114
  ].filter(Boolean).join(", ");
2100
2115
  }
2101
2116
  /** 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) => {
2117
+ failureReport(tally, failed) {
2118
+ const colored = this.config.color !== "none";
2119
+ const red = (text) => paint(text, ["red"], colored);
2120
+ return [tally, ...failed.map((process) => {
2106
2121
  const { command } = process;
2107
2122
  const label = command.label.trim();
2108
- const prefix = makePrefix({
2123
+ const prefix = label === "" ? "" : defaultLabel({
2109
2124
  ...command,
2110
2125
  label
2111
- }, color, void 0, "stderr");
2126
+ }, colored);
2112
2127
  return ` ${red("✖")} ${prefix}${command.command} ${red(process.describeExit())}`;
2113
- });
2114
- return [this.tally(), ...rows].join("\n");
2128
+ })].join("\n");
2115
2129
  }
2116
2130
  failed() {
2117
2131
  return this.processes.filter((process) => process.isFailed());
@@ -2250,7 +2264,17 @@ function version() {
2250
2264
  async function runset(first, second) {
2251
2265
  return Run.fromConfigJs(toConfigJs(first, second)).start();
2252
2266
  }
2253
- if (import.meta.main) process.exitCode = await main(process.argv.slice(2));
2267
+ if (import.meta.main || isEntryPoint()) main(process.argv.slice(2)).then((code) => {
2268
+ process.exitCode = code;
2269
+ }).catch((error) => {
2270
+ console.error(error);
2271
+ process.exitCode = 1;
2272
+ }).finally(() => {
2273
+ setTimeout(() => {
2274
+ console.error("runset: forced exit, a handle kept the process alive");
2275
+ process.exit();
2276
+ }, 500).unref();
2277
+ });
2254
2278
  /** A lone object carrying `commands` is a config; anything else is commands. */
2255
2279
  function toConfigJs(first, second) {
2256
2280
  if (second === void 0 && isConfigObject(first)) return first;
@@ -2261,7 +2285,20 @@ function toConfigJs(first, second) {
2261
2285
  };
2262
2286
  }
2263
2287
  function isConfigObject(value) {
2264
- return typeof value === "object" && value !== null && !Array.isArray(value) && Array.isArray(value.commands);
2288
+ return isPlainObject(value) && Array.isArray(value.commands);
2289
+ }
2290
+ /**
2291
+ * Node 24.2 reports `import.meta.main` as false for a `.ts` entry point, which
2292
+ * is how the tests run this file; 24.3 fixed it. The module URL is a real
2293
+ * path, so the argument is resolved to one too — macOS's temp dir is a link.
2294
+ */
2295
+ function isEntryPoint() {
2296
+ const entry = process.argv[1];
2297
+ try {
2298
+ return entry !== void 0 && fs.realpathSync(entry) === fileURLToPath(import.meta.url);
2299
+ } catch {
2300
+ return false;
2301
+ }
2265
2302
  }
2266
2303
  //#endregion
2267
- export { CliError, ConfigError, EXIT_ACTIONS, NormalizeError, Run, RunsetError, runset as default, runset, isCommandSettings, isStreamDestination };
2304
+ export { CliError, ConfigError, NormalizeError, Run, RunsetError, runset as default, runset };