@ix-xs/node-comfort 1.2.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 (78) hide show
  1. package/CHANGELOG.md +340 -0
  2. package/README.md +137 -548
  3. package/index.js +80 -35
  4. package/package.json +161 -33
  5. package/src/Arr.js +1050 -418
  6. package/src/Async.js +817 -0
  7. package/src/Cache.js +433 -0
  8. package/src/Checker.js +1202 -539
  9. package/src/Cli.js +1293 -0
  10. package/src/Color.js +556 -0
  11. package/src/Crypto.js +1082 -0
  12. package/src/Emitter.js +356 -0
  13. package/src/Env.js +689 -0
  14. package/src/FS.js +1626 -894
  15. package/src/Func.js +823 -356
  16. package/src/Http.js +700 -0
  17. package/src/Id.js +568 -200
  18. package/src/Logger.js +963 -378
  19. package/src/Num.js +965 -360
  20. package/src/Obj.js +1077 -379
  21. package/src/SQLite.js +1629 -1005
  22. package/src/Schema.js +2101 -0
  23. package/src/Str.js +1209 -432
  24. package/src/Sys.js +654 -0
  25. package/src/Time.js +1533 -440
  26. package/src/Utils.js +342 -325
  27. package/src/errors.js +335 -0
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +735 -526
  30. package/types/src/Arr.d.ts +533 -145
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +372 -0
  33. package/types/src/Async.d.ts.map +1 -0
  34. package/types/src/Cache.d.ts +221 -0
  35. package/types/src/Cache.d.ts.map +1 -0
  36. package/types/src/Checker.d.ts +651 -232
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +664 -0
  39. package/types/src/Cli.d.ts.map +1 -0
  40. package/types/src/Color.d.ts +300 -0
  41. package/types/src/Color.d.ts.map +1 -0
  42. package/types/src/Crypto.d.ts +620 -0
  43. package/types/src/Crypto.d.ts.map +1 -0
  44. package/types/src/Emitter.d.ts +189 -0
  45. package/types/src/Emitter.d.ts.map +1 -0
  46. package/types/src/Env.d.ts +412 -0
  47. package/types/src/Env.d.ts.map +1 -0
  48. package/types/src/FS.d.ts +717 -238
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +508 -135
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +444 -0
  53. package/types/src/Http.d.ts.map +1 -0
  54. package/types/src/Id.d.ts +199 -66
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +414 -46
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +514 -137
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +453 -104
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +754 -308
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +1189 -0
  65. package/types/src/Schema.d.ts.map +1 -0
  66. package/types/src/Str.d.ts +617 -166
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +314 -0
  69. package/types/src/Sys.d.ts.map +1 -0
  70. package/types/src/Time.d.ts +759 -204
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +158 -102
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +316 -0
  75. package/types/src/errors.d.ts.map +1 -0
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
  78. package/types/index.d.ts.map +0 -1
