@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/src/Arr.js +13 -5
  5. package/src/Checker.js +56 -8
  6. package/src/Cli.js +60 -18
  7. package/src/Color.js +32 -6
  8. package/src/Crypto.js +429 -78
  9. package/src/Emitter.js +9 -9
  10. package/src/Env.js +90 -8
  11. package/src/FS.js +369 -59
  12. package/src/Func.js +1 -1
  13. package/src/Http.js +266 -75
  14. package/src/Id.js +53 -15
  15. package/src/Logger.js +100 -22
  16. package/src/Num.js +3 -2
  17. package/src/Obj.js +225 -36
  18. package/src/SQLite.js +155 -98
  19. package/src/Schema.js +281 -40
  20. package/src/Str.js +77 -29
  21. package/src/Sys.js +124 -23
  22. package/src/Time.js +98 -24
  23. package/src/Utils.js +18 -3
  24. package/src/errors.js +75 -4
  25. package/src/internal/shared.js +68 -14
  26. package/src/internal/terminal.js +49 -0
  27. package/types/index.d.ts +28 -5
  28. package/types/src/Arr.d.ts +4 -2
  29. package/types/src/Arr.d.ts.map +1 -1
  30. package/types/src/Checker.d.ts +29 -3
  31. package/types/src/Checker.d.ts.map +1 -1
  32. package/types/src/Cli.d.ts +6 -4
  33. package/types/src/Cli.d.ts.map +1 -1
  34. package/types/src/Color.d.ts +2 -2
  35. package/types/src/Color.d.ts.map +1 -1
  36. package/types/src/Crypto.d.ts +126 -28
  37. package/types/src/Crypto.d.ts.map +1 -1
  38. package/types/src/Emitter.d.ts +6 -6
  39. package/types/src/Emitter.d.ts.map +1 -1
  40. package/types/src/Env.d.ts +19 -1
  41. package/types/src/Env.d.ts.map +1 -1
  42. package/types/src/FS.d.ts +30 -23
  43. package/types/src/FS.d.ts.map +1 -1
  44. package/types/src/Func.d.ts +1 -1
  45. package/types/src/Http.d.ts +45 -8
  46. package/types/src/Http.d.ts.map +1 -1
  47. package/types/src/Id.d.ts.map +1 -1
  48. package/types/src/Logger.d.ts +11 -3
  49. package/types/src/Logger.d.ts.map +1 -1
  50. package/types/src/Num.d.ts +1 -1
  51. package/types/src/Num.d.ts.map +1 -1
  52. package/types/src/Obj.d.ts +15 -2
  53. package/types/src/Obj.d.ts.map +1 -1
  54. package/types/src/SQLite.d.ts +105 -13
  55. package/types/src/SQLite.d.ts.map +1 -1
  56. package/types/src/Schema.d.ts +61 -5
  57. package/types/src/Schema.d.ts.map +1 -1
  58. package/types/src/Str.d.ts +32 -7
  59. package/types/src/Str.d.ts.map +1 -1
  60. package/types/src/Sys.d.ts +43 -10
  61. package/types/src/Sys.d.ts.map +1 -1
  62. package/types/src/Time.d.ts +12 -7
  63. package/types/src/Time.d.ts.map +1 -1
  64. package/types/src/Utils.d.ts +16 -6
  65. package/types/src/Utils.d.ts.map +1 -1
  66. package/types/src/errors.d.ts +6 -2
  67. package/types/src/errors.d.ts.map +1 -1
  68. package/types/src/internal/shared.d.ts +7 -0
  69. package/types/src/internal/shared.d.ts.map +1 -1
  70. package/types/src/internal/terminal.d.ts +13 -0
  71. 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>} */ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" });
111
- const HTML_ENTITIES = /** @type {Record<string, string>} */ ({
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
- .replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1 $2")
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 < sLength && start < tLength && s[start] === t[start]) start++;
199
- while (sLength > start && tLength > start && s[sLength - 1] === t[tLength - 1]) {
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 (sLength === 0) return tLength;
206
- if (tLength === 0) return sLength;
207
- if (_row.length <= tLength) _row = new Uint32Array(tLength * 2);
208
- const row = _row;
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
- let diagonal = row[0];
213
- row[0] = i;
214
- for (let j = 1; j <= tLength; j++) {
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 (row[j - 1] + 1 < best) best = row[j - 1] + 1;
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 attributes.
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
- const start = Math.max(0, options.start ?? 0);
912
- const end = Math.max(0, options.end ?? 4);
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
- if (start + end >= chars.length) return chars.join("");
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"); // 3
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
- return x === y ? 0 : _distance(_units(x), _units(y));
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
- const score = longest === 0 ? 1 : 1 - _distance(needle, units) / longest;
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] Throw when the exit code isn't 0. Defaults to `true`.
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 shim = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(resolved);
115
- const line = [_escapeCommand(nodePath.normalize(resolved)), ...args.map((a) => _escapeArg(a, shim))].join(" ");
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 key = `${command}\0${searchPath}\0${process.cwd()}`;
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.join(dir.replace(/^"|"$/g, ""), command)).find(_runnable);
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
- * Opens a URL, file or folder with the default app.
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 "" ${_escapeArg(value, false)}`], { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true })
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
  /**