@ix-xs/node-comfort 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +32 -30
  3. package/index.js +3 -3
  4. package/package.json +8 -7
  5. package/src/Arr.js +222 -58
  6. package/src/Async.js +322 -90
  7. package/src/Cache.js +148 -44
  8. package/src/Checker.js +257 -26
  9. package/src/Cli.js +404 -145
  10. package/src/Color.js +224 -83
  11. package/src/Crypto.js +481 -106
  12. package/src/Emitter.js +104 -37
  13. package/src/Env.js +176 -42
  14. package/src/FS.js +203 -60
  15. package/src/Func.js +226 -76
  16. package/src/Http.js +311 -149
  17. package/src/Id.js +281 -96
  18. package/src/Logger.js +302 -78
  19. package/src/Num.js +182 -60
  20. package/src/Obj.js +340 -125
  21. package/src/SQLite.js +426 -105
  22. package/src/Schema.js +602 -87
  23. package/src/Str.js +316 -86
  24. package/src/Sys.js +197 -54
  25. package/src/Time.js +572 -317
  26. package/src/Utils.js +62 -41
  27. package/src/errors.js +54 -11
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +59 -17
  30. package/types/src/Arr.d.ts +78 -0
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +52 -1
  33. package/types/src/Async.d.ts.map +1 -1
  34. package/types/src/Cache.d.ts +33 -7
  35. package/types/src/Cache.d.ts.map +1 -1
  36. package/types/src/Checker.d.ts +155 -2
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +97 -15
  39. package/types/src/Cli.d.ts.map +1 -1
  40. package/types/src/Color.d.ts +7 -1
  41. package/types/src/Color.d.ts.map +1 -1
  42. package/types/src/Crypto.d.ts +99 -13
  43. package/types/src/Crypto.d.ts.map +1 -1
  44. package/types/src/Emitter.d.ts +36 -17
  45. package/types/src/Emitter.d.ts.map +1 -1
  46. package/types/src/Env.d.ts +31 -1
  47. package/types/src/Env.d.ts.map +1 -1
  48. package/types/src/FS.d.ts +36 -5
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +147 -19
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +113 -43
  53. package/types/src/Http.d.ts.map +1 -1
  54. package/types/src/Id.d.ts +60 -8
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +53 -2
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +48 -7
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +67 -4
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +123 -14
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +256 -7
  65. package/types/src/Schema.d.ts.map +1 -1
  66. package/types/src/Str.d.ts +49 -1
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +51 -6
  69. package/types/src/Sys.d.ts.map +1 -1
  70. package/types/src/Time.d.ts +130 -34
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +15 -11
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +27 -3
  75. package/types/src/errors.d.ts.map +1 -1
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
package/src/Cli.js CHANGED
@@ -15,9 +15,9 @@
15
15
  * spin.succeed("Installed");
16
16
  */
17
17
 
18
- const readline = require("node:readline");
19
18
  const { parseArgs } = require("node:util");
20
19
  const color = require("./Color.js");
20
+ const { AbortError } = require("./errors.js");
21
21
 
22
22
  /**
23
23
  * Border styles for `table()` and `box()`.
@@ -143,14 +143,15 @@ const color = require("./Color.js");
143
143
  * @property {unknown} [default] Used when the flag is missing.
144
144
  * @property {boolean} [multiple] Can be repeated (`--tag a --tag b`); you get an array.
145
145
  * @property {boolean} [required] Fail if it's missing.
146
- * @property {readonly string[]} [choices] Allowed values, for string flags.
146
+ * @property {readonly string[] | readonly number[]} [choices] Allowed values.
147
+ * @property {string} [env] An environment variable to read when the flag isn't given, like `"PORT"`. The command line wins, then the variable, then `default`.
147
148
  * @property {string} [description] Shown in `--help`.
148
149
  */
149
150
 
150
151
  /**
151
152
  * The type of a flag's value.
152
153
  * @template {FlagSpec} F
153
- * @typedef {F["type"] extends "number" ? number : F["type"] extends "boolean" ? boolean : F extends { choices: readonly (infer C)[] } ? C : string} FlagValue
154
+ * @typedef {F["type"] extends "boolean" ? boolean : F extends { choices: readonly (infer C)[] } ? C : F["type"] extends "number" ? number : string} FlagValue
154
155
  */
155
156
 
156
157
  /**
@@ -166,9 +167,12 @@ const color = require("./Color.js");
166
167
  * @property {string} [name] Program name in the help. Defaults to the script name.
167
168
  * @property {string} [description] Shown at the top of the help.
168
169
  * @property {string} [usage] A usage line, like `"<file> [options]"`.
170
+ * @property {Record<string, string>} [commands] Subcommands and what they do, like `{ build: "Build the project" }`. The first argument, when there is one, must be one of them and comes back as `command`. They share the flags of the program.
171
+ * @property {string[]} [examples] Command lines shown at the end of the help.
169
172
  * @property {boolean} [help] Answer `--help` and `-h` by printing the help and exiting. Defaults to `true`.
170
173
  * @property {string} [version] Answer `--version` with this version.
171
174
  * @property {boolean} [strict] Reject unknown flags, with a suggestion. Defaults to `true`.
175
+ * @property {Record<string, string | undefined>} [env] Where `env` flags are read from. Defaults to `process.env`.
172
176
  */
173
177
 
174
178
  /**
@@ -177,9 +181,22 @@ const color = require("./Color.js");
177
181
  * @typedef {object} ParsedArgs
178
182
  * @property {ParsedFlags<S>} flags
179
183
  * @property {string[]} positionals Arguments that aren't flags.
184
+ * @property {string | undefined} command The subcommand, when `commands` was given.
180
185
  * @property {() => string} help The generated help text.
181
186
  */
182
187
 
188
+ /**
189
+ * Options for `spin()`: the spinner's, plus what to say at the end.
190
+ * @template T
191
+ * @typedef {SpinnerOptions & { success?: string | ((result: T) => string), fail?: string | ((error: unknown) => string) }} SpinOptions
192
+ */
193
+
194
+ /**
195
+ * Options for `tree()`.
196
+ * @typedef {object} TreeOptions
197
+ * @property {boolean} [ascii] Draw with `|`, `+` and `-` instead of box characters.
198
+ */
199
+
183
200
  /** @type {Record<BorderStyle, string[]>} top-left, top, top-right, right, bottom-right, bottom, bottom-left, left, mid-left, mid, mid-right, cross, top-mid, bottom-mid */
