@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.
- package/CHANGELOG.md +150 -0
- package/README.md +32 -30
- package/index.js +3 -3
- package/package.json +8 -7
- package/src/Arr.js +222 -58
- package/src/Async.js +322 -90
- package/src/Cache.js +148 -44
- package/src/Checker.js +257 -26
- package/src/Cli.js +404 -145
- package/src/Color.js +224 -83
- package/src/Crypto.js +481 -106
- package/src/Emitter.js +104 -37
- package/src/Env.js +176 -42
- package/src/FS.js +203 -60
- package/src/Func.js +226 -76
- package/src/Http.js +311 -149
- package/src/Id.js +281 -96
- package/src/Logger.js +302 -78
- package/src/Num.js +182 -60
- package/src/Obj.js +340 -125
- package/src/SQLite.js +426 -105
- package/src/Schema.js +602 -87
- package/src/Str.js +316 -86
- package/src/Sys.js +197 -54
- package/src/Time.js +572 -317
- package/src/Utils.js +62 -41
- package/src/errors.js +54 -11
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +59 -17
- package/types/src/Arr.d.ts +78 -0
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +52 -1
- package/types/src/Async.d.ts.map +1 -1
- package/types/src/Cache.d.ts +33 -7
- package/types/src/Cache.d.ts.map +1 -1
- package/types/src/Checker.d.ts +155 -2
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +97 -15
- package/types/src/Cli.d.ts.map +1 -1
- package/types/src/Color.d.ts +7 -1
- package/types/src/Color.d.ts.map +1 -1
- package/types/src/Crypto.d.ts +99 -13
- package/types/src/Crypto.d.ts.map +1 -1
- package/types/src/Emitter.d.ts +36 -17
- package/types/src/Emitter.d.ts.map +1 -1
- package/types/src/Env.d.ts +31 -1
- package/types/src/Env.d.ts.map +1 -1
- package/types/src/FS.d.ts +36 -5
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +147 -19
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +113 -43
- package/types/src/Http.d.ts.map +1 -1
- package/types/src/Id.d.ts +60 -8
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +53 -2
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +48 -7
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +67 -4
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +123 -14
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +256 -7
- package/types/src/Schema.d.ts.map +1 -1
- package/types/src/Str.d.ts +49 -1
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +51 -6
- package/types/src/Sys.d.ts.map +1 -1
- package/types/src/Time.d.ts +130 -34
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +15 -11
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +27 -3
- package/types/src/errors.d.ts.map +1 -1
- package/types/src/internal/shared.d.ts +123 -0
- 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
|
|
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 "
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
const
|
|
304
|
-
|
|
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((
|
|
308
|
-
const widths =
|
|
309
|
-
const
|
|
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 (
|
|
356
|
+
if (markdown) {
|
|
312
357
|
const line = (/** @type {string[]} */ cells) => `| ${cells.join(" | ")} |`;
|
|
313
|
-
const
|
|
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((
|
|
320
|
-
out.push(line(
|
|
321
|
-
const dashes = "-".repeat(
|
|
322
|
-
return
|
|
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
|
|
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} */
|
|
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((
|
|
333
|
-
const
|
|
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(
|
|
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
|
|
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(
|
|
412
|
+
const lines = String(text).split(_LINE_BREAK);
|
|
363
413
|
const title = options.title ? ` ${options.title} ` : "";
|
|
364
|
-
|
|
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(`${
|
|
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 =
|
|
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 (
|
|
430
|
-
|
|
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 (
|
|
574
|
+
if (spinning && interactive) render();
|
|
452
575
|
},
|
|
453
576
|
get isSpinning() {
|
|
454
|
-
return
|
|
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
|
-
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 }}
|
|
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,
|
|
742
|
+
/** @type {WeakMap<NodeJS.ReadableStream, _LineReader>} */
|
|
573
743
|
const _readers = new WeakMap();
|
|
574
744
|
|
|
575
745
|
/**
|
|
576
746
|
* @param {NodeJS.ReadableStream} input
|
|
577
|
-
* @returns {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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("❯") : " "} ${
|
|
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
|
-
|
|
768
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
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 === "
|
|
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 =
|
|
793
|
-
|
|
794
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
892
|
-
* `--help`. A typo gets a
|
|
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]"
|
|
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
|
-
|
|
931
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
960
|
-
|
|
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
|
|
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
|
-
|
|
978
|
-
if (value === "" || !Number.isFinite(
|
|
979
|
-
|
|
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 && !
|
|
982
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|