@ix-xs/node-comfort 2.1.0 → 2.2.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 +153 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/Arr.js +13 -5
- package/src/Checker.js +56 -8
- package/src/Cli.js +60 -18
- package/src/Color.js +32 -6
- package/src/Crypto.js +429 -78
- package/src/Emitter.js +9 -9
- package/src/Env.js +90 -8
- package/src/FS.js +369 -59
- package/src/Func.js +1 -1
- package/src/Http.js +266 -75
- package/src/Id.js +53 -15
- package/src/Logger.js +100 -22
- package/src/Num.js +3 -2
- package/src/Obj.js +225 -36
- package/src/SQLite.js +155 -98
- package/src/Schema.js +281 -40
- package/src/Str.js +77 -29
- package/src/Sys.js +124 -23
- package/src/Time.js +98 -24
- package/src/Utils.js +18 -3
- package/src/errors.js +75 -4
- package/src/internal/shared.js +68 -14
- package/src/internal/terminal.js +49 -0
- package/types/index.d.ts +28 -5
- package/types/src/Arr.d.ts +4 -2
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Checker.d.ts +29 -3
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +6 -4
- package/types/src/Cli.d.ts.map +1 -1
- package/types/src/Color.d.ts +2 -2
- package/types/src/Color.d.ts.map +1 -1
- package/types/src/Crypto.d.ts +126 -28
- package/types/src/Crypto.d.ts.map +1 -1
- package/types/src/Emitter.d.ts +6 -6
- package/types/src/Emitter.d.ts.map +1 -1
- package/types/src/Env.d.ts +19 -1
- package/types/src/Env.d.ts.map +1 -1
- package/types/src/FS.d.ts +30 -23
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +1 -1
- package/types/src/Http.d.ts +45 -8
- package/types/src/Http.d.ts.map +1 -1
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +11 -3
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +1 -1
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +15 -2
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +105 -13
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +61 -5
- package/types/src/Schema.d.ts.map +1 -1
- package/types/src/Str.d.ts +32 -7
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +43 -10
- package/types/src/Sys.d.ts.map +1 -1
- package/types/src/Time.d.ts +12 -7
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +16 -6
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +6 -2
- package/types/src/errors.d.ts.map +1 -1
- package/types/src/internal/shared.d.ts +7 -0
- package/types/src/internal/shared.d.ts.map +1 -1
- package/types/src/internal/terminal.d.ts +13 -0
- package/types/src/internal/terminal.d.ts.map +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,158 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [2.2.0] - 2026-11-03
|
|
8
|
+
|
|
9
|
+
A security release. Three audits went through every module looking for what an attacker could do with the
|
|
10
|
+
values a program receives: a request body, a header, a file name, a `.env` in a cloned repository, a log
|
|
11
|
+
line. More than fifty problems came out of them, a few serious ones. They are all fixed, each with a test
|
|
12
|
+
that failed before. Most fixes change nothing for code that works today, and the ones you could notice are
|
|
13
|
+
listed under "Changed". The package also makes use of what Node.js 26 brings.
|
|
14
|
+
|
|
15
|
+
### Security
|
|
16
|
+
|
|
17
|
+
- **JWT**: a public key could be used as an HMAC secret when it was read from a file with a BOM, had text
|
|
18
|
+
before its PEM header, or was in DER form, so anyone could sign `HS256` tokens with it. Anything that looks
|
|
19
|
+
like an asymmetric key is now refused as a secret, and `HS*` secrets need at least 32 bytes (RFC 7518).
|
|
20
|
+
An invalid `clockTolerance` or `now` no longer turns the expiry check off, signatures must be written in
|
|
21
|
+
canonical Base64URL, and a `crit` header is refused unless you list it in `options.crit`.
|
|
22
|
+
- **sys**: on Windows, an argument passed to an npm command (`npm.cmd`, global shims) could run another
|
|
23
|
+
command (the "BatBadBut" problem). Arguments are now escaped twice for every `.cmd` or `.bat` that passes
|
|
24
|
+
them on, and a line break, which can't be escaped, is refused.
|
|
25
|
+
- **SQLite**: `update()`, `delete()` and `increment()` with a filter whose values were all `undefined`
|
|
26
|
+
(`{ id: undefined }`, `{ $or: [{}] }`, `{ id: { notIn: [] } }`) changed or deleted the whole table. They
|
|
27
|
+
now throw. A filter value taken from a JSON body could also become an operator (`{ "ne": "x" }` matched
|
|
28
|
+
every row): the new `operators: "tagged"` option and `SQLite.op` close that door, see "Added".
|
|
29
|
+
- **http**: with a `baseURL`, a path like `"https://evil.example"`, `"\\evil.example"` or `"../../admin"`
|
|
30
|
+
left the base and took the client's `auth` header with it. Paths now stay under the base. Responses are
|
|
31
|
+
limited to 100 MB once decompressed (a 500 KB gzip bomb used to fill 512 MB), redirects to another origin
|
|
32
|
+
drop `x-api-key` and the other secret headers, the temporary file of `download()` has an unguessable name,
|
|
33
|
+
and an `HttpError` no longer shows query-string secrets or cookies in its message and `toJSON()`.
|
|
34
|
+
- **env**: the `.env` loaded automatically at startup set `NODE_OPTIONS` (code run in every child process),
|
|
35
|
+
`NODE_TLS_REJECT_UNAUTHORIZED` and other dangerous variables. They are now left out, with one warning on
|
|
36
|
+
stderr. `env.parse()` no longer reads `process.env` for `${VAR}`, and a value that expands to more than
|
|
37
|
+
64 KB is refused.
|
|
38
|
+
- **Denial of service**: a few hundred bytes could block the process for seconds or minutes in
|
|
39
|
+
`isMimeType()`, the duration parser (`"5m"`, behind every `timeout`, `ttl` and `expiresIn`), `time.add()`,
|
|
40
|
+
the case converters of `str`, the paths of `obj`, a recursive `schema.union()`, `.env` comments and
|
|
41
|
+
expansion, `time.parse()`, `obj.set()` with a key like `"a.4000000000"`, `arr.unzip()`, `num.parseBytes()`
|
|
42
|
+
and `fs.sanitizeFilename()`. All of them are now linear.
|
|
43
|
+
- **logger and cli**: a logged value could send escape sequences to the terminal (write to the clipboard,
|
|
44
|
+
change the title, clear the screen), forge a log line with `\r` or `\n`, or be read as markup. They are now
|
|
45
|
+
shown as text, and the colors and links you add with `nc.color` still work. `redact` also covers `Map`,
|
|
46
|
+
`Set` and the causes of errors, and log files are created readable by their owner only.
|
|
47
|
+
- **id**: a program started from a startup snapshot (`node --build-snapshot`, single executable
|
|
48
|
+
applications) printed the same ids at each start. They are now drawn again in each process.
|
|
49
|
+
- **memoize**: `func.memoize()`, `func.dedupe()` and `cache.wrap()` could give one call the cached result of
|
|
50
|
+
another, for example `{ role: "admin" }` and `{ role: "guest" }` held in a `Set`.
|
|
51
|
+
- **fs**: copying or moving a file onto itself, or a folder into itself, deleted it. `copy()` followed
|
|
52
|
+
junctions out of the folder on Windows, `emptyFolder()` emptied the folder a junction pointed to, and
|
|
53
|
+
`sanitizeFilename()` let `COM¹`, `CONIN$` and right-to-left characters through.
|
|
54
|
+
- **Smaller fixes**: TOTP secrets need 10 bytes (16 for new ones from `totpSecret()`) and the window is
|
|
55
|
+
capped at 10, `verifyPassword()` refuses hashes that would take minutes, `safeEqual("", "")` is `false`, `str.mask()` no longer shows a short
|
|
56
|
+
secret in full, `isURL()` and `schema.string().url()` refuse text that only parses once cleaned
|
|
57
|
+
(`https://a.com/"><script>`), `sys.open()` only opens web and mail links, and `obj.set()` converts each
|
|
58
|
+
key once, so a key object can't pass the `__proto__` check and then change.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **crypto**: Argon2id passwords, with `hashPassword(password, { algorithm: "argon2id" })` (Node.js 24.7 or
|
|
63
|
+
later). The hashes use the standard PHC format, so other libraries can read them. `verifyPassword()` reads
|
|
64
|
+
both kinds, and `needsRehash()` says when a hash should move to the algorithm you ask for.
|
|
65
|
+
- **crypto**: `totpStep()` gives the time step of a valid code, and `verifyTOTP({ after })` refuses a code
|
|
66
|
+
that was already used. `verifyJWT()` takes `typ` and `crit`, and `clockTolerance` takes text like `"30s"`.
|
|
67
|
+
- **SQLite**: `SQLite.op` builds operators (`SQLite.op.gte(18)`, `SQLite.op.in([1, 2])`) that a value from
|
|
68
|
+
JSON can't imitate, and `new SQLite(path, { operators: "tagged" })` only accepts those. In that mode, a read
|
|
69
|
+
throws on an `undefined` filter value too, so a missing field can't drop its condition.
|
|
70
|
+
- **http**: `maxResponseSize`, `allowAbsoluteUrls` and `sensitiveHeaders` options, and the client follows
|
|
71
|
+
redirects itself (`redirect: "follow" | "manual" | "error"`).
|
|
72
|
+
- **sys**: `open(target, { protocols, allowUNC })` and `which(command, { cwd })`.
|
|
73
|
+
- **schema**: `parse()`, `safeParse()` and `is()` take `{ maxDepth }` (1000 by default), and a value nested
|
|
74
|
+
deeper gets a `"too_deep"` issue instead of a stack overflow.
|
|
75
|
+
- **str**: `levenshtein(a, b, { maxDistance })` and `closest(..., { maxDistance })` stop counting early.
|
|
76
|
+
- **checker**: `isDataURL(value, { types })` limits the accepted media types.
|
|
77
|
+
- **logger**: the `mode` of log files (`0o600` by default).
|
|
78
|
+
- **`using`**: watchers, schedules (`every()`, `cron()`), spinners, progress bars, `when()` tasks,
|
|
79
|
+
`dontCrash()`, and the functions returned by `emitter.on()`, `onAny()` and `sys.onShutdown()` work with
|
|
80
|
+
`using` (Node.js 24 and later): they stop at the end of the block.
|
|
81
|
+
- **time**: dates can be `Temporal` objects (`Temporal.Instant`, `Temporal.ZonedDateTime`).
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- **Node.js 22.23.4, 24.22.1, 26.12.1 or a later version of these lines is required** (`engines`). Older
|
|
86
|
+
versions can be crashed by a single malicious HTTP response, which no package can prevent (2.1.1 only
|
|
87
|
+
warned about it). Node.js 25 is not supported.
|
|
88
|
+
- **Faster on Node.js 26**: time zones go through `Temporal`, and `time.add()` or `time.startOf()` with a
|
|
89
|
+
zone are more than twice as fast.
|
|
90
|
+
- JWT `HS*` secrets shorter than 32 bytes throw a `TypeError`, when signing and when verifying.
|
|
91
|
+
- TOTP secrets shorter than 10 bytes throw, `totpSecret()` needs at least 16 bytes, and `window` must be an
|
|
92
|
+
integer from 0 to 10.
|
|
93
|
+
- `update()`, `delete()`, `increment()` and `set()` of SQLite throw on an `undefined` filter value, an empty
|
|
94
|
+
`$and` or `$or`, or a filter that matches every row (use `clearTable()` to empty a table).
|
|
95
|
+
- `http`: with a `baseURL`, a URL outside it throws a `TypeError` unless you pass `allowAbsoluteUrls: true`,
|
|
96
|
+
and then the client's `auth`, `headers` and `query` stay home. A `params` value of `.` or `..` throws.
|
|
97
|
+
Bodies over 100 MB throw `ERR_RESPONSE_TOO_LARGE`, and `redirect: "error"` throws `ERR_REDIRECT`, which
|
|
98
|
+
is not retried.
|
|
99
|
+
- `sys.run()` on Windows rejects a line break in an argument to a `.cmd` or `.bat`. A relative command is
|
|
100
|
+
looked up from `options.cwd`, and `extendEnv: false` without `env` gives an empty environment.
|
|
101
|
+
- `sys.open()` throws for a scheme other than `http:`, `https:` and `mailto:`, for a path that doesn't
|
|
102
|
+
exist, for a control character and, on Windows, for a network path or a double quote.
|
|
103
|
+
- `env.parse()` only expands `${VAR}` from the text itself and the `env` option: pass `{ env: process.env }`
|
|
104
|
+
for the old behaviour. `env.load()` is unchanged.
|
|
105
|
+
- The logger shows control characters as text, indents the lines of a message after the first one even
|
|
106
|
+
without a timestamp (but not for `log()` with no scope and no timestamp, which prints like `console.log`),
|
|
107
|
+
and reads markup in the first argument only. Without colors (and in files), colors
|
|
108
|
+
are removed from logged strings. In JSON, a logged object can't replace `time`, `level`, `scope` or `msg`.
|
|
109
|
+
- `color.strip()` also removes `\r`, `\b` and every other control character but tabs and new lines.
|
|
110
|
+
- `str.mask()` hides everything when `start` and `end` would show the whole text, `arr.pluck()` and the
|
|
111
|
+
other string iteratees ignore what an item inherits from `Object.prototype`, and `obj.get({}, "toString")`
|
|
112
|
+
is `undefined`.
|
|
113
|
+
- `obj.set()` and `obj.unflatten()` make an object, not an array, for a number key above 10000.
|
|
114
|
+
- `isURL()` and `schema.string().url()` refuse spaces, double quotes, `<`, `>`, backticks and backslashes, and
|
|
115
|
+
`isEmail()`, `isHostname()`, `isMimeType()` and `isCron()` are stricter on look-alike characters, label
|
|
116
|
+
lengths, line breaks and trailing garbage. `time.cron()` refuses `"*/5/x"` too.
|
|
117
|
+
- `memoize`, `dedupe` and `cache.wrap()` compare `Set`, `Map` and class instances given as arguments by
|
|
118
|
+
identity.
|
|
119
|
+
- `fs.copyFile()`, `moveFile()`, `copy()`, `move()` and the folder helpers return `false` (or `0`) instead
|
|
120
|
+
of losing data when the destination is the source or inside it.
|
|
121
|
+
- `schema.union()` stops checking an option at its first issue, and `is()` stops at the first issue: the
|
|
122
|
+
refinements after it no longer run.
|
|
123
|
+
- `crypto.verifyPassword()` throws a `RangeError` for a hash that asks for more work than allowed (scrypt
|
|
124
|
+
with `p` over 16, Argon2 with more than 1 GiB of memory), without doing that work: the password may be
|
|
125
|
+
right, the hash has to be replaced.
|
|
126
|
+
|
|
127
|
+
### Fixed
|
|
128
|
+
|
|
129
|
+
- `fs.move()` keeps the destination when the move fails, `findUp({ stopAt })` honors a path with a trailing
|
|
130
|
+
separator or another letter case, and `color.link()` can't be cut short by its own text.
|
|
131
|
+
- `fs.moveFilesIn()` deleted every selected file, even one whose copy failed. Now a file that can't be
|
|
132
|
+
copied stays where it is, and `moveFile()` can change only the letter case of a name on Windows and macOS.
|
|
133
|
+
- A response body is cancelled when `onDownloadProgress` throws, so the connection doesn't stay open.
|
|
134
|
+
- `s.literal(5n)` and enums of bigints report a failure instead of throwing.
|
|
135
|
+
|
|
136
|
+
### Repository
|
|
137
|
+
|
|
138
|
+
- Every GitHub Action is pinned to a commit, the jobs keep no git credentials, and CodeQL scans the code and
|
|
139
|
+
the workflows on each pull request. Dependabot keeps the tools up to date.
|
|
140
|
+
- `scripts/bench.js` measures 63 hot paths and compares two runs.
|
|
141
|
+
|
|
142
|
+
## [2.1.1] - 2026-10-30
|
|
143
|
+
|
|
144
|
+
### Security
|
|
145
|
+
|
|
146
|
+
- **Update Node.js if your program calls servers you don't control.** Versions of Node.js before 22.23.4,
|
|
147
|
+
24.22.1 and 26.12.1 can be crashed by a single malicious HTTP response (GHSA-xjj6-f33q-929c,
|
|
148
|
+
CVE-2026-109869), in the copy of undici behind their `fetch()`. `nc.http` uses that `fetch()`, so it is
|
|
149
|
+
exposed like any other client: the error can be caught, but the process stops right after, and no code in
|
|
150
|
+
a package can prevent it. The package still accepts older versions of Node.js. Node.js 25 has no fix.
|
|
151
|
+
|
|
152
|
+
### Documentation
|
|
153
|
+
|
|
154
|
+
- The reference is now in French too, like the rest of the website, and the landing page and the page for
|
|
155
|
+
an address that does not exist no longer have English left in them.
|
|
156
|
+
- A dozen sentences of the JSDoc were wrong or unclear, among them the parameter of `func.throttle()` and
|
|
157
|
+
the options of `fs.move()`, `fs.hashFile()` and `fs.sanitizeFilename()`. Your editor now shows the right ones.
|
|
158
|
+
|
|
7
159
|
## [2.1.0] - 2026-10-12
|
|
8
160
|
|
|
9
161
|
Every module was read again, line by line. The result: about sixty new functions and options, a hot path
|
|
@@ -335,6 +487,7 @@ Each item explains what to change. Most projects only need the SQLite and Node.j
|
|
|
335
487
|
|
|
336
488
|
- SQLite: the folder of the database was created in one place and the file opened in another.
|
|
337
489
|
|
|
490
|
+
[2.1.1]: https://github.com/ix-xs/node-comfort/compare/v2.1.0...v2.1.1
|
|
338
491
|
[2.1.0]: https://github.com/ix-xs/node-comfort/compare/v2.0.0...v2.1.0
|
|
339
492
|
[2.0.0]: https://github.com/ix-xs/node-comfort/compare/v1.2.0...v2.0.0
|
|
340
493
|
[1.2.0]: https://github.com/ix-xs/node-comfort/releases/tag/v1.2.0
|
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ nc.sys.onShutdown(() => db.close());
|
|
|
51
51
|
npm install @ix-xs/node-comfort
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
Node.js 22.
|
|
54
|
+
Node.js 22.23.4, 24.22.1, 26.12.1 or a later version of these lines. Older versions [can be crashed by a malicious HTTP response](https://ix-xs.github.io/node-comfort/system/http/#servers-you-dont-control), and no package can prevent it. On Node.js 26, time zones go through `Temporal` and are more than twice as fast.
|
|
55
55
|
|
|
56
56
|
## Why node-comfort
|
|
57
57
|
|
|
@@ -60,7 +60,7 @@ Node.js 22.13 or newer. That's the first version where `node:sqlite` works witho
|
|
|
60
60
|
- **Fast to load.** Namespaces load the first time you use them. Requiring the package takes about 4 ms, and a script that only logs never loads the database or the HTTP client.
|
|
61
61
|
- **Safe by default.** Prototype-pollution-proof objects, scrypt passwords, AES-256-GCM, constant-time comparisons, parameterized SQL, atomic file writes.
|
|
62
62
|
- **One way of doing things.** Durations are always `"5m"` or milliseconds, options always come last, and every error has a stable `code`.
|
|
63
|
-
- **Tested everywhere.** Linux, macOS and Windows, Node.js 22 and
|
|
63
|
+
- **Tested everywhere.** Linux, macOS and Windows, Node.js 22, 24 and 26, TypeScript 5.9, 6 and 7.
|
|
64
64
|
|
|
65
65
|
## What's inside
|
|
66
66
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ix-xs/node-comfort",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "The zero-dependency standard library for Node.js: logger, filesystem, SQLite, schema validation, HTTP client, CLI toolkit, cache, env, async pools, colors, events, i18n dates, ids, crypto and hundreds of fully typed helpers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"utils",
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
"LICENSE"
|
|
179
179
|
],
|
|
180
180
|
"engines": {
|
|
181
|
-
"node": "
|
|
181
|
+
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
|
182
182
|
},
|
|
183
183
|
"publishConfig": {
|
|
184
184
|
"access": "public"
|
package/src/Arr.js
CHANGED
|
@@ -65,6 +65,9 @@ const { intl, preciseSum } = require("./internal/shared.js");
|
|
|
65
65
|
const _fn = (iteratee) => {
|
|
66
66
|
if (typeof iteratee === "function") return /** @type {(item: any, index: number) => any} */ (iteratee);
|
|
67
67
|
if (typeof iteratee === "string" || typeof iteratee === "number" || typeof iteratee === "symbol") {
|
|
68
|
+
// Members of Object.prototype (`__proto__`, `constructor`...) are not data, unless the item has its own.
|
|
69
|
+
// Getters of a class still work.
|
|
70
|
+
if (iteratee in Object.prototype) return (item) => (item == null || !Object.hasOwn(item, iteratee) ? undefined : item[iteratee]);
|
|
68
71
|
return (item) => (item == null ? undefined : item[iteratee]);
|
|
69
72
|
}
|
|
70
73
|
return (item) => item;
|
|
@@ -384,6 +387,7 @@ function partition(array, predicate) {
|
|
|
384
387
|
* @returns {Array<T[K]>}
|
|
385
388
|
*/
|
|
386
389
|
function pluck(array, key) {
|
|
390
|
+
if (key in Object.prototype) return array.map(_fn(key));
|
|
387
391
|
return array.map((item) => (item == null ? /** @type {T[K]} */ (/** @type {unknown} */ (undefined)) : item[key]));
|
|
388
392
|
}
|
|
389
393
|
|
|
@@ -729,7 +733,8 @@ function flatten(array, depth) {
|
|
|
729
733
|
}
|
|
730
734
|
|
|
731
735
|
/**
|
|
732
|
-
* Pairs items by position. Shorter arrays leave `undefined` holes
|
|
736
|
+
* Pairs items by position. Shorter arrays leave `undefined` holes, and
|
|
737
|
+
* values that are not arrays count as empty ones.
|
|
733
738
|
*
|
|
734
739
|
* @example
|
|
735
740
|
* arr.zip(["a", "b"], [1, 2]); // [["a", 1], ["b", 2]]
|
|
@@ -743,7 +748,8 @@ function zip(...arrays) {
|
|
|
743
748
|
}
|
|
744
749
|
|
|
745
750
|
/**
|
|
746
|
-
* The opposite of `zip()`: rows become columns.
|
|
751
|
+
* The opposite of `zip()`: rows become columns. Rows that are not arrays
|
|
752
|
+
* count as empty ones.
|
|
747
753
|
*
|
|
748
754
|
* @example
|
|
749
755
|
* arr.unzip([["a", 1], ["b", 2]]); // [["a", "b"], [1, 2]]
|
|
@@ -753,15 +759,17 @@ function zip(...arrays) {
|
|
|
753
759
|
* @returns {Array<Array<T | undefined>>}
|
|
754
760
|
*/
|
|
755
761
|
function unzip(tuples) {
|
|
756
|
-
return _zip(tuples);
|
|
762
|
+
return Array.isArray(tuples) ? _zip(tuples) : [];
|
|
757
763
|
}
|
|
758
764
|
|
|
759
765
|
/**
|
|
760
766
|
* @template T
|
|
761
|
-
* @param {ReadonlyArray<ReadonlyArray<T>>}
|
|
767
|
+
* @param {ReadonlyArray<ReadonlyArray<T>>} input
|
|
762
768
|
* @returns {Array<Array<T | undefined>>}
|
|
763
769
|
*/
|
|
764
|
-
const _zip = (
|
|
770
|
+
const _zip = (input) => {
|
|
771
|
+
// Only real arrays: a parsed `{ "length": 1e9 }` would allocate a billion rows.
|
|
772
|
+
const arrays = input.map((array) => (Array.isArray(array) ? array : []));
|
|
765
773
|
let size = 0;
|
|
766
774
|
for (const array of arrays) if (array.length > size) size = array.length;
|
|
767
775
|
/** @type {Array<Array<T | undefined>>} */
|
package/src/Checker.js
CHANGED
|
@@ -15,13 +15,24 @@ const shared = require("./internal/shared.js");
|
|
|
15
15
|
|
|
16
16
|
/** @type {typeof import("node:net") | undefined} */
|
|
17
17
|
let _net;
|
|
18
|
+
/** @type {typeof import("node:url") | undefined} */
|
|
19
|
+
let _url;
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
// No "i" flag: with "u", it would let the Kelvin sign match "k" and the long s match "s".
|
|
22
|
+
const EMAIL = /^(?!\.)(?!.*\.\.)[\p{L}\p{N}.!#$%&'*+/=?^_`{|}~-]{1,64}(?<!\.)@(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+(?:\p{L}{2,}|[xX][nN]--[A-Za-z0-9-]{2,})$/u;
|
|
23
|
+
// The same rules for ASCII addresses, the common case, which need no further check.
|
|
24
|
+
const EMAIL_ASCII = /^(?!\.)(?!.*\.\.)[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}(?<!\.)@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+(?:[A-Za-z]{2,}|[xX][nN]--[A-Za-z0-9-]{2,})$/;
|
|
20
25
|
const TLD = /\.(?:[a-z]{2,}|xn--[a-z0-9-]{2,})\.?$/i;
|
|
21
26
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
22
27
|
const UUID_NIL_OR_MAX = /^(?:0{8}-0{4}-0{4}-0{4}-0{12}|f{8}-f{4}-f{4}-f{4}-f{12})$/i;
|
|
23
28
|
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|([+-])(\d{2}):?(\d{2}))?)?$/;
|
|
24
29
|
const HOST_LABEL = /^[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?$/u;
|
|
30
|
+
// Characters a URL parser silently drops, encodes or rewrites.
|
|
31
|
+
// The apostrophe is legal, as in /wiki/Ender's_Game.
|
|
32
|
+
const URL_UNSAFE = /[\x00-\x20\x7f<>"`\\]/;
|
|
33
|
+
// Quoted and bare parameter values can't overlap, so there is nothing to backtrack into.
|
|
34
|
+
const MIME_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(?:\s*;\s*[a-z0-9-]+=(?:"[^"\r\n]*"|[^;\s"]+))*$/i;
|
|
35
|
+
const DATA_URL = /^data:(?:[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+)?(?:;[a-z0-9-]+=[^;,]*)*(?:;base64)?,/i;
|
|
25
36
|
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
26
37
|
|
|
27
38
|
/**
|
|
@@ -59,6 +70,12 @@ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
|
59
70
|
* @property {boolean} [urlSafe] Expect the URL-safe alphabet (`-` and `_`), padding optional.
|
|
60
71
|
*/
|
|
61
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Options for `isDataURL()`.
|
|
75
|
+
* @typedef {object} IsDataURLOptions
|
|
76
|
+
* @property {string[]} [types] Accepted media types, like `["image/png", "image/jpeg"]`, case ignored. A URL without a type, like `data:,Hello`, counts as `text/plain`. Without this option, any type passes.
|
|
77
|
+
*/
|
|
78
|
+
|
|
62
79
|
/**
|
|
63
80
|
* Is it an array?
|
|
64
81
|
*
|
|
@@ -580,7 +597,10 @@ function isOneOf(value, allowed) {
|
|
|
580
597
|
/**
|
|
581
598
|
* Does it look like an email address? The rules are practical rather than
|
|
582
599
|
* the full RFC: one `@`, no stray dots, a real top-level domain.
|
|
583
|
-
* International addresses are accepted
|
|
600
|
+
* International addresses are accepted, but not look-alike characters such
|
|
601
|
+
* as the Kelvin sign in the domain. A valid address can still contain
|
|
602
|
+
* quotes, `&`, `|` or backticks: it is not safe to put unescaped into HTML
|
|
603
|
+
* or a shell command.
|
|
584
604
|
*
|
|
585
605
|
* @example
|
|
586
606
|
* nc.isEmail("jose@exemple.fr"); // true
|
|
@@ -591,11 +611,19 @@ function isOneOf(value, allowed) {
|
|
|
591
611
|
* @returns {value is string}
|
|
592
612
|
*/
|
|
593
613
|
function isEmail(value) {
|
|
594
|
-
|
|
614
|
+
if (typeof value !== "string" || value.length > 254) return false;
|
|
615
|
+
if (EMAIL_ASCII.test(value)) return true;
|
|
616
|
+
if (shared.ASCII.test(value) || !EMAIL.test(value)) return false;
|
|
617
|
+
// Compatibility characters (Kelvin sign, fullwidth letters...) become other letters in DNS.
|
|
618
|
+
const domain = value.slice(value.lastIndexOf("@") + 1);
|
|
619
|
+
return domain === domain.normalize("NFKC");
|
|
595
620
|
}
|
|
596
621
|
|
|
597
622
|
/**
|
|
598
623
|
* Is it an absolute URL with an allowed protocol (http and https by default)?
|
|
624
|
+
* The text must already be clean: spaces, control characters, double
|
|
625
|
+
* quotes, backticks, `<`, `>` and backslashes are refused, even where the URL
|
|
626
|
+
* parser would quietly fix them. An apostrophe is fine.
|
|
599
627
|
*
|
|
600
628
|
* @example
|
|
601
629
|
* nc.isURL("https://example.com/a?b=1"); // true
|
|
@@ -607,7 +635,7 @@ function isEmail(value) {
|
|
|
607
635
|
* @returns {value is string}
|
|
608
636
|
*/
|
|
609
637
|
function isURL(value, options = {}) {
|
|
610
|
-
if (typeof value !== "string") return false;
|
|
638
|
+
if (typeof value !== "string" || URL_UNSAFE.test(value)) return false;
|
|
611
639
|
const url = URL.parse(value);
|
|
612
640
|
if (!url) return false;
|
|
613
641
|
const protocols = options.protocols;
|
|
@@ -891,6 +919,8 @@ function isJWT(value) {
|
|
|
891
919
|
/**
|
|
892
920
|
* Is it a host name, like `example.com`, `api.internal` or `localhost`?
|
|
893
921
|
* International names are accepted. IP addresses aren't: see `isIP`.
|
|
922
|
+
* Lengths are counted the way DNS sees them, once converted to ASCII
|
|
923
|
+
* (`xn--`): 63 bytes per label, 253 in all.
|
|
894
924
|
*
|
|
895
925
|
* @example
|
|
896
926
|
* nc.isHostname("api.example.com"); // true
|
|
@@ -907,6 +937,11 @@ function isHostname(value, options = {}) {
|
|
|
907
937
|
const labels = value.split(".");
|
|
908
938
|
if (!labels.every((label) => HOST_LABEL.test(label))) return false;
|
|
909
939
|
if (/^\d+$/.test(labels[labels.length - 1])) return false;
|
|
940
|
+
if (!shared.ASCII.test(value)) {
|
|
941
|
+
// An international label takes more room once encoded: "é" x 63 is 67 bytes long.
|
|
942
|
+
const ascii = (_url ??= require("node:url")).domainToASCII(value);
|
|
943
|
+
if (ascii === "" || ascii.length > 253 || ascii.split(".").some((label) => label.length > 63)) return false;
|
|
944
|
+
}
|
|
910
945
|
return !options.requireTld || (labels.length > 1 && TLD.test(value));
|
|
911
946
|
}
|
|
912
947
|
|
|
@@ -991,17 +1026,25 @@ function isULID(value) {
|
|
|
991
1026
|
}
|
|
992
1027
|
|
|
993
1028
|
/**
|
|
994
|
-
* Is it a `data:` URL, like an inline image?
|
|
1029
|
+
* Is it a `data:` URL, like an inline image? Only the header is checked,
|
|
1030
|
+
* not the data. Without `types`, any media type passes, `text/html` and
|
|
1031
|
+
* `image/svg+xml` included, and both can run scripts.
|
|
995
1032
|
*
|
|
996
1033
|
* @example
|
|
997
1034
|
* nc.isDataURL("data:image/png;base64,iVBORw0KGgo="); // true
|
|
998
1035
|
* nc.isDataURL("data:,Hello"); // true
|
|
1036
|
+
* nc.isDataURL("data:text/html,<p>Hi</p>", { types: ["image/png"] }); // false
|
|
999
1037
|
*
|
|
1000
1038
|
* @param {unknown} value
|
|
1039
|
+
* @param {IsDataURLOptions} [options]
|
|
1001
1040
|
* @returns {value is string}
|
|
1002
1041
|
*/
|
|
1003
|
-
function isDataURL(value) {
|
|
1004
|
-
|
|
1042
|
+
function isDataURL(value, options = {}) {
|
|
1043
|
+
if (typeof value !== "string" || !DATA_URL.test(value)) return false;
|
|
1044
|
+
const types = options.types;
|
|
1045
|
+
if (!types) return true;
|
|
1046
|
+
const type = value.slice(5, value.search(/[;,]/)).toLowerCase() || "text/plain";
|
|
1047
|
+
return types.some((allowed) => allowed.toLowerCase() === type);
|
|
1005
1048
|
}
|
|
1006
1049
|
|
|
1007
1050
|
/**
|
|
@@ -1016,7 +1059,7 @@ function isDataURL(value) {
|
|
|
1016
1059
|
* @returns {value is string}
|
|
1017
1060
|
*/
|
|
1018
1061
|
function isMimeType(value) {
|
|
1019
|
-
return typeof value === "string" &&
|
|
1062
|
+
return typeof value === "string" && MIME_TYPE.test(value);
|
|
1020
1063
|
}
|
|
1021
1064
|
|
|
1022
1065
|
/**
|
|
@@ -1071,6 +1114,11 @@ function isLocale(value) {
|
|
|
1071
1114
|
*/
|
|
1072
1115
|
function isCron(value) {
|
|
1073
1116
|
if (typeof value !== "string") return false;
|
|
1117
|
+
// "*/5/x" is not a step and "1-5-x" is not a range, even though the parser stops reading early.
|
|
1118
|
+
for (const part of value.trim().split(/[\s,]+/)) {
|
|
1119
|
+
const step = part.split("/");
|
|
1120
|
+
if (step.length > 2 || step[0].split("-").length > 2) return false;
|
|
1121
|
+
}
|
|
1074
1122
|
try {
|
|
1075
1123
|
shared.parseCron(value);
|
|
1076
1124
|
return true;
|
package/src/Cli.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* boxes. When nobody's at the keyboard (CI, pipes), prompts read plain lines
|
|
7
7
|
* and animations print only their final state.
|
|
8
8
|
*
|
|
9
|
+
* Tables, boxes, trees and menus show what a terminal could act on as text
|
|
10
|
+
* (`\x1b`, `\r`), so a value can't move the cursor or break the layout.
|
|
11
|
+
* Colors and links in them still work.
|
|
12
|
+
*
|
|
9
13
|
* @example
|
|
10
14
|
* const { flags } = cli.args({
|
|
11
15
|
* port: { type: "number", short: "p", default: 3000, description: "Port to listen on" },
|
|
@@ -66,8 +70,12 @@ const { AbortError } = require("./errors.js");
|
|
|
66
70
|
*/
|
|
67
71
|
|
|
68
72
|
/**
|
|
69
|
-
* A spinner. Every method returns it.
|
|
70
|
-
* @typedef {
|
|
73
|
+
* A spinner. Every method returns it. With `using`, it stops at the end of the block.
|
|
74
|
+
* @typedef {_SpinnerMethods & Disposable} Spinner
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {object} _SpinnerMethods
|
|
71
79
|
* @property {(text?: string) => Spinner} start Starts spinning, optionally with new text.
|
|
72
80
|
* @property {() => Spinner} stop Stops and clears the line.
|
|
73
81
|
* @property {(text?: string) => Spinner} succeed Stops with a green `✔`.
|
|
@@ -91,8 +99,12 @@ const { AbortError } = require("./errors.js");
|
|
|
91
99
|
*/
|
|
92
100
|
|
|
93
101
|
/**
|
|
94
|
-
* A progress bar.
|
|
95
|
-
* @typedef {
|
|
102
|
+
* A progress bar. With `using`, it stops at the end of the block.
|
|
103
|
+
* @typedef {_ProgressBarMethods & Disposable} ProgressBar
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @typedef {object} _ProgressBarMethods
|
|
96
108
|
* @property {(amount?: number, label?: string) => ProgressBar} tick Adds to the value, 1 by default.
|
|
97
109
|
* @property {(value: number, label?: string) => ProgressBar} update Sets the value.
|
|
98
110
|
* @property {() => ProgressBar} stop Draws the final state and frees the line.
|
|
@@ -257,18 +269,28 @@ const _align = (text, width, align) => {
|
|
|
257
269
|
return text + " ".repeat(gap);
|
|
258
270
|
};
|
|
259
271
|
|
|
272
|
+
const { safe: _safe } = require("./internal/terminal.js");
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Text a terminal can't act on: control characters and escape sequences are
|
|
276
|
+
* shown as text (`\x1b`, `\r`). Colors and links stay, a cell may be colored
|
|
277
|
+
* on purpose.
|
|
278
|
+
* @param {string} text
|
|
279
|
+
*/
|
|
280
|
+
const _text = (text) => _safe(text, true);
|
|
281
|
+
|
|
260
282
|
/** @param {unknown} value */
|
|
261
283
|
const _cell = (value) => {
|
|
262
284
|
if (value === null || value === undefined) return "";
|
|
263
285
|
if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
|
|
264
286
|
if (typeof value === "object") {
|
|
265
287
|
try {
|
|
266
|
-
return JSON.stringify(value);
|
|
288
|
+
return _text(JSON.stringify(value) ?? String(value));
|
|
267
289
|
} catch {
|
|
268
|
-
return String(value);
|
|
290
|
+
return _text(String(value));
|
|
269
291
|
}
|
|
270
292
|
}
|
|
271
|
-
return String(value);
|
|
293
|
+
return _text(String(value));
|
|
272
294
|
};
|
|
273
295
|
|
|
274
296
|
/** @param {NodeJS.WriteStream | undefined} stream */
|
|
@@ -328,7 +350,7 @@ function table(rows, options = {}) {
|
|
|
328
350
|
const columns = keys.map((column) => (typeof column === "object" ? column : { key: column }));
|
|
329
351
|
if (!columns.length) return "";
|
|
330
352
|
const showHeader = options.header ?? !isArrayRows;
|
|
331
|
-
const headers = columns.map((column) => column.header ?? String(column.key));
|
|
353
|
+
const headers = columns.map((column) => _text(column.header ?? String(column.key)));
|
|
332
354
|
const widths = headers.map((header) => Math.max(markdown ? 3 : 1, showHeader ? color.width(header) : 0));
|
|
333
355
|
const numeric = columns.map(() => rows.length > 0);
|
|
334
356
|
|
|
@@ -409,8 +431,8 @@ function box(text, options = {}) {
|
|
|
409
431
|
const palette = _palette(options.colors);
|
|
410
432
|
const b = BORDERS[options.border ?? "rounded"];
|
|
411
433
|
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} ` : "";
|
|
434
|
+
const lines = _text(String(text)).split(_LINE_BREAK);
|
|
435
|
+
const title = options.title ? ` ${_text(options.title)} ` : "";
|
|
414
436
|
let longest = Math.max(options.width ?? 0, color.width(title) + 2 - pad.x * 2);
|
|
415
437
|
for (const line of lines) longest = Math.max(longest, color.width(line));
|
|
416
438
|
const inner = longest + pad.x * 2;
|
|
@@ -487,7 +509,8 @@ function tree(data, options = {}) {
|
|
|
487
509
|
entries.forEach(([label, value], i) => {
|
|
488
510
|
const isLast = i === entries.length - 1;
|
|
489
511
|
const nested = isBranch(value);
|
|
490
|
-
const
|
|
512
|
+
const name = _text(label);
|
|
513
|
+
const text = nested || value === null || value === undefined || value === true ? name : `${name}: ${_cell(value)}`;
|
|
491
514
|
const [first, ...rest] = text.split(_LINE_BREAK);
|
|
492
515
|
out.push(indent + (isLast ? last : branch) + first);
|
|
493
516
|
for (const line of rest) out.push(indent + (isLast ? blank : pipe) + line);
|
|
@@ -562,6 +585,10 @@ function spinner(text = "", options = {}) {
|
|
|
562
585
|
return self;
|
|
563
586
|
},
|
|
564
587
|
stop: () => finish("", undefined),
|
|
588
|
+
[Symbol.dispose]: () => {
|
|
589
|
+
// Leaving the block early, by an error or a return, stops the spinner: one that already finished stays as it is.
|
|
590
|
+
if (spinning) finish("", undefined);
|
|
591
|
+
},
|
|
565
592
|
succeed: (finalText) => finish(_paint(palette, "green", "✔"), finalText),
|
|
566
593
|
fail: (finalText) => finish(_paint(palette, "red", "✖"), finalText),
|
|
567
594
|
warn: (finalText) => finish(_paint(palette, "yellow", "⚠"), finalText),
|
|
@@ -698,6 +725,9 @@ function progress(options) {
|
|
|
698
725
|
stream.write(interactive ? `\n${SHOW_CURSOR}` : `${line()}\n`);
|
|
699
726
|
return self;
|
|
700
727
|
},
|
|
728
|
+
[Symbol.dispose]() {
|
|
729
|
+
self.stop();
|
|
730
|
+
},
|
|
701
731
|
get value() {
|
|
702
732
|
return value;
|
|
703
733
|
},
|
|
@@ -908,17 +938,29 @@ async function confirm(question, options = {}) {
|
|
|
908
938
|
const _normalizeChoices = (choices) =>
|
|
909
939
|
choices.map((c) => (typeof c === "string" ? { label: c, value: /** @type {V} */ (/** @type {unknown} */ (c)) } : c));
|
|
910
940
|
|
|
941
|
+
/**
|
|
942
|
+
* The question and its numbered choices, for when nobody can use the arrow keys.
|
|
943
|
+
* @param {string} question
|
|
944
|
+
* @param {Array<{ label: string, disabled?: boolean }>} items
|
|
945
|
+
*/
|
|
946
|
+
const _numbered = (question, items) =>
|
|
947
|
+
`? ${_text(String(question))}\n${items.map((it, i) => ` ${i + 1}. ${_text(String(it.label))}${it.disabled ? " (disabled)" : ""}`).join("\n")}\n`;
|
|
948
|
+
|
|
911
949
|
/**
|
|
912
950
|
* The arrow-key list behind `select()` and `multiselect()`.
|
|
913
951
|
* @template V
|
|
914
|
-
* @param {string}
|
|
952
|
+
* @param {string} rawQuestion
|
|
915
953
|
* @param {Array<{ label: string, value: V, hint?: string, disabled?: boolean }>} items
|
|
916
954
|
* @param {SelectOptions} options
|
|
917
955
|
* @param {boolean} multiple
|
|
918
956
|
* @returns {Promise<number[]>}
|
|
919
957
|
*/
|
|
920
|
-
const _interactiveList = (
|
|
958
|
+
const _interactiveList = (rawQuestion, items, options, multiple) =>
|
|
921
959
|
new Promise((resolve, reject) => {
|
|
960
|
+
// Choices often come from data: shown as text, they can't drive the terminal.
|
|
961
|
+
const question = _text(String(rawQuestion));
|
|
962
|
+
const labels = items.map((item) => _text(String(item.label)));
|
|
963
|
+
const hints = items.map((item) => (item.hint ? _text(String(item.hint)) : ""));
|
|
922
964
|
const input = options.input ?? process.stdin;
|
|
923
965
|
const output = options.output ?? process.stdout;
|
|
924
966
|
const palette = color.create(color.detect(output));
|
|
@@ -942,8 +984,8 @@ const _interactiveList = (question, items, options, multiple) =>
|
|
|
942
984
|
const item = items[i];
|
|
943
985
|
const active = i === cursor;
|
|
944
986
|
const mark = multiple ? (selected.has(i) ? palette.green("◉ ") : "◯ ") : "";
|
|
945
|
-
const text = item.disabled ? palette.gray(`${
|
|
946
|
-
lines.push(`${active ? palette.cyan("❯") : " "} ${mark}${text}${
|
|
987
|
+
const text = item.disabled ? palette.gray(`${labels[i]} (disabled)`) : active ? palette.cyan(labels[i]) : labels[i];
|
|
988
|
+
lines.push(`${active ? palette.cyan("❯") : " "} ${mark}${text}${hints[i] ? ` ${palette.gray(hints[i])}` : ""}`);
|
|
947
989
|
}
|
|
948
990
|
if (error) lines.push(palette.red(`✖ ${error}`));
|
|
949
991
|
// Going back up assumes one row per line, so nothing may wrap.
|
|
@@ -994,7 +1036,7 @@ const _interactiveList = (question, items, options, multiple) =>
|
|
|
994
1036
|
} else {
|
|
995
1037
|
const picked = multiple ? [...selected].sort((a, b) => a - b) : [cursor];
|
|
996
1038
|
// 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) =>
|
|
1039
|
+
output.write(`${_up(drawn)}${CLEAR_BELOW}${_oneRow(`${palette.green("✔")} ${question} ${palette.cyan(picked.map((i) => labels[i]).join(", "))}`, output)}\n`);
|
|
998
1040
|
cleanup();
|
|
999
1041
|
resolve(picked);
|
|
1000
1042
|
return;
|
|
@@ -1038,7 +1080,7 @@ async function select(question, choices, options = {}) {
|
|
|
1038
1080
|
return items[index].value;
|
|
1039
1081
|
}
|
|
1040
1082
|
const output = options.output ?? process.stdout;
|
|
1041
|
-
output.write(
|
|
1083
|
+
output.write(_numbered(question, items));
|
|
1042
1084
|
for (;;) {
|
|
1043
1085
|
const answer = (await _ask(`Choose 1-${items.length}${options.default !== undefined ? ` (${options.default + 1})` : ""}: `, input, output)).trim();
|
|
1044
1086
|
const index = answer === "" && options.default !== undefined ? options.default : Number(answer) - 1;
|
|
@@ -1068,7 +1110,7 @@ async function multiselect(question, choices, options = {}) {
|
|
|
1068
1110
|
return indexes.map((i) => items[i].value);
|
|
1069
1111
|
}
|
|
1070
1112
|
const output = options.output ?? process.stdout;
|
|
1071
|
-
output.write(
|
|
1113
|
+
output.write(_numbered(question, items));
|
|
1072
1114
|
for (;;) {
|
|
1073
1115
|
const answer = (await _ask("Choose (comma-separated numbers): ", input, output)).trim();
|
|
1074
1116
|
const indexes = answer === "" ? [] : answer.split(/[\s,]+/).filter(Boolean).map((n) => Number(n) - 1);
|