184
201
  const BORDERS = {
185
202
  rounded: ["╭", "─", "╮", "│", "╯", "─", "╰", "│", "├", "─", "┤", "┼", "┬", "┴"],
@@ -193,6 +210,25 @@ const BORDERS = {
193
210
  const SHOW_CURSOR = "\u001b[?25h";
194
211
  const HIDE_CURSOR = "\u001b[?25l";
195
212
  const CLEAR_LINE = "\r\u001b[2K";
213
+ const CLEAR_BELOW = "\u001b[0J";
214
+ const ESCAPE = "\u001b";
215
+ /** @param {number} lines */
216
+ const _up = (lines) => `\u001b[${lines}A`;
217
+
218
+ const _FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
219
+ const _LINE_BREAK = /\r?\n/;
220
+ const _LINE_BREAKS = /\r?\n/g;
221
+
222
+ /**
223
+ * Makes sure a status line fits on one row: a line that wraps can't be
224
+ * cleared with a single escape code.
225
+ * @param {string} line
226
+ * @param {{ columns?: number }} stream
227
+ */
228
+ const _oneRow = (line, stream) => {
229
+ const flat = line.includes("\n") ? line.replace(_LINE_BREAKS, " ") : line;
230
+ return stream.columns ? color.truncate(flat, stream.columns - 1) : flat;
231
+ };
196
232
 
197
233
  /** @param {boolean | undefined} forced */
198
234
  const _palette = (forced) => (forced === undefined ? color : color.create(forced ? (color.level > 0 ? color.level : 3) : 0));
@@ -221,21 +257,6 @@ const _align = (text, width, align) => {
221
257
  return text + " ".repeat(gap);
222
258
  };
223
259
 
224
- /**
225
- * @param {string} text
226
- * @param {number} max
227
- */
228
- const _cut = (text, max) => {
229
- if (color.width(text) <= max) return text;
230
- const plain = color.strip(text);
231
- let out = "";
232
- for (const { segment } of new Intl.Segmenter().segment(plain)) {
233
- if (color.width(out + segment) > max - 1) break;
234
- out += segment;
235
- }
236
- return `${out}…`;
237
- };
238
-
239
260
  /** @param {unknown} value */
240
261
  const _cell = (value) => {
241
262
  if (value === null || value === undefined) return "";
@@ -290,53 +311,82 @@ const _restoreCursorOnExit = (stream) => {
290
311
  function table(rows, options = {}) {
291
312
  const palette = _palette(options.colors);
292
313
  const padding = " ".repeat(Math.max(0, options.padding ?? 1));
314
+ const markdown = options.border === "markdown";
293
315
  const isArrayRows = rows.length > 0 && Array.isArray(rows[0]);
294
- /** @type {TableColumn[]} */
295
- const columns = (options.columns ?? (isArrayRows
296
- ? Array.from({ length: Math.max(0, ...rows.map((r) => /** @type {unknown[]} */ (r).length)) }, (_, i) => i)
297
- : [...new Set(rows.flatMap((r) => Object.keys(/** @type {object} */ (r ?? {}))))]
298
- )).map((c) => (typeof c === "object" ? c : { key: c }));
299
-
300
- const body = rows.map((row) => columns.map((col) => {
301
- const raw = /** @type {any} */ (row)?.[col.key];
302
- const value = col.format ? col.format(raw, row) : raw;
303
- const text = _cell(value);
304
- return { text: col.maxWidth ? _cut(text, col.maxWidth) : text, numeric: typeof raw === "number" || typeof raw === "bigint" };
305
- }));
316
+ /** @type {Array<string | number | TableColumn>} */
317
+ let keys = options.columns ?? [];
318
+ if (!options.columns && isArrayRows) {
319
+ let longest = 0;
320
+ for (const row of rows) longest = Math.max(longest, /** @type {unknown[]} */ (row).length);
321
+ keys = Array.from({ length: longest }, (_, i) => i);
322
+ } else if (!options.columns) {
323
+ /** @type {Set<string>} */
324
+ const seen = new Set();
325
+ for (const row of rows) for (const key of Object.keys(row ?? {})) seen.add(key);
326
+ keys = [...seen];
327
+ }
328
+ const columns = keys.map((column) => (typeof column === "object" ? column : { key: column }));
329
+ if (!columns.length) return "";
306
330
  const showHeader = options.header ?? !isArrayRows;
307
- const headers = columns.map((c) => c.header ?? String(c.key));
308
- const widths = columns.map((c, i) => Math.max(showHeader ? color.width(headers[i]) : 0, ...body.map((r) => color.width(r[i].text)), 1));
309
- const alignOf = (/** @type {number} */ i, /** @type {boolean} */ numeric) => columns[i].align ?? (numeric ? "right" : "left");
331
+ const headers = columns.map((column) => column.header ?? String(column.key));
332
+ const widths = headers.map((header) => Math.max(markdown ? 3 : 1, showHeader ? color.width(header) : 0));
333
+ const numeric = columns.map(() => rows.length > 0);
334
+
335
+ /** @type {Array<Array<{ lines: string[], numeric: boolean }>>} */
336
+ const body = [];
337
+ for (const row of rows) {
338
+ /** @type {Array<{ lines: string[], numeric: boolean }>} */
339
+ const cells = [];
340
+ for (let i = 0; i < columns.length; i++) {
341
+ const column = columns[i];
342
+ const raw = /** @type {any} */ (row)?.[column.key];
343
+ const text = _cell(column.format ? column.format(raw, row) : raw);
344
+ let lines = text.includes("\n") ? text.split(_LINE_BREAK) : [text];
345
+ if (column.maxWidth) lines = lines.map((line) => color.truncate(line, /** @type {number} */ (column.maxWidth)));
346
+ // Markdown has no multi-line cells, and a pipe would end the cell.
347
+ if (markdown) lines = [lines.join("<br>").replace(/\|/g, "\\|")];
348
+ for (const line of lines) widths[i] = Math.max(widths[i], color.width(line));
349
+ const isNumber = typeof raw === "number" || typeof raw === "bigint";
350
+ if (!isNumber) numeric[i] = false;
351
+ cells.push({ lines, numeric: isNumber });
352
+ }
353
+ body.push(cells);
354
+ }
310
355
 
311
- if (options.border === "markdown") {
356
+ if (markdown) {
312
357
  const line = (/** @type {string[]} */ cells) => `| ${cells.join(" | ")} |`;
313
- const escaped = body.map((r) => r.map((c) => ({ ...c, text: c.text.replace(/\|/g, "\\|") })));
314
- const mdWidths = widths.map((w, i) => Math.max(3, w, ...escaped.map((r) => color.width(r[i].text))));
315
- /** @type {Array<"left" | "center" | "right">} */
316
- const mdAlign = columns.map((c, i) => c.align ?? (body.length > 0 && body.every((r) => r[i].numeric) ? "right" : "left"));
358
+ const aligns = columns.map((column, i) => column.align ?? (numeric[i] ? "right" : "left"));
317
359
  const out = [];
318
360
  if (showHeader) {
319
- out.push(line(headers.map((h, i) => _align(h, mdWidths[i], mdAlign[i]))));
320
- out.push(line(mdWidths.map((w, i) => {
321
- const dashes = "-".repeat(w);
322
- return mdAlign[i] === "center" ? `:${dashes.slice(2)}:` : mdAlign[i] === "right" ? `${dashes.slice(1)}:` : dashes;
361
+ out.push(line(headers.map((header, i) => _align(header, widths[i], aligns[i]))));
362
+ out.push(line(widths.map((width, i) => {
363
+ const dashes = "-".repeat(width);
364
+ return aligns[i] === "center" ? `:${dashes.slice(2)}:` : aligns[i] === "right" ? `${dashes.slice(1)}:` : dashes;
323
365
  })));
324
366
  }
325
- for (const r of escaped) out.push(line(r.map((c, i) => _align(c.text, mdWidths[i], mdAlign[i]))));
367
+ for (const cells of body) out.push(line(cells.map((cell, i) => _align(cell.lines[0], widths[i], aligns[i]))));
326
368
  return out.join("\n");
327
369
  }
328
370
 
329
- const b = BORDERS[options.border ?? "rounded"];
330
- const dim = (/** @type {string} */ s) => (palette.level > 0 ? palette.gray(s) : s);
371
+ const b = BORDERS[/** @type {BorderStyle} */ (options.border ?? "rounded")];
372
+ const dim = (/** @type {string} */ text) => (palette.level > 0 ? palette.gray(text) : text);
331
373
  const rule = (/** @type {string} */ left, /** @type {string} */ fill, /** @type {string} */ mid, /** @type {string} */ right) =>
332
- dim(left + widths.map((w) => fill.repeat(w + padding.length * 2)).join(mid) + right);
333
- const row = (/** @type {string[]} */ cells) => dim(b[7]) + cells.map((c) => padding + c + padding).join(dim(b[3])) + dim(b[3]);
374
+ dim(left + widths.map((width) => fill.repeat(width + padding.length * 2)).join(mid) + right);
375
+ const edge = dim(b[7]);
376
+ const separator = padding + dim(b[3]) + padding;
377
+ const line = (/** @type {string[]} */ cells) => edge + padding + cells.join(separator) + padding + dim(b[3]);
334
378
  const out = [rule(b[0], b[1], b[12], b[2])];
335
379
  if (showHeader) {
336
- out.push(row(headers.map((h, i) => _align(palette.level > 0 ? palette.bold(h) : h, widths[i], columns[i].align ?? "left"))));
380
+ out.push(line(headers.map((header, i) => _align(palette.level > 0 ? palette.bold(header) : header, widths[i], columns[i].align ?? "left"))));
337
381
  out.push(rule(b[8], b[9], b[11], b[10]));
338
382
  }
339
- for (const r of body) out.push(row(r.map((c, i) => _align(c.text, widths[i], alignOf(i, c.numeric)))));
383
+ for (const cells of body) {
384
+ let height = 1;
385
+ for (const cell of cells) height = Math.max(height, cell.lines.length);
386
+ for (let k = 0; k < height; k++) {
387
+ out.push(line(cells.map((cell, i) => _align(cell.lines[k] ?? "", widths[i], columns[i].align ?? (cell.numeric ? "right" : "left")))));
388
+ }
389
+ }
340
390
  out.push(rule(b[6], b[5], b[13], b[4]));
341
391
  return out.join("\n");
342
392
  }
@@ -346,10 +396,10 @@ function table(rows, options = {}) {
346
396
  *
347
397
  * @example
348
398
  * console.log(cli.box("Server ready\nhttp://localhost:3000", { title: "my-app", borderColor: "green" }));
349
- * // ╭─ my-app ──────────────────╮
350
- * // │ Server ready │
351
- * // │ http://localhost:3000 │
352
- * // ╰───────────────────────────╯
399
+ * // ╭─ my-app ────────────────╮
400
+ * // │ Server ready │
401
+ * // │ http://localhost:3000 │
402
+ * // ╰─────────────────────────╯
353
403
  *
354
404
  * @param {string} text
355
405
  * @param {BoxOptions} [options]
@@ -359,24 +409,96 @@ function box(text, options = {}) {
359
409
  const palette = _palette(options.colors);
360
410
  const b = BORDERS[options.border ?? "rounded"];
361
411
  const pad = typeof options.padding === "number" ? { x: options.padding, y: Math.floor(options.padding / 2) } : { x: 2, y: 0, ...options.padding };
362
- const lines = String(text).split(/\r?\n/);
412
+ const lines = String(text).split(_LINE_BREAK);
363
413
  const title = options.title ? ` ${options.title} ` : "";
364
- const inner = Math.max(options.width ?? 0, ...lines.map((l) => color.width(l)), color.width(title) + 2 - pad.x * 2) + pad.x * 2;
414
+ let longest = Math.max(options.width ?? 0, color.width(title) + 2 - pad.x * 2);
415
+ for (const line of lines) longest = Math.max(longest, color.width(line));
416
+ const inner = longest + pad.x * 2;
365
417
  const paint = (/** @type {string} */ s) => _paint(palette, options.borderColor, s);
366
418
  const top = title
367
419
  ? paint(b[0] + b[1]) + (palette.level > 0 ? palette.bold(title) : title) + paint(b[1].repeat(Math.max(0, inner - color.width(title) - 1)) + b[2])
368
420
  : paint(b[0] + b[1].repeat(inner) + b[2]);
421
+ const left = paint(b[7]) + " ".repeat(pad.x);
422
+ const right = " ".repeat(pad.x) + paint(b[3]);
369
423
  const blank = paint(b[7]) + " ".repeat(inner) + paint(b[3]);
370
424
  const out = [top];
371
425
  for (let i = 0; i < pad.y; i++) out.push(blank);
372
- for (const line of lines) {
373
- out.push(paint(b[7]) + " ".repeat(pad.x) + _align(line, inner - pad.x * 2, options.align ?? "left") + " ".repeat(pad.x) + paint(b[3]));
374
- }
426
+ for (const line of lines) out.push(left + _align(line, longest, options.align ?? "left") + right);
375
427
  for (let i = 0; i < pad.y; i++) out.push(blank);
376
428
  out.push(paint(b[6] + b[5].repeat(inner) + b[4]));
377
429
  return out.join("\n");
378
430
  }
379
431
 
432
+ /**
433
+ * Draws nested data as a tree, like the `tree` command does for folders.
434
+ * Keys are branches. A value that is an object or an array opens a level,
435
+ * `null` or `true` makes a plain leaf, anything else is shown after the key.
436
+ *
437
+ * @example
438
+ * console.log(cli.tree({
439
+ * src: { "index.js": null, lib: ["a.js", "b.js"] },
440
+ * "package.json": "1.2 kB",
441
+ * }));
442
+ * // ├── src
443
+ * // │ ├── index.js
444
+ * // │ └── lib
445
+ * // │ ├── a.js
446
+ * // │ └── b.js
447
+ * // └── package.json: 1.2 kB
448
+ *
449
+ * @param {Record<string, unknown> | unknown[]} data
450
+ * @param {TreeOptions} [options]
451
+ * @returns {string}
452
+ */
453
+ function tree(data, options = {}) {
454
+ const [branch, last, pipe, blank] = options.ascii ? ["+-- ", "+-- ", "| ", " "] : ["├── ", "└── ", "│ ", " "];
455
+ /** @type {string[]} */
456
+ const out = [];
457
+ /** @type {object[]} */
458
+ const open = [];
459
+ /** @param {unknown} value */
460
+ const isBranch = (value) => value !== null && typeof value === "object" && !(value instanceof Date);
461
+ /**
462
+ * @param {object} node
463
+ * @returns {Array<[string, unknown]>}
464
+ */
465
+ const entriesOf = (node) => {
466
+ if (!Array.isArray(node)) return Object.entries(node);
467
+ /** @type {Array<[string, unknown]>} */
468
+ const entries = [];
469
+ for (const item of node) {
470
+ // An object in a list has no name of its own: its keys take its place.
471
+ if (isBranch(item) && !Array.isArray(item)) entries.push(...Object.entries(item));
472
+ else entries.push([_cell(item), null]);
473
+ }
474
+ return entries;
475
+ };
476
+ /**
477
+ * @param {object} node
478
+ * @param {string} indent
479
+ */
480
+ const walk = (node, indent) => {
481
+ if (open.includes(node)) {
482
+ out.push(`${indent}${last}(circular)`);
483
+ return;
484
+ }
485
+ open.push(node);
486
+ const entries = entriesOf(node);
487
+ entries.forEach(([label, value], i) => {
488
+ const isLast = i === entries.length - 1;
489
+ const nested = isBranch(value);
490
+ const text = nested || value === null || value === undefined || value === true ? label : `${label}: ${_cell(value)}`;
491
+ const [first, ...rest] = text.split(_LINE_BREAK);
492
+ out.push(indent + (isLast ? last : branch) + first);
493
+ for (const line of rest) out.push(indent + (isLast ? blank : pipe) + line);
494
+ if (nested) walk(/** @type {object} */ (value), indent + (isLast ? blank : pipe));
495
+ });
496
+ open.pop();
497
+ };
498
+ if (isBranch(data)) walk(data, "");
499
+ return out.join("\n");
500
+ }
501
+
380
502
  /**
381
503
  * A spinner for work in progress. Call `.start()`, then finish with
382
504
  * `.succeed()`, `.fail()`, `.warn()`, `.info()` or `.stop()`.
@@ -396,24 +518,25 @@ function box(text, options = {}) {
396
518
  */
397
519
  function spinner(text = "", options = {}) {
398
520
  const stream = options.stream ?? process.stderr;
399
- const frames = options.frames ?? ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
521
+ const frames = options.frames?.length ? options.frames : _FRAMES;
400
522
  const palette = color.create(color.detect(stream));
401
523
  const interactive = _isTTY(stream);
402
524
  /** @type {NodeJS.Timeout | undefined} */
403
525
  let timer;
526
+ let spinning = false;
404
527
  let frame = 0;
405
528
  let current = text;
406
529
 
407
530
  const render = () => {
408
- stream.write(`${CLEAR_LINE}${_paint(palette, options.color ?? "cyan", frames[frame % frames.length])} ${current}`);
409
- frame++;
531
+ stream.write(CLEAR_LINE + _oneRow(`${_paint(palette, options.color ?? "cyan", frames[frame++ % frames.length])} ${current}`, stream));
410
532
  };
411
533
  /**
412
534
  * @param {string} symbol
413
535
  * @param {string | undefined} finalText
414
536
  */
415
537
  const finish = (symbol, finalText) => {
416
- const wasSpinning = timer !== undefined;
538
+ const wasSpinning = spinning;
539
+ spinning = false;
417
540
  clearInterval(timer);
418
541
  timer = undefined;
419
542
  if (interactive && wasSpinning) stream.write(`${CLEAR_LINE}${SHOW_CURSOR}`);
@@ -426,12 +549,12 @@ function spinner(text = "", options = {}) {
426
549
  start(newText) {
427
550
  if (newText !== undefined) current = newText;
428
551
  if (!interactive) {
429
- if (timer === undefined) stream.write(`- ${current}\n`);
430
- timer = setInterval(() => {}, 1 << 30);
431
- timer.unref();
552
+ if (!spinning) stream.write(`- ${current}\n`);
553
+ spinning = true;
432
554
  return self;
433
555
  }
434
556
  clearInterval(timer);
557
+ spinning = true;
435
558
  _restoreCursorOnExit(stream);
436
559
  stream.write(HIDE_CURSOR);
437
560
  render();
@@ -448,15 +571,53 @@ function spinner(text = "", options = {}) {
448
571
  },
449
572
  set text(value) {
450
573
  current = value;
451
- if (timer !== undefined && interactive) render();
574
+ if (spinning && interactive) render();
452
575
  },
453
576
  get isSpinning() {
454
- return timer !== undefined;
577
+ return spinning;
455
578
  },
456
579
  };
457
580
  return self;
458
581
  }
459
582
 
583
+ /**
584
+ * Shows a spinner while some work runs, then `✔` or `✖` depending on how it
585
+ * ended. Resolves with the result of the work, or rejects with its error.
586
+ *
587
+ * @example
588
+ * const users = await cli.spin("Loading users", () => db.getAll("users"));
589
+ * await cli.spin("Deploying", deploy(), {
590
+ * success: (url) => `Deployed to ${url}`,
591
+ * fail: (error) => `Deploy failed: ${error.message}`,
592
+ * });
593
+ *
594
+ * @template T
595
+ * @param {string} text
596
+ * @param {Promise<T> | ((spinner: Spinner) => T | Promise<T>)} task A promise, or a function that gets the spinner so it can update the text.
597
+ * @param {SpinOptions<T>} [options]
598
+ * @returns {Promise<T>}
599
+ */
600
+ async function spin(text, task, options = {}) {
601
+ const current = spinner(text, options).start();
602
+ try {
603
+ const result = await (typeof task === "function" ? task(current) : task);
604
+ current.succeed(typeof options.success === "function" ? options.success(result) : options.success);
605
+ return result;
606
+ } catch (error) {
607
+ current.fail(typeof options.fail === "function" ? options.fail(error) : options.fail);
608
+ throw error;
609
+ }
610
+ }
611
+
612
+ const _PROGRESS_TOKEN = /\{(bar|percent|value|total|eta|elapsed|rate|label)\}/g;
613
+
614
+ /** @param {number} seconds */
615
+ const _clock = (seconds) => {
616
+ if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h${String(Math.floor((seconds % 3600) / 60)).padStart(2, "0")}m`;
617
+ if (seconds >= 60) return `${Math.floor(seconds / 60)}m${String(Math.floor(seconds % 60)).padStart(2, "0")}s`;
618
+ return `${Math.ceil(seconds)}s`;
619
+ };
620
+
460
621
  /**
461
622
  * A progress bar with percentage, ETA and speed. It redraws at most every
462
623
  * 50 ms, so calling `tick()` in a tight loop is fine.
@@ -476,38 +637,47 @@ function progress(options) {
476
637
  const stream = options.stream ?? process.stderr;
477
638
  const interactive = _isTTY(stream);
478
639
  const palette = color.create(color.detect(stream));
640
+ const format = options.format ?? "{bar} {percent}% | {value}/{total} | ETA {eta}";
641
+ const width = Math.max(1, Math.floor(options.width ?? 30));
642
+ const complete = options.complete ?? "█";
643
+ const incomplete = options.incomplete ?? "░";
479
644
  const started = Date.now();
480
645
  let value = 0;
481
646
  let total = Math.max(0, options.total);
482
647
  let label = options.label ?? "";
483
648
  let lastRender = 0;
484
649
  let stopped = false;
650
+ let cursorHidden = false;
485
651
 
486
652
  const line = () => {
487
653
  const ratio = total > 0 ? Math.min(1, value / total) : 1;
488
- const width = options.width ?? 30;
489
654
  const filled = Math.round(ratio * width);
490
655
  const elapsed = (Date.now() - started) / 1000;
491
656
  const rate = elapsed > 0 ? value / elapsed : 0;
492
- const eta = rate > 0 && ratio < 1 ? (total - value) / rate : 0;
493
- const fmt = (/** @type {number} */ s) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m` : s >= 60 ? `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, "0")}s` : `${Math.ceil(s)}s`);
494
- const bar = _paint(palette, "cyan", (options.complete ?? "█").repeat(filled)) + _paint(palette, "gray", (options.incomplete ?? "░").repeat(width - filled));
495
- return (options.format ?? "{bar} {percent}% | {value}/{total} | ETA {eta}")
496
- .replace("{bar}", bar)
497
- .replace("{percent}", String(Math.floor(ratio * 100)).padStart(3))
498
- .replace("{value}", String(value))
499
- .replace("{total}", String(total))
500
- .replace("{eta}", fmt(eta))
501
- .replace("{elapsed}", fmt(elapsed))
502
- .replace("{rate}", `${Math.round(rate * 10) / 10}/s`)
503
- .replace("{label}", label);
657
+ /** @type {Record<string, string>} */
658
+ const values = {
659
+ bar: _paint(palette, "cyan", complete.repeat(filled)) + _paint(palette, "gray", incomplete.repeat(width - filled)),
660
+ percent: String(Math.floor(ratio * 100)).padStart(3),
661
+ value: String(value),
662
+ total: String(total),
663
+ eta: _clock(rate > 0 && ratio < 1 ? (total - value) / rate : 0),
664
+ elapsed: _clock(elapsed),
665
+ rate: `${Math.round(rate * 10) / 10}/s`,
666
+ label,
667
+ };
668
+ return format.replace(_PROGRESS_TOKEN, (_token, name) => values[name]);
504
669
  };
505
670
  const draw = (/** @type {boolean} */ force) => {
506
671
  if (!interactive || stopped) return;
507
672
  const now = Date.now();
508
673
  if (!force && now - lastRender < 50 && value < total) return;
509
674
  lastRender = now;
510
- stream.write(`${CLEAR_LINE}${line()}`);
675
+ if (!cursorHidden) {
676
+ cursorHidden = true;
677
+ _restoreCursorOnExit(stream);
678
+ stream.write(HIDE_CURSOR);
679
+ }
680
+ stream.write(CLEAR_LINE + _oneRow(line(), stream));
511
681
  };
512
682
 
513
683
  /** @type {ProgressBar} */
@@ -525,7 +695,7 @@ function progress(options) {
525
695
  if (stopped) return self;
526
696
  draw(true);
527
697
  stopped = true;
528
- stream.write(interactive ? "\n" : `${line()}\n`);
698
+ stream.write(interactive ? `\n${SHOW_CURSOR}` : `${line()}\n`);
529
699
  return self;
530
700
  },
531
701
  get value() {
@@ -566,21 +736,21 @@ const _ask = (question, input, output) =>
566
736
  /**
567
737
  * One line reader per input stream, shared by every prompt, so piped lines
568
738
  * that arrive early are queued instead of lost.
569
- * @typedef {{ rl: readline.Interface, lines: string[], waiting: Array<{ resolve: (line: string) => void, reject: (error: Error) => void }>, closed: boolean }} LineReader
739
+ * @typedef {{ rl: import("node:readline").Interface, lines: string[], waiting: Array<{ resolve: (line: string) => void, reject: (error: Error) => void }>, closed: boolean }} _LineReader
570
740
  */
571
741
 
572
- /** @type {WeakMap<NodeJS.ReadableStream, LineReader>} */
742
+ /** @type {WeakMap<NodeJS.ReadableStream, _LineReader>} */
573
743
  const _readers = new WeakMap();
574
744
 
575
745
  /**
576
746
  * @param {NodeJS.ReadableStream} input
577
- * @returns {LineReader}
747
+ * @returns {_LineReader}
578
748
  */
579
749
  const _reader = (input) => {
580
750
  const existing = _readers.get(input);
581
751
  if (existing) return existing;
582
- const rl = readline.createInterface({ input, terminal: false, crlfDelay: Infinity });
583
- /** @type {LineReader} */
752
+ const rl = require("node:readline").createInterface({ input, terminal: false, crlfDelay: Infinity });
753
+ /** @type {_LineReader} */
584
754
  const reader = { rl, lines: [], waiting: [], closed: false };
585
755
  rl.on("line", (line) => {
586
756
  const next = reader.waiting.shift();
@@ -661,6 +831,8 @@ function password(question, options = {}) {
661
831
  input.resume();
662
832
  input.setEncoding("utf8");
663
833
  const onData = (/** @type {string} */ chunk) => {
834
+ // Arrow and function keys arrive as an escape sequence: not part of a password.
835
+ if (chunk.length > 1 && chunk.startsWith(ESCAPE)) return;
664
836
  for (const char of chunk) {
665
837
  if (char === "\r" || char === "\n") {
666
838
  cleanup();
@@ -668,10 +840,11 @@ function password(question, options = {}) {
668
840
  resolve(value);
669
841
  return;
670
842
  }
671
- if (char === "\u0003") {
843
+ // Ctrl+C, Ctrl+D, Escape
844
+ if (char === "\u0003" || char === "\u0004" || char === ESCAPE) {
672
845
  cleanup();
673
846
  output.write("\n");
674
- reject(new Error("Cancelled"));
847
+ reject(new AbortError("Cancelled"));
675
848
  return;
676
849
  }
677
850
  if (char === "\u007f" || char === "\b") {
@@ -681,6 +854,12 @@ function password(question, options = {}) {
681
854
  }
682
855
  continue;
683
856
  }
857
+ // Ctrl+U clears the line, as in a shell
858
+ if (char === "\u0015") {
859
+ if (mask) output.write("\b \b".repeat([...value].length));
860
+ value = "";
861
+ continue;
862
+ }
684
863
  if (char < " ") continue;
685
864
  value += char;
686
865
  if (mask) output.write(mask);
@@ -744,72 +923,87 @@ const _interactiveList = (question, items, options, multiple) =>
744
923
  const output = options.output ?? process.stdout;
745
924
  const palette = color.create(color.detect(output));
746
925
  const pageSize = Math.max(1, Math.min(options.pageSize ?? 10, items.length));
926
+ const hint = multiple ? "(↑↓ move, space select, a all, enter confirm)" : "(↑↓ move, enter confirm)";
927
+ const usable = items.map((item, i) => (item.disabled ? -1 : i)).filter((i) => i !== -1);
747
928
  let cursor = Math.min(Math.max(0, options.default ?? 0), items.length - 1);
748
- while (items[cursor]?.disabled && cursor < items.length - 1) cursor++;
929
+ if (items[cursor].disabled) cursor = usable.find((i) => i > cursor) ?? usable[0];
930
+ /** @type {Set<number>} */
749
931
  const selected = new Set();
750
932
  let offset = 0;
751
933
  let drawn = 0;
752
934
  let error = "";
753
935
 
754
936
  const draw = () => {
755
- if (drawn) output.write(`\u001b[${drawn}A`);
756
937
  if (cursor < offset) offset = cursor;
757
938
  if (cursor >= offset + pageSize) offset = cursor - pageSize + 1;
758
- const lines = [`${palette.cyan("?")} ${question} ${palette.gray(multiple ? "(↑↓ move, space select, enter confirm)" : "(↑↓ move, enter confirm)")}`];
939
+ const position = items.length > pageSize ? ` ${cursor + 1}/${items.length}` : "";
940
+ const lines = [`${palette.cyan("?")} ${question} ${palette.gray(hint + position)}`];
759
941
  for (let i = offset; i < offset + pageSize; i++) {
760
942
  const item = items[i];
761
943
  const active = i === cursor;
762
- const box = multiple ? (selected.has(i) ? palette.green("◉ ") : "◯ ") : "";
944
+ const mark = multiple ? (selected.has(i) ? palette.green("◉ ") : "◯ ") : "";
763
945
  const text = item.disabled ? palette.gray(`${item.label} (disabled)`) : active ? palette.cyan(item.label) : item.label;
764
- lines.push(`${active ? palette.cyan("❯") : " "} ${box}${text}${item.hint ? ` ${palette.gray(item.hint)}` : ""}`);
946
+ lines.push(`${active ? palette.cyan("❯") : " "} ${mark}${text}${item.hint ? ` ${palette.gray(item.hint)}` : ""}`);
765
947
  }
766
948
  if (error) lines.push(palette.red(`✖ ${error}`));
767
- output.write(lines.map((l) => `\u001b[2K${l}`).join("\n") + "\n");
768
- if (drawn > lines.length) output.write(`\u001b[0J`);
949
+ // Going back up assumes one row per line, so nothing may wrap.
950
+ const text = lines.map((line) => `\u001b[2K${_oneRow(line, output)}`).join("\n");
951
+ output.write(`${drawn ? _up(drawn) : ""}${text}\n${drawn > lines.length ? CLEAR_BELOW : ""}`);
769
952
  drawn = lines.length;
770
953
  };
771
954
  const move = (/** @type {number} */ step) => {
772
- let next = cursor;
773
- for (let tries = 0; tries < items.length; tries++) {
774
- next = (next + step + items.length) % items.length;
775
- if (!items[next].disabled) break;
776
- }
777
- cursor = next;
955
+ const at = usable.indexOf(cursor);
956
+ cursor = usable[(at + step + usable.length) % usable.length];
957
+ };
958
+ const jump = (/** @type {number} */ target) => {
959
+ // the closest choice that can be picked, looking towards the target first
960
+ const clamped = Math.max(0, Math.min(items.length - 1, target));
961
+ cursor = usable.reduce((best, i) => (Math.abs(i - clamped) < Math.abs(best - clamped) ? i : best), cursor);
962
+ };
963
+ const cleanup = () => {
964
+ input.off("keypress", onKey);
965
+ input.setRawMode(false);
966
+ input.pause();
967
+ output.write(SHOW_CURSOR);
778
968
  };
779
- const onKey = (/** @type {string} */ _str, /** @type {{ name?: string, ctrl?: boolean }} */ key) => {
969
+ const onKey = (/** @type {string} */ _str, /** @type {{ name?: string, ctrl?: boolean }} */ key = {}) => {
780
970
  error = "";
781
- if (key.ctrl && key.name === "c") {
971
+ if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") {
782
972
  cleanup();
783
- reject(new Error("Cancelled"));
973
+ reject(new AbortError("Cancelled"));
784
974
  return;
785
975
  }
786
976
  if (key.name === "up" || key.name === "k") move(-1);
787
977
  else if (key.name === "down" || key.name === "j") move(1);
788
- else if (key.name === "space" && multiple && !items[cursor].disabled) {
978
+ else if (key.name === "home") jump(0);
979
+ else if (key.name === "end") jump(items.length - 1);
980
+ else if (key.name === "pageup") jump(cursor - pageSize);
981
+ else if (key.name === "pagedown") jump(cursor + pageSize);
982
+ else if (key.name === "space" && multiple) {
789
983
  if (selected.has(cursor)) selected.delete(cursor);
790
984
  else selected.add(cursor);
791
985
  } else if (key.name === "a" && multiple) {
792
- const all = items.every((it, i) => it.disabled || selected.has(i));
793
- items.forEach((it, i) => (it.disabled ? null : all ? selected.delete(i) : selected.add(i)));
794
- } else if (key.name === "return") {
986
+ const all = usable.every((i) => selected.has(i));
987
+ for (const i of usable) {
988
+ if (all) selected.delete(i);
989
+ else selected.add(i);
990
+ }
991
+ } else if (key.name === "return" || key.name === "enter") {
795
992
  if (multiple && selected.size < (options.min ?? 0)) {
796
993
  error = `Select at least ${options.min}`;
797
994
  } else {
995
+ const picked = multiple ? [...selected].sort((a, b) => a - b) : [cursor];
996
+ // Fold the list into one line that says what was chosen.
997
+ output.write(`${_up(drawn)}${CLEAR_BELOW}${_oneRow(`${palette.green("✔")} ${question} ${palette.cyan(picked.map((i) => items[i].label).join(", "))}`, output)}\n`);
798
998
  cleanup();
799
- resolve(multiple ? [...selected].sort((a, b) => a - b) : [cursor]);
999
+ resolve(picked);
800
1000
  return;
801
1001
  }
802
1002
  }
803
1003
  draw();
804
1004
  };
805
- const cleanup = () => {
806
- input.off("keypress", onKey);
807
- input.setRawMode(false);
808
- input.pause();
809
- output.write(SHOW_CURSOR);
810
- };
811
1005
  _releaseReader(input);
812
- readline.emitKeypressEvents(input);
1006
+ require("node:readline").emitKeypressEvents(input);
813
1007
  input.setRawMode(true);
814
1008
  input.resume();
815
1009
  _restoreCursorOnExit(output);
@@ -837,7 +1031,7 @@ const _interactiveList = (question, items, options, multiple) =>
837
1031
  */
838
1032
  async function select(question, choices, options = {}) {
839
1033
  const items = _normalizeChoices(choices);
840
- if (!items.length) throw new RangeError("select() needs at least one choice");
1034
+ if (!items.some((item) => !item.disabled)) throw new RangeError("select() needs at least one choice that can be picked");
841
1035
  const input = options.input ?? process.stdin;
842
1036
  if (input.isTTY && typeof input.setRawMode === "function") {
843
1037
  const [index] = await _interactiveList(question, items, options, false);
@@ -869,15 +1063,15 @@ async function select(question, choices, options = {}) {
869
1063
  async function multiselect(question, choices, options = {}) {
870
1064
  const items = _normalizeChoices(choices);
871
1065
  const input = options.input ?? process.stdin;
872
- if (items.length && input.isTTY && typeof input.setRawMode === "function") {
1066
+ if (items.some((item) => !item.disabled) && input.isTTY && typeof input.setRawMode === "function") {
873
1067
  const indexes = await _interactiveList(question, items, options, true);
874
1068
  return indexes.map((i) => items[i].value);
875
1069
  }
876
1070
  const output = options.output ?? process.stdout;
877
- output.write(`? ${question}\n${items.map((it, i) => ` ${i + 1}. ${it.label}`).join("\n")}\n`);
1071
+ output.write(`? ${question}\n${items.map((it, i) => ` ${i + 1}. ${it.label}${it.disabled ? " (disabled)" : ""}`).join("\n")}\n`);
878
1072
  for (;;) {
879
1073
  const answer = (await _ask("Choose (comma-separated numbers): ", input, output)).trim();
880
- const indexes = answer === "" ? [] : answer.split(/[\s,]+/).map((n) => Number(n) - 1);
1074
+ const indexes = answer === "" ? [] : answer.split(/[\s,]+/).filter(Boolean).map((n) => Number(n) - 1);
881
1075
  if (indexes.every((i) => Number.isInteger(i) && items[i] && !items[i].disabled) && indexes.length >= (options.min ?? 0)) {
882
1076
  return [...new Set(indexes)].sort((a, b) => a - b).map((i) => items[i].value);
883
1077
  }
@@ -885,58 +1079,98 @@ async function multiselect(question, choices, options = {}) {
885
1079
  }
886
1080
  }
887
1081
 
1082
+ const _TRUE = ["1", "true", "yes", "on", "y"];
1083
+ const _FALSE = ["0", "false", "no", "off", "n", ""];
1084
+
1085
+ /**
1086
+ * @param {string} word
1087
+ * @param {string[]} known
1088
+ * @param {string} [prefix]
1089
+ */
1090
+ const _suggest = (word, known, prefix = "") => {
1091
+ const guess = require("./Str.js").closest(word, known);
1092
+ return guess ? `. Did you mean ${prefix}${guess}?` : "";
1093
+ };
1094
+
888
1095
  /**
889
1096
  * Parses command-line arguments from a schema, and types the result:
890
1097
  * `flags.port` is a `number`. Handles short aliases, defaults, required
891
- * flags, allowed values, repeated flags and `--no-flag`, and generates
892
- * `--help`. A typo gets a "Did you mean...?" suggestion.
1098
+ * flags, allowed values, repeated flags, `--no-flag`, environment variables
1099
+ * as a fallback and subcommands, and generates `--help`. A typo gets a
1100
+ * "Did you mean...?" suggestion.
893
1101
  *
894
1102
  * @example
895
1103
  * // node server.js --port 8080 -v --tag api --tag web public/
896
1104
  * const { flags, positionals } = cli.args({
897
- * port: { type: "number", short: "p", default: 3000, description: "Port to listen on" },
1105
+ * port: { type: "number", short: "p", default: 3000, env: "PORT", description: "Port to listen on" },
898
1106
  * verbose: { type: "boolean", short: "v" },
899
1107
  * tag: { type: "string", multiple: true },
900
1108
  * env: { type: "string", choices: ["dev", "prod"], default: "dev" },
901
1109
  * }, { description: "Start the web server", version: "1.0.0" });
902
1110
  * // flags: { port: 8080, verbose: true, tag: ["api", "web"], env: "dev" }
903
1111
  *
1112
+ * // node tool.js build --watch
1113
+ * const { command, flags: buildFlags } = cli.args({ watch: { type: "boolean" } }, {
1114
+ * commands: { build: "Build the project", serve: "Start the dev server" },
1115
+ * });
1116
+ * // command: "build"
1117
+ *
904
1118
  * @template {Record<string, FlagSpec>} S
905
1119
  * @param {S} schema
906
1120
  * @param {ArgsOptions} [options]
907
1121
  * @returns {ParsedArgs<S>}
908
- * @throws {TypeError} For unknown flags, bad numbers, missing required flags or values not in `choices`. The message is ready to show.
1122
+ * @throws {TypeError} For unknown flags or commands, bad numbers, missing required flags or values not in `choices`. The message is ready to show.
909
1123
  */
910
1124
  function args(schema, options = {}) {
911
1125
  const argv = options.argv ?? process.argv.slice(2);
912
1126
  const name = options.name ?? require("node:path").basename(process.argv[1] ?? "cli", ".js");
1127
+ const env = options.env ?? process.env;
1128
+ const commands = options.commands ? Object.keys(options.commands) : [];
913
1129
 
914
1130
  const help = () => {
915
1131
  const lines = [];
916
1132
  if (options.description) lines.push(options.description, "");
917
- lines.push(`Usage: ${name} ${options.usage ?? "[options]"}`, "", "Options:");
1133
+ lines.push(`Usage: ${name} ${options.usage ?? (commands.length ? "<command> [options]" : "[options]")}`);
1134
+ /** @param {Array<[string, string]>} rows */
1135
+ const list = (rows) => {
1136
+ const width = Math.max(...rows.map(([left]) => left.length));
1137
+ for (const [left, right] of rows) lines.push(` ${left.padEnd(width)} ${right}`.trimEnd());
1138
+ };
1139
+ if (options.commands && commands.length) {
1140
+ lines.push("", "Commands:");
1141
+ list(Object.entries(options.commands));
1142
+ }
1143
+ lines.push("", "Options:");
1144
+ /** @type {Array<[string, string]>} */
918
1145
  const rows = Object.entries(schema).map(([key, spec]) => {
919
1146
  const flag = `${spec.short ? `-${spec.short}, ` : " "}--${key}${spec.type === "boolean" ? "" : ` <${spec.type}>`}`;
920
1147
  const details = [
921
1148
  spec.description ?? "",
922
1149
  spec.choices ? `(${spec.choices.join(" | ")})` : "",
923
1150
  spec.default !== undefined ? `[default: ${JSON.stringify(spec.default)}]` : "",
1151
+ spec.env ? `[env: ${spec.env}]` : "",
924
1152
  spec.required ? "[required]" : "",
925
1153
  ].filter(Boolean).join(" ");
926
1154
  return [flag, details];
927
1155
  });
928
1156
  if (options.help !== false) rows.push(["-h, --help", "Show this help"]);
929
1157
  if (options.version) rows.push([" --version", "Show the version"]);
930
- const width = Math.max(...rows.map(([f]) => f.length));
931
- for (const [flag, details] of rows) lines.push(` ${flag.padEnd(width)} ${details}`.trimEnd());
1158
+ list(rows);
1159
+ if (options.examples?.length) {
1160
+ lines.push("", "Examples:");
1161
+ for (const example of options.examples) lines.push(` ${example}`);
1162
+ }
932
1163
  return lines.join("\n");
933
1164
  };
934
1165
 
935
- if (options.help !== false && (argv.includes("--help") || argv.includes("-h")) && !("help" in schema)) {
1166
+ // Everything after "--" belongs to the program, even if it looks like a flag.
1167
+ const end = argv.indexOf("--");
1168
+ const own = end === -1 ? argv : argv.slice(0, end);
1169
+ if (options.help !== false && (own.includes("--help") || own.includes("-h")) && !Object.hasOwn(schema, "help")) {
936
1170
  process.stdout.write(`${help()}\n`);
937
1171
  process.exit(0);
938
1172
  }
939
- if (options.version && argv.includes("--version") && !("version" in schema)) {
1173
+ if (options.version && own.includes("--version") && !Object.hasOwn(schema, "version")) {
940
1174
  process.stdout.write(`${options.version}\n`);
941
1175
  process.exit(0);
942
1176
  }
@@ -955,35 +1189,56 @@ function args(schema, options = {}) {
955
1189
  } catch (error) {
956
1190
  const message = /** @type {Error} */ (error).message;
957
1191
  const unknown = /Unknown option '(-{1,2})([^']+)'/.exec(message);
958
- if (unknown) {
959
- const { closest } = require("./Str.js");
960
- const guess = closest(unknown[2], Object.keys(schema));
961
- throw new TypeError(`Unknown option ${unknown[1]}${unknown[2]}${guess ? `. Did you mean --${guess}?` : ""}`);
962
- }
963
- throw new TypeError(message);
1192
+ if (unknown) throw new TypeError(`Unknown option ${unknown[1]}${unknown[2]}${_suggest(unknown[2].replace(/^no-/, ""), Object.keys(schema), "--")}`);
1193
+ const missing = /Option '(?:-\w, )?--([^ ']+) <value>' argument missing/.exec(message);
1194
+ throw new TypeError(missing ? `Option --${missing[1]} needs a value` : message.split("\n")[0]);
964
1195
  }
965
1196
 
966
1197
  /** @type {Record<string, any>} */
967
1198
  const flags = {};
968
1199
  for (const [key, spec] of Object.entries(schema)) {
969
- const raw = /** @type {any} */ (parsed.values)[key];
1200
+ const fromEnv = spec.env !== undefined ? env[spec.env] : undefined;
1201
+ const source = /** @type {any} */ (parsed.values)[key] !== undefined ? `Option --${key}` : `Environment variable ${spec.env}`;
1202
+ let raw = /** @type {any} */ (parsed.values)[key];
1203
+ if (raw === undefined && fromEnv !== undefined) {
1204
+ // In a variable, a list is written with commas.
1205
+ raw = spec.multiple ? fromEnv.split(",").map((part) => part.trim()).filter(Boolean) : fromEnv;
1206
+ }
970
1207
  if (raw === undefined) {
971
- if (spec.required) throw new TypeError(`Missing required option --${key}`);
1208
+ if (spec.required) throw new TypeError(`Missing required option --${key}${spec.env ? ` (or ${spec.env} in the environment)` : ""}`);
972
1209
  flags[key] = spec.default !== undefined ? spec.default : spec.type === "boolean" ? false : spec.multiple ? [] : undefined;
973
1210
  continue;
974
1211
  }
975
1212
  const convert = (/** @type {any} */ value) => {
1213
+ /** @type {any} */
1214
+ let result = value;
976
1215
  if (spec.type === "number") {
977
- const n = Number(value);
978
- if (value === "" || !Number.isFinite(n)) throw new TypeError(`Option --${key} expects a number, got "${value}"`);
979
- return n;
1216
+ result = Number(value);
1217
+ if (String(value).trim() === "" || !Number.isFinite(result)) throw new TypeError(`${source} expects a number, got "${value}"`);
1218
+ } else if (spec.type === "boolean" && typeof value === "string") {
1219
+ const text = value.trim().toLowerCase();
1220
+ if (!_TRUE.includes(text) && !_FALSE.includes(text)) throw new TypeError(`${source} expects true or false, got "${value}"`);
1221
+ result = _TRUE.includes(text);
980
1222
  }
981
- if (spec.choices && !spec.choices.includes(value)) throw new TypeError(`Option --${key} must be one of: ${spec.choices.join(", ")} (got "${value}")`);
982
- return value;
1223
+ if (spec.choices && !(/** @type {readonly unknown[]} */ (spec.choices)).includes(result)) {
1224
+ throw new TypeError(`${source} must be one of: ${spec.choices.join(", ")} (got "${value}")`);
1225
+ }
1226
+ return result;
983
1227
  };
984
1228
  flags[key] = Array.isArray(raw) ? raw.map(convert) : convert(raw);
985
1229
  }
986
- return { flags: /** @type {ParsedFlags<S>} */ (flags), positionals: parsed.positionals, help };
1230
+
1231
+ const positionals = parsed.positionals;
1232
+ /** @type {string | undefined} */
1233
+ let command;
1234
+ if (commands.length && positionals.length) {
1235
+ command = positionals[0];
1236
+ if (!commands.includes(command)) {
1237
+ throw new TypeError(`Unknown command "${command}"${_suggest(command, commands) || `. Available: ${commands.join(", ")}`}`);
1238
+ }
1239
+ positionals.shift();
1240
+ }
1241
+ return { flags: /** @type {ParsedFlags<S>} */ (flags), positionals, command, help };
987
1242
  }
988
1243
 
989
1244
  /**
@@ -1005,7 +1260,9 @@ function isInteractive() {
1005
1260
  * @returns {{ columns: number, rows: number }}
1006
1261
  */
1007
1262
  function size() {
1008
- return { columns: process.stdout.columns || 80, rows: process.stdout.rows || 24 };
1263
+ const columns = process.stdout.columns || process.stderr.columns || Number(process.env.COLUMNS) || 80;
1264
+ const rows = process.stdout.rows || process.stderr.rows || Number(process.env.LINES) || 24;
1265
+ return { columns, rows };
1009
1266
  }
1010
1267
 
1011
1268
  /**
@@ -1020,7 +1277,9 @@ function clear() {
1020
1277
  module.exports = {
1021
1278
  table,
1022
1279
  box,
1280
+ tree,
1023
1281
  spinner,
1282
+ spin,
1024
1283
  progress,
1025
1284
  prompt,
1026
1285
  password,