@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/src/Str.js
CHANGED
|
@@ -87,6 +87,13 @@
|
|
|
87
87
|
* @typedef {object} ClosestOptions
|
|
88
88
|
* @property {number} [threshold] Minimum similarity (0 to 1) to accept a match. Defaults to `0.4`.
|
|
89
89
|
* @property {boolean} [caseSensitive] Take case into account.
|
|
90
|
+
* @property {number} [maxDistance] Ignore candidates more than this many edits away. The search also gets faster.
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Options for `levenshtein()`.
|
|
95
|
+
* @typedef {object} LevenshteinOptions
|
|
96
|
+
* @property {number} [maxDistance] Stop counting past this many edits, which is much faster on long texts. A larger distance then comes back as `maxDistance + 1`.
|
|
90
97
|
*/
|
|
91
98
|
|
|
92
99
|
/**
|
|
@@ -108,13 +115,14 @@ const SPECIAL_LATIN = /[\u00df\u1e9e\u00e6\u00c6\u0153\u0152\u00f8\u00d8\u0111\u
|
|
|
108
115
|
const HTML_UNSAFE = /[&<>"']/;
|
|
109
116
|
const HTML_UNSAFE_ALL = /[&<>"']/g;
|
|
110
117
|
const HTML_ESCAPES = /** @type {Record<string, string>} */ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" });
|
|
111
|
-
|
|
118
|
+
// No prototype, so "&constructor;" finds nothing.
|
|
119
|
+
const HTML_ENTITIES = /** @type {Record<string, string>} */ (Object.assign(Object.create(null), {
|
|
112
120
|
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0",
|
|
113
121
|
copy: "\u00a9", reg: "\u00ae", trade: "\u2122", hellip: "\u2026", mdash: "\u2014", ndash: "\u2013",
|
|
114
122
|
lsquo: "\u2018", rsquo: "\u2019", ldquo: "\u201c", rdquo: "\u201d", laquo: "\u00ab", raquo: "\u00bb",
|
|
115
123
|
euro: "\u20ac", pound: "\u00a3", yen: "\u00a5", cent: "\u00a2", deg: "\u00b0", middot: "\u00b7",
|
|
116
124
|
bull: "\u2022", times: "\u00d7", divide: "\u00f7", sect: "\u00a7", para: "\u00b6",
|
|
117
|
-
});
|
|
125
|
+
}));
|
|
118
126
|
const TEMPLATE_DEFAULT = /\{\s*([\w$.-]+)\s*\}/g;
|
|
119
127
|
/** @type {Map<string, RegExp>} */
|
|
120
128
|
const _templateRegexes = new Map();
|
|
@@ -133,7 +141,8 @@ const _SPECIAL_LATIN = /** @type {Record<string, string>} */ ({
|
|
|
133
141
|
const _words = (input) =>
|
|
134
142
|
String(input)
|
|
135
143
|
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1 $2")
|
|
136
|
-
|
|
144
|
+
// A lookahead, not "(\p{Lu}+)(\p{Lu}\p{Ll})": that one is quadratic on a long run of capitals.
|
|
145
|
+
.replace(/(\p{Lu})(?=\p{Lu}\p{Ll})/gu, "$1 ")
|
|
137
146
|
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
138
147
|
.trim()
|
|
139
148
|
.split(/\s+/)
|
|
@@ -182,45 +191,65 @@ const _templateValue = (value, key) => {
|
|
|
182
191
|
return typeof found === "function" ? undefined : found;
|
|
183
192
|
};
|
|
184
193
|
|
|
185
|
-
/** One row of the edit-distance table, reused between calls. */
|
|
194
|
+
/** One row of the edit-distance table, reused between calls for short texts. */
|
|
186
195
|
let _row = new Uint32Array(64);
|
|
196
|
+
/** Longer rows are allocated for the call, so a huge text doesn't keep its memory. */
|
|
197
|
+
const _ROW_REUSE = 4096;
|
|
187
198
|
|
|
188
199
|
/**
|
|
189
|
-
* Levenshtein distance between two sequences of characters.
|
|
200
|
+
* Levenshtein distance between two sequences of characters. Past `max`, it
|
|
201
|
+
* stops and returns `max + 1`.
|
|
190
202
|
* @param {ArrayLike<string>} s
|
|
191
203
|
* @param {ArrayLike<string>} t
|
|
204
|
+
* @param {number} [max]
|
|
192
205
|
* @returns {number}
|
|
193
206
|
*/
|
|
194
|
-
const _distance = (s, t) => {
|
|
207
|
+
const _distance = (s, t, max = Infinity) => {
|
|
208
|
+
// The row follows the shorter text.
|
|
209
|
+
if (t.length > s.length) [s, t] = [t, s];
|
|
195
210
|
let sLength = s.length;
|
|
196
211
|
let tLength = t.length;
|
|
212
|
+
if (sLength - tLength > max) return max + 1;
|
|
197
213
|
let start = 0;
|
|
198
|
-
while (start <
|
|
199
|
-
while (
|
|
214
|
+
while (start < tLength && s[start] === t[start]) start++;
|
|
215
|
+
while (tLength > start && s[sLength - 1] === t[tLength - 1]) {
|
|
200
216
|
sLength--;
|
|
201
217
|
tLength--;
|
|
202
218
|
}
|
|
203
219
|
sLength -= start;
|
|
204
220
|
tLength -= start;
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
221
|
+
if (tLength === 0) return sLength > max ? max + 1 : sLength;
|
|
222
|
+
/** @type {Uint32Array} */
|
|
223
|
+
let row;
|
|
224
|
+
if (tLength >= _ROW_REUSE) row = new Uint32Array(tLength + 1);
|
|
225
|
+
else {
|
|
226
|
+
if (_row.length <= tLength) _row = new Uint32Array(Math.min(tLength * 2, _ROW_REUSE));
|
|
227
|
+
row = _row;
|
|
228
|
+
}
|
|
209
229
|
for (let j = 0; j <= tLength; j++) row[j] = j;
|
|
230
|
+
// Only cells within `max` of the diagonal can stay under `max`: the others are skipped.
|
|
231
|
+
const banded = max < sLength;
|
|
210
232
|
for (let i = 1; i <= sLength; i++) {
|
|
211
233
|
const char = s[start + i - 1];
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
234
|
+
const from = banded && i > max ? i - max : 1;
|
|
235
|
+
const to = banded && i + max < tLength ? i + max : tLength;
|
|
236
|
+
let diagonal = row[from - 1];
|
|
237
|
+
let left = from === 1 ? i : max + 1;
|
|
238
|
+
if (from === 1) row[0] = i;
|
|
239
|
+
let lowest = left;
|
|
240
|
+
for (let j = from; j <= to; j++) {
|
|
215
241
|
const above = row[j];
|
|
216
242
|
let best = char === t[start + j - 1] ? diagonal : diagonal + 1;
|
|
217
243
|
if (above + 1 < best) best = above + 1;
|
|
218
|
-
if (
|
|
244
|
+
if (left + 1 < best) best = left + 1;
|
|
219
245
|
row[j] = best;
|
|
246
|
+
left = best;
|
|
220
247
|
diagonal = above;
|
|
248
|
+
if (best < lowest) lowest = best;
|
|
221
249
|
}
|
|
250
|
+
if (lowest > max) return max + 1;
|
|
222
251
|
}
|
|
223
|
-
return row[tLength];
|
|
252
|
+
return row[tLength] > max ? max + 1 : row[tLength];
|
|
224
253
|
};
|
|
225
254
|
|
|
226
255
|
/**
|
|
@@ -426,7 +455,9 @@ function words(input) {
|
|
|
426
455
|
|
|
427
456
|
/**
|
|
428
457
|
* Removes accents (`é` becomes `e`) and spells out special Latin letters
|
|
429
|
-
* (`ß` becomes `ss`, `æ` becomes `ae`).
|
|
458
|
+
* (`ß` becomes `ss`, `æ` becomes `ae`). Look-alike forms become plain ones
|
|
459
|
+
* too (Unicode NFKD), so a fullwidth `<` becomes `<`: deburr before
|
|
460
|
+
* escaping HTML, never after.
|
|
430
461
|
*
|
|
431
462
|
* @example
|
|
432
463
|
* str.deburr("Crème brûlée"); // "Creme brulee"
|
|
@@ -490,7 +521,8 @@ function squish(input) {
|
|
|
490
521
|
|
|
491
522
|
/**
|
|
492
523
|
* Removes HTML tags and keeps the text. This is not a sanitizer: to show
|
|
493
|
-
* untrusted text in HTML, use `escapeHTML()`.
|
|
524
|
+
* untrusted text in HTML, use `escapeHTML()`. Broken markup can even leave
|
|
525
|
+
* a tag behind: `"<<a>script>"` gives `"<script>"`.
|
|
494
526
|
*
|
|
495
527
|
* @example
|
|
496
528
|
* str.stripTags("<p>Hello <b>world</b></p>"); // "Hello world"
|
|
@@ -503,7 +535,9 @@ function stripTags(input) {
|
|
|
503
535
|
}
|
|
504
536
|
|
|
505
537
|
/**
|
|
506
|
-
* Escapes `& < > " '` so the text is safe inside HTML content and
|
|
538
|
+
* Escapes `& < > " '` so the text is safe inside HTML content and quoted
|
|
539
|
+
* attribute values. It is not enough for an unquoted attribute, a URL
|
|
540
|
+
* (`javascript:` stays as it is), a script or a style.
|
|
507
541
|
*
|
|
508
542
|
* @example
|
|
509
543
|
* str.escapeHTML('<a href="x">Tom & Jerry</a>');
|
|
@@ -895,12 +929,14 @@ function reverse(input) {
|
|
|
895
929
|
}
|
|
896
930
|
|
|
897
931
|
/**
|
|
898
|
-
* Hides part of a string, like a card number or a token.
|
|
932
|
+
* Hides part of a string, like a card number or a token. When `start` and
|
|
933
|
+
* `end` would leave the whole text visible, all of it is hidden instead.
|
|
899
934
|
*
|
|
900
935
|
* @example
|
|
901
936
|
* str.mask("4242424242424242"); // "************4242"
|
|
902
937
|
* str.mask("4242 4242 4242 4242", { keepSpaces: true }); // "**** **** **** 4242"
|
|
903
938
|
* str.mask("secret-token", { start: 2, end: 2, char: "•" }); // "se••••••••en"
|
|
939
|
+
* str.mask("1234"); // "****"
|
|
904
940
|
*
|
|
905
941
|
* @param {unknown} input
|
|
906
942
|
* @param {MaskOptions} [options]
|
|
@@ -908,10 +944,11 @@ function reverse(input) {
|
|
|
908
944
|
*/
|
|
909
945
|
function mask(input, options = {}) {
|
|
910
946
|
const chars = _graphemes(String(input));
|
|
911
|
-
|
|
912
|
-
|
|
947
|
+
let start = Math.max(0, options.start ?? 0);
|
|
948
|
+
let end = Math.max(0, options.end ?? 4);
|
|
913
949
|
const char = options.char ?? "*";
|
|
914
|
-
|
|
950
|
+
// Too short to show anything safely: hide it all rather than reveal it all.
|
|
951
|
+
if (start + end >= chars.length) start = end = 0;
|
|
915
952
|
return chars
|
|
916
953
|
.map((c, i) => {
|
|
917
954
|
if (i < start || i >= chars.length - end) return c;
|
|
@@ -965,7 +1002,8 @@ function plural(value, forms, options = {}) {
|
|
|
965
1002
|
|
|
966
1003
|
/**
|
|
967
1004
|
* Fills `{placeholders}` with values. Dotted paths like `{user.name}` work,
|
|
968
|
-
* and missing values are left alone unless you give a `fallback`.
|
|
1005
|
+
* and missing values are left alone unless you give a `fallback`. Values
|
|
1006
|
+
* are not escaped: to build HTML, pass them through `escapeHTML()` first.
|
|
969
1007
|
*
|
|
970
1008
|
* @example
|
|
971
1009
|
* str.template("Hi {name}, you have {count} messages", { name: "Jo", count: 3 });
|
|
@@ -1091,18 +1129,23 @@ function compare(a, b, options = {}) {
|
|
|
1091
1129
|
|
|
1092
1130
|
/**
|
|
1093
1131
|
* The number of single-character edits needed to turn `a` into `b`.
|
|
1132
|
+
* The work grows with the product of both lengths: on long or untrusted
|
|
1133
|
+
* texts, set `maxDistance`.
|
|
1094
1134
|
*
|
|
1095
1135
|
* @example
|
|
1096
|
-
* str.levenshtein("kitten", "sitting");
|
|
1136
|
+
* str.levenshtein("kitten", "sitting"); // 3
|
|
1137
|
+
* str.levenshtein("kitten", "sitting", { maxDistance: 1 }); // 2, which means "more than 1"
|
|
1097
1138
|
*
|
|
1098
1139
|
* @param {unknown} a
|
|
1099
1140
|
* @param {unknown} b
|
|
1141
|
+
* @param {LevenshteinOptions} [options]
|
|
1100
1142
|
* @returns {number}
|
|
1101
1143
|
*/
|
|
1102
|
-
function levenshtein(a, b) {
|
|
1144
|
+
function levenshtein(a, b, options = {}) {
|
|
1103
1145
|
const x = String(a);
|
|
1104
1146
|
const y = String(b);
|
|
1105
|
-
|
|
1147
|
+
const max = options.maxDistance;
|
|
1148
|
+
return x === y ? 0 : _distance(_units(x), _units(y), max === undefined || !(max >= 0) ? Infinity : Math.floor(max));
|
|
1106
1149
|
}
|
|
1107
1150
|
|
|
1108
1151
|
/**
|
|
@@ -1140,6 +1183,7 @@ function similarity(a, b) {
|
|
|
1140
1183
|
*/
|
|
1141
1184
|
function closest(input, candidates, options = {}) {
|
|
1142
1185
|
const threshold = options.threshold ?? 0.4;
|
|
1186
|
+
const maxDistance = options.maxDistance === undefined || !(options.maxDistance >= 0) ? Infinity : Math.floor(options.maxDistance);
|
|
1143
1187
|
const norm = (/** @type {string} */ s) => (options.caseSensitive ? s : s.toLowerCase());
|
|
1144
1188
|
const needle = _units(norm(String(input)));
|
|
1145
1189
|
let best;
|
|
@@ -1149,7 +1193,11 @@ function closest(input, candidates, options = {}) {
|
|
|
1149
1193
|
const longest = Math.max(needle.length, units.length);
|
|
1150
1194
|
// The length gap alone bounds the score: skip candidates that can't win.
|
|
1151
1195
|
if (longest > 0 && 1 - Math.abs(needle.length - units.length) / longest <= bestScore) continue;
|
|
1152
|
-
|
|
1196
|
+
// Neither can a candidate further away than the threshold or the best so far allows (rounded up, to stay exact).
|
|
1197
|
+
const max = Math.min(maxDistance, Math.ceil((1 - Math.max(threshold, bestScore)) * longest));
|
|
1198
|
+
const distance = longest === 0 ? 0 : _distance(needle, units, max);
|
|
1199
|
+
if (distance > max) continue;
|
|
1200
|
+
const score = longest === 0 ? 1 : 1 - distance / longest;
|
|
1153
1201
|
if (score > bestScore) {
|
|
1154
1202
|
bestScore = score;
|
|
1155
1203
|
best = candidate;
|
package/src/Sys.js
CHANGED
|
@@ -18,7 +18,7 @@ const fs = require("node:fs");
|
|
|
18
18
|
const os = require("node:os");
|
|
19
19
|
const nodePath = require("node:path");
|
|
20
20
|
const { ProcessError, AbortError } = require("./errors.js");
|
|
21
|
-
const { toMs } = require("./internal/shared.js");
|
|
21
|
+
const { toMs, disposable } = require("./internal/shared.js");
|
|
22
22
|
|
|
23
23
|
// Loaded on first use: most programs never start another one.
|
|
24
24
|
/** @type {typeof import("node:child_process") | undefined} */
|
|
@@ -30,14 +30,14 @@ const _cp = () => (_childProcess ??= require("node:child_process"));
|
|
|
30
30
|
* @typedef {object} RunOptions
|
|
31
31
|
* @property {string} [cwd] Where to run the command.
|
|
32
32
|
* @property {Record<string, string | undefined>} [env] Extra environment variables, added to `process.env`. `undefined` removes one.
|
|
33
|
-
* @property {boolean} [extendEnv] Set to `false` to pass only `env`, without `process.env`.
|
|
33
|
+
* @property {boolean} [extendEnv] Set to `false` to pass only `env`, without `process.env`. Without `env`, the command then gets an empty environment.
|
|
34
34
|
* @property {string | Buffer} [input] Written to the command's stdin.
|
|
35
35
|
* @property {number | string} [timeout] Kill the command after this long, like `"30s"`. On Windows, what it started is stopped with it.
|
|
36
36
|
* @property {AbortSignal} [signal] Kills the command when aborted. On Windows, what it started is stopped with it.
|
|
37
37
|
* @property {"pipe" | "inherit"} [stdio] `"inherit"` shows the output live instead of capturing it. Defaults to `"pipe"`.
|
|
38
38
|
* @property {(line: string) => void} [onStdout] Called for each line of stdout.
|
|
39
39
|
* @property {(line: string) => void} [onStderr] Called for each line of stderr.
|
|
40
|
-
* @property {boolean} [reject]
|
|
40
|
+
* @property {boolean} [reject] Reject when the exit code isn't 0. Defaults to `true`.
|
|
41
41
|
* @property {boolean} [trim] Drop the final newline of the output. Defaults to `true`.
|
|
42
42
|
*/
|
|
43
43
|
|
|
@@ -66,6 +66,19 @@ const _cp = () => (_childProcess ??= require("node:child_process"));
|
|
|
66
66
|
* @typedef {NodeJS.Signals | "shutdown"} ShutdownReason
|
|
67
67
|
*/
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Options for `which()`.
|
|
71
|
+
* @typedef {object} WhichOptions
|
|
72
|
+
* @property {string} [cwd] Where a relative path like `"./bin/tool"`, or a relative folder in `PATH`, is looked up from. Defaults to `process.cwd()`.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Options for `open()`.
|
|
77
|
+
* @typedef {object} OpenOptions
|
|
78
|
+
* @property {string[]} [protocols] The URL schemes that may be opened, like `"https:"` or `"vscode:"`. Defaults to `["http:", "https:", "mailto:"]`: `file:`, `search-ms:` and the schemes other apps register can start programs, so they're refused unless you name them. An allowed `file:` URL is checked like a path.
|
|
79
|
+
* @property {boolean} [allowUNC] Allow a Windows network path like `\\server\share\file`. Defaults to `false`: just looking one up can send your Windows credentials to that server.
|
|
80
|
+
*/
|
|
81
|
+
|
|
69
82
|
/**
|
|
70
83
|
* Returned by `info()`.
|
|
71
84
|
* @typedef {object} SystemInfo
|
|
@@ -101,18 +114,58 @@ const _escapeArg = (arg, doubleEscape) => {
|
|
|
101
114
|
return doubleEscape ? out.replace(META, "^$1") : out;
|
|
102
115
|
};
|
|
103
116
|
|
|
117
|
+
/** @type {Map<string, boolean>} */
|
|
118
|
+
const _forwarding = new Map();
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Does this batch file pass its arguments on with `%*`? cmd.exe then reads
|
|
122
|
+
* them a second time, so they need a second escape.
|
|
123
|
+
* @param {string} file
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
const _forwardsArgs = (file) => {
|
|
127
|
+
const known = _forwarding.get(file);
|
|
128
|
+
if (known !== undefined) return known;
|
|
129
|
+
let forwards = /node_modules[\\/]\.bin[\\/]/i.test(file);
|
|
130
|
+
if (!forwards) {
|
|
131
|
+
try {
|
|
132
|
+
const head = Buffer.alloc(8192);
|
|
133
|
+
const fd = fs.openSync(file, "r");
|
|
134
|
+
try {
|
|
135
|
+
forwards = /%\*/.test(head.toString("latin1", 0, fs.readSync(fd, head, 0, head.length, 0)));
|
|
136
|
+
} finally {
|
|
137
|
+
fs.closeSync(fd);
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// Unreadable: the second escape is the one that lets nothing through.
|
|
141
|
+
forwards = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (_forwarding.size >= 200) _forwarding.clear();
|
|
145
|
+
_forwarding.set(file, forwards);
|
|
146
|
+
return forwards;
|
|
147
|
+
};
|
|
148
|
+
|
|
104
149
|
/**
|
|
105
150
|
* Handles Windows `.cmd` and `.bat` files.
|
|
106
151
|
* @param {string} file
|
|
107
152
|
* @param {string[]} args
|
|
153
|
+
* @param {string | undefined} cwd
|
|
154
|
+
* @param {string} display
|
|
108
155
|
* @returns {{ file: string, args: string[], verbatim: boolean }}
|
|
109
156
|
*/
|
|
110
|
-
const _prepare = (file, args) => {
|
|
157
|
+
const _prepare = (file, args, cwd, display) => {
|
|
111
158
|
if (process.platform !== "win32") return { file, args, verbatim: false };
|
|
112
|
-
const resolved = which(file) ?? file;
|
|
159
|
+
const resolved = which(file, { cwd }) ?? file;
|
|
113
160
|
if (!/\.(cmd|bat)$/i.test(resolved)) return { file: resolved, args, verbatim: false };
|
|
114
|
-
const
|
|
115
|
-
const
|
|
161
|
+
const forwards = _forwardsArgs(resolved);
|
|
162
|
+
for (const arg of args) {
|
|
163
|
+
// Like Rust, refuse what cmd.exe can't be given safely: it ends the command
|
|
164
|
+
// at a line break, and a file that reads %1 splits quotes its own way.
|
|
165
|
+
if (/[\r\n]/.test(arg)) throw new ProcessError(`Can't pass a line break to a batch file: ${display}`, { command: display });
|
|
166
|
+
if (!forwards && arg.includes('"')) throw new ProcessError(`Can't pass a quote to a batch file that doesn't use %*: ${display}`, { command: display });
|
|
167
|
+
}
|
|
168
|
+
const line = [_escapeCommand(nodePath.normalize(resolved)), ...args.map((a) => _escapeArg(a, forwards))].join(" ");
|
|
116
169
|
return { file: process.env.comspec || "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], verbatim: true };
|
|
117
170
|
};
|
|
118
171
|
|
|
@@ -121,7 +174,7 @@ const _prepare = (file, args) => {
|
|
|
121
174
|
* @returns {Record<string, string | undefined> | undefined}
|
|
122
175
|
*/
|
|
123
176
|
const _env = (options) => {
|
|
124
|
-
if (options.extendEnv === false) return options.env;
|
|
177
|
+
if (options.extendEnv === false) return options.env ?? {};
|
|
125
178
|
if (!options.env) return process.env;
|
|
126
179
|
/** @type {Record<string, string | undefined>} */
|
|
127
180
|
const merged = { ...process.env };
|
|
@@ -145,7 +198,7 @@ const _spawn = (file, args, shell, display, options) =>
|
|
|
145
198
|
new Promise((resolve, reject) => {
|
|
146
199
|
const started = Date.now();
|
|
147
200
|
const inherit = options.stdio === "inherit";
|
|
148
|
-
const prepared = shell ? { file, args, verbatim: false } : _prepare(file, args);
|
|
201
|
+
const prepared = shell ? { file, args, verbatim: false } : _prepare(file, args, options.cwd, display);
|
|
149
202
|
const timeoutMs = options.timeout === undefined ? undefined : toMs(options.timeout, "timeout");
|
|
150
203
|
if (options.signal?.aborted) return reject(new AbortError(`Command aborted: ${display}`, { cause: options.signal.reason }));
|
|
151
204
|
|
|
@@ -266,7 +319,7 @@ const _spawn = (file, args, shell, display, options) =>
|
|
|
266
319
|
* @param {string[]} [args=[]]
|
|
267
320
|
* @param {RunOptions} [options]
|
|
268
321
|
* @returns {Promise<RunResult>}
|
|
269
|
-
* @throws {ProcessError} If it can't start, fails (unless `reject: false`) or times out.
|
|
322
|
+
* @throws {ProcessError} If it can't start, fails (unless `reject: false`) or times out. On Windows, also when a `.cmd` or `.bat` file would get an argument with a line break, or with a quote when the file doesn't pass its arguments on with `%*`: cmd.exe can't be given those safely.
|
|
270
323
|
*/
|
|
271
324
|
function run(file, args = [], options = {}) {
|
|
272
325
|
const display = [file, ...args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a))].join(" ");
|
|
@@ -305,20 +358,24 @@ const _runnable = (file) => {
|
|
|
305
358
|
};
|
|
306
359
|
|
|
307
360
|
/**
|
|
308
|
-
* Finds an executable in `PATH`, like the `which` command.
|
|
361
|
+
* Finds an executable in `PATH`, like the `which` command. A path, like
|
|
362
|
+
* `"./bin/tool"`, is looked up from `cwd` instead.
|
|
309
363
|
*
|
|
310
364
|
* @example
|
|
311
365
|
* sys.which("git"); // "/usr/bin/git"
|
|
312
366
|
* sys.which("nope"); // undefined
|
|
367
|
+
* sys.which("./tool", { cwd: "./app" });
|
|
313
368
|
*
|
|
314
369
|
* @param {string} command
|
|
370
|
+
* @param {WhichOptions} [options]
|
|
315
371
|
* @returns {string | undefined}
|
|
316
372
|
*/
|
|
317
|
-
function which(command) {
|
|
373
|
+
function which(command, options = {}) {
|
|
318
374
|
if (typeof command !== "string" || !command) return undefined;
|
|
319
375
|
const isWin = process.platform === "win32";
|
|
320
376
|
const searchPath = process.env.PATH ?? process.env.Path ?? "";
|
|
321
|
-
const
|
|
377
|
+
const cwd = nodePath.resolve(options.cwd ?? process.cwd());
|
|
378
|
+
const key = `${command}\0${searchPath}\0${cwd}`;
|
|
322
379
|
const known = _found.get(key);
|
|
323
380
|
// A found program is checked again with one call, in case it was removed since.
|
|
324
381
|
if (known !== undefined && _runnable(known)) return known;
|
|
@@ -327,11 +384,11 @@ function which(command) {
|
|
|
327
384
|
/** @type {string | undefined} */
|
|
328
385
|
let found;
|
|
329
386
|
if (command.includes("/") || command.includes("\\")) {
|
|
330
|
-
found = candidates(nodePath.resolve(command)).find(_runnable);
|
|
387
|
+
found = candidates(nodePath.resolve(cwd, command)).find(_runnable);
|
|
331
388
|
} else {
|
|
332
389
|
for (const dir of searchPath.split(nodePath.delimiter)) {
|
|
333
390
|
if (!dir) continue;
|
|
334
|
-
found = candidates(nodePath.
|
|
391
|
+
found = candidates(nodePath.resolve(cwd, dir.replace(/^"|"$/g, ""), command)).find(_runnable);
|
|
335
392
|
if (found) break;
|
|
336
393
|
}
|
|
337
394
|
}
|
|
@@ -345,20 +402,64 @@ function which(command) {
|
|
|
345
402
|
}
|
|
346
403
|
|
|
347
404
|
/**
|
|
348
|
-
*
|
|
405
|
+
* The absolute path that `open()` may hand to the opener.
|
|
406
|
+
* @param {string} target
|
|
407
|
+
* @param {OpenOptions} options
|
|
408
|
+
* @returns {string}
|
|
409
|
+
*/
|
|
410
|
+
const _openPath = (target, options) => {
|
|
411
|
+
const full = nodePath.resolve(target);
|
|
412
|
+
// Checked before anything touches the disk: looking up a network path is enough to leak credentials.
|
|
413
|
+
if (process.platform === "win32" && /^[\\/]{2}/.test(full) && !options.allowUNC) {
|
|
414
|
+
throw new TypeError(`open(): network paths are refused without allowUNC: ${full}`);
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
fs.statSync(full);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
throw new Error(`open(): no file or folder at ${full}`, { cause: error });
|
|
420
|
+
}
|
|
421
|
+
return full;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Opens a URL, file or folder with the default app. Only `http:`,
|
|
426
|
+
* `https:` and `mailto:` URLs are opened unless you allow more with
|
|
427
|
+
* `protocols`; anything else is a path, which must exist. A program is
|
|
428
|
+
* started, as with a double click: don't pass a path you didn't choose.
|
|
349
429
|
*
|
|
350
430
|
* @example
|
|
351
431
|
* sys.open("http://localhost:3000");
|
|
432
|
+
* sys.open("./report.html");
|
|
433
|
+
* sys.open("vscode://file/C:/app/index.js", { protocols: ["vscode:"] });
|
|
352
434
|
*
|
|
353
|
-
* @param {string} target
|
|
435
|
+
* @param {string} target A URL, or the path of a file or folder.
|
|
436
|
+
* @param {OpenOptions} [options]
|
|
354
437
|
* @returns {boolean} `true` if the opener started.
|
|
438
|
+
* @throws {TypeError} If the URL's scheme isn't allowed, the target holds a control character (or a double quote, on Windows), or it's a network path without `allowUNC`.
|
|
439
|
+
* @throws {Error} If the path doesn't exist.
|
|
355
440
|
*/
|
|
356
|
-
function open(target) {
|
|
441
|
+
function open(target, options = {}) {
|
|
442
|
+
if (typeof target !== "string" || !target) throw new TypeError("open(): expected a URL or a path");
|
|
443
|
+
// Control characters have no business in a URL or a file name.
|
|
444
|
+
if (/[\x00-\x1f\x7f]/.test(target)) throw new TypeError(`open(): control characters are refused: ${JSON.stringify(target)}`);
|
|
445
|
+
// A quote ends the target early for Windows' start. open and xdg-open get it as an argument, and it's legal in a file name there.
|
|
446
|
+
if (process.platform === "win32" && target.includes('"')) throw new TypeError(`open(): quotes are refused on Windows: ${JSON.stringify(target)}`);
|
|
447
|
+
const allowed = (options.protocols ?? ["http:", "https:", "mailto:"]).map((p) => String(p).toLowerCase().replace(/:?$/, ":"));
|
|
448
|
+
/** @type {string} */
|
|
449
|
+
let value;
|
|
450
|
+
// A single letter before the colon is a Windows drive, not a scheme.
|
|
451
|
+
if (/^[a-z][a-z\d+.-]+:/i.test(target)) {
|
|
452
|
+
const url = new URL(target);
|
|
453
|
+
if (!allowed.includes(url.protocol)) throw new TypeError(`open(): the ${url.protocol} scheme isn't allowed (allowed: ${allowed.join(", ")})`);
|
|
454
|
+
// href encodes spaces and quotes, so the opener gets a single argument.
|
|
455
|
+
value = url.protocol === "file:" ? _openPath(require("node:url").fileURLToPath(url), options) : url.href;
|
|
456
|
+
} else {
|
|
457
|
+
value = _openPath(target, options);
|
|
458
|
+
}
|
|
357
459
|
try {
|
|
358
|
-
const value = /^[a-z][a-z\d+.-]*:/i.test(target) ? target : nodePath.resolve(target);
|
|
359
460
|
const child =
|
|
360
461
|
process.platform === "win32"
|
|
361
|
-
? _cp().spawn(process.env.comspec || "cmd.exe", ["/d", "/s", "/c", `start "" ${
|
|
462
|
+
? _cp().spawn(process.env.comspec || "cmd.exe", ["/d", "/s", "/c", `start "" ${`"${value}"`.replace(META, "^$1")}`], { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true })
|
|
362
463
|
: _cp().spawn(process.platform === "darwin" ? "open" : "xdg-open", [value], { detached: true, stdio: "ignore" });
|
|
363
464
|
child.on("error", () => {});
|
|
364
465
|
child.unref();
|
|
@@ -389,7 +490,7 @@ const _shutdownConfig = { timeout: 10_000, exitCode: 0 };
|
|
|
389
490
|
*
|
|
390
491
|
* @param {(reason: ShutdownReason) => unknown} handler Can be async. Gets the signal that stopped the process.
|
|
391
492
|
* @param {ShutdownOptions} [options]
|
|
392
|
-
* @returns {() => void} Removes the handler.
|
|
493
|
+
* @returns {(() => void) & Disposable} Removes the handler. With `using`, at the end of the block.
|
|
393
494
|
*/
|
|
394
495
|
function onShutdown(handler, options = {}) {
|
|
395
496
|
if (options.timeout !== undefined) _shutdownConfig.timeout = toMs(options.timeout, "timeout");
|
|
@@ -407,10 +508,10 @@ function onShutdown(handler, options = {}) {
|
|
|
407
508
|
// unsupported signal on this platform
|
|
408
509
|
}
|
|
409
510
|
}
|
|
410
|
-
return () => {
|
|
511
|
+
return disposable(() => {
|
|
411
512
|
const index = _shutdownHandlers.lastIndexOf(handler);
|
|
412
513
|
if (index !== -1) _shutdownHandlers.splice(index, 1);
|
|
413
|
-
};
|
|
514
|
+
});
|
|
414
515
|
}
|
|
415
516
|
|
|
416
517
|
/**
|