@ix-xs/node-comfort 1.2.0 → 2.0.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 +190 -0
- package/README.md +136 -549
- package/index.js +80 -35
- package/package.json +160 -33
- package/src/Arr.js +887 -419
- package/src/Async.js +585 -0
- package/src/Cache.js +329 -0
- package/src/Checker.js +971 -539
- package/src/Cli.js +1034 -0
- package/src/Color.js +415 -0
- package/src/Crypto.js +707 -0
- package/src/Emitter.js +289 -0
- package/src/Env.js +555 -0
- package/src/FS.js +1483 -894
- package/src/Func.js +670 -353
- package/src/Http.js +538 -0
- package/src/Id.js +384 -201
- package/src/Logger.js +739 -378
- package/src/Num.js +844 -361
- package/src/Obj.js +855 -372
- package/src/SQLite.js +1308 -1005
- package/src/Schema.js +1586 -0
- package/src/Str.js +983 -436
- package/src/Sys.js +511 -0
- package/src/Time.js +1260 -422
- package/src/Utils.js +321 -325
- package/src/errors.js +292 -0
- package/types/index.d.ts +693 -526
- package/types/src/Arr.d.ts +455 -145
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +321 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +195 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +501 -235
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +582 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +294 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +534 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +170 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +382 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +686 -238
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +380 -135
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +374 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +149 -68
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +363 -46
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +473 -137
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +390 -104
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +646 -309
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +940 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +569 -166
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +269 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +663 -204
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +154 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +292 -0
- package/types/src/errors.d.ts.map +1 -0
- package/types/index.d.ts.map +0 -1
package/src/Cli.js
ADDED
|
@@ -0,0 +1,1034 @@
|
|
|
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 readline = require("node:readline");
|
|
19
|
+
const { parseArgs } = require("node:util");
|
|
20
|
+
const color = require("./Color.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[]} [choices] Allowed values, for string flags.
|
|
147
|
+
* @property {string} [description] Shown in `--help`.
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The type of a flag's value.
|
|
152
|
+
* @template {FlagSpec} F
|
|
153
|
+
* @typedef {F["type"] extends "number" ? number : F["type"] extends "boolean" ? boolean : F extends { choices: readonly (infer C)[] } ? C : string} FlagValue
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The parsed flags, typed from the schema.
|
|
158
|
+
* @template {Record<string, FlagSpec>} S
|
|
159
|
+
* @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
|
|
160
|
+
*/
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Options for `args()`.
|
|
164
|
+
* @typedef {object} ArgsOptions
|
|
165
|
+
* @property {string[]} [argv] What to parse. Defaults to `process.argv.slice(2)`.
|
|
166
|
+
* @property {string} [name] Program name in the help. Defaults to the script name.
|
|
167
|
+
* @property {string} [description] Shown at the top of the help.
|
|
168
|
+
* @property {string} [usage] A usage line, like `"<file> [options]"`.
|
|
169
|
+
* @property {boolean} [help] Answer `--help` and `-h` by printing the help and exiting. Defaults to `true`.
|
|
170
|
+
* @property {string} [version] Answer `--version` with this version.
|
|
171
|
+
* @property {boolean} [strict] Reject unknown flags, with a suggestion. Defaults to `true`.
|
|
172
|
+
*/
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* What `args()` returns.
|
|
176
|
+
* @template {Record<string, FlagSpec>} S
|
|
177
|
+
* @typedef {object} ParsedArgs
|
|
178
|
+
* @property {ParsedFlags<S>} flags
|
|
179
|
+
* @property {string[]} positionals Arguments that aren't flags.
|
|
180
|
+
* @property {() => string} help The generated help text.
|
|
181
|
+
*/
|
|
182
|
+
|
|
183
|
+
/** @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
|
+
const BORDERS = {
|
|
185
|
+
rounded: ["╭", "─", "╮", "│", "╯", "─", "╰", "│", "├", "─", "┤", "┼", "┬", "┴"],
|
|
186
|
+
single: ["┌", "─", "┐", "│", "┘", "─", "└", "│", "├", "─", "┤", "┼", "┬", "┴"],
|
|
187
|
+
double: ["╔", "═", "╗", "║", "╝", "═", "╚", "║", "╠", "═", "╣", "╬", "╦", "╩"],
|
|
188
|
+
heavy: ["┏", "━", "┓", "┃", "┛", "━", "┗", "┃", "┣", "━", "┫", "╋", "┳", "┻"],
|
|
189
|
+
ascii: ["+", "-", "+", "|", "+", "-", "+", "|", "+", "-", "+", "+", "+", "+"],
|
|
190
|
+
none: [" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const SHOW_CURSOR = "\u001b[?25h";
|
|
194
|
+
const HIDE_CURSOR = "\u001b[?25l";
|
|
195
|
+
const CLEAR_LINE = "\r\u001b[2K";
|
|
196
|
+
|
|
197
|
+
/** @param {boolean | undefined} forced */
|
|
198
|
+
const _palette = (forced) => (forced === undefined ? color : color.create(forced ? (color.level > 0 ? color.level : 3) : 0));
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {import("./Color").ColorModule} palette
|
|
202
|
+
* @param {string | undefined} name
|
|
203
|
+
* @param {string} text
|
|
204
|
+
*/
|
|
205
|
+
const _paint = (palette, name, text) => {
|
|
206
|
+
if (!name || palette.level === 0) return text;
|
|
207
|
+
if (name.startsWith("#")) return palette.hex(name)(text);
|
|
208
|
+
const chain = /** @type {any} */ (palette)[name];
|
|
209
|
+
return typeof chain === "function" ? chain(text) : text;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {string} text
|
|
214
|
+
* @param {number} width
|
|
215
|
+
* @param {"left" | "center" | "right"} align
|
|
216
|
+
*/
|
|
217
|
+
const _align = (text, width, align) => {
|
|
218
|
+
const gap = Math.max(0, width - color.width(text));
|
|
219
|
+
if (align === "right") return " ".repeat(gap) + text;
|
|
220
|
+
if (align === "center") return " ".repeat(Math.floor(gap / 2)) + text + " ".repeat(Math.ceil(gap / 2));
|
|
221
|
+
return text + " ".repeat(gap);
|
|
222
|
+
};
|
|
223
|
+
|
|
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
|
+
/** @param {unknown} value */
|
|
240
|
+
const _cell = (value) => {
|
|
241
|
+
if (value === null || value === undefined) return "";
|
|
242
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
|
|
243
|
+
if (typeof value === "object") {
|
|
244
|
+
try {
|
|
245
|
+
return JSON.stringify(value);
|
|
246
|
+
} catch {
|
|
247
|
+
return String(value);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return String(value);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/** @param {NodeJS.WriteStream | undefined} stream */
|
|
254
|
+
const _isTTY = (stream) => Boolean(stream && stream.isTTY) && process.env.TERM !== "dumb" && !process.env.CI;
|
|
255
|
+
|
|
256
|
+
let _cursorHooked = false;
|
|
257
|
+
/** @param {NodeJS.WriteStream} stream */
|
|
258
|
+
const _restoreCursorOnExit = (stream) => {
|
|
259
|
+
if (_cursorHooked) return;
|
|
260
|
+
_cursorHooked = true;
|
|
261
|
+
process.once("exit", () => {
|
|
262
|
+
try {
|
|
263
|
+
stream.write(SHOW_CURSOR);
|
|
264
|
+
} catch {
|
|
265
|
+
// stream already closed
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Draws a table from an array of objects (keys become columns) or an array
|
|
272
|
+
* of arrays. Colors, emoji and CJK characters line up correctly.
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* console.log(cli.table([
|
|
276
|
+
* { name: "Ada", role: "admin", logins: 42 },
|
|
277
|
+
* { name: "Bob", role: "user", logins: 7 },
|
|
278
|
+
* ]));
|
|
279
|
+
* // ╭──────┬───────┬────────╮
|
|
280
|
+
* // │ name │ role │ logins │
|
|
281
|
+
* // ├──────┼───────┼────────┤
|
|
282
|
+
* // │ Ada │ admin │ 42 │
|
|
283
|
+
* // │ Bob │ user │ 7 │
|
|
284
|
+
* // ╰──────┴───────┴────────╯
|
|
285
|
+
*
|
|
286
|
+
* @param {Array<Record<string, unknown>> | unknown[][]} rows
|
|
287
|
+
* @param {TableOptions} [options]
|
|
288
|
+
* @returns {string}
|
|
289
|
+
*/
|
|
290
|
+
function table(rows, options = {}) {
|
|
291
|
+
const palette = _palette(options.colors);
|
|
292
|
+
const padding = " ".repeat(Math.max(0, options.padding ?? 1));
|
|
293
|
+
const isArrayRows = rows.length > 0 && Array.isArray(rows[0]);
|
|
294
|
+
/** @type {TableColumn[]} */
|
|
295
|
+
const columns = (options.columns ?? (isArrayRows
|
|
296
|
+
? Array.from({ length: Math.max(0, ...rows.map((r) => /** @type {unknown[]} */ (r).length)) }, (_, i) => i)
|
|
297
|
+
: [...new Set(rows.flatMap((r) => Object.keys(/** @type {object} */ (r ?? {}))))]
|
|
298
|
+
)).map((c) => (typeof c === "object" ? c : { key: c }));
|
|
299
|
+
|
|
300
|
+
const body = rows.map((row) => columns.map((col) => {
|
|
301
|
+
const raw = /** @type {any} */ (row)?.[col.key];
|
|
302
|
+
const value = col.format ? col.format(raw, row) : raw;
|
|
303
|
+
const text = _cell(value);
|
|
304
|
+
return { text: col.maxWidth ? _cut(text, col.maxWidth) : text, numeric: typeof raw === "number" || typeof raw === "bigint" };
|
|
305
|
+
}));
|
|
306
|
+
const showHeader = options.header ?? !isArrayRows;
|
|
307
|
+
const headers = columns.map((c) => c.header ?? String(c.key));
|
|
308
|
+
const widths = columns.map((c, i) => Math.max(showHeader ? color.width(headers[i]) : 0, ...body.map((r) => color.width(r[i].text)), 1));
|
|
309
|
+
const alignOf = (/** @type {number} */ i, /** @type {boolean} */ numeric) => columns[i].align ?? (numeric ? "right" : "left");
|
|
310
|
+
|
|
311
|
+
if (options.border === "markdown") {
|
|
312
|
+
const line = (/** @type {string[]} */ cells) => `| ${cells.join(" | ")} |`;
|
|
313
|
+
const escaped = body.map((r) => r.map((c) => ({ ...c, text: c.text.replace(/\|/g, "\\|") })));
|
|
314
|
+
const mdWidths = widths.map((w, i) => Math.max(3, w, ...escaped.map((r) => color.width(r[i].text))));
|
|
315
|
+
/** @type {Array<"left" | "center" | "right">} */
|
|
316
|
+
const mdAlign = columns.map((c, i) => c.align ?? (body.length > 0 && body.every((r) => r[i].numeric) ? "right" : "left"));
|
|
317
|
+
const out = [];
|
|
318
|
+
if (showHeader) {
|
|
319
|
+
out.push(line(headers.map((h, i) => _align(h, mdWidths[i], mdAlign[i]))));
|
|
320
|
+
out.push(line(mdWidths.map((w, i) => {
|
|
321
|
+
const dashes = "-".repeat(w);
|
|
322
|
+
return mdAlign[i] === "center" ? `:${dashes.slice(2)}:` : mdAlign[i] === "right" ? `${dashes.slice(1)}:` : dashes;
|
|
323
|
+
})));
|
|
324
|
+
}
|
|
325
|
+
for (const r of escaped) out.push(line(r.map((c, i) => _align(c.text, mdWidths[i], mdAlign[i]))));
|
|
326
|
+
return out.join("\n");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const b = BORDERS[options.border ?? "rounded"];
|
|
330
|
+
const dim = (/** @type {string} */ s) => (palette.level > 0 ? palette.gray(s) : s);
|
|
331
|
+
const rule = (/** @type {string} */ left, /** @type {string} */ fill, /** @type {string} */ mid, /** @type {string} */ right) =>
|
|
332
|
+
dim(left + widths.map((w) => fill.repeat(w + padding.length * 2)).join(mid) + right);
|
|
333
|
+
const row = (/** @type {string[]} */ cells) => dim(b[7]) + cells.map((c) => padding + c + padding).join(dim(b[3])) + dim(b[3]);
|
|
334
|
+
const out = [rule(b[0], b[1], b[12], b[2])];
|
|
335
|
+
if (showHeader) {
|
|
336
|
+
out.push(row(headers.map((h, i) => _align(palette.level > 0 ? palette.bold(h) : h, widths[i], columns[i].align ?? "left"))));
|
|
337
|
+
out.push(rule(b[8], b[9], b[11], b[10]));
|
|
338
|
+
}
|
|
339
|
+
for (const r of body) out.push(row(r.map((c, i) => _align(c.text, widths[i], alignOf(i, c.numeric)))));
|
|
340
|
+
out.push(rule(b[6], b[5], b[13], b[4]));
|
|
341
|
+
return out.join("\n");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Draws a box around text.
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* 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
|
+
* // ╰───────────────────────────╯
|
|
353
|
+
*
|
|
354
|
+
* @param {string} text
|
|
355
|
+
* @param {BoxOptions} [options]
|
|
356
|
+
* @returns {string}
|
|
357
|
+
*/
|
|
358
|
+
function box(text, options = {}) {
|
|
359
|
+
const palette = _palette(options.colors);
|
|
360
|
+
const b = BORDERS[options.border ?? "rounded"];
|
|
361
|
+
const pad = typeof options.padding === "number" ? { x: options.padding, y: Math.floor(options.padding / 2) } : { x: 2, y: 0, ...options.padding };
|
|
362
|
+
const lines = String(text).split(/\r?\n/);
|
|
363
|
+
const title = options.title ? ` ${options.title} ` : "";
|
|
364
|
+
const inner = Math.max(options.width ?? 0, ...lines.map((l) => color.width(l)), color.width(title) + 2 - pad.x * 2) + pad.x * 2;
|
|
365
|
+
const paint = (/** @type {string} */ s) => _paint(palette, options.borderColor, s);
|
|
366
|
+
const top = title
|
|
367
|
+
? 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
|
+
: paint(b[0] + b[1].repeat(inner) + b[2]);
|
|
369
|
+
const blank = paint(b[7]) + " ".repeat(inner) + paint(b[3]);
|
|
370
|
+
const out = [top];
|
|
371
|
+
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
|
+
}
|
|
375
|
+
for (let i = 0; i < pad.y; i++) out.push(blank);
|
|
376
|
+
out.push(paint(b[6] + b[5].repeat(inner) + b[4]));
|
|
377
|
+
return out.join("\n");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* A spinner for work in progress. Call `.start()`, then finish with
|
|
382
|
+
* `.succeed()`, `.fail()`, `.warn()`, `.info()` or `.stop()`.
|
|
383
|
+
*
|
|
384
|
+
* @example
|
|
385
|
+
* const spin = cli.spinner("Downloading").start();
|
|
386
|
+
* try {
|
|
387
|
+
* await download();
|
|
388
|
+
* spin.succeed("Downloaded 12 files");
|
|
389
|
+
* } catch (error) {
|
|
390
|
+
* spin.fail(`Download failed: ${error.message}`);
|
|
391
|
+
* }
|
|
392
|
+
*
|
|
393
|
+
* @param {string} [text=""]
|
|
394
|
+
* @param {SpinnerOptions} [options]
|
|
395
|
+
* @returns {Spinner} Not started yet.
|
|
396
|
+
*/
|
|
397
|
+
function spinner(text = "", options = {}) {
|
|
398
|
+
const stream = options.stream ?? process.stderr;
|
|
399
|
+
const frames = options.frames ?? ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
400
|
+
const palette = color.create(color.detect(stream));
|
|
401
|
+
const interactive = _isTTY(stream);
|
|
402
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
403
|
+
let timer;
|
|
404
|
+
let frame = 0;
|
|
405
|
+
let current = text;
|
|
406
|
+
|
|
407
|
+
const render = () => {
|
|
408
|
+
stream.write(`${CLEAR_LINE}${_paint(palette, options.color ?? "cyan", frames[frame % frames.length])} ${current}`);
|
|
409
|
+
frame++;
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* @param {string} symbol
|
|
413
|
+
* @param {string | undefined} finalText
|
|
414
|
+
*/
|
|
415
|
+
const finish = (symbol, finalText) => {
|
|
416
|
+
const wasSpinning = timer !== undefined;
|
|
417
|
+
clearInterval(timer);
|
|
418
|
+
timer = undefined;
|
|
419
|
+
if (interactive && wasSpinning) stream.write(`${CLEAR_LINE}${SHOW_CURSOR}`);
|
|
420
|
+
if (symbol || finalText !== undefined) stream.write(`${symbol}${symbol ? " " : ""}${finalText ?? current}\n`);
|
|
421
|
+
return self;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
/** @type {Spinner} */
|
|
425
|
+
const self = {
|
|
426
|
+
start(newText) {
|
|
427
|
+
if (newText !== undefined) current = newText;
|
|
428
|
+
if (!interactive) {
|
|
429
|
+
if (timer === undefined) stream.write(`- ${current}\n`);
|
|
430
|
+
timer = setInterval(() => {}, 1 << 30);
|
|
431
|
+
timer.unref();
|
|
432
|
+
return self;
|
|
433
|
+
}
|
|
434
|
+
clearInterval(timer);
|
|
435
|
+
_restoreCursorOnExit(stream);
|
|
436
|
+
stream.write(HIDE_CURSOR);
|
|
437
|
+
render();
|
|
438
|
+
timer = setInterval(render, options.interval ?? 80);
|
|
439
|
+
return self;
|
|
440
|
+
},
|
|
441
|
+
stop: () => finish("", undefined),
|
|
442
|
+
succeed: (finalText) => finish(_paint(palette, "green", "✔"), finalText),
|
|
443
|
+
fail: (finalText) => finish(_paint(palette, "red", "✖"), finalText),
|
|
444
|
+
warn: (finalText) => finish(_paint(palette, "yellow", "⚠"), finalText),
|
|
445
|
+
info: (finalText) => finish(_paint(palette, "blue", "ℹ"), finalText),
|
|
446
|
+
get text() {
|
|
447
|
+
return current;
|
|
448
|
+
},
|
|
449
|
+
set text(value) {
|
|
450
|
+
current = value;
|
|
451
|
+
if (timer !== undefined && interactive) render();
|
|
452
|
+
},
|
|
453
|
+
get isSpinning() {
|
|
454
|
+
return timer !== undefined;
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
return self;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* A progress bar with percentage, ETA and speed. It redraws at most every
|
|
462
|
+
* 50 ms, so calling `tick()` in a tight loop is fine.
|
|
463
|
+
*
|
|
464
|
+
* @example
|
|
465
|
+
* const bar = cli.progress({ total: files.length, format: "{bar} {percent}% {label}" });
|
|
466
|
+
* for (const file of files) {
|
|
467
|
+
* await upload(file);
|
|
468
|
+
* bar.tick(1, file.name);
|
|
469
|
+
* }
|
|
470
|
+
* bar.stop();
|
|
471
|
+
*
|
|
472
|
+
* @param {ProgressOptions} options
|
|
473
|
+
* @returns {ProgressBar}
|
|
474
|
+
*/
|
|
475
|
+
function progress(options) {
|
|
476
|
+
const stream = options.stream ?? process.stderr;
|
|
477
|
+
const interactive = _isTTY(stream);
|
|
478
|
+
const palette = color.create(color.detect(stream));
|
|
479
|
+
const started = Date.now();
|
|
480
|
+
let value = 0;
|
|
481
|
+
let total = Math.max(0, options.total);
|
|
482
|
+
let label = options.label ?? "";
|
|
483
|
+
let lastRender = 0;
|
|
484
|
+
let stopped = false;
|
|
485
|
+
|
|
486
|
+
const line = () => {
|
|
487
|
+
const ratio = total > 0 ? Math.min(1, value / total) : 1;
|
|
488
|
+
const width = options.width ?? 30;
|
|
489
|
+
const filled = Math.round(ratio * width);
|
|
490
|
+
const elapsed = (Date.now() - started) / 1000;
|
|
491
|
+
const rate = elapsed > 0 ? value / elapsed : 0;
|
|
492
|
+
const eta = rate > 0 && ratio < 1 ? (total - value) / rate : 0;
|
|
493
|
+
const fmt = (/** @type {number} */ s) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m` : s >= 60 ? `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, "0")}s` : `${Math.ceil(s)}s`);
|
|
494
|
+
const bar = _paint(palette, "cyan", (options.complete ?? "█").repeat(filled)) + _paint(palette, "gray", (options.incomplete ?? "░").repeat(width - filled));
|
|
495
|
+
return (options.format ?? "{bar} {percent}% | {value}/{total} | ETA {eta}")
|
|
496
|
+
.replace("{bar}", bar)
|
|
497
|
+
.replace("{percent}", String(Math.floor(ratio * 100)).padStart(3))
|
|
498
|
+
.replace("{value}", String(value))
|
|
499
|
+
.replace("{total}", String(total))
|
|
500
|
+
.replace("{eta}", fmt(eta))
|
|
501
|
+
.replace("{elapsed}", fmt(elapsed))
|
|
502
|
+
.replace("{rate}", `${Math.round(rate * 10) / 10}/s`)
|
|
503
|
+
.replace("{label}", label);
|
|
504
|
+
};
|
|
505
|
+
const draw = (/** @type {boolean} */ force) => {
|
|
506
|
+
if (!interactive || stopped) return;
|
|
507
|
+
const now = Date.now();
|
|
508
|
+
if (!force && now - lastRender < 50 && value < total) return;
|
|
509
|
+
lastRender = now;
|
|
510
|
+
stream.write(`${CLEAR_LINE}${line()}`);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/** @type {ProgressBar} */
|
|
514
|
+
const self = {
|
|
515
|
+
tick(amount = 1, newLabel) {
|
|
516
|
+
return self.update(value + amount, newLabel);
|
|
517
|
+
},
|
|
518
|
+
update(next, newLabel) {
|
|
519
|
+
value = Math.max(0, next);
|
|
520
|
+
if (newLabel !== undefined) label = newLabel;
|
|
521
|
+
draw(false);
|
|
522
|
+
return self;
|
|
523
|
+
},
|
|
524
|
+
stop() {
|
|
525
|
+
if (stopped) return self;
|
|
526
|
+
draw(true);
|
|
527
|
+
stopped = true;
|
|
528
|
+
stream.write(interactive ? "\n" : `${line()}\n`);
|
|
529
|
+
return self;
|
|
530
|
+
},
|
|
531
|
+
get value() {
|
|
532
|
+
return value;
|
|
533
|
+
},
|
|
534
|
+
get total() {
|
|
535
|
+
return total;
|
|
536
|
+
},
|
|
537
|
+
set total(next) {
|
|
538
|
+
total = Math.max(0, next);
|
|
539
|
+
draw(true);
|
|
540
|
+
},
|
|
541
|
+
get percent() {
|
|
542
|
+
return total > 0 ? Math.min(100, (value / total) * 100) : 100;
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
draw(true);
|
|
546
|
+
return self;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* @param {string} question
|
|
551
|
+
* @param {NodeJS.ReadableStream} input
|
|
552
|
+
* @param {NodeJS.WritableStream} output
|
|
553
|
+
* @returns {Promise<string>}
|
|
554
|
+
*/
|
|
555
|
+
const _ask = (question, input, output) =>
|
|
556
|
+
new Promise((resolve, reject) => {
|
|
557
|
+
output.write(question);
|
|
558
|
+
const reader = _reader(input);
|
|
559
|
+
const buffered = reader.lines.shift();
|
|
560
|
+
if (buffered !== undefined) return resolve(buffered);
|
|
561
|
+
if (reader.closed) return reject(new Error("Input closed before an answer was given"));
|
|
562
|
+
reader.waiting.push({ resolve, reject });
|
|
563
|
+
reader.rl.resume();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* One line reader per input stream, shared by every prompt, so piped lines
|
|
568
|
+
* that arrive early are queued instead of lost.
|
|
569
|
+
* @typedef {{ rl: readline.Interface, lines: string[], waiting: Array<{ resolve: (line: string) => void, reject: (error: Error) => void }>, closed: boolean }} LineReader
|
|
570
|
+
*/
|
|
571
|
+
|
|
572
|
+
/** @type {WeakMap<NodeJS.ReadableStream, LineReader>} */
|
|
573
|
+
const _readers = new WeakMap();
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* @param {NodeJS.ReadableStream} input
|
|
577
|
+
* @returns {LineReader}
|
|
578
|
+
*/
|
|
579
|
+
const _reader = (input) => {
|
|
580
|
+
const existing = _readers.get(input);
|
|
581
|
+
if (existing) return existing;
|
|
582
|
+
const rl = readline.createInterface({ input, terminal: false, crlfDelay: Infinity });
|
|
583
|
+
/** @type {LineReader} */
|
|
584
|
+
const reader = { rl, lines: [], waiting: [], closed: false };
|
|
585
|
+
rl.on("line", (line) => {
|
|
586
|
+
const next = reader.waiting.shift();
|
|
587
|
+
if (next) next.resolve(line);
|
|
588
|
+
else reader.lines.push(line);
|
|
589
|
+
if (!reader.waiting.length) rl.pause();
|
|
590
|
+
});
|
|
591
|
+
rl.on("close", () => {
|
|
592
|
+
reader.closed = true;
|
|
593
|
+
for (const pending of reader.waiting.splice(0)) pending.reject(new Error("Input closed before an answer was given"));
|
|
594
|
+
});
|
|
595
|
+
_readers.set(input, reader);
|
|
596
|
+
return reader;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Detaches the line reader before raw mode, so keystrokes (a password)
|
|
601
|
+
* never get queued as a line.
|
|
602
|
+
* @param {NodeJS.ReadableStream} input
|
|
603
|
+
*/
|
|
604
|
+
const _releaseReader = (input) => {
|
|
605
|
+
const reader = _readers.get(input);
|
|
606
|
+
if (!reader) return;
|
|
607
|
+
_readers.delete(input);
|
|
608
|
+
reader.rl.close();
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Asks a question and returns the answer. With `validate`, it keeps asking
|
|
613
|
+
* until the answer is valid.
|
|
614
|
+
*
|
|
615
|
+
* @example
|
|
616
|
+
* const name = await cli.prompt("Project name?", { default: "my-app" });
|
|
617
|
+
* const age = await cli.prompt("Age?", {
|
|
618
|
+
* validate: (answer) => /^\d+$/.test(answer) || "Please enter a number",
|
|
619
|
+
* });
|
|
620
|
+
*
|
|
621
|
+
* @param {string} question
|
|
622
|
+
* @param {PromptOptions} [options]
|
|
623
|
+
* @returns {Promise<string>} Trimmed.
|
|
624
|
+
*/
|
|
625
|
+
async function prompt(question, options = {}) {
|
|
626
|
+
const output = options.output ?? process.stdout;
|
|
627
|
+
const input = options.input ?? process.stdin;
|
|
628
|
+
const palette = color.create(color.detect(/** @type {any} */ (output)));
|
|
629
|
+
const suffix = options.default !== undefined ? ` ${palette.level ? palette.gray(`(${options.default})`) : `(${options.default})`}` : "";
|
|
630
|
+
for (;;) {
|
|
631
|
+
const raw = (await _ask(`${palette.level ? palette.cyan("?") : "?"} ${question}${suffix} `, input, output)).trim();
|
|
632
|
+
const answer = raw === "" && options.default !== undefined ? options.default : raw;
|
|
633
|
+
const verdict = options.validate ? await options.validate(answer) : true;
|
|
634
|
+
if (verdict === true) return answer;
|
|
635
|
+
output.write(`${palette.level ? palette.red("✖") : "✖"} ${verdict}\n`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Asks for a secret without showing it; each character appears as `*`.
|
|
641
|
+
*
|
|
642
|
+
* @example
|
|
643
|
+
* const token = await cli.password("API token?");
|
|
644
|
+
*
|
|
645
|
+
* @param {string} question
|
|
646
|
+
* @param {{ mask?: string, input?: NodeJS.ReadStream, output?: NodeJS.WriteStream }} [options] `mask: ""` shows nothing at all.
|
|
647
|
+
* @returns {Promise<string>}
|
|
648
|
+
*/
|
|
649
|
+
function password(question, options = {}) {
|
|
650
|
+
const input = options.input ?? process.stdin;
|
|
651
|
+
const output = options.output ?? process.stdout;
|
|
652
|
+
const mask = options.mask ?? "*";
|
|
653
|
+
const palette = color.create(color.detect(output));
|
|
654
|
+
const label = `${palette.level ? palette.cyan("?") : "?"} ${question} `;
|
|
655
|
+
if (!input.isTTY || typeof input.setRawMode !== "function") return _ask(label, input, output).then((a) => a.replace(/\r?\n$/, ""));
|
|
656
|
+
return new Promise((resolve, reject) => {
|
|
657
|
+
let value = "";
|
|
658
|
+
output.write(label);
|
|
659
|
+
_releaseReader(input);
|
|
660
|
+
input.setRawMode(true);
|
|
661
|
+
input.resume();
|
|
662
|
+
input.setEncoding("utf8");
|
|
663
|
+
const onData = (/** @type {string} */ chunk) => {
|
|
664
|
+
for (const char of chunk) {
|
|
665
|
+
if (char === "\r" || char === "\n") {
|
|
666
|
+
cleanup();
|
|
667
|
+
output.write("\n");
|
|
668
|
+
resolve(value);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (char === "\u0003") {
|
|
672
|
+
cleanup();
|
|
673
|
+
output.write("\n");
|
|
674
|
+
reject(new Error("Cancelled"));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (char === "\u007f" || char === "\b") {
|
|
678
|
+
if (value) {
|
|
679
|
+
value = [...value].slice(0, -1).join("");
|
|
680
|
+
if (mask) output.write("\b \b");
|
|
681
|
+
}
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (char < " ") continue;
|
|
685
|
+
value += char;
|
|
686
|
+
if (mask) output.write(mask);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
const cleanup = () => {
|
|
690
|
+
input.off("data", onData);
|
|
691
|
+
input.setRawMode(false);
|
|
692
|
+
input.pause();
|
|
693
|
+
};
|
|
694
|
+
input.on("data", onData);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Asks a yes/no question. Understands `y`, `yes`, `o`, `oui`, `true`, `1`
|
|
700
|
+
* and their opposites; an empty answer gives the default.
|
|
701
|
+
*
|
|
702
|
+
* @example
|
|
703
|
+
* if (await cli.confirm("Delete 42 files?")) await cleanup();
|
|
704
|
+
*
|
|
705
|
+
* @param {string} question
|
|
706
|
+
* @param {ConfirmOptions} [options]
|
|
707
|
+
* @returns {Promise<boolean>}
|
|
708
|
+
*/
|
|
709
|
+
async function confirm(question, options = {}) {
|
|
710
|
+
const output = options.output ?? process.stdout;
|
|
711
|
+
const input = options.input ?? process.stdin;
|
|
712
|
+
const def = options.default ?? false;
|
|
713
|
+
const palette = color.create(color.detect(/** @type {any} */ (output)));
|
|
714
|
+
const hint = palette.level ? palette.gray(def ? "(Y/n)" : "(y/N)") : def ? "(Y/n)" : "(y/N)";
|
|
715
|
+
for (;;) {
|
|
716
|
+
const answer = (await _ask(`${palette.level ? palette.cyan("?") : "?"} ${question} ${hint} `, input, output)).trim().toLowerCase();
|
|
717
|
+
if (answer === "") return def;
|
|
718
|
+
if (["y", "yes", "o", "oui", "true", "1"].includes(answer)) return true;
|
|
719
|
+
if (["n", "no", "non", "false", "0"].includes(answer)) return false;
|
|
720
|
+
output.write(`${palette.level ? palette.red("✖") : "✖"} Please answer yes or no\n`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* @template V
|
|
726
|
+
* @param {Array<Choice<V>>} choices
|
|
727
|
+
* @returns {Array<{ label: string, value: V, hint?: string, disabled?: boolean }>}
|
|
728
|
+
*/
|
|
729
|
+
const _normalizeChoices = (choices) =>
|
|
730
|
+
choices.map((c) => (typeof c === "string" ? { label: c, value: /** @type {V} */ (/** @type {unknown} */ (c)) } : c));
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The arrow-key list behind `select()` and `multiselect()`.
|
|
734
|
+
* @template V
|
|
735
|
+
* @param {string} question
|
|
736
|
+
* @param {Array<{ label: string, value: V, hint?: string, disabled?: boolean }>} items
|
|
737
|
+
* @param {SelectOptions} options
|
|
738
|
+
* @param {boolean} multiple
|
|
739
|
+
* @returns {Promise<number[]>}
|
|
740
|
+
*/
|
|
741
|
+
const _interactiveList = (question, items, options, multiple) =>
|
|
742
|
+
new Promise((resolve, reject) => {
|
|
743
|
+
const input = options.input ?? process.stdin;
|
|
744
|
+
const output = options.output ?? process.stdout;
|
|
745
|
+
const palette = color.create(color.detect(output));
|
|
746
|
+
const pageSize = Math.max(1, Math.min(options.pageSize ?? 10, items.length));
|
|
747
|
+
let cursor = Math.min(Math.max(0, options.default ?? 0), items.length - 1);
|
|
748
|
+
while (items[cursor]?.disabled && cursor < items.length - 1) cursor++;
|
|
749
|
+
const selected = new Set();
|
|
750
|
+
let offset = 0;
|
|
751
|
+
let drawn = 0;
|
|
752
|
+
let error = "";
|
|
753
|
+
|
|
754
|
+
const draw = () => {
|
|
755
|
+
if (drawn) output.write(`\u001b[${drawn}A`);
|
|
756
|
+
if (cursor < offset) offset = cursor;
|
|
757
|
+
if (cursor >= offset + pageSize) offset = cursor - pageSize + 1;
|
|
758
|
+
const lines = [`${palette.cyan("?")} ${question} ${palette.gray(multiple ? "(↑↓ move, space select, enter confirm)" : "(↑↓ move, enter confirm)")}`];
|
|
759
|
+
for (let i = offset; i < offset + pageSize; i++) {
|
|
760
|
+
const item = items[i];
|
|
761
|
+
const active = i === cursor;
|
|
762
|
+
const box = multiple ? (selected.has(i) ? palette.green("◉ ") : "◯ ") : "";
|
|
763
|
+
const text = item.disabled ? palette.gray(`${item.label} (disabled)`) : active ? palette.cyan(item.label) : item.label;
|
|
764
|
+
lines.push(`${active ? palette.cyan("❯") : " "} ${box}${text}${item.hint ? ` ${palette.gray(item.hint)}` : ""}`);
|
|
765
|
+
}
|
|
766
|
+
if (error) lines.push(palette.red(`✖ ${error}`));
|
|
767
|
+
output.write(lines.map((l) => `\u001b[2K${l}`).join("\n") + "\n");
|
|
768
|
+
if (drawn > lines.length) output.write(`\u001b[0J`);
|
|
769
|
+
drawn = lines.length;
|
|
770
|
+
};
|
|
771
|
+
const move = (/** @type {number} */ step) => {
|
|
772
|
+
let next = cursor;
|
|
773
|
+
for (let tries = 0; tries < items.length; tries++) {
|
|
774
|
+
next = (next + step + items.length) % items.length;
|
|
775
|
+
if (!items[next].disabled) break;
|
|
776
|
+
}
|
|
777
|
+
cursor = next;
|
|
778
|
+
};
|
|
779
|
+
const onKey = (/** @type {string} */ _str, /** @type {{ name?: string, ctrl?: boolean }} */ key) => {
|
|
780
|
+
error = "";
|
|
781
|
+
if (key.ctrl && key.name === "c") {
|
|
782
|
+
cleanup();
|
|
783
|
+
reject(new Error("Cancelled"));
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (key.name === "up" || key.name === "k") move(-1);
|
|
787
|
+
else if (key.name === "down" || key.name === "j") move(1);
|
|
788
|
+
else if (key.name === "space" && multiple && !items[cursor].disabled) {
|
|
789
|
+
if (selected.has(cursor)) selected.delete(cursor);
|
|
790
|
+
else selected.add(cursor);
|
|
791
|
+
} else if (key.name === "a" && multiple) {
|
|
792
|
+
const all = items.every((it, i) => it.disabled || selected.has(i));
|
|
793
|
+
items.forEach((it, i) => (it.disabled ? null : all ? selected.delete(i) : selected.add(i)));
|
|
794
|
+
} else if (key.name === "return") {
|
|
795
|
+
if (multiple && selected.size < (options.min ?? 0)) {
|
|
796
|
+
error = `Select at least ${options.min}`;
|
|
797
|
+
} else {
|
|
798
|
+
cleanup();
|
|
799
|
+
resolve(multiple ? [...selected].sort((a, b) => a - b) : [cursor]);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
draw();
|
|
804
|
+
};
|
|
805
|
+
const cleanup = () => {
|
|
806
|
+
input.off("keypress", onKey);
|
|
807
|
+
input.setRawMode(false);
|
|
808
|
+
input.pause();
|
|
809
|
+
output.write(SHOW_CURSOR);
|
|
810
|
+
};
|
|
811
|
+
_releaseReader(input);
|
|
812
|
+
readline.emitKeypressEvents(input);
|
|
813
|
+
input.setRawMode(true);
|
|
814
|
+
input.resume();
|
|
815
|
+
_restoreCursorOnExit(output);
|
|
816
|
+
output.write(HIDE_CURSOR);
|
|
817
|
+
input.on("keypress", onKey);
|
|
818
|
+
draw();
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Lets the user pick one choice with the arrow keys and Enter. Without an
|
|
823
|
+
* interactive terminal, it prints a numbered list and reads a number.
|
|
824
|
+
*
|
|
825
|
+
* @example
|
|
826
|
+
* const framework = await cli.select("Framework?", ["Express", "Fastify", "Koa"]);
|
|
827
|
+
* const plan = await cli.select("Plan?", [
|
|
828
|
+
* { label: "Free", value: "free", hint: "0 €" },
|
|
829
|
+
* { label: "Pro", value: "pro", hint: "9 €/month" },
|
|
830
|
+
* ]);
|
|
831
|
+
*
|
|
832
|
+
* @template [V=string]
|
|
833
|
+
* @param {string} question
|
|
834
|
+
* @param {Array<Choice<V>>} choices
|
|
835
|
+
* @param {SelectOptions} [options]
|
|
836
|
+
* @returns {Promise<V>}
|
|
837
|
+
*/
|
|
838
|
+
async function select(question, choices, options = {}) {
|
|
839
|
+
const items = _normalizeChoices(choices);
|
|
840
|
+
if (!items.length) throw new RangeError("select() needs at least one choice");
|
|
841
|
+
const input = options.input ?? process.stdin;
|
|
842
|
+
if (input.isTTY && typeof input.setRawMode === "function") {
|
|
843
|
+
const [index] = await _interactiveList(question, items, options, false);
|
|
844
|
+
return items[index].value;
|
|
845
|
+
}
|
|
846
|
+
const output = options.output ?? process.stdout;
|
|
847
|
+
output.write(`? ${question}\n${items.map((it, i) => ` ${i + 1}. ${it.label}${it.disabled ? " (disabled)" : ""}`).join("\n")}\n`);
|
|
848
|
+
for (;;) {
|
|
849
|
+
const answer = (await _ask(`Choose 1-${items.length}${options.default !== undefined ? ` (${options.default + 1})` : ""}: `, input, output)).trim();
|
|
850
|
+
const index = answer === "" && options.default !== undefined ? options.default : Number(answer) - 1;
|
|
851
|
+
if (Number.isInteger(index) && items[index] && !items[index].disabled) return items[index].value;
|
|
852
|
+
output.write("✖ Invalid choice\n");
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Lets the user pick several choices: arrows to move, Space to toggle, `a`
|
|
858
|
+
* for all, Enter to confirm.
|
|
859
|
+
*
|
|
860
|
+
* @example
|
|
861
|
+
* const features = await cli.multiselect("Features?", ["TypeScript", "ESLint", "Tests", "Docker"], { min: 1 });
|
|
862
|
+
*
|
|
863
|
+
* @template [V=string]
|
|
864
|
+
* @param {string} question
|
|
865
|
+
* @param {Array<Choice<V>>} choices
|
|
866
|
+
* @param {SelectOptions} [options]
|
|
867
|
+
* @returns {Promise<V[]>} In list order.
|
|
868
|
+
*/
|
|
869
|
+
async function multiselect(question, choices, options = {}) {
|
|
870
|
+
const items = _normalizeChoices(choices);
|
|
871
|
+
const input = options.input ?? process.stdin;
|
|
872
|
+
if (items.length && input.isTTY && typeof input.setRawMode === "function") {
|
|
873
|
+
const indexes = await _interactiveList(question, items, options, true);
|
|
874
|
+
return indexes.map((i) => items[i].value);
|
|
875
|
+
}
|
|
876
|
+
const output = options.output ?? process.stdout;
|
|
877
|
+
output.write(`? ${question}\n${items.map((it, i) => ` ${i + 1}. ${it.label}`).join("\n")}\n`);
|
|
878
|
+
for (;;) {
|
|
879
|
+
const answer = (await _ask("Choose (comma-separated numbers): ", input, output)).trim();
|
|
880
|
+
const indexes = answer === "" ? [] : answer.split(/[\s,]+/).map((n) => Number(n) - 1);
|
|
881
|
+
if (indexes.every((i) => Number.isInteger(i) && items[i] && !items[i].disabled) && indexes.length >= (options.min ?? 0)) {
|
|
882
|
+
return [...new Set(indexes)].sort((a, b) => a - b).map((i) => items[i].value);
|
|
883
|
+
}
|
|
884
|
+
output.write(`✖ Invalid selection${options.min ? ` (at least ${options.min})` : ""}\n`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Parses command-line arguments from a schema, and types the result:
|
|
890
|
+
* `flags.port` is a `number`. Handles short aliases, defaults, required
|
|
891
|
+
* flags, allowed values, repeated flags and `--no-flag`, and generates
|
|
892
|
+
* `--help`. A typo gets a "Did you mean...?" suggestion.
|
|
893
|
+
*
|
|
894
|
+
* @example
|
|
895
|
+
* // node server.js --port 8080 -v --tag api --tag web public/
|
|
896
|
+
* const { flags, positionals } = cli.args({
|
|
897
|
+
* port: { type: "number", short: "p", default: 3000, description: "Port to listen on" },
|
|
898
|
+
* verbose: { type: "boolean", short: "v" },
|
|
899
|
+
* tag: { type: "string", multiple: true },
|
|
900
|
+
* env: { type: "string", choices: ["dev", "prod"], default: "dev" },
|
|
901
|
+
* }, { description: "Start the web server", version: "1.0.0" });
|
|
902
|
+
* // flags: { port: 8080, verbose: true, tag: ["api", "web"], env: "dev" }
|
|
903
|
+
*
|
|
904
|
+
* @template {Record<string, FlagSpec>} S
|
|
905
|
+
* @param {S} schema
|
|
906
|
+
* @param {ArgsOptions} [options]
|
|
907
|
+
* @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.
|
|
909
|
+
*/
|
|
910
|
+
function args(schema, options = {}) {
|
|
911
|
+
const argv = options.argv ?? process.argv.slice(2);
|
|
912
|
+
const name = options.name ?? require("node:path").basename(process.argv[1] ?? "cli", ".js");
|
|
913
|
+
|
|
914
|
+
const help = () => {
|
|
915
|
+
const lines = [];
|
|
916
|
+
if (options.description) lines.push(options.description, "");
|
|
917
|
+
lines.push(`Usage: ${name} ${options.usage ?? "[options]"}`, "", "Options:");
|
|
918
|
+
const rows = Object.entries(schema).map(([key, spec]) => {
|
|
919
|
+
const flag = `${spec.short ? `-${spec.short}, ` : " "}--${key}${spec.type === "boolean" ? "" : ` <${spec.type}>`}`;
|
|
920
|
+
const details = [
|
|
921
|
+
spec.description ?? "",
|
|
922
|
+
spec.choices ? `(${spec.choices.join(" | ")})` : "",
|
|
923
|
+
spec.default !== undefined ? `[default: ${JSON.stringify(spec.default)}]` : "",
|
|
924
|
+
spec.required ? "[required]" : "",
|
|
925
|
+
].filter(Boolean).join(" ");
|
|
926
|
+
return [flag, details];
|
|
927
|
+
});
|
|
928
|
+
if (options.help !== false) rows.push(["-h, --help", "Show this help"]);
|
|
929
|
+
if (options.version) rows.push([" --version", "Show the version"]);
|
|
930
|
+
const width = Math.max(...rows.map(([f]) => f.length));
|
|
931
|
+
for (const [flag, details] of rows) lines.push(` ${flag.padEnd(width)} ${details}`.trimEnd());
|
|
932
|
+
return lines.join("\n");
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
if (options.help !== false && (argv.includes("--help") || argv.includes("-h")) && !("help" in schema)) {
|
|
936
|
+
process.stdout.write(`${help()}\n`);
|
|
937
|
+
process.exit(0);
|
|
938
|
+
}
|
|
939
|
+
if (options.version && argv.includes("--version") && !("version" in schema)) {
|
|
940
|
+
process.stdout.write(`${options.version}\n`);
|
|
941
|
+
process.exit(0);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** @type {Record<string, { type: "string" | "boolean", short?: string, multiple?: boolean }>} */
|
|
945
|
+
const config = {};
|
|
946
|
+
for (const [key, spec] of Object.entries(schema)) {
|
|
947
|
+
config[key] = { type: spec.type === "boolean" ? "boolean" : "string" };
|
|
948
|
+
if (spec.short) config[key].short = spec.short;
|
|
949
|
+
if (spec.multiple) config[key].multiple = true;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
let parsed;
|
|
953
|
+
try {
|
|
954
|
+
parsed = parseArgs({ args: argv, options: config, allowPositionals: true, strict: options.strict ?? true, allowNegative: true });
|
|
955
|
+
} catch (error) {
|
|
956
|
+
const message = /** @type {Error} */ (error).message;
|
|
957
|
+
const unknown = /Unknown option '(-{1,2})([^']+)'/.exec(message);
|
|
958
|
+
if (unknown) {
|
|
959
|
+
const { closest } = require("./Str.js");
|
|
960
|
+
const guess = closest(unknown[2], Object.keys(schema));
|
|
961
|
+
throw new TypeError(`Unknown option ${unknown[1]}${unknown[2]}${guess ? `. Did you mean --${guess}?` : ""}`);
|
|
962
|
+
}
|
|
963
|
+
throw new TypeError(message);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** @type {Record<string, any>} */
|
|
967
|
+
const flags = {};
|
|
968
|
+
for (const [key, spec] of Object.entries(schema)) {
|
|
969
|
+
const raw = /** @type {any} */ (parsed.values)[key];
|
|
970
|
+
if (raw === undefined) {
|
|
971
|
+
if (spec.required) throw new TypeError(`Missing required option --${key}`);
|
|
972
|
+
flags[key] = spec.default !== undefined ? spec.default : spec.type === "boolean" ? false : spec.multiple ? [] : undefined;
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
const convert = (/** @type {any} */ value) => {
|
|
976
|
+
if (spec.type === "number") {
|
|
977
|
+
const n = Number(value);
|
|
978
|
+
if (value === "" || !Number.isFinite(n)) throw new TypeError(`Option --${key} expects a number, got "${value}"`);
|
|
979
|
+
return n;
|
|
980
|
+
}
|
|
981
|
+
if (spec.choices && !spec.choices.includes(value)) throw new TypeError(`Option --${key} must be one of: ${spec.choices.join(", ")} (got "${value}")`);
|
|
982
|
+
return value;
|
|
983
|
+
};
|
|
984
|
+
flags[key] = Array.isArray(raw) ? raw.map(convert) : convert(raw);
|
|
985
|
+
}
|
|
986
|
+
return { flags: /** @type {ParsedFlags<S>} */ (flags), positionals: parsed.positionals, help };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Is someone at the keyboard? True when stdin and stdout are terminals and
|
|
991
|
+
* we're not in CI.
|
|
992
|
+
*
|
|
993
|
+
* @example
|
|
994
|
+
* const name = cli.isInteractive() ? await cli.prompt("Name?") : "default";
|
|
995
|
+
*
|
|
996
|
+
* @returns {boolean}
|
|
997
|
+
*/
|
|
998
|
+
function isInteractive() {
|
|
999
|
+
return Boolean(process.stdin.isTTY) && _isTTY(process.stdout);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* The terminal size, or 80x24 when it's unknown.
|
|
1004
|
+
*
|
|
1005
|
+
* @returns {{ columns: number, rows: number }}
|
|
1006
|
+
*/
|
|
1007
|
+
function size() {
|
|
1008
|
+
return { columns: process.stdout.columns || 80, rows: process.stdout.rows || 24 };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Clears the screen. Does nothing when stdout isn't a terminal.
|
|
1013
|
+
*
|
|
1014
|
+
* @returns {void}
|
|
1015
|
+
*/
|
|
1016
|
+
function clear() {
|
|
1017
|
+
if (process.stdout.isTTY) process.stdout.write("\u001b[2J\u001b[3J\u001b[H");
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
module.exports = {
|
|
1021
|
+
table,
|
|
1022
|
+
box,
|
|
1023
|
+
spinner,
|
|
1024
|
+
progress,
|
|
1025
|
+
prompt,
|
|
1026
|
+
password,
|
|
1027
|
+
confirm,
|
|
1028
|
+
select,
|
|
1029
|
+
multiselect,
|
|
1030
|
+
args,
|
|
1031
|
+
isInteractive,
|
|
1032
|
+
size,
|
|
1033
|
+
clear,
|
|
1034
|
+
};
|