runset 0.0.2 → 0.0.4

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,1324 @@ 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
- "scripts",
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
- scripts;
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.scripts = file.scripts ?? {};
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.scripts !== "object" || this.scripts === null || Array.isArray(this.scripts) ? "scripts 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"
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
758
799
  },
759
- periwinkle: {
760
- index: 147,
761
- fallback: "blueBright"
800
+ {
801
+ name: "serial",
802
+ short: "s",
803
+ takesValue: false
762
804
  },
763
- peach: {
764
- index: 216,
765
- fallback: "yellowBright"
805
+ {
806
+ name: "recursive",
807
+ short: "r",
808
+ takesValue: false
766
809
  },
767
- teal: {
768
- index: 79,
769
- fallback: "cyanBright"
810
+ {
811
+ name: "jobs",
812
+ short: "j",
813
+ takesValue: true
770
814
  },
771
- lilac: {
772
- index: 177,
773
- fallback: "magentaBright"
815
+ {
816
+ name: "killTimeout",
817
+ takesValue: true
774
818
  },
775
- lime: {
776
- index: 149,
777
- fallback: "greenBright"
819
+ {
820
+ name: "onSuccess",
821
+ takesValue: true
778
822
  },
779
- steel: {
780
- index: 110,
781
- fallback: "blueBright"
823
+ {
824
+ name: "onFailure",
825
+ takesValue: true
782
826
  },
783
- pink: {
784
- index: 218,
785
- fallback: "magentaBright"
827
+ {
828
+ name: "config",
829
+ short: "c",
830
+ takesValue: true
786
831
  },
787
- seafoam: {
788
- index: 121,
789
- fallback: "cyanBright"
832
+ {
833
+ name: "cwd",
834
+ takesValue: true
790
835
  },
791
- apricot: {
792
- index: 215,
793
- fallback: "yellowBright"
836
+ {
837
+ name: "env",
838
+ short: "e",
839
+ takesValue: true
794
840
  },
795
- mauve: {
796
- index: 139,
797
- fallback: "magentaBright"
841
+ {
842
+ name: "color",
843
+ negated: "none",
844
+ takesValue: true
798
845
  },
799
- jade: {
800
- index: 114,
801
- fallback: "greenBright"
846
+ {
847
+ name: "logLevel",
848
+ takesValue: true
802
849
  },
803
- sand: {
804
- index: 180,
805
- fallback: "yellowBright"
806
- },
807
- iris: {
808
- index: 141,
809
- fallback: "blueBright"
810
- },
811
- ice: {
812
- index: 117,
813
- fallback: "cyanBright"
814
- },
815
- salmon: {
816
- index: 174,
817
- fallback: "redBright"
818
- },
819
- olive: {
820
- index: 150,
821
- fallback: "greenBright"
822
- },
823
- navy: {
824
- index: 24,
825
- fallback: "blue",
826
- foreground: "paper"
827
- },
828
- plum: {
829
- index: 96,
830
- fallback: "magenta",
831
- foreground: "paper"
832
- },
833
- forest: {
834
- index: 22,
835
- fallback: "green",
836
- foreground: "paper"
837
- },
838
- wine: {
839
- index: 89,
840
- fallback: "red",
841
- foreground: "paper"
842
- },
843
- ocean: {
844
- index: 25,
845
- fallback: "blue",
846
- foreground: "paper"
847
- },
848
- indigo: {
849
- index: 61,
850
- fallback: "blue",
851
- foreground: "paper"
852
- },
853
- copper: {
854
- index: 94,
855
- fallback: "red",
856
- foreground: "paper"
857
- },
858
- pine: {
859
- index: 23,
860
- fallback: "cyan",
861
- foreground: "paper"
862
- },
863
- violet: {
864
- index: 55,
865
- fallback: "magenta",
866
- foreground: "paper"
867
- },
868
- brick: {
869
- index: 88,
870
- fallback: "red",
871
- foreground: "paper"
872
- },
873
- slate: {
874
- index: 60,
875
- fallback: "blue",
876
- foreground: "paper"
877
- },
878
- cocoa: {
879
- index: 95,
880
- fallback: "red",
881
- foreground: "paper"
882
- },
883
- ink: {
884
- index: 235,
885
- fallback: "black"
886
- },
887
- paper: {
888
- index: 255,
889
- fallback: "whiteBright"
890
- }
891
- };
892
- /** Every shade by its foreground and its background name, e.g. `mint`, `bgMint`. */
893
- const SHADE_NAMES = new Map(Object.entries(SHADES).flatMap(([name, custom]) => [[name, custom], [toCamelCase(`bg-${name}`), custom]]));
894
- const BASIC_PALETTE = [
895
- {
896
- bgColor: "bgGreen",
897
- color: "black"
898
- },
899
- {
900
- bgColor: "bgBlue",
901
- color: "white"
850
+ {
851
+ name: "dryRun",
852
+ takesValue: false
902
853
  },
903
854
  {
904
- bgColor: "bgMagenta",
905
- color: "white"
906
- },
907
- {
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
+ takesValue: false
934
882
  },
935
883
  {
936
- bgColor: "bgCyanBright",
937
- color: "black"
884
+ name: "help",
885
+ short: "h",
886
+ takesValue: false
938
887
  },
939
888
  {
940
- bgColor: "bgYellowBright",
941
- color: "black"
889
+ name: "version",
890
+ short: "v",
891
+ takesValue: false
942
892
  }
943
893
  ];
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
894
  /**
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.
895
+ * Splits argv into options, commands, and the literal arguments after `--`.
896
+ * Each `-p`/`-s` becomes a settings entry where it was typed, the same object
897
+ * a config file would write.
1007
898
  */
1008
- function link(segments) {
899
+ function parseCli(rawArgv) {
900
+ const argv = regroupQuoted(rawArgv);
901
+ const options = {};
1009
902
  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);
1025
- }
1026
- open = segment.parallel ? open : used + 1;
1027
- }
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);
903
+ let positional = [];
904
+ let help = false;
905
+ let version = false;
906
+ let cursor = 0;
907
+ function setOption(spec, raw) {
908
+ switch (spec.name) {
909
+ case "env": {
910
+ const text = String(raw);
911
+ const eq = text.indexOf("=");
912
+ if (eq < 1) throw new CliError(`Option "--env" takes NAME=value, not "${text}".`);
913
+ (options.env ??= {})[text.slice(0, eq)] = text.slice(eq + 1);
914
+ break;
915
+ }
916
+ case "help":
917
+ help = true;
918
+ break;
919
+ case "jobs":
920
+ case "killTimeout":
921
+ options[spec.name] = Number(raw);
922
+ break;
923
+ case "parallel":
924
+ case "serial":
925
+ commands.push({ parallel: spec.name === "parallel" });
926
+ break;
927
+ case "version":
928
+ version = true;
929
+ break;
930
+ default: options[spec.name] = raw;
1041
931
  }
1042
- current.push(item);
1043
932
  }
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;
933
+ function takeValue(label) {
934
+ const value = argv[cursor + 1];
935
+ if (value === void 0) throw new CliError(`Option "${label}" needs a value.`);
936
+ cursor += 1;
937
+ return value;
1059
938
  }
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 = " ";
939
+ function readLong(body) {
940
+ const eq = body.indexOf("=");
941
+ const name = eq === -1 ? body : body.slice(0, eq);
942
+ const inlineValue = eq === -1 ? void 0 : body.slice(eq + 1);
943
+ const flag = findLongFlag(name);
944
+ if (flag === void 0 && /^no-?[A-Za-z]/.test(name)) {
945
+ const negated = findLongFlag(name.replace(/^no-?/, ""));
946
+ if (negated && (!negated.takesValue || negated.negated !== void 0)) {
947
+ setOption(negated, negated.negated ?? false);
948
+ return;
949
+ }
1070
950
  }
951
+ if (flag === void 0) throw new CliError(`Unknown option "--${name}".`);
952
+ if (flag.takesValue) setOption(flag, inlineValue ?? takeValue(`--${name}`));
953
+ else setOption(flag, true);
1071
954
  }
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);
955
+ function readShortCluster(body) {
956
+ for (let position = 0; position < body.length; position += 1) {
957
+ const letter = body[position];
958
+ const flag = findShortFlag(letter);
959
+ if (flag === void 0) throw new CliError(`Unknown option "-${letter}".`);
960
+ if (!flag.takesValue) {
961
+ setOption(flag, true);
962
+ continue;
963
+ }
964
+ const rest = body.slice(position + 1);
965
+ setOption(flag, rest === "" ? takeValue(`-${letter}`) : rest);
966
+ return;
967
+ }
1085
968
  }
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;
969
+ for (; cursor < argv.length; cursor += 1) {
970
+ const token = argv[cursor];
971
+ if (token === "--") {
972
+ positional = argv.slice(cursor + 1);
973
+ break;
974
+ }
975
+ if (token.startsWith("--") && token.length > 2) readLong(token.slice(2));
976
+ else if (token.startsWith("-") && token.length > 1) readShortCluster(token.slice(1));
977
+ else commands.push(token);
1095
978
  }
979
+ return {
980
+ argv,
981
+ commands,
982
+ help,
983
+ options,
984
+ positional,
985
+ version
986
+ };
1096
987
  }
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
- }
988
+ function findLongFlag(name) {
989
+ const camel = toCamelCase(name);
990
+ return FLAGS.find((flag) => flag.name === camel);
1118
991
  }
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;
992
+ function findShortFlag(letter) {
993
+ return FLAGS.find((flag) => flag.short === letter);
1124
994
  }
1125
995
  //#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`\$&`);
996
+ //#region src/config/Config.ts
997
+ const DEFAULT_KILL_TIMEOUT = 5e3;
998
+ const LOG_LEVELS = /* @__PURE__ */ new Set([
999
+ "error",
1000
+ "warn",
1001
+ "info",
1002
+ "debug"
1003
+ ]);
1004
+ const LABEL_MODES = /* @__PURE__ */ new Set([
1005
+ "none",
1006
+ "auto",
1007
+ "custom",
1008
+ "all"
1009
+ ]);
1010
+ const COLOR_MODES = /* @__PURE__ */ new Set([
1011
+ "auto",
1012
+ "none",
1013
+ "basic",
1014
+ "soft",
1015
+ "all"
1016
+ ]);
1017
+ /** Every key a config may set; a record, so a new one cannot be missed. */
1018
+ const KNOWN_KEYS = {
1019
+ color: true,
1020
+ commands: true,
1021
+ cwd: true,
1022
+ dryRun: true,
1023
+ env: true,
1024
+ formatLabel: true,
1025
+ jobs: true,
1026
+ killTimeout: true,
1027
+ labels: true,
1028
+ logLevel: true,
1029
+ onFailure: true,
1030
+ onSuccess: true,
1031
+ output: true,
1032
+ parallel: true,
1033
+ recursive: true,
1034
+ scripts: true,
1035
+ showCommand: true,
1036
+ showExitCode: true,
1037
+ stderr: true,
1038
+ stdout: true,
1039
+ wrap: true
1040
+ };
1041
+ /** Parses, loads and validates; anything left out comes from the process. */
1042
+ function createConfig({ cli = parseCli(process.argv.slice(2)), configJs, cwd = process.cwd(), destinations = process, env = process.env } = {}) {
1043
+ const config = new Config({
1044
+ cli,
1045
+ configJs,
1046
+ cwd,
1047
+ destinations,
1048
+ env
1049
+ });
1050
+ config.validate();
1051
+ return config;
1137
1052
  }
1053
+ /** The resolved run-wide configuration: CLI flag → config file → default. */
1054
+ var Config = class {
1055
+ commands;
1056
+ scripts;
1057
+ /** Everything after `--`, for placeholders. */
1058
+ args;
1059
+ cwd;
1060
+ env;
1061
+ jobs;
1062
+ killTimeout;
1063
+ onSuccess;
1064
+ onFailure;
1065
+ color;
1066
+ parallel;
1067
+ recursive;
1068
+ showCommand;
1069
+ showExitCode;
1070
+ wrap;
1071
+ /** `COLUMNS` as runset was started with it: the width where no TTY says. */
1072
+ envColumns;
1073
+ labels;
1074
+ logLevel;
1075
+ dryRun;
1076
+ formatLabel;
1077
+ stdout;
1078
+ stderr;
1079
+ destinations;
1080
+ /** Printed by the run once it is built. */
1081
+ warnings = [];
1082
+ constructor({ cli, configJs, cwd, destinations, env }) {
1083
+ const { options } = cli;
1084
+ this.cwd = path.resolve(cwd, options.cwd ?? ".");
1085
+ this.destinations = destinations;
1086
+ const context = {
1087
+ argv: cli.argv,
1088
+ cwd: this.cwd,
1089
+ env
1090
+ };
1091
+ const file = configJs === void 0 ? loadConfigJs(context, options.config) : unwrapConfigJs(configJs, context);
1092
+ for (const key of Object.keys(file)) if (!Object.hasOwn(KNOWN_KEYS, key)) this.warnings.push(`Unknown config key "${key}" was ignored.`);
1093
+ if (file.cwd !== void 0 && options.cwd === void 0) this.cwd = path.resolve(cwd, file.cwd);
1094
+ check(envError("env", file.env ?? {}), ConfigError);
1095
+ this.env = {
1096
+ ...env,
1097
+ ...file.env,
1098
+ ...options.env
1099
+ };
1100
+ this.commands = [...file.commands ?? [], ...cli.commands].filter(Boolean);
1101
+ this.scripts = file.scripts ?? {};
1102
+ this.args = cli.positional;
1103
+ this.jobs = options.jobs ?? file.jobs ?? Number.POSITIVE_INFINITY;
1104
+ this.killTimeout = options.killTimeout ?? file.killTimeout ?? DEFAULT_KILL_TIMEOUT;
1105
+ this.parallel = file.parallel ?? false;
1106
+ this.recursive = options.recursive ?? file.recursive ?? false;
1107
+ this.showCommand = options.showCommand ?? file.showCommand ?? false;
1108
+ this.showExitCode = options.showExitCode ?? file.showExitCode ?? false;
1109
+ this.wrap = options.wrap ?? file.wrap ?? true;
1110
+ this.envColumns = parseColumns(env.COLUMNS);
1111
+ this.labels = options.labels ?? file.labels ?? "auto";
1112
+ this.onSuccess = options.onSuccess ?? file.onSuccess ?? "continue";
1113
+ this.onFailure = options.onFailure ?? file.onFailure ?? "stop";
1114
+ this.dryRun = options.dryRun ?? file.dryRun ?? false;
1115
+ this.logLevel = options.logLevel ?? file.logLevel ?? "info";
1116
+ this.color = resolveColor(options.color ?? file.color, env, destinations);
1117
+ this.formatLabel = file.formatLabel;
1118
+ const streams = layerStreams({
1119
+ stderr: defaultStd("stderr"),
1120
+ stdout: defaultStd("stdout")
1121
+ }, [file, options]);
1122
+ this.stdout = streams.stdout;
1123
+ this.stderr = streams.stderr;
1124
+ }
1125
+ /** Throws on the first invalid value; types too, as config files are arbitrary JS. */
1126
+ validate() {
1127
+ check([
1128
+ oneOfError("logLevel", this.logLevel, LOG_LEVELS),
1129
+ numberError("jobs", this.jobs, 1),
1130
+ numberError("killTimeout", this.killTimeout, 0),
1131
+ oneOfError("color", this.color, COLOR_MODES),
1132
+ booleanError("parallel", this.parallel),
1133
+ booleanError("recursive", this.recursive),
1134
+ booleanError("showCommand", this.showCommand),
1135
+ booleanError("showExitCode", this.showExitCode),
1136
+ booleanError("wrap", this.wrap),
1137
+ booleanError("dryRun", this.dryRun),
1138
+ oneOfError("labels", this.labels, LABEL_MODES),
1139
+ stdError("stdout", this.stdout),
1140
+ stdError("stderr", this.stderr),
1141
+ this.formatLabel !== void 0 && typeof this.formatLabel !== "function" ? "formatLabel must be a function." : void 0,
1142
+ isPlainObject(this.scripts) ? void 0 : "scripts must be an object.",
1143
+ actionError("onSuccess", this.onSuccess),
1144
+ actionError("onFailure", this.onFailure)
1145
+ ].find((error) => error !== void 0), ConfigError);
1146
+ }
1147
+ /** True when `commands` holds something to run, not just settings entries. */
1148
+ hasCommands() {
1149
+ return this.commands.some((command) => Boolean(command) && !isSettingsEntry(command));
1150
+ }
1151
+ };
1138
1152
  /**
1139
- * `*` matches within one `:`-separated segment, `**` spans one or more.
1140
- * `build:**:*` means `build:**`.
1153
+ * A mode as given; under `auto`, whether to color at all, then `soft` where
1154
+ * the terminal shows 256 colors. `all` is only ever asked for. Anything else is
1155
+ * left for `validate` to refuse.
1141
1156
  */
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
- };
1157
+ function resolveColor(mode, env, destinations) {
1158
+ if (mode !== void 0 && mode !== "auto") return mode;
1159
+ if (!detectColor(env, destinations)) return "none";
1160
+ return WriteStream.prototype.getColorDepth(env) >= 8 ? "soft" : "basic";
1287
1161
  }
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;
1162
+ /** `NO_COLOR`/`FORCE_COLOR`, then whether both streams are TTYs. */
1163
+ function detectColor(env, destinations) {
1164
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
1165
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
1166
+ return isTerminal(destinations.stdout) && isTerminal(destinations.stderr);
1309
1167
  }
1310
1168
  //#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.scripts, token.name)) {
1380
- if (seen.has(token.name)) throw new NormalizeError(`Command "${token.name}" refers to itself.`);
1381
- const resolved = resolveEntry(config.scripts[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 \"scripts\" 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 `scripts` 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
- };
1169
+ //#region src/utils/colors.ts
1170
+ const KNOWN = new Set(Object.keys(inspect.colors));
1171
+ const SHADES = {
1172
+ mint: {
1173
+ index: 115,
1174
+ fallback: "greenBright"
1175
+ },
1176
+ sky: {
1177
+ index: 111,
1178
+ fallback: "blueBright"
1179
+ },
1180
+ rose: {
1181
+ index: 211,
1182
+ fallback: "magentaBright"
1183
+ },
1184
+ amber: {
1185
+ index: 179,
1186
+ fallback: "yellowBright"
1187
+ },
1188
+ lavender: {
1189
+ index: 183,
1190
+ fallback: "magentaBright"
1191
+ },
1192
+ aqua: {
1193
+ index: 80,
1194
+ fallback: "cyanBright"
1195
+ },
1196
+ coral: {
1197
+ index: 210,
1198
+ fallback: "redBright"
1199
+ },
1200
+ sage: {
1201
+ index: 151,
1202
+ fallback: "greenBright"
1203
+ },
1204
+ periwinkle: {
1205
+ index: 147,
1206
+ fallback: "blueBright"
1207
+ },
1208
+ peach: {
1209
+ index: 216,
1210
+ fallback: "yellowBright"
1211
+ },
1212
+ teal: {
1213
+ index: 79,
1214
+ fallback: "cyanBright"
1215
+ },
1216
+ lilac: {
1217
+ index: 177,
1218
+ fallback: "magentaBright"
1219
+ },
1220
+ lime: {
1221
+ index: 149,
1222
+ fallback: "greenBright"
1223
+ },
1224
+ steel: {
1225
+ index: 110,
1226
+ fallback: "blueBright"
1227
+ },
1228
+ pink: {
1229
+ index: 218,
1230
+ fallback: "magentaBright"
1231
+ },
1232
+ seafoam: {
1233
+ index: 121,
1234
+ fallback: "cyanBright"
1235
+ },
1236
+ apricot: {
1237
+ index: 215,
1238
+ fallback: "yellowBright"
1239
+ },
1240
+ mauve: {
1241
+ index: 139,
1242
+ fallback: "magentaBright"
1243
+ },
1244
+ jade: {
1245
+ index: 114,
1246
+ fallback: "greenBright"
1247
+ },
1248
+ sand: {
1249
+ index: 180,
1250
+ fallback: "yellowBright"
1251
+ },
1252
+ iris: {
1253
+ index: 141,
1254
+ fallback: "blueBright"
1255
+ },
1256
+ ice: {
1257
+ index: 117,
1258
+ fallback: "cyanBright"
1259
+ },
1260
+ salmon: {
1261
+ index: 174,
1262
+ fallback: "redBright"
1263
+ },
1264
+ olive: {
1265
+ index: 150,
1266
+ fallback: "greenBright"
1267
+ },
1268
+ navy: {
1269
+ index: 24,
1270
+ fallback: "blue",
1271
+ foreground: "paper"
1272
+ },
1273
+ plum: {
1274
+ index: 96,
1275
+ fallback: "magenta",
1276
+ foreground: "paper"
1277
+ },
1278
+ forest: {
1279
+ index: 22,
1280
+ fallback: "green",
1281
+ foreground: "paper"
1282
+ },
1283
+ wine: {
1284
+ index: 89,
1285
+ fallback: "red",
1286
+ foreground: "paper"
1287
+ },
1288
+ ocean: {
1289
+ index: 25,
1290
+ fallback: "blue",
1291
+ foreground: "paper"
1292
+ },
1293
+ indigo: {
1294
+ index: 61,
1295
+ fallback: "blue",
1296
+ foreground: "paper"
1297
+ },
1298
+ copper: {
1299
+ index: 94,
1300
+ fallback: "red",
1301
+ foreground: "paper"
1302
+ },
1303
+ pine: {
1304
+ index: 23,
1305
+ fallback: "cyan",
1306
+ foreground: "paper"
1307
+ },
1308
+ violet: {
1309
+ index: 55,
1310
+ fallback: "magenta",
1311
+ foreground: "paper"
1312
+ },
1313
+ brick: {
1314
+ index: 88,
1315
+ fallback: "red",
1316
+ foreground: "paper"
1317
+ },
1318
+ slate: {
1319
+ index: 60,
1320
+ fallback: "blue",
1321
+ foreground: "paper"
1322
+ },
1323
+ cocoa: {
1324
+ index: 95,
1325
+ fallback: "red",
1326
+ foreground: "paper"
1327
+ },
1328
+ ink: {
1329
+ index: 235,
1330
+ fallback: "black"
1331
+ },
1332
+ paper: {
1333
+ index: 255,
1334
+ fallback: "whiteBright"
1448
1335
  }
1449
- function resolveEntry(entry, inherited, overrides, seen) {
1450
- return Array.isArray(entry) ? resolveList(entry, inherited, overrides, seen) : resolve(entry, inherited, overrides, seen);
1336
+ };
1337
+ /** Every shade by its foreground and its background name, e.g. `mint`, `bgMint`. */
1338
+ const SHADE_NAMES = new Map(Object.entries(SHADES).flatMap(([name, custom]) => [[name, custom], [toCamelCase(`bg-${name}`), custom]]));
1339
+ const BASIC_PALETTE = [
1340
+ {
1341
+ bgColor: "bgGreen",
1342
+ color: "black"
1343
+ },
1344
+ {
1345
+ bgColor: "bgBlue",
1346
+ color: "white"
1347
+ },
1348
+ {
1349
+ bgColor: "bgMagenta",
1350
+ color: "white"
1351
+ },
1352
+ {
1353
+ bgColor: "bgCyan",
1354
+ color: "black"
1355
+ },
1356
+ {
1357
+ bgColor: "bgYellow",
1358
+ color: "black"
1359
+ },
1360
+ {
1361
+ bgColor: "bgRed",
1362
+ color: "white"
1363
+ },
1364
+ {
1365
+ bgColor: "bgGray",
1366
+ color: "white"
1367
+ },
1368
+ {
1369
+ bgColor: "bgGreenBright",
1370
+ color: "black"
1371
+ },
1372
+ {
1373
+ bgColor: "bgBlueBright",
1374
+ color: "black"
1375
+ },
1376
+ {
1377
+ bgColor: "bgMagentaBright",
1378
+ color: "black"
1379
+ },
1380
+ {
1381
+ bgColor: "bgCyanBright",
1382
+ color: "black"
1383
+ },
1384
+ {
1385
+ bgColor: "bgYellowBright",
1386
+ color: "black"
1451
1387
  }
1452
- const { commands } = resolveList(config.commands, {}, {}, /* @__PURE__ */ new Set());
1453
- commands.sort((first, second) => first.stage - second.stage);
1454
- return commands;
1388
+ ];
1389
+ /** Interleave hue families so adjacent assignments remain easy to tell apart. */
1390
+ const PALETTE = Object.entries(SHADES).filter(([name]) => name !== "ink" && name !== "paper").map(([name, { foreground }]) => ({
1391
+ bgColor: toCamelCase(`bg-${name}`),
1392
+ color: foreground ?? "ink"
1393
+ }));
1394
+ /** What `colorLabels` hands out under each mode; `none` still picks, unseen. */
1395
+ const PALETTES = {
1396
+ none: BASIC_PALETTE,
1397
+ basic: BASIC_PALETTE,
1398
+ soft: PALETTE.filter((pair) => pair.color === "ink"),
1399
+ all: PALETTE
1400
+ };
1401
+ /** A built-in style or one of runset's foreground/background shades. */
1402
+ function isColorName(name) {
1403
+ return KNOWN.has(name) || shade(name) !== void 0;
1455
1404
  }
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] : [];
1405
+ /**
1406
+ * Styles text; a shade is painted from the 256-color table. Plain text when
1407
+ * not `enabled`.
1408
+ */
1409
+ function paint(text, colors, enabled = true) {
1410
+ if (!enabled) return text;
1411
+ let result = text;
1412
+ for (const name of colors.toReversed()) {
1413
+ const custom = shade(name);
1414
+ if (custom !== void 0) {
1415
+ const background = name.startsWith("bg");
1416
+ const open = `\u001B[${background ? 48 : 38};5;${custom.index}m`;
1417
+ const close = `\u001B[${background ? 49 : 39}m`;
1418
+ result = open + result.replaceAll(close, open) + close;
1419
+ } else if (KNOWN.has(name)) result = styleText(name, result, { validateStream: false });
1420
+ }
1421
+ return result;
1461
1422
  }
1462
- function packageNameOf(pkg) {
1463
- return pkg.name || path.basename(path.dirname(pkg.filePath));
1423
+ /** The theme color used for a shade on a basic terminal. */
1424
+ function fallbackColor(name) {
1425
+ const custom = shade(name);
1426
+ if (custom === void 0) return name;
1427
+ return name.startsWith("bg") ? toCamelCase(`bg-${custom.fallback}`) : custom.fallback;
1464
1428
  }
1465
- function isSettingsEntry(definition) {
1466
- return typeof definition === "object" && definition !== null && !Array.isArray(definition) && isCommandSettings(definition);
1429
+ /** A stable palette index for a label. */
1430
+ function paletteIndex(label, palette = PALETTE) {
1431
+ /** The largest modulus that keeps `hash * 31 + code` an exact integer. */
1432
+ const HASH_MODULUS = 2147483647;
1433
+ let hash = 0;
1434
+ for (const character of label) hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % HASH_MODULUS;
1435
+ return hash % palette.length;
1467
1436
  }
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
- };
1437
+ function shade(name) {
1438
+ return SHADE_NAMES.get(name);
1474
1439
  }
1440
+ //#endregion
1441
+ //#region src/plan/labels.ts
1475
1442
  /**
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.
1443
+ * Settles who is labelled, per `config.labels`. Under `auto`, a command
1444
+ * sharing a stage is named after itself, whether or not someone in its stage
1445
+ * has a label of their own. Under `custom` it gets a blank label instead,
1446
+ * holding the column when someone in its stage has one.
1478
1447
  */
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;
1448
+ function assignAutoLabels({ commands, config }) {
1449
+ const mode = config.labels;
1450
+ if (mode === "none") {
1451
+ for (const command of commands) command.label = "";
1452
+ return;
1488
1453
  }
1489
- return resolveCwd(options, base);
1454
+ const named = [];
1455
+ for (const stage of groupByStage(commands, (command) => command.stage)) {
1456
+ const shared = stage.length > 1;
1457
+ const labeled = stage.some((command) => command.label !== "");
1458
+ for (const command of stage) {
1459
+ if (command.label !== "") continue;
1460
+ if (mode === "all" || mode === "auto" && shared) {
1461
+ command.label = nameOf(command);
1462
+ named.push(command);
1463
+ } else if (shared && labeled) command.label = " ";
1464
+ }
1465
+ }
1466
+ numberRepeats(named);
1490
1467
  }
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);
1468
+ /**
1469
+ * Gives each labelled command without colors of its own a palette pair: the
1470
+ * one its label hashes to, or the next free when that is taken, from the
1471
+ * palette of `config.color`. Without shades every one is first swapped for its
1472
+ * theme color, so nothing past the plan has to know how many colors there are.
1473
+ */
1474
+ function colorLabels({ commands, config }) {
1475
+ const palette = PALETTES[config.color];
1476
+ if (config.color === "none" || config.color === "basic") for (const command of commands) {
1477
+ command.bgColor = fallbackColor(command.bgColor);
1478
+ command.color = fallbackColor(command.color);
1479
+ }
1480
+ const labeled = commands.filter((command) => command.label !== "");
1481
+ const taken = new Set(labeled.map((command) => palette.findIndex((pair) => pair.bgColor === command.bgColor)).filter((index) => index !== -1));
1482
+ for (const command of labeled) {
1483
+ if (command.color !== "" || command.bgColor !== "" || command.label.trim() === "") continue;
1484
+ const index = claim(paletteIndex(command.label, palette), taken, palette.length);
1485
+ taken.add(index);
1486
+ const pair = palette[index];
1487
+ command.bgColor = pair.bgColor;
1488
+ command.color = pair.color;
1497
1489
  }
1498
- return result;
1499
1490
  }
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);
1491
+ /** Pads every label to the widest one. */
1492
+ function alignLabels({ commands }) {
1493
+ const labeled = commands.filter((command) => command.label !== "");
1494
+ const width = Math.max(0, ...labeled.map((command) => command.label.length));
1495
+ for (const command of labeled) command.label = command.label.padEnd(width);
1496
+ }
1497
+ /** The script name, or the program a shell command runs. */
1498
+ function nameOf(command) {
1499
+ return command.packageName ?? command.scriptName ?? command.command.split(/\s/, 1)[0] ?? "";
1500
+ }
1501
+ /** `serve`, `serve` → `serve#1`, `serve#2`. */
1502
+ function numberRepeats(named) {
1503
+ const totals = /* @__PURE__ */ new Map();
1504
+ for (const command of named) totals.set(command.label, (totals.get(command.label) ?? 0) + 1);
1505
+ const counters = /* @__PURE__ */ new Map();
1506
+ for (const command of named) {
1507
+ if ((totals.get(command.label) ?? 0) < 2) continue;
1508
+ const index = (counters.get(command.label) ?? 0) + 1;
1509
+ counters.set(command.label, index);
1510
+ command.label = `${command.label}#${index}`;
1511
+ }
1512
+ }
1513
+ function claim(preferred, taken, size) {
1514
+ if (taken.size >= size) return preferred;
1515
+ let index = preferred;
1516
+ while (taken.has(index)) index = (index + 1) % size;
1517
+ return index;
1519
1518
  }
1520
1519
  //#endregion
1521
1520
  //#region src/plan/plan.ts
@@ -1602,13 +1601,11 @@ var FileRegistry = class {
1602
1601
  error;
1603
1602
  streams = /* @__PURE__ */ new Map();
1604
1603
  cwd;
1605
- handler;
1606
- constructor(cwd) {
1604
+ onFailure;
1605
+ /** `onFailure` is called once, with the first failure. */
1606
+ constructor(cwd, onFailure) {
1607
1607
  this.cwd = cwd;
1608
- }
1609
- /** Called once, with the first failure. */
1610
- onFailure(handler) {
1611
- this.handler = handler;
1608
+ this.onFailure = onFailure;
1612
1609
  }
1613
1610
  open(destination) {
1614
1611
  const filePath = path.resolve(this.cwd, destination);
@@ -1626,12 +1623,12 @@ var FileRegistry = class {
1626
1623
  async closeAll() {
1627
1624
  const streams = [...this.streams.values()];
1628
1625
  this.streams.clear();
1629
- await Promise.all(streams.map(async (stream) => closed(stream)));
1626
+ await Promise.all(streams.map((stream) => closed(stream)));
1630
1627
  }
1631
1628
  fail(destination, error) {
1632
1629
  if (this.error !== void 0) return;
1633
1630
  this.error = /* @__PURE__ */ new Error(`cannot write to "${destination}": ${error.message}`);
1634
- this.handler?.(this.error);
1631
+ this.onFailure(this.error);
1635
1632
  }
1636
1633
  };
1637
1634
  function closed(stream) {
@@ -1679,8 +1676,7 @@ var Logger = class {
1679
1676
  }
1680
1677
  write(level, message) {
1681
1678
  if (RANK[level] > RANK[this.level]) return;
1682
- const text = this.color ? paint(message, COLORS[level]) : message;
1683
- this.stream.write(`${text}\n`);
1679
+ this.stream.write(`${paint(message, COLORS[level], this.color)}\n`);
1684
1680
  }
1685
1681
  };
1686
1682
  //#endregion
@@ -1721,17 +1717,28 @@ function createEnv({ base, cwd, packageInfo, scriptName }) {
1721
1717
  const PATH_KEY = process.platform === "win32" ? "Path" : "PATH";
1722
1718
  const pathKey = Object.keys(base).find((key) => key.toUpperCase() === "PATH") ?? PATH_KEY;
1723
1719
  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;
1720
+ const own = {};
1721
+ if (packageInfo.name !== "") own.npm_package_name = packageInfo.name;
1722
+ if (packageInfo.version !== "") own.npm_package_version = packageInfo.version;
1730
1723
  if (scriptName !== void 0) {
1731
- env.npm_lifecycle_event = scriptName;
1732
- env.npm_lifecycle_script = packageInfo.scripts[scriptName] ?? "";
1724
+ own.npm_lifecycle_event = scriptName;
1725
+ own.npm_lifecycle_script = packageInfo.scripts[scriptName] ?? "";
1733
1726
  }
1734
- return env;
1727
+ return {
1728
+ ...withoutSpellingsOf(base, Object.keys(own)),
1729
+ ...own,
1730
+ [pathKey]: [...binDirs, base[pathKey]].filter(Boolean).join(path.delimiter)
1731
+ };
1732
+ }
1733
+ /**
1734
+ * Windows names are case-insensitive, and of two spellings a child is given
1735
+ * the one that sorts first — an outer npm's `NPM_…` over ours — so on Windows
1736
+ * every other spelling of a name about to be set is dropped.
1737
+ */
1738
+ function withoutSpellingsOf(env, names) {
1739
+ if (process.platform !== "win32") return env;
1740
+ const replaced = new Set(names.map((name) => name.toUpperCase()));
1741
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !replaced.has(key.toUpperCase())));
1735
1742
  }
1736
1743
  //#endregion
1737
1744
  //#region src/run/OutputSink.ts
@@ -1740,31 +1747,26 @@ function createEnv({ base, cwd, packageInfo, scriptName }) {
1740
1747
  * `grouped` holds everything until {@link flush}, `none` discards.
1741
1748
  */
1742
1749
  var OutputSink = class {
1750
+ /** A grouped stream's output, or a prefixed stream's unfinished line. */
1743
1751
  buffered = "";
1744
- /** A prefixed stream's unfinished line, held until its newline. */
1745
- pending = "";
1746
1752
  opened;
1747
1753
  /** Whether everything written so far ended with a newline. */
1748
1754
  atLineStart = true;
1749
1755
  std;
1750
1756
  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) {
1757
+ /** The columns a fixed prefix takes; measured per line otherwise. */
1758
+ prefixWidth;
1759
+ context;
1760
+ constructor(std, prefix, context) {
1757
1761
  this.std = std;
1758
1762
  this.prefix = prefix;
1759
- this.destinations = destinations;
1760
- this.files = files;
1761
- this.wrap = wrap;
1762
- this.envColumns = envColumns;
1763
+ this.prefixWidth = typeof prefix === "string" ? visibleWidth(prefix) : void 0;
1764
+ this.context = context;
1763
1765
  }
1764
1766
  /** Opened on first use, so a command that never writes truncates nothing. */
1765
1767
  get target() {
1766
- if (isStreamDestination(this.std.destination)) return this.destinations[this.std.destination];
1767
- this.opened ??= this.files.open(this.std.destination);
1768
+ if (isStreamDestination(this.std.destination)) return this.context.config.destinations[this.std.destination];
1769
+ this.opened ??= this.context.files.open(this.std.destination);
1768
1770
  return this.opened;
1769
1771
  }
1770
1772
  /**
@@ -1772,8 +1774,9 @@ var OutputSink = class {
1772
1774
  * else `COLUMNS`. A file has none, and neither has a run without `wrap`.
1773
1775
  */
1774
1776
  columns() {
1775
- if (!this.wrap || !isStreamDestination(this.std.destination)) return;
1776
- return terminalColumns(this.target) ?? this.envColumns;
1777
+ const { envColumns, wrap } = this.context.config;
1778
+ if (!wrap || !isStreamDestination(this.std.destination)) return;
1779
+ return terminalColumns(this.target) ?? envColumns;
1777
1780
  }
1778
1781
  write(chunk) {
1779
1782
  if (this.std.destination === "none" || chunk === "") return;
@@ -1786,11 +1789,13 @@ var OutputSink = class {
1786
1789
  this.target.write(chunk);
1787
1790
  return;
1788
1791
  }
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);
1792
+ const end = chunk.lastIndexOf("\n");
1793
+ if (end === -1) {
1794
+ this.buffered += chunk;
1795
+ return;
1796
+ }
1797
+ const complete = this.buffered + chunk.slice(0, end + 1);
1798
+ this.buffered = chunk.slice(end + 1);
1794
1799
  this.target.write(this.applyPrefix(complete));
1795
1800
  }
1796
1801
  /** A line of runset's own, on a line of its own: after any unfinished one. */
@@ -1799,27 +1804,32 @@ var OutputSink = class {
1799
1804
  }
1800
1805
  /** Emits what is held back: a `grouped` stream, or a partial line. */
1801
1806
  flush() {
1802
- const held = [this.pending, this.buffered].filter((text) => text !== "");
1803
- this.pending = "";
1807
+ const held = this.buffered;
1804
1808
  this.buffered = "";
1805
- for (const text of held) this.target.write(this.applyPrefix(text));
1809
+ if (held !== "") this.target.write(this.applyPrefix(held));
1806
1810
  }
1807
1811
  applyPrefix(chunk) {
1808
1812
  const { prefix } = this;
1809
1813
  if (prefix === "") return chunk;
1810
1814
  const endsWithNewline = chunk.endsWith("\n");
1811
1815
  const body = endsWithNewline ? chunk.slice(0, -1) : chunk;
1816
+ const end = endsWithNewline ? "\n" : "";
1812
1817
  const columns = this.columns();
1813
- return body.split("\n").map((line) => this.label(line, prefix, columns)).join("\n") + (endsWithNewline ? "\n" : "");
1818
+ if (typeof prefix === "string" && columns === void 0) return prefix + body.replaceAll("\n", `\n${prefix}`) + end;
1819
+ return body.split("\n").map((line) => this.label(line, prefix, columns)).join("\n") + end;
1814
1820
  }
1815
1821
  /** One line, prefixed; wrapped first when it would overflow the terminal. */
1816
1822
  label(line, prefix, columns) {
1817
1823
  const text = typeof prefix === "string" ? prefix : prefix();
1818
1824
  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");
1825
+ const cr = line.endsWith("\r") ? "\r" : "";
1826
+ const width = this.prefixWidth ?? visibleWidth(text);
1827
+ return wrapLine(line.slice(0, line.length - cr.length), columns - width, width).map((piece) => text + piece).join("\n") + cr;
1821
1828
  }
1822
1829
  };
1830
+ function isStreamDestination(destination) {
1831
+ return destination === "stdout" || destination === "stderr";
1832
+ }
1823
1833
  //#endregion
1824
1834
  //#region src/run/Process.ts
1825
1835
  const USE_PROCESS_GROUPS = process.platform !== "win32";
@@ -1842,9 +1852,9 @@ var Process = class {
1842
1852
  constructor(command, context) {
1843
1853
  this.command = command;
1844
1854
  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);
1855
+ const { color, formatLabel } = context.config;
1856
+ const colored = color !== "none";
1857
+ const sink = (stream) => new OutputSink(command[stream], makePrefix(command, colored, formatLabel, stream), context);
1848
1858
  this.stdout = sink("stdout");
1849
1859
  this.stderr = sink("stderr");
1850
1860
  }
@@ -1862,12 +1872,13 @@ var Process = class {
1862
1872
  let stopTheRun = false;
1863
1873
  for (;;) {
1864
1874
  this.started = true;
1865
- if (this.context.config.showCommand) {
1866
- const run = this.context.config.color === "none" ? "run" : paint("run", ["blue"]);
1875
+ const { color, showCommand, showExitCode } = this.context.config;
1876
+ if (showCommand) {
1877
+ const run = paint("run", ["blue"], color !== "none");
1867
1878
  this.stdout.writeLine(`${run} ${this.command.command}`);
1868
1879
  }
1869
1880
  await this.spawn();
1870
- if (this.context.config.showExitCode) this.stdout.writeLine(this.exitLine());
1881
+ if (showExitCode) this.stdout.writeLine(this.exitLine());
1871
1882
  this.stdout.flush();
1872
1883
  this.stderr.flush();
1873
1884
  this.context.logger.debug(`< ${this.command.line} ${this.describeExit()}`);
@@ -1895,12 +1906,12 @@ var Process = class {
1895
1906
  * in gray, so it can be found without its label.
1896
1907
  */
1897
1908
  exitLine() {
1898
- const color = this.context.config.color !== "none";
1909
+ const colored = this.context.config.color !== "none";
1899
1910
  const duration = formatDuration(performance.now() - this.attemptStart);
1900
- if (this.exitCode === 0 && this.signal === void 0 && !this.terminated) return `${color ? paint("✓", ["green"]) : "✓"} ${duration}`;
1911
+ if (this.exitCode === 0 && this.signal === void 0 && !this.terminated) return `${paint("✓", ["green"], colored)} ${duration}`;
1901
1912
  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}`;
1913
+ const command = paint(this.command.command, ["gray"], colored);
1914
+ return `${paint(status, [tint], colored)} · ${duration} ${command}`;
1904
1915
  }
1905
1916
  /** `code N`, the signal's name, or `stopped`. */
1906
1917
  shortExit() {
@@ -2017,6 +2028,7 @@ async function schedule(processes, stopped, jobs) {
2017
2028
  //#region src/run/Run.ts
2018
2029
  /** One run: the processes built from a plan, and what stops them. */
2019
2030
  var Run = class Run {
2031
+ /** @internal */
2020
2032
  config;
2021
2033
  /** @internal */
2022
2034
  processes;
@@ -2025,9 +2037,8 @@ var Run = class Run {
2025
2037
  stopping;
2026
2038
  constructor({ commands, config, packageInfo }) {
2027
2039
  this.config = config;
2028
- this.files = new FileRegistry(config.cwd);
2040
+ this.files = new FileRegistry(config.cwd, () => this.stopEverything({ reason: "policy" }));
2029
2041
  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
2042
  const context = {
2032
2043
  config,
2033
2044
  files: this.files,
@@ -2036,7 +2047,6 @@ var Run = class Run {
2036
2047
  requestStop: () => this.stopEverything({ reason: "policy" })
2037
2048
  };
2038
2049
  this.processes = commands.map((command) => new Process(command, context));
2039
- this.files.onFailure(() => this.stopEverything({ reason: "policy" }));
2040
2050
  }
2041
2051
  /** A library caller's config object, through the same pipeline as the CLI. */
2042
2052
  static fromConfigJs(configJs) {
@@ -2058,6 +2068,7 @@ var Run = class Run {
2058
2068
  }
2059
2069
  /** Rejects with a {@link RunsetError} when the run did not succeed. */
2060
2070
  async start() {
2071
+ for (const warning of this.config.warnings) this.logger.warn(`runset: ${warning}`);
2061
2072
  if (this.config.dryRun) {
2062
2073
  this.config.destinations.stdout.write(`${this.describe()}\n`);
2063
2074
  return this;
@@ -2075,43 +2086,45 @@ var Run = class Run {
2075
2086
  await this.files.closeAll();
2076
2087
  }
2077
2088
  const failed = this.failed();
2078
- const exitCode = failed[0]?.getReportedExitCode();
2089
+ const exitCode = this.getExitCode();
2079
2090
  const { stopping } = this;
2080
- if (this.files.error) throw new RunsetError(this.files.error.message, exitCode ?? 1);
2091
+ if (this.files.error) throw new RunsetError(this.files.error.message, exitCode || 1);
2081
2092
  if (stopping?.signal) throw new RunsetError(`the run was stopped by ${stopping.signal}`, signalExitCode(stopping.signal), "");
2082
2093
  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());
2094
+ if (failed.length > 0) {
2095
+ const tally = this.tally(failed);
2096
+ throw new RunsetError(`${tally}: ${failed.map((process) => describeFailure(process)).join(", ")}`, exitCode, this.failureReport(tally, failed));
2097
+ }
2084
2098
  return this;
2085
2099
  }
2086
2100
  terminate(options = {}) {
2087
2101
  this.stopEverything({ reason: "terminate" }, options);
2088
2102
  }
2089
2103
  /** `1 of 5 commands failed, 2 stopped, 1 not started`. */
2090
- tally() {
2104
+ tally(failed) {
2091
2105
  const { processes } = this;
2092
2106
  const total = processes.length;
2093
2107
  const stopped = processes.filter((process) => process.started && process.terminated).length;
2094
2108
  const notStarted = processes.filter((process) => !process.started).length;
2095
2109
  return [
2096
- `${this.failed().length} of ${total} command${total === 1 ? "" : "s"} failed`,
2110
+ `${failed.length} of ${total} command${total === 1 ? "" : "s"} failed`,
2097
2111
  stopped > 0 && `${stopped} stopped`,
2098
2112
  notStarted > 0 && `${notStarted} not started`
2099
2113
  ].filter(Boolean).join(", ");
2100
2114
  }
2101
2115
  /** 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) => {
2116
+ failureReport(tally, failed) {
2117
+ const colored = this.config.color !== "none";
2118
+ const red = (text) => paint(text, ["red"], colored);
2119
+ return [tally, ...failed.map((process) => {
2106
2120
  const { command } = process;
2107
2121
  const label = command.label.trim();
2108
- const prefix = makePrefix({
2122
+ const prefix = label === "" ? "" : defaultLabel({
2109
2123
  ...command,
2110
2124
  label
2111
- }, color, void 0, "stderr");
2125
+ }, colored);
2112
2126
  return ` ${red("✖")} ${prefix}${command.command} ${red(process.describeExit())}`;
2113
- });
2114
- return [this.tally(), ...rows].join("\n");
2127
+ })].join("\n");
2115
2128
  }
2116
2129
  failed() {
2117
2130
  return this.processes.filter((process) => process.isFailed());
@@ -2190,7 +2203,7 @@ Output:
2190
2203
  none | auto | custom | all (default: auto)
2191
2204
  --show-command print each command as it starts
2192
2205
  --show-exit-code mark each exit and its duration: ✓ 2.1s, ✗ code 1 · 2.1s
2193
- -w, --wrap wrap labelled lines to the terminal, a label on each
2206
+ --no-wrap leave long labelled lines whole instead of wrapping
2194
2207
 
2195
2208
  General:
2196
2209
  -c, --config <path> load a config file, skipping the default lookup
@@ -2250,7 +2263,17 @@ function version() {
2250
2263
  async function runset(first, second) {
2251
2264
  return Run.fromConfigJs(toConfigJs(first, second)).start();
2252
2265
  }
2253
- if (import.meta.main) process.exitCode = await main(process.argv.slice(2));
2266
+ if (import.meta.main || isEntryPoint()) main(process.argv.slice(2)).then((code) => {
2267
+ process.exitCode = code;
2268
+ }).catch((error) => {
2269
+ console.error(error);
2270
+ process.exitCode = 1;
2271
+ }).finally(() => {
2272
+ setTimeout(() => {
2273
+ console.error("runset: forced exit, a handle kept the process alive");
2274
+ process.exit();
2275
+ }, 500).unref();
2276
+ });
2254
2277
  /** A lone object carrying `commands` is a config; anything else is commands. */
2255
2278
  function toConfigJs(first, second) {
2256
2279
  if (second === void 0 && isConfigObject(first)) return first;
@@ -2261,7 +2284,20 @@ function toConfigJs(first, second) {
2261
2284
  };
2262
2285
  }
2263
2286
  function isConfigObject(value) {
2264
- return typeof value === "object" && value !== null && !Array.isArray(value) && Array.isArray(value.commands);
2287
+ return isPlainObject(value) && Array.isArray(value.commands);
2288
+ }
2289
+ /**
2290
+ * Node 24.2 reports `import.meta.main` as false for a `.ts` entry point, which
2291
+ * is how the tests run this file; 24.3 fixed it. The module URL is a real
2292
+ * path, so the argument is resolved to one too — macOS's temp dir is a link.
2293
+ */
2294
+ function isEntryPoint() {
2295
+ const entry = process.argv[1];
2296
+ try {
2297
+ return entry !== void 0 && fs.realpathSync(entry) === fileURLToPath(import.meta.url);
2298
+ } catch {
2299
+ return false;
2300
+ }
2265
2301
  }
2266
2302
  //#endregion
2267
- export { CliError, ConfigError, EXIT_ACTIONS, NormalizeError, Run, RunsetError, runset as default, runset, isCommandSettings, isStreamDestination };
2303
+ export { CliError, ConfigError, NormalizeError, Run, RunsetError, runset as default, runset };