package/src/Cli.js ADDED
@@ -0,0 +1,1293 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * What you need for command-line tools: typed arguments with a generated
5
+ * `--help`, prompts, arrow-key menus, spinners, progress bars, tables and
6
+ * boxes. When nobody's at the keyboard (CI, pipes), prompts read plain lines
7
+ * and animations print only their final state.
8
+ *
9
+ * @example
10
+ * const { flags } = cli.args({
11
+ * port: { type: "number", short: "p", default: 3000, description: "Port to listen on" },
12
+ * });
13
+ * const name = await cli.prompt("Project name?", { default: "my-app" });
14
+ * const spin = cli.spinner("Installing").start();
15
+ * spin.succeed("Installed");
16
+ */
17
+
18
+ const { parseArgs } = require("node:util");
19
+ const color = require("./Color.js");
20
+ const { AbortError } = require("./errors.js");
21
+
22
+ /**
23
+ * Border styles for `table()` and `box()`.
24
+ * @typedef {"rounded" | "single" | "double" | "heavy" | "ascii" | "none"} BorderStyle
25
+ */
26
+
27
+ /**
28
+ * A column of `table()`.
29
+ * @typedef {object} TableColumn
30
+ * @property {string | number} key Property name for object rows, index for array rows.
31
+ * @property {string} [header] Defaults to the key.
32
+ * @property {"left" | "center" | "right"} [align] Defaults to right for numbers, left otherwise.
33
+ * @property {number} [maxWidth] Longer values are cut with `…`.
34
+ * @property {(value: unknown, row: any) => unknown} [format] Changes the value before it's shown.
35
+ */
36
+
37
+ /**
38
+ * Options for `table()`.
39
+ * @typedef {object} TableOptions
40
+ * @property {Array<string | TableColumn>} [columns] Which columns, in which order. Defaults to every key found.
41
+ * @property {BorderStyle | "markdown"} [border] `"markdown"` gives a Markdown table. Defaults to `"rounded"`.
42
+ * @property {boolean} [header] Show the header row. Defaults to `true`.
43
+ * @property {boolean} [colors] Bold header and faint borders. Automatic by default.
44
+ * @property {number} [padding] Spaces around cell content. Defaults to `1`.
45
+ */
46
+
47
+ /**
48
+ * Options for `box()`.
49
+ * @typedef {object} BoxOptions
50
+ * @property {string} [title] Shown in the top border.
51
+ * @property {BorderStyle} [border] Defaults to `"rounded"`.
52
+ * @property {number | { x?: number, y?: number }} [padding] Space around the text. Defaults to `{ x: 2, y: 0 }`.
53
+ * @property {"left" | "center" | "right"} [align] Defaults to `"left"`.
54
+ * @property {string} [borderColor] A style name like `"green"`, or a hex code.
55
+ * @property {number} [width] Fixed inner width. Defaults to the longest line.
56
+ * @property {boolean} [colors] Automatic by default.
57
+ */
58
+
59
+ /**
60
+ * Options for `spinner()`.
61
+ * @typedef {object} SpinnerOptions
62
+ * @property {string[]} [frames] Animation frames. Defaults to braille dots.
63
+ * @property {number} [interval] Milliseconds per frame. Defaults to `80`.
64
+ * @property {string} [color] A style name or hex code. Defaults to `"cyan"`.
65
+ * @property {NodeJS.WriteStream} [stream] Defaults to stderr, which keeps stdout clean for data.
66
+ */
67
+
68
+ /**
69
+ * A spinner. Every method returns it.
70
+ * @typedef {object} Spinner
71
+ * @property {(text?: string) => Spinner} start Starts spinning, optionally with new text.
72
+ * @property {() => Spinner} stop Stops and clears the line.
73
+ * @property {(text?: string) => Spinner} succeed Stops with a green `✔`.
74
+ * @property {(text?: string) => Spinner} fail Stops with a red `✖`.
75
+ * @property {(text?: string) => Spinner} warn Stops with a yellow `⚠`.
76
+ * @property {(text?: string) => Spinner} info Stops with a blue `ℹ`.
77
+ * @property {string} text The text next to the spinner. Change it any time.
78
+ * @property {boolean} isSpinning
79
+ */
80
+
81
+ /**
82
+ * Options for `progress()`.
83
+ * @typedef {object} ProgressOptions
84
+ * @property {number} total The value that means 100%.
85
+ * @property {number} [width] Bar width in characters. Defaults to `30`.
86
+ * @property {string} [format] Can use `{bar}`, `{percent}`, `{value}`, `{total}`, `{eta}`, `{elapsed}`, `{rate}` and `{label}`.
87
+ * @property {string} [complete] Filled character. Defaults to `"█"`.
88
+ * @property {string} [incomplete] Empty character. Defaults to `"░"`.
89
+ * @property {string} [label] Text for `{label}`.
90
+ * @property {NodeJS.WriteStream} [stream] Defaults to stderr.
91
+ */
92
+
93
+ /**
94
+ * A progress bar.
95
+ * @typedef {object} ProgressBar
96
+ * @property {(amount?: number, label?: string) => ProgressBar} tick Adds to the value, 1 by default.
97
+ * @property {(value: number, label?: string) => ProgressBar} update Sets the value.
98
+ * @property {() => ProgressBar} stop Draws the final state and frees the line.
99
+ * @property {number} value
100
+ * @property {number} total You can change it along the way.
101
+ * @property {number} percent From 0 to 100.
102
+ */
103
+
104
+ /**
105
+ * Options for `prompt()`.
106
+ * @typedef {object} PromptOptions
107
+ * @property {string} [default] Used when the answer is empty.
108
+ * @property {(answer: string) => true | string | Promise<true | string>} [validate] Return `true` to accept, or a message to ask again.
109
+ * @property {NodeJS.ReadableStream} [input] Defaults to stdin.
110
+ * @property {NodeJS.WritableStream} [output] Defaults to stdout.
111
+ */
112
+
113
+ /**
114
+ * Options for `confirm()`.
115
+ * @typedef {object} ConfirmOptions
116
+ * @property {boolean} [default] The answer when the user just presses Enter. Defaults to `false`.
117
+ * @property {NodeJS.ReadableStream} [input] Defaults to stdin.
118
+ * @property {NodeJS.WritableStream} [output] Defaults to stdout.
119
+ */
120
+
121
+ /**
122
+ * A choice for `select()` and `multiselect()`: a string, or a label with a
123
+ * value of any type.
124
+ * @template [V=string]
125
+ * @typedef {string | { label: string, value: V, hint?: string, disabled?: boolean }} Choice
126
+ */
127
+
128
+ /**
129
+ * Options for `select()` and `multiselect()`.
130
+ * @typedef {object} SelectOptions
131
+ * @property {number} [default] Index of the choice highlighted at first. Defaults to `0`.
132
+ * @property {number} [pageSize] How many choices are visible at once. Defaults to `10`.
133
+ * @property {number} [min] `multiselect()` only: minimum number of choices.
134
+ * @property {NodeJS.ReadStream} [input] Defaults to stdin.
135
+ * @property {NodeJS.WriteStream} [output] Defaults to stdout.
136
+ */
137
+
138
+ /**
139
+ * A flag for `args()`.
140
+ * @typedef {object} FlagSpec
141
+ * @property {"string" | "number" | "boolean"} type Numbers are checked. Booleans also accept `--no-name`.
142
+ * @property {string} [short] One-letter alias, like `"p"` for `-p`.
143
+ * @property {unknown} [default] Used when the flag is missing.
144
+ * @property {boolean} [multiple] Can be repeated (`--tag a --tag b`); you get an array.
145
+ * @property {boolean} [required] Fail if it's missing.
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`.
148
+ * @property {string} [description] Shown in `--help`.
149
+ */
150
+
151
+ /**
152
+ * The type of a flag's value.
153
+ * @template {FlagSpec} F
154
+ * @typedef {F["type"] extends "boolean" ? boolean : F extends { choices: readonly (infer C)[] } ? C : F["type"] extends "number" ? number : string} FlagValue
155
+ */
156
+
157
+ /**
158
+ * The parsed flags, typed from the schema.
159
+ * @template {Record<string, FlagSpec>} S
160
+ * @typedef {{ [K in keyof S]: S[K] extends { multiple: true } ? Array<FlagValue<S[K]>> : S[K] extends { default: any } | { required: true } | { type: "boolean" } ? FlagValue<S[K]> : FlagValue<S[K]> | undefined }} ParsedFlags
161
+ */
162
+
163
+ /**
164
+ * Options for `args()`.
165
+ * @typedef {object} ArgsOptions
166
+ * @property {string[]} [argv] What to parse. Defaults to `process.argv.slice(2)`.
167
+ * @property {string} [name] Program name in the help. Defaults to the script name.
168
+ * @property {string} [description] Shown at the top of the help.
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.
172
+ * @property {boolean} [help] Answer `--help` and `-h` by printing the help and exiting. Defaults to `true`.
173
+ * @property {string} [version] Answer `--version` with this version.
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`.
176
+ */
177
+
178
+ /**
179
+ * What `args()` returns.
180
+ * @template {Record<string, FlagSpec>} S
181
+ * @typedef {object} ParsedArgs
182
+ * @property {ParsedFlags<S>} flags
183
+ * @property {string[]} positionals Arguments that aren't flags.
184
+ * @property {string | undefined} command The subcommand, when `commands` was given.
185
+ * @property {() => string} help The generated help text.
186
+ */
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
+
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 */
201
+ const BORDERS = {
202
+ rounded: ["╭", "─", "╮", "│", "╯", "─", "╰", "│", "├", "─", "┤", "┼", "┬", "┴"],
203
+ single: ["┌", "─", "┐", "│", "┘", "─", "└", "│", "├", "─", "┤", "┼", "┬", "┴"],
204
+ double: ["╔", "═", "╗", "║", "╝", "═", "╚", "║", "╠", "═", "╣", "╬", "╦", "╩"],
205
+ heavy: ["┏", "━", "┓", "┃", "┛", "━", "┗", "┃", "┣", "━", "┫", "╋", "┳", "┻"],
206
+ ascii: ["+", "-", "+", "|", "+", "-", "+", "|", "+", "-", "+", "+", "+", "+"],
207
+ none: [" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
208
+ };
209
+
210
+ const SHOW_CURSOR = "\u001b[?25h";
211
+ const HIDE_CURSOR = "\u001b[?25l";
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
+ };
232
+
233
+ /** @param {boolean | undefined} forced */
234
+ const _palette = (forced) => (forced === undefined ? color : color.create(forced ? (color.level > 0 ? color.level : 3) : 0));
235
+
236
+ /**
237
+ * @param {import("./Color").ColorModule} palette
238
+ * @param {string | undefined} name
239
+ * @param {string} text
240
+ */
241
+ const _paint = (palette, name, text) => {
242
+ if (!name || palette.level === 0) return text;
243
+ if (name.startsWith("#")) return palette.hex(name)(text);
244
+ const chain = /** @type {any} */ (palette)[name];
245
+ return typeof chain === "function" ? chain(text) : text;
246
+ };
247
+
248
+ /**
249
+ * @param {string} text
250
+ * @param {number} width
251
+ * @param {"left" | "center" | "right"} align
252
+ */
253
+ const _align = (text, width, align) => {
254
+ const gap = Math.max(0, width - color.width(text));
255
+ if (align === "right") return " ".repeat(gap) + text;
256
+ if (align === "center") return " ".repeat(Math.floor(gap / 2)) + text + " ".repeat(Math.ceil(gap / 2));
257
+ return text + " ".repeat(gap);
258
+ };
259
+
260
+ /** @param {unknown} value */
261
+ const _cell = (value) => {
262
+ if (value === null || value === undefined) return "";
263
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
264
+ if (typeof value === "object") {
265
+ try {
266
+ return JSON.stringify(value);
267
+ } catch {
268
+ return String(value);
269
+ }
270
+ }
271
+ return String(value);
272
+ };
273
+
274
+ /** @param {NodeJS.WriteStream | undefined} stream */
275
+ const _isTTY = (stream) => Boolean(stream && stream.isTTY) && process.env.TERM !== "dumb" && !process.env.CI;
276
+
277
+ let _cursorHooked = false;
278
+ /** @param {NodeJS.WriteStream} stream */
279
+ const _restoreCursorOnExit = (stream) => {
280
+ if (_cursorHooked) return;
281
+ _cursorHooked = true;
282
+ process.once("exit", () => {
283
+ try {
284
+ stream.write(SHOW_CURSOR);
285
+ } catch {
286
+ // stream already closed
287
+ }
288
+ });
289
+ };
290
+
291
+ /**
292
+ * Draws a table from an array of objects (keys become columns) or an array
293
+ * of arrays. Colors, emoji and CJK characters line up correctly.
294
+ *
295
+ * @example
296
+ * console.log(cli.table([
297
+ * { name: "Ada", role: "admin", logins: 42 },
298
+ * { name: "Bob", role: "user", logins: 7 },
299
+ * ]));
300
+ * // ╭──────┬───────┬────────╮
301
+ * // │ name │ role │ logins │
302
+ * // ├──────┼───────┼────────┤
303
+ * // │ Ada │ admin │ 42 │
304
+ * // │ Bob │ user │ 7 │
305
+ * // ╰──────┴───────┴────────╯
306
+ *
307
+ * @param {Array<Record<string, unknown>> | unknown[][]} rows
308
+ * @param {TableOptions} [options]
309
+ * @returns {string}
310
+ */
311
+ function table(rows, options = {}) {
312
+ const palette = _palette(options.colors);
313
+ const padding = " ".repeat(Math.max(0, options.padding ?? 1));
314
+ const markdown = options.border === "markdown";
315
+ const isArrayRows = rows.length > 0 && Array.isArray(rows[0]);
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 "";
330
+ const showHeader = options.header ?? !isArrayRows;
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
+ }
355
+
356
+ if (markdown) {
357
+ const line = (/** @type {string[]} */ cells) => `| ${cells.join(" | ")} |`;
358
+ const aligns = columns.map((column, i) => column.align ?? (numeric[i] ? "right" : "left"));
359
+ const out = [];
360
+ if (showHeader) {
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;
365
+ })));
366
+ }
367
+ for (const cells of body) out.push(line(cells.map((cell, i) => _align(cell.lines[0], widths[i], aligns[i]))));
368
+ return out.join("\n");
369
+ }
370
+
371
+ const b = BORDERS[/** @type {BorderStyle} */ (options.border ?? "rounded")];
372
+ const dim = (/** @type {string} */ text) => (palette.level > 0 ? palette.gray(text) : text);
373
+ const rule = (/** @type {string} */ left, /** @type {string} */ fill, /** @type {string} */ mid, /** @type {string} */ right) =>
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]);
378
+ const out = [rule(b[0], b[1], b[12], b[2])];
379
+ if (showHeader) {
380
+ out.push(line(headers.map((header, i) => _align(palette.level > 0 ? palette.bold(header) : header, widths[i], columns[i].align ?? "left"))));
381
+ out.push(rule(b[8], b[9], b[11], b[10]));
382
+ }
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
+ }
390
+ out.push(rule(b[6], b[5], b[13], b[4]));
391
+ return out.join("\n");
392
+ }
393
+
394
+ /**
395
+ * Draws a box around text.
396
+ *
397
+ * @example
398
+ * console.log(cli.box("Server ready\nhttp://localhost:3000", { title: "my-app", borderColor: "green" }));
399
+ * // ╭─ my-app ────────────────╮
400
+ * // │ Server ready │
401
+ * // │ http://localhost:3000 │
402
+ * // ╰─────────────────────────╯
403
+ *
404
+ * @param {string} text
405
+ * @param {BoxOptions} [options]
406
+ * @returns {string}
407
+ */
408
+ function box(text, options = {}) {
409
+ const palette = _palette(options.colors);
410
+ const b = BORDERS[options.border ?? "rounded"];
411
+ const pad = typeof options.padding === "number" ? { x: options.padding, y: Math.floor(options.padding / 2) } : { x: 2, y: 0, ...options.padding };
412
+ const lines = String(text).split(_LINE_BREAK);
413
+ const title = options.title ? ` ${options.title} ` : "";
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;
417
+ const paint = (/** @type {string} */ s) => _paint(palette, options.borderColor, s);
418
+ const top = title
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])
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]);
423
+ const blank = paint(b[7]) + " ".repeat(inner) + paint(b[3]);
424
+ const out = [top];
425
+ for (let i = 0; i < pad.y; i++) out.push(blank);
426
+ for (const line of lines) out.push(left + _align(line, longest, options.align ?? "left") + right);
427
+ for (let i = 0; i < pad.y; i++) out.push(blank);
428
+ out.push(paint(b[6] + b[5].repeat(inner) + b[4]));
429
+ return out.join("\n");
430
+ }
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
+
502
+ /**
503
+ * A spinner for work in progress. Call `.start()`, then finish with
504
+ * `.succeed()`, `.fail()`, `.warn()`, `.info()` or `.stop()`.
505
+ *
506
+ * @example
507
+ * const spin = cli.spinner("Downloading").start();
508
+ * try {
509
+ * await download();
510
+ * spin.succeed("Downloaded 12 files");
511
+ * } catch (error) {
512
+ * spin.fail(`Download failed: ${error.message}`);
513
+ * }
514
+ *
515
+ * @param {string} [text=""]
516
+ * @param {SpinnerOptions} [options]
517
+ * @returns {Spinner} Not started yet.
518
+ */
519
+ function spinner(text = "", options = {}) {
520
+ const stream = options.stream ?? process.stderr;
521
+ const frames = options.frames?.length ? options.frames : _FRAMES;
522
+ const palette = color.create(color.detect(stream));
523
+ const interactive = _isTTY(stream);
524
+ /** @type {NodeJS.Timeout | undefined} */
525
+ let timer;
526
+ let spinning = false;
527
+ let frame = 0;
528
+ let current = text;
529
+
530
+ const render = () => {
531
+ stream.write(CLEAR_LINE + _oneRow(`${_paint(palette, options.color ?? "cyan", frames[frame++ % frames.length])} ${current}`, stream));
532
+ };
533
+ /**
534
+ * @param {string} symbol
535
+ * @param {string | undefined} finalText
536
+ */
537
+ const finish = (symbol, finalText) => {
538
+ const wasSpinning = spinning;
539
+ spinning = false;
540
+ clearInterval(timer);
541
+ timer = undefined;
542
+ if (interactive && wasSpinning) stream.write(`${CLEAR_LINE}${SHOW_CURSOR}`);
543
+ if (symbol || finalText !== undefined) stream.write(`${symbol}${symbol ? " " : ""}${finalText ?? current}\n`);
544
+ return self;
545
+ };
546
+
547
+ /** @type {Spinner} */
548
+ const self = {
549
+ start(newText) {
550
+ if (newText !== undefined) current = newText;
551
+ if (!interactive) {
552
+ if (!spinning) stream.write(`- ${current}\n`);
553
+ spinning = true;
554
+ return self;
555
+ }
556
+ clearInterval(timer);
557
+ spinning = true;
558
+ _restoreCursorOnExit(stream);
559
+ stream.write(HIDE_CURSOR);
560
+ render();
561
+ timer = setInterval(render, options.interval ?? 80);
562
+ return self;
563
+ },
564
+ stop: () => finish("", undefined),
565
+ succeed: (finalText) => finish(_paint(palette, "green", "✔"), finalText),
566
+ fail: (finalText) => finish(_paint(palette, "red", "✖"), finalText),
567
+ warn: (finalText) => finish(_paint(palette, "yellow", "⚠"), finalText),
568
+ info: (finalText) => finish(_paint(palette, "blue", "ℹ"), finalText),
569
+ get text() {
570
+ return current;
571
+ },
572
+ set text(value) {
573
+ current = value;
574
+ if (spinning && interactive) render();
575
+ },
576
+ get isSpinning() {
577
+ return spinning;
578
+ },
579
+ };
580
+ return self;
581
+ }
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
+
621
+ /**
622
+ * A progress bar with percentage, ETA and speed. It redraws at most every
623
+ * 50 ms, so calling `tick()` in a tight loop is fine.
624
+ *
625
+ * @example
626
+ * const bar = cli.progress({ total: files.length, format: "{bar} {percent}% {label}" });
627
+ * for (const file of files) {
628
+ * await upload(file);
629
+ * bar.tick(1, file.name);
630
+ * }
631
+ * bar.stop();
632
+ *
633
+ * @param {ProgressOptions} options
634
+ * @returns {ProgressBar}
635
+ */
636
+ function progress(options) {
637
+ const stream = options.stream ?? process.stderr;
638
+ const interactive = _isTTY(stream);
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 ?? "░";
644
+ const started = Date.now();
645
+ let value = 0;
646
+ let total = Math.max(0, options.total);
647
+ let label = options.label ?? "";
648
+ let lastRender = 0;
649
+ let stopped = false;
650
+ let cursorHidden = false;
651
+
652
+ const line = () => {
653
+ const ratio = total > 0 ? Math.min(1, value / total) : 1;
654
+ const filled = Math.round(ratio * width);
655
+ const elapsed = (Date.now() - started) / 1000;
656
+ const rate = elapsed > 0 ? value / elapsed : 0;
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]);
669
+ };
670
+ const draw = (/** @type {boolean} */ force) => {
671
+ if (!interactive || stopped) return;
672
+ const now = Date.now();
673
+ if (!force && now - lastRender < 50 && value < total) return;
674
+ lastRender = now;
675
+ if (!cursorHidden) {
676
+ cursorHidden = true;
677
+ _restoreCursorOnExit(stream);
678
+ stream.write(HIDE_CURSOR);
679
+ }
680
+ stream.write(CLEAR_LINE + _oneRow(line(), stream));
681
+ };
682
+
683
+ /** @type {ProgressBar} */
684
+ const self = {
685
+ tick(amount = 1, newLabel) {
686
+ return self.update(value + amount, newLabel);
687
+ },
688
+ update(next, newLabel) {
689
+ value = Math.max(0, next);
690
+ if (newLabel !== undefined) label = newLabel;
691
+ draw(false);
692
+ return self;
693
+ },
694
+ stop() {
695
+ if (stopped) return self;
696
+ draw(true);
697
+ stopped = true;
698
+ stream.write(interactive ? `\n${SHOW_CURSOR}` : `${line()}\n`);
699
+ return self;
700
+ },
701
+ get value() {
702
+ return value;
703
+ },
704
+ get total() {
705
+ return total;
706
+ },
707
+ set total(next) {
708
+ total = Math.max(0, next);
709
+ draw(true);
710
+ },
711
+ get percent() {
712
+ return total > 0 ? Math.min(100, (value / total) * 100) : 100;
713
+ },
714
+ };
715
+ draw(true);
716
+ return self;
717
+ }
718
+
719
+ /**
720
+ * @param {string} question
721
+ * @param {NodeJS.ReadableStream} input
722
+ * @param {NodeJS.WritableStream} output
723
+ * @returns {Promise<string>}
724
+ */
725
+ const _ask = (question, input, output) =>
726
+ new Promise((resolve, reject) => {
727
+ output.write(question);
728
+ const reader = _reader(input);
729
+ const buffered = reader.lines.shift();
730
+ if (buffered !== undefined) return resolve(buffered);
731
+ if (reader.closed) return reject(new Error("Input closed before an answer was given"));
732
+ reader.waiting.push({ resolve, reject });
733
+ reader.rl.resume();
734
+ });
735
+
736
+ /**
737
+ * One line reader per input stream, shared by every prompt, so piped lines
738
+ * that arrive early are queued instead of lost.
739
+ * @typedef {{ rl: import("node:readline").Interface, lines: string[], waiting: Array<{ resolve: (line: string) => void, reject: (error: Error) => void }>, closed: boolean }} _LineReader
740
+ */
741
+
742
+ /** @type {WeakMap<NodeJS.ReadableStream, _LineReader>} */
743
+ const _readers = new WeakMap();
744
+
745
+ /**
746
+ * @param {NodeJS.ReadableStream} input
747
+ * @returns {_LineReader}
748
+ */
749
+ const _reader = (input) => {
750
+ const existing = _readers.get(input);
751
+ if (existing) return existing;
752
+ const rl = require("node:readline").createInterface({ input, terminal: false, crlfDelay: Infinity });
753
+ /** @type {_LineReader} */
754
+ const reader = { rl, lines: [], waiting: [], closed: false };
755
+ rl.on("line", (line) => {
756
+ const next = reader.waiting.shift();
757
+ if (next) next.resolve(line);
758
+ else reader.lines.push(line);
759
+ if (!reader.waiting.length) rl.pause();
760
+ });
761
+ rl.on("close", () => {
762
+ reader.closed = true;
763
+ for (const pending of reader.waiting.splice(0)) pending.reject(new Error("Input closed before an answer was given"));
764
+ });
765
+ _readers.set(input, reader);
766
+ return reader;
767
+ };
768
+
769
+ /**
770
+ * Detaches the line reader before raw mode, so keystrokes (a password)
771
+ * never get queued as a line.
772
+ * @param {NodeJS.ReadableStream} input
773
+ */
774
+ const _releaseReader = (input) => {
775
+ const reader = _readers.get(input);
776
+ if (!reader) return;
777
+ _readers.delete(input);
778
+ reader.rl.close();
779
+ };
780
+
781
+ /**
782
+ * Asks a question and returns the answer. With `validate`, it keeps asking
783
+ * until the answer is valid.
784
+ *
785
+ * @example
786
+ * const name = await cli.prompt("Project name?", { default: "my-app" });
787
+ * const age = await cli.prompt("Age?", {
788
+ * validate: (answer) => /^\d+$/.test(answer) || "Please enter a number",
789
+ * });
790
+ *
791
+ * @param {string} question
792
+ * @param {PromptOptions} [options]
793
+ * @returns {Promise<string>} Trimmed.
794
+ */
795
+ async function prompt(question, options = {}) {
796
+ const output = options.output ?? process.stdout;
797
+ const input = options.input ?? process.stdin;
798
+ const palette = color.create(color.detect(/** @type {any} */ (output)));
799
+ const suffix = options.default !== undefined ? ` ${palette.level ? palette.gray(`(${options.default})`) : `(${options.default})`}` : "";
800
+ for (;;) {
801
+ const raw = (await _ask(`${palette.level ? palette.cyan("?") : "?"} ${question}${suffix} `, input, output)).trim();
802
+ const answer = raw === "" && options.default !== undefined ? options.default : raw;
803
+ const verdict = options.validate ? await options.validate(answer) : true;
804
+ if (verdict === true) return answer;
805
+ output.write(`${palette.level ? palette.red("✖") : "✖"} ${verdict}\n`);
806
+ }
807
+ }
808
+
809
+ /**
810
+ * Asks for a secret without showing it; each character appears as `*`.
811
+ *
812
+ * @example
813
+ * const token = await cli.password("API token?");
814
+ *
815
+ * @param {string} question
816
+ * @param {{ mask?: string, input?: NodeJS.ReadStream, output?: NodeJS.WriteStream }} [options] `mask: ""` shows nothing at all.
817
+ * @returns {Promise<string>}
818
+ */
819
+ function password(question, options = {}) {
820
+ const input = options.input ?? process.stdin;
821
+ const output = options.output ?? process.stdout;
822
+ const mask = options.mask ?? "*";
823
+ const palette = color.create(color.detect(output));
824
+ const label = `${palette.level ? palette.cyan("?") : "?"} ${question} `;
825
+ if (!input.isTTY || typeof input.setRawMode !== "function") return _ask(label, input, output).then((a) => a.replace(/\r?\n$/, ""));
826
+ return new Promise((resolve, reject) => {
827
+ let value = "";
828
+ output.write(label);
829
+ _releaseReader(input);
830
+ input.setRawMode(true);
831
+ input.resume();
832
+ input.setEncoding("utf8");
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;
836
+ for (const char of chunk) {
837
+ if (char === "\r" || char === "\n") {
838
+ cleanup();
839
+ output.write("\n");
840
+ resolve(value);
841
+ return;
842
+ }
843
+ // Ctrl+C, Ctrl+D, Escape
844
+ if (char === "\u0003" || char === "\u0004" || char === ESCAPE) {
845
+ cleanup();
846
+ output.write("\n");
847
+ reject(new AbortError("Cancelled"));
848
+ return;
849
+ }
850
+ if (char === "\u007f" || char === "\b") {
851
+ if (value) {
852
+ value = [...value].slice(0, -1).join("");
853
+ if (mask) output.write("\b \b");
854
+ }
855
+ continue;
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
+ }
863
+ if (char < " ") continue;
864
+ value += char;
865
+ if (mask) output.write(mask);
866
+ }
867
+ };
868
+ const cleanup = () => {
869
+ input.off("data", onData);
870
+ input.setRawMode(false);
871
+ input.pause();
872
+ };
873
+ input.on("data", onData);
874
+ });
875
+ }
876
+
877
+ /**
878
+ * Asks a yes/no question. Understands `y`, `yes`, `o`, `oui`, `true`, `1`
879
+ * and their opposites; an empty answer gives the default.
880
+ *
881
+ * @example
882
+ * if (await cli.confirm("Delete 42 files?")) await cleanup();
883
+ *
884
+ * @param {string} question
885
+ * @param {ConfirmOptions} [options]
886
+ * @returns {Promise<boolean>}
887
+ */
888
+ async function confirm(question, options = {}) {
889
+ const output = options.output ?? process.stdout;
890
+ const input = options.input ?? process.stdin;
891
+ const def = options.default ?? false;
892
+ const palette = color.create(color.detect(/** @type {any} */ (output)));
893
+ const hint = palette.level ? palette.gray(def ? "(Y/n)" : "(y/N)") : def ? "(Y/n)" : "(y/N)";
894
+ for (;;) {
895
+ const answer = (await _ask(`${palette.level ? palette.cyan("?") : "?"} ${question} ${hint} `, input, output)).trim().toLowerCase();
896
+ if (answer === "") return def;
897
+ if (["y", "yes", "o", "oui", "true", "1"].includes(answer)) return true;
898
+ if (["n", "no", "non", "false", "0"].includes(answer)) return false;
899
+ output.write(`${palette.level ? palette.red("✖") : "✖"} Please answer yes or no\n`);
900
+ }
901
+ }
902
+
903
+ /**
904
+ * @template V
905
+ * @param {Array<Choice<V>>} choices
906
+ * @returns {Array<{ label: string, value: V, hint?: string, disabled?: boolean }>}
907
+ */
908
+ const _normalizeChoices = (choices) =>
909
+ choices.map((c) => (typeof c === "string" ? { label: c, value: /** @type {V} */ (/** @type {unknown} */ (c)) } : c));
910
+
911
+ /**
912
+ * The arrow-key list behind `select()` and `multiselect()`.
913
+ * @template V
914
+ * @param {string} question
915
+ * @param {Array<{ label: string, value: V, hint?: string, disabled?: boolean }>} items
916
+ * @param {SelectOptions} options
917
+ * @param {boolean} multiple
918
+ * @returns {Promise<number[]>}
919
+ */
920
+ const _interactiveList = (question, items, options, multiple) =>
921
+ new Promise((resolve, reject) => {
922
+ const input = options.input ?? process.stdin;
923
+ const output = options.output ?? process.stdout;
924
+ const palette = color.create(color.detect(output));
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);
928
+ let cursor = Math.min(Math.max(0, options.default ?? 0), items.length - 1);
929
+ if (items[cursor].disabled) cursor = usable.find((i) => i > cursor) ?? usable[0];
930
+ /** @type {Set<number>} */
931
+ const selected = new Set();
932
+ let offset = 0;
933
+ let drawn = 0;
934
+ let error = "";
935
+
936
+ const draw = () => {
937
+ if (cursor < offset) offset = cursor;
938
+ if (cursor >= offset + pageSize) offset = cursor - pageSize + 1;
939
+ const position = items.length > pageSize ? ` ${cursor + 1}/${items.length}` : "";
940
+ const lines = [`${palette.cyan("?")} ${question} ${palette.gray(hint + position)}`];
941
+ for (let i = offset; i < offset + pageSize; i++) {
942
+ const item = items[i];
943
+ const active = i === cursor;
944
+ const mark = multiple ? (selected.has(i) ? palette.green("◉ ") : "◯ ") : "";
945
+ const text = item.disabled ? palette.gray(`${item.label} (disabled)`) : active ? palette.cyan(item.label) : item.label;
946
+ lines.push(`${active ? palette.cyan("❯") : " "} ${mark}${text}${item.hint ? ` ${palette.gray(item.hint)}` : ""}`);
947
+ }
948
+ if (error) lines.push(palette.red(`✖ ${error}`));
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 : ""}`);
952
+ drawn = lines.length;
953
+ };
954
+ const move = (/** @type {number} */ step) => {
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);
968
+ };
969
+ const onKey = (/** @type {string} */ _str, /** @type {{ name?: string, ctrl?: boolean }} */ key = {}) => {
970
+ error = "";
971
+ if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") {
972
+ cleanup();
973
+ reject(new AbortError("Cancelled"));
974
+ return;
975
+ }
976
+ if (key.name === "up" || key.name === "k") move(-1);
977
+ else if (key.name === "down" || key.name === "j") move(1);
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) {
983
+ if (selected.has(cursor)) selected.delete(cursor);
984
+ else selected.add(cursor);
985
+ } else if (key.name === "a" && multiple) {
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") {
992
+ if (multiple && selected.size < (options.min ?? 0)) {
993
+ error = `Select at least ${options.min}`;
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`);
998
+ cleanup();
999
+ resolve(picked);
1000
+ return;
1001
+ }
1002
+ }
1003
+ draw();
1004
+ };
1005
+ _releaseReader(input);
1006
+ require("node:readline").emitKeypressEvents(input);
1007
+ input.setRawMode(true);
1008
+ input.resume();
1009
+ _restoreCursorOnExit(output);
1010
+ output.write(HIDE_CURSOR);
1011
+ input.on("keypress", onKey);
1012
+ draw();
1013
+ });
1014
+
1015
+ /**
1016
+ * Lets the user pick one choice with the arrow keys and Enter. Without an
1017
+ * interactive terminal, it prints a numbered list and reads a number.
1018
+ *
1019
+ * @example
1020
+ * const framework = await cli.select("Framework?", ["Express", "Fastify", "Koa"]);
1021
+ * const plan = await cli.select("Plan?", [
1022
+ * { label: "Free", value: "free", hint: "0 €" },
1023
+ * { label: "Pro", value: "pro", hint: "9 €/month" },
1024
+ * ]);
1025
+ *
1026
+ * @template [V=string]
1027
+ * @param {string} question
1028
+ * @param {Array<Choice<V>>} choices
1029
+ * @param {SelectOptions} [options]
1030
+ * @returns {Promise<V>}
1031
+ */
1032
+ async function select(question, choices, options = {}) {
1033
+ const items = _normalizeChoices(choices);
1034
+ if (!items.some((item) => !item.disabled)) throw new RangeError("select() needs at least one choice that can be picked");
1035
+ const input = options.input ?? process.stdin;
1036
+ if (input.isTTY && typeof input.setRawMode === "function") {
1037
+ const [index] = await _interactiveList(question, items, options, false);
1038
+ return items[index].value;
1039
+ }
1040
+ const output = options.output ?? process.stdout;
1041
+ output.write(`? ${question}\n${items.map((it, i) => ` ${i + 1}. ${it.label}${it.disabled ? " (disabled)" : ""}`).join("\n")}\n`);
1042
+ for (;;) {
1043
+ const answer = (await _ask(`Choose 1-${items.length}${options.default !== undefined ? ` (${options.default + 1})` : ""}: `, input, output)).trim();
1044
+ const index = answer === "" && options.default !== undefined ? options.default : Number(answer) - 1;
1045
+ if (Number.isInteger(index) && items[index] && !items[index].disabled) return items[index].value;
1046
+ output.write("✖ Invalid choice\n");
1047
+ }
1048
+ }
1049
+
1050
+ /**
1051
+ * Lets the user pick several choices: arrows to move, Space to toggle, `a`
1052
+ * for all, Enter to confirm.
1053
+ *
1054
+ * @example
1055
+ * const features = await cli.multiselect("Features?", ["TypeScript", "ESLint", "Tests", "Docker"], { min: 1 });
1056
+ *
1057
+ * @template [V=string]
1058
+ * @param {string} question
1059
+ * @param {Array<Choice<V>>} choices
1060
+ * @param {SelectOptions} [options]
1061
+ * @returns {Promise<V[]>} In list order.
1062
+ */
1063
+ async function multiselect(question, choices, options = {}) {
1064
+ const items = _normalizeChoices(choices);
1065
+ const input = options.input ?? process.stdin;
1066
+ if (items.some((item) => !item.disabled) && input.isTTY && typeof input.setRawMode === "function") {
1067
+ const indexes = await _interactiveList(question, items, options, true);
1068
+ return indexes.map((i) => items[i].value);
1069
+ }
1070
+ const output = options.output ?? process.stdout;
1071
+ output.write(`? ${question}\n${items.map((it, i) => ` ${i + 1}. ${it.label}${it.disabled ? " (disabled)" : ""}`).join("\n")}\n`);
1072
+ for (;;) {
1073
+ const answer = (await _ask("Choose (comma-separated numbers): ", input, output)).trim();
1074
+ const indexes = answer === "" ? [] : answer.split(/[\s,]+/).filter(Boolean).map((n) => Number(n) - 1);
1075
+ if (indexes.every((i) => Number.isInteger(i) && items[i] && !items[i].disabled) && indexes.length >= (options.min ?? 0)) {
1076
+ return [...new Set(indexes)].sort((a, b) => a - b).map((i) => items[i].value);
1077
+ }
1078
+ output.write(`✖ Invalid selection${options.min ? ` (at least ${options.min})` : ""}\n`);
1079
+ }
1080
+ }
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
+
1095
+ /**
1096
+ * Parses command-line arguments from a schema, and types the result:
1097
+ * `flags.port` is a `number`. Handles short aliases, defaults, required
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.
1101
+ *
1102
+ * @example
1103
+ * // node server.js --port 8080 -v --tag api --tag web public/
1104
+ * const { flags, positionals } = cli.args({
1105
+ * port: { type: "number", short: "p", default: 3000, env: "PORT", description: "Port to listen on" },
1106
+ * verbose: { type: "boolean", short: "v" },
1107
+ * tag: { type: "string", multiple: true },
1108
+ * env: { type: "string", choices: ["dev", "prod"], default: "dev" },
1109
+ * }, { description: "Start the web server", version: "1.0.0" });
1110
+ * // flags: { port: 8080, verbose: true, tag: ["api", "web"], env: "dev" }
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
+ *
1118
+ * @template {Record<string, FlagSpec>} S
1119
+ * @param {S} schema
1120
+ * @param {ArgsOptions} [options]
1121
+ * @returns {ParsedArgs<S>}
1122
+ * @throws {TypeError} For unknown flags or commands, bad numbers, missing required flags or values not in `choices`. The message is ready to show.
1123
+ */
1124
+ function args(schema, options = {}) {
1125
+ const argv = options.argv ?? process.argv.slice(2);
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) : [];
1129
+
1130
+ const help = () => {
1131
+ const lines = [];
1132
+ if (options.description) lines.push(options.description, "");
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]>} */
1145
+ const rows = Object.entries(schema).map(([key, spec]) => {
1146
+ const flag = `${spec.short ? `-${spec.short}, ` : " "}--${key}${spec.type === "boolean" ? "" : ` <${spec.type}>`}`;
1147
+ const details = [
1148
+ spec.description ?? "",
1149
+ spec.choices ? `(${spec.choices.join(" | ")})` : "",
1150
+ spec.default !== undefined ? `[default: ${JSON.stringify(spec.default)}]` : "",
1151
+ spec.env ? `[env: ${spec.env}]` : "",
1152
+ spec.required ? "[required]" : "",
1153
+ ].filter(Boolean).join(" ");
1154
+ return [flag, details];
1155
+ });
1156
+ if (options.help !== false) rows.push(["-h, --help", "Show this help"]);
1157
+ if (options.version) rows.push([" --version", "Show the version"]);
1158
+ list(rows);
1159
+ if (options.examples?.length) {
1160
+ lines.push("", "Examples:");
1161
+ for (const example of options.examples) lines.push(` ${example}`);
1162
+ }
1163
+ return lines.join("\n");
1164
+ };
1165
+
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")) {
1170
+ process.stdout.write(`${help()}\n`);
1171
+ process.exit(0);
1172
+ }
1173
+ if (options.version && own.includes("--version") && !Object.hasOwn(schema, "version")) {
1174
+ process.stdout.write(`${options.version}\n`);
1175
+ process.exit(0);
1176
+ }
1177
+
1178
+ /** @type {Record<string, { type: "string" | "boolean", short?: string, multiple?: boolean }>} */
1179
+ const config = {};
1180
+ for (const [key, spec] of Object.entries(schema)) {
1181
+ config[key] = { type: spec.type === "boolean" ? "boolean" : "string" };
1182
+ if (spec.short) config[key].short = spec.short;
1183
+ if (spec.multiple) config[key].multiple = true;
1184
+ }
1185
+
1186
+ let parsed;
1187
+ try {
1188
+ parsed = parseArgs({ args: argv, options: config, allowPositionals: true, strict: options.strict ?? true, allowNegative: true });
1189
+ } catch (error) {
1190
+ const message = /** @type {Error} */ (error).message;
1191
+ const unknown = /Unknown option '(-{1,2})([^']+)'/.exec(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]);
1195
+ }
1196
+
1197
+ /** @type {Record<string, any>} */
1198
+ const flags = {};
1199
+ for (const [key, spec] of Object.entries(schema)) {
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
+ }
1207
+ if (raw === undefined) {
1208
+ if (spec.required) throw new TypeError(`Missing required option --${key}${spec.env ? ` (or ${spec.env} in the environment)` : ""}`);
1209
+ flags[key] = spec.default !== undefined ? spec.default : spec.type === "boolean" ? false : spec.multiple ? [] : undefined;
1210
+ continue;
1211
+ }
1212
+ const convert = (/** @type {any} */ value) => {
1213
+ /** @type {any} */
1214
+ let result = value;
1215
+ if (spec.type === "number") {
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);
1222
+ }
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;
1227
+ };
1228
+ flags[key] = Array.isArray(raw) ? raw.map(convert) : convert(raw);
1229
+ }
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 };
1242
+ }
1243
+
1244
+ /**
1245
+ * Is someone at the keyboard? True when stdin and stdout are terminals and
1246
+ * we're not in CI.
1247
+ *
1248
+ * @example
1249
+ * const name = cli.isInteractive() ? await cli.prompt("Name?") : "default";
1250
+ *
1251
+ * @returns {boolean}
1252
+ */
1253
+ function isInteractive() {
1254
+ return Boolean(process.stdin.isTTY) && _isTTY(process.stdout);
1255
+ }
1256
+
1257
+ /**
1258
+ * The terminal size, or 80x24 when it's unknown.
1259
+ *
1260
+ * @returns {{ columns: number, rows: number }}
1261
+ */
1262
+ function size() {
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 };
1266
+ }
1267
+
1268
+ /**
1269
+ * Clears the screen. Does nothing when stdout isn't a terminal.
1270
+ *
1271
+ * @returns {void}
1272
+ */
1273
+ function clear() {
1274
+ if (process.stdout.isTTY) process.stdout.write("\u001b[2J\u001b[3J\u001b[H");
1275
+ }
1276
+
1277
+ module.exports = {
1278
+ table,
1279
+ box,
1280
+ tree,
1281
+ spinner,
1282
+ spin,
1283
+ progress,
1284
+ prompt,
1285
+ password,
1286
+ confirm,
1287
+ select,
1288
+ multiselect,
1289
+ args,
1290
+ isInteractive,
1291
+ size,
1292
+ clear,
1293
+ };