@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.
- package/CHANGELOG.md +340 -0
- package/README.md +137 -548
- package/index.js +80 -35
- package/package.json +161 -33
- package/src/Arr.js +1050 -418
- package/src/Async.js +817 -0
- package/src/Cache.js +433 -0
- package/src/Checker.js +1202 -539
- package/src/Cli.js +1293 -0
- package/src/Color.js +556 -0
- package/src/Crypto.js +1082 -0
- package/src/Emitter.js +356 -0
- package/src/Env.js +689 -0
- package/src/FS.js +1626 -894
- package/src/Func.js +823 -356
- package/src/Http.js +700 -0
- package/src/Id.js +568 -200
- package/src/Logger.js +963 -378
- package/src/Num.js +965 -360
- package/src/Obj.js +1077 -379
- package/src/SQLite.js +1629 -1005
- package/src/Schema.js +2101 -0
- package/src/Str.js +1209 -432
- package/src/Sys.js +654 -0
- package/src/Time.js +1533 -440
- package/src/Utils.js +342 -325
- package/src/errors.js +335 -0
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +735 -526
- package/types/src/Arr.d.ts +533 -145
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +372 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +221 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +651 -232
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +664 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +300 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +620 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +189 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +412 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +717 -238
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +508 -135
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +444 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +199 -66
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +414 -46
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +514 -137
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +453 -104
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +754 -308
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +1189 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +617 -166
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +314 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +759 -204
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +158 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +316 -0
- package/types/src/errors.d.ts.map +1 -0
- package/types/src/internal/shared.d.ts +123 -0
- package/types/src/internal/shared.d.ts.map +1 -0
- 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
|
+
};
|