@ix-xs/node-comfort 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +32 -30
  3. package/index.js +3 -3
  4. package/package.json +8 -7
  5. package/src/Arr.js +222 -58
  6. package/src/Async.js +322 -90
  7. package/src/Cache.js +148 -44
  8. package/src/Checker.js +257 -26
  9. package/src/Cli.js +404 -145
  10. package/src/Color.js +224 -83
  11. package/src/Crypto.js +481 -106
  12. package/src/Emitter.js +104 -37
  13. package/src/Env.js +176 -42
  14. package/src/FS.js +203 -60
  15. package/src/Func.js +226 -76
  16. package/src/Http.js +311 -149
  17. package/src/Id.js +281 -96
  18. package/src/Logger.js +302 -78
  19. package/src/Num.js +182 -60
  20. package/src/Obj.js +340 -125
  21. package/src/SQLite.js +426 -105
  22. package/src/Schema.js +602 -87
  23. package/src/Str.js +316 -86
  24. package/src/Sys.js +197 -54
  25. package/src/Time.js +572 -317
  26. package/src/Utils.js +62 -41
  27. package/src/errors.js +54 -11
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +59 -17
  30. package/types/src/Arr.d.ts +78 -0
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +52 -1
  33. package/types/src/Async.d.ts.map +1 -1
  34. package/types/src/Cache.d.ts +33 -7
  35. package/types/src/Cache.d.ts.map +1 -1
  36. package/types/src/Checker.d.ts +155 -2
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +97 -15
  39. package/types/src/Cli.d.ts.map +1 -1
  40. package/types/src/Color.d.ts +7 -1
  41. package/types/src/Color.d.ts.map +1 -1
  42. package/types/src/Crypto.d.ts +99 -13
  43. package/types/src/Crypto.d.ts.map +1 -1
  44. package/types/src/Emitter.d.ts +36 -17
  45. package/types/src/Emitter.d.ts.map +1 -1
  46. package/types/src/Env.d.ts +31 -1
  47. package/types/src/Env.d.ts.map +1 -1
  48. package/types/src/FS.d.ts +36 -5
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +147 -19
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +113 -43
  53. package/types/src/Http.d.ts.map +1 -1
  54. package/types/src/Id.d.ts +60 -8
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +53 -2
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +48 -7
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +67 -4
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +123 -14
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +256 -7
  65. package/types/src/Schema.d.ts.map +1 -1
  66. package/types/src/Str.d.ts +49 -1
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +51 -6
  69. package/types/src/Sys.d.ts.map +1 -1
  70. package/types/src/Time.d.ts +130 -34
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +15 -11
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +27 -3
  75. package/types/src/errors.d.ts.map +1 -1
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
package/src/FS.js CHANGED
@@ -21,7 +21,9 @@
21
21
  const fs = require("node:fs");
22
22
  const os = require("node:os");
23
23
  const nodePath = require("node:path");
24
+ const util = require("node:util");
24
25
  const { fileURLToPath } = require("node:url");
26
+ const { sleepSync, toMs } = require("./internal/shared.js");
25
27
 
26
28
  /**
27
29
  * Options for `copyFoldersIn()` and `moveFoldersIn()`.
@@ -75,7 +77,8 @@ const { fileURLToPath } = require("node:url");
75
77
  * @typedef {object} WriteFileOptions
76
78
  * @property {boolean} [atomic] Write to a temporary file and rename it, so nobody reads a half-written file and a crash can't corrupt it. Defaults to `true`.
77
79
  * @property {BufferEncoding} [encoding] Defaults to `"utf8"`.
78
- * @property {number} [mode] Permissions, like `0o600` for secrets.
80
+ * @property {number} [mode] Permissions, like `0o600` for secrets. A file that already exists keeps the ones it has.
81
+ * @property {boolean} [fsync] Wait until the data is on the disk before returning. Slower, for files that must survive a power cut.
79
82
  * @property {number} [spaces] Indentation when writing an object as JSON. Defaults to `2`.
80
83
  */
81
84
 
@@ -85,6 +88,8 @@ const { fileURLToPath } = require("node:url");
85
88
  * @property {number} [spaces] Defaults to `2`.
86
89
  * @property {boolean} [atomic] Defaults to `true`.
87
90
  * @property {(key: string, value: unknown) => unknown} [replacer] Passed to `JSON.stringify`.
91
+ * @property {number} [mode] Permissions, like `0o600` for secrets. A file that already exists keeps the ones it has.
92
+ * @property {boolean} [fsync] Wait until the data is on the disk before returning.
88
93
  */
89
94
 
90
95
  /**
@@ -126,13 +131,13 @@ const { fileURLToPath } = require("node:url");
126
131
  * @property {string} [path] What to watch. Defaults to the working directory.
127
132
  * @property {boolean} [recursive] Watch nested folders too.
128
133
  * @property {(event: "rename" | "change", file: string) => boolean} [filter] Return `false` to ignore an event.
129
- * @property {number} [debounce] Merge events on the same file within this many ms. Editors often save in several steps.
134
+ * @property {number | string} [debounce] Merge events on the same file within this long, in ms or like `"100ms"`. Editors often save in several steps.
130
135
  */
131
136
 
132
137
  /**
133
138
  * Returned by `watch()`.
134
139
  * @typedef {object} Watcher
135
- * @property {<E extends "change" | "rename" | "all">(event: E, callback: E extends "all" ? (event: "change" | "rename", file: string) => void : (file: string) => void) => Watcher} on Listens for changes. `"all"` also gets the event name.
140
+ * @property {<E extends "change" | "rename" | "all" | "error">(event: E, callback: E extends "all" ? (event: "change" | "rename", file: string) => void : E extends "error" ? (error: Error) => void : (file: string) => void) => Watcher} on Listens for changes. `"all"` also gets the event name. `"error"` tells you the watch broke, for instance because the folder was deleted.
136
141
  * @property {() => void} stop Stops watching.
137
142
  * @property {() => Watcher} pause Ignores events until `resume()`.
138
143
  * @property {() => Watcher} resume
@@ -146,20 +151,8 @@ const INDEX_FILE = nodePath.join(__dirname, "..", "index.js");
146
151
  * @returns {string}
147
152
  */
148
153
  const _callerDir = () => {
149
- const originalPrepare = Error.prepareStackTrace;
150
- const originalLimit = Error.stackTraceLimit;
151
- /** @type {NodeJS.CallSite[] | undefined} */
152
- let stack;
153
- try {
154
- Error.stackTraceLimit = 50;
155
- Error.prepareStackTrace = (_, frames) => frames;
156
- stack = /** @type {any} */ (new Error()).stack;
157
- } finally {
158
- Error.prepareStackTrace = originalPrepare;
159
- Error.stackTraceLimit = originalLimit;
160
- }
161
- for (const frame of stack ?? []) {
162
- let file = frame.getFileName();
154
+ for (const frame of util.getCallSites(50)) {
155
+ let file = frame.scriptName;
163
156
  if (!file) continue;
164
157
  if (file.startsWith("file://")) file = fileURLToPath(file);
165
158
  if (file.startsWith("node:") || file.includes("node_modules")) continue;
@@ -209,25 +202,36 @@ const _existing = (input, type) => {
209
202
  const _walk = (root, type, options) => {
210
203
  /** @type {string[]} */
211
204
  const out = [];
205
+ _walkInto(root, type, options, new Set(options.ignore), out);
206
+ return out;
207
+ };
208
+
209
+ /**
210
+ * @param {string} dir
211
+ * @param {"file" | "folder"} type
212
+ * @param {Required<ListOptions>} options
213
+ * @param {Set<string>} ignore
214
+ * @param {string[]} out Filled in place: no array is built and thrown away at each level.
215
+ */
216
+ const _walkInto = (dir, type, options, ignore, out) => {
212
217
  /** @type {fs.Dirent[]} */
213
218
  let entries;
214
219
  try {
215
- entries = fs.readdirSync(root, { withFileTypes: true });
220
+ entries = fs.readdirSync(dir, { withFileTypes: true });
216
221
  } catch {
217
- return out;
222
+ return;
218
223
  }
219
224
  for (const entry of entries) {
220
225
  if (!options.hidden && entry.name.startsWith(".")) continue;
221
- const full = nodePath.join(root, entry.name);
226
+ const full = nodePath.join(dir, entry.name);
222
227
  if (entry.isDirectory()) {
223
- if (options.ignore.includes(entry.name)) continue;
228
+ if (ignore.has(entry.name)) continue;
224
229
  if (type === "folder") out.push(full);
225
- if (options.recursive) out.push(..._walk(full, type, options));
230
+ if (options.recursive) _walkInto(full, type, options, ignore, out);
226
231
  } else if (type === "file" && (entry.isFile() || entry.isSymbolicLink())) {
227
232
  out.push(full);
228
233
  }
229
234
  }
230
- return out;
231
235
  };
232
236
 
233
237
  /**
@@ -252,6 +256,26 @@ const _content = (data, spaces) => {
252
256
  return JSON.stringify(data, null, spaces);
253
257
  };
254
258
 
259
+ const _RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
260
+
261
+ /**
262
+ * Renames a file. On Windows, an antivirus or an indexer can hold the
263
+ * target for a few milliseconds: a refused rename is tried again.
264
+ * @param {string} from
265
+ * @param {string} to
266
+ */
267
+ const _rename = (from, to) => {
268
+ for (let attempt = 0; ; attempt++) {
269
+ try {
270
+ fs.renameSync(from, to);
271
+ return;
272
+ } catch (error) {
273
+ if (process.platform !== "win32" || attempt >= 8 || !_RETRY_CODES.has(/** @type {NodeJS.ErrnoException} */ (error).code ?? "")) throw error;
274
+ sleepSync(5 * (attempt + 1));
275
+ }
276
+ }
277
+ };
278
+
255
279
  /**
256
280
  * @param {string} target
257
281
  * @param {string | Buffer} content
@@ -259,17 +283,31 @@ const _content = (data, spaces) => {
259
283
  */
260
284
  const _write = (target, content, options) => {
261
285
  fs.mkdirSync(nodePath.dirname(target), { recursive: true });
262
- /** @type {fs.WriteFileOptions} */
263
- const writeOptions = { encoding: options.encoding ?? "utf8" };
264
- if (options.mode !== undefined) writeOptions.mode = options.mode;
286
+ const encoding = options.encoding ?? "utf8";
265
287
  if (options.atomic === false) {
266
- fs.writeFileSync(target, content, writeOptions);
288
+ /** @type {fs.WriteFileOptions} */
289
+ const direct = { encoding, flush: options.fsync ?? false };
290
+ if (options.mode !== undefined) direct.mode = options.mode;
291
+ fs.writeFileSync(target, content, direct);
267
292
  return;
268
293
  }
294
+ // The new file takes the place of the old one, so it must take its permissions too:
295
+ // a secret readable by its owner only stays that way.
296
+ let mode = options.mode;
297
+ if (mode === undefined) {
298
+ try {
299
+ mode = fs.statSync(target).mode & 0o777;
300
+ } catch {
301
+ mode = undefined;
302
+ }
303
+ }
269
304
  const temp = `${target}.${process.pid}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}.tmp`;
270
305
  try {
306
+ /** @type {fs.WriteFileOptions} */
307
+ const writeOptions = { encoding, flush: options.fsync ?? false };
308
+ if (mode !== undefined) writeOptions.mode = mode;
271
309
  fs.writeFileSync(temp, content, writeOptions);
272
- fs.renameSync(temp, target);
310
+ _rename(temp, target);
273
311
  } catch (error) {
274
312
  fs.rmSync(temp, { force: true });
275
313
  throw error;
@@ -315,7 +353,7 @@ const _stripJSONComments = (text) => {
315
353
  * @param {boolean} dot
316
354
  * @returns {RegExp}
317
355
  */
318
- const _globToRegExp = (pattern, dot) => {
356
+ const _compileGlob = (pattern, dot) => {
319
357
  const noDot = dot ? "" : "(?!\\.)";
320
358
  let re = "";
321
359
  let i = 0;
@@ -377,6 +415,26 @@ const _globToRegExp = (pattern, dot) => {
377
415
  return new RegExp(`^${re}$`);
378
416
  };
379
417
 
418
+ /** @type {Map<string, RegExp>} */
419
+ const _globs = new Map();
420
+
421
+ /**
422
+ * A glob pattern as a regular expression, compiled once.
423
+ * @param {string} pattern Forward slashes only.
424
+ * @param {boolean} dot Whether `*` matches names starting with a dot.
425
+ * @returns {RegExp}
426
+ */
427
+ const _globToRegExp = (pattern, dot) => {
428
+ const key = dot ? `.${pattern}` : `/${pattern}`;
429
+ let regex = _globs.get(key);
430
+ if (!regex) {
431
+ regex = _compileGlob(pattern, dot);
432
+ if (_globs.size >= 500) _globs.clear();
433
+ _globs.set(key, regex);
434
+ }
435
+ return regex;
436
+ };
437
+
380
438
  /**
381
439
  * @param {string} path
382
440
  * @returns {string} The real path, symlinks and short names resolved.
@@ -544,8 +602,11 @@ const _globIn = (cwd, patterns, options, absolute) => {
544
602
  const matchers = patterns.map((p) => _globToRegExp(p, options.dot ?? false));
545
603
  const ignores = (options.ignore ?? ["**/node_modules/**", "**/.git/**"]).map((p) => _globToRegExp(p.replace(/\\/g, "/"), true));
546
604
  const type = options.type ?? "files";
605
+ // Without "**", a pattern can't match deeper than it has parts: no need to read further down.
606
+ let maxDepth = 0;
547
607
  const bases = patterns.map((p) => {
548
608
  const segments = p.split("/");
609
+ maxDepth = p.includes("**") ? Infinity : Math.max(maxDepth, segments.length);
549
610
  const staticParts = [];
550
611
  for (const segment of segments.slice(0, -1)) {
551
612
  if (/[*?[{]/.test(segment)) break;
@@ -558,8 +619,11 @@ const _globIn = (cwd, patterns, options, absolute) => {
558
619
  /** @type {Set<string>} */
559
620
  const visited = new Set();
560
621
 
561
- /** @param {string} relDir */
562
- const visit = (relDir) => {
622
+ /**
623
+ * @param {string} relDir
624
+ * @param {number} depth How many parts `relDir` has.
625
+ */
626
+ const visit = (relDir, depth) => {
563
627
  if (visited.has(relDir)) return;
564
628
  visited.add(relDir);
565
629
  /** @type {fs.Dirent[]} */
@@ -575,10 +639,10 @@ const _globIn = (cwd, patterns, options, absolute) => {
575
639
  if (ignores.some((re) => re.test(rel) || (isDir && re.test(`${rel}/`)))) continue;
576
640
  const wanted = type === "all" || (type === "folders" ? isDir : !isDir);
577
641
  if (wanted && matchers.some((re) => re.test(rel))) results.add(rel);
578
- if (isDir) visit(rel);
642
+ if (isDir && depth + 1 < maxDepth) visit(rel, depth + 1);
579
643
  }
580
644
  };
581
- for (const base of new Set(bases)) visit(base);
645
+ for (const base of new Set(bases)) visit(base, base ? base.split("/").length : 0);
582
646
  return [...results].sort().map((rel) => (absolute ? nodePath.join(cwd, rel) : rel));
583
647
  };
584
648
 
@@ -596,10 +660,10 @@ const _globIn = (cwd, patterns, options, absolute) => {
596
660
  */
597
661
  function find(name, options = {}) {
598
662
  const type = options.type ?? "file";
599
- const ignore = options.ignore ?? ["node_modules", ".git"];
663
+ const ignore = new Set(options.ignore ?? ["node_modules", ".git"]);
600
664
  const queue = [_abs(options.cwd)];
601
- while (queue.length) {
602
- const dir = /** @type {string} */ (queue.shift());
665
+ for (let next = 0; next < queue.length; next++) {
666
+ const dir = queue[next];
603
667
  /** @type {fs.Dirent[]} */
604
668
  let entries;
605
669
  try {
@@ -611,11 +675,41 @@ function find(name, options = {}) {
611
675
  const isDir = entry.isDirectory();
612
676
  if (entry.name === name && (type === "any" || (type === "folder" ? isDir : !isDir))) return nodePath.join(dir, entry.name);
613
677
  }
614
- for (const entry of entries) if (entry.isDirectory() && !ignore.includes(entry.name)) queue.push(nodePath.join(dir, entry.name));
678
+ for (const entry of entries) if (entry.isDirectory() && !ignore.has(entry.name)) queue.push(nodePath.join(dir, entry.name));
615
679
  }
616
680
  return undefined;
617
681
  }
618
682
 
683
+ /**
684
+ * Looks for a file or folder in a folder, then in its parent, and so on up
685
+ * to the root. It's how you find the `package.json` or the `.git` a file
686
+ * belongs to.
687
+ *
688
+ * @example
689
+ * nc.findUp("package.json"); // the closest one above the working directory
690
+ * nc.findUp(".git", { type: "folder", cwd: __dirname });
691
+ * nc.findUp(["pnpm-lock.yaml", "yarn.lock", "package-lock.json"]);
692
+ *
693
+ * @param {string | string[]} name One name, or several to try in each folder, in this order.
694
+ * @param {{ cwd?: string, type?: "file" | "folder" | "any", stopAt?: string }} [options] Where to start (the working directory by default), what to look for (`"any"` by default), and a folder not to go above.
695
+ * @returns {string | undefined} The absolute path.
696
+ */
697
+ function findUp(name, options = {}) {
698
+ const names = Array.isArray(name) ? name : [name];
699
+ const type = options.type ?? "any";
700
+ const stopAt = options.stopAt === undefined ? undefined : _abs(options.stopAt);
701
+ let dir = _abs(options.cwd);
702
+ for (;;) {
703
+ for (const candidate of names) {
704
+ const found = _existing(nodePath.join(dir, candidate), type);
705
+ if (found) return found;
706
+ }
707
+ const parent = nodePath.dirname(dir);
708
+ if (parent === dir || dir === stopAt) return undefined;
709
+ dir = parent;
710
+ }
711
+ }
712
+
619
713
  /**
620
714
  * Reads a file as text, or as a Buffer with `"buffer"`.
621
715
  *
@@ -642,11 +736,11 @@ function find(name, options = {}) {
642
736
  * @returns {string | Buffer | undefined}
643
737
  */
644
738
  function readFile(path, encoding = "utf8") {
645
- const target = _existing(path, "file");
646
- if (!target) return undefined;
739
+ if (!path) return undefined;
647
740
  try {
648
- return encoding === "buffer" ? fs.readFileSync(target) : fs.readFileSync(target, encoding);
741
+ return encoding === "buffer" ? fs.readFileSync(_abs(path)) : fs.readFileSync(_abs(path), encoding);
649
742
  } catch {
743
+ // missing, a folder, or unreadable
650
744
  return undefined;
651
745
  }
652
746
  }
@@ -729,7 +823,7 @@ function writeJSON(path, data, options = {}) {
729
823
  try {
730
824
  const text = JSON.stringify(data, /** @type {any} */ (options.replacer), options.spaces ?? 2);
731
825
  if (text === undefined) return false;
732
- _write(_abs(path), `${text}\n`, { atomic: options.atomic });
826
+ _write(_abs(path), `${text}\n`, { atomic: options.atomic, mode: options.mode, fsync: options.fsync });
733
827
  return true;
734
828
  } catch {
735
829
  return false;
@@ -749,8 +843,14 @@ function writeJSON(path, data, options = {}) {
749
843
  function appendFile(path, data) {
750
844
  try {
751
845
  const target = _abs(path);
752
- fs.mkdirSync(nodePath.dirname(target), { recursive: true });
753
- fs.appendFileSync(target, Buffer.isBuffer(data) ? data : String(data));
846
+ const content = Buffer.isBuffer(data) ? data : String(data);
847
+ try {
848
+ fs.appendFileSync(target, content);
849
+ } catch (error) {
850
+ if (/** @type {NodeJS.ErrnoException} */ (error).code !== "ENOENT") throw error;
851
+ fs.mkdirSync(nodePath.dirname(target), { recursive: true });
852
+ fs.appendFileSync(target, content);
853
+ }
754
854
  return true;
755
855
  } catch {
756
856
  return false;
@@ -1026,17 +1126,23 @@ function move(source, destination, options = {}) {
1026
1126
  if (!src) return false;
1027
1127
  const dest = _abs(destination);
1028
1128
  try {
1029
- if (fs.existsSync(dest)) {
1030
- if (options.overwrite === false) return false;
1031
- fs.rmSync(dest, { recursive: true, force: true });
1032
- }
1129
+ if (options.overwrite === false && fs.existsSync(dest)) return false;
1033
1130
  fs.mkdirSync(nodePath.dirname(dest), { recursive: true });
1034
1131
  try {
1132
+ // A rename replaces a file in one step: the destination is never missing, not even for an instant.
1035
1133
  fs.renameSync(src, dest);
1036
1134
  } catch (error) {
1037
- if (/** @type {NodeJS.ErrnoException} */ (error).code !== "EXDEV") throw error;
1038
- fs.cpSync(src, dest, { recursive: true });
1039
- fs.rmSync(src, { recursive: true, force: true });
1135
+ const code = /** @type {NodeJS.ErrnoException} */ (error).code;
1136
+ if (code === "EXDEV") {
1137
+ // another drive: copy, then delete
1138
+ fs.rmSync(dest, { recursive: true, force: true });
1139
+ fs.cpSync(src, dest, { recursive: true });
1140
+ fs.rmSync(src, { recursive: true, force: true });
1141
+ } else {
1142
+ // a folder in the way, or a file where a folder goes
1143
+ fs.rmSync(dest, { recursive: true, force: true });
1144
+ _rename(src, dest);
1145
+ }
1040
1146
  }
1041
1147
  return true;
1042
1148
  } catch {
@@ -1191,8 +1297,8 @@ function moveFilesIn(options) {
1191
1297
  const root = _existing(options.path, "folder");
1192
1298
  if (!root || !options.dest) return undefined;
1193
1299
  const filter = typeof options.filter === "function" ? options.filter : () => true;
1194
- const files = (getFilesIn(root, options.recursive ?? true) ?? []).filter(filter);
1195
- const copied = copyFilesIn({ ...options, path: root, filter: (f) => files.includes(f) });
1300
+ const files = new Set((getFilesIn(root, options.recursive ?? true) ?? []).filter(filter));
1301
+ const copied = copyFilesIn({ ...options, path: root, filter: (file) => files.has(file) });
1196
1302
  for (const file of files) fs.rmSync(file, { force: true });
1197
1303
  return copied;
1198
1304
  }
@@ -1270,8 +1376,13 @@ function stat(path) {
1270
1376
  * @returns {number | undefined} `undefined` if it doesn't exist.
1271
1377
  */
1272
1378
  function fileSize(path) {
1273
- const target = _existing(path, "file");
1274
- return target ? fs.statSync(target).size : undefined;
1379
+ if (!path) return undefined;
1380
+ try {
1381
+ const stats = fs.statSync(_abs(path));
1382
+ return stats.isFile() ? stats.size : undefined;
1383
+ } catch {
1384
+ return undefined;
1385
+ }
1275
1386
  }
1276
1387
 
1277
1388
  /**
@@ -1355,7 +1466,17 @@ function sanitizeFilename(name, options = {}) {
1355
1466
  let clean = String(name).replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/g, replacement).replace(/[. ]+$/, "");
1356
1467
  if (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i.test(clean)) clean = `_${clean}`;
1357
1468
  if (clean === "." || clean === "..") clean = replacement;
1358
- while (Buffer.byteLength(clean) > 255) clean = [...clean].slice(0, -1).join("");
1469
+ if (Buffer.byteLength(clean) > 255) {
1470
+ // File systems count bytes, and a character cut in two would be broken.
1471
+ let bytes = 0;
1472
+ let kept = "";
1473
+ for (const char of clean) {
1474
+ bytes += Buffer.byteLength(char);
1475
+ if (bytes > 255) break;
1476
+ kept += char;
1477
+ }
1478
+ clean = kept.replace(/[. ]+$/, "");
1479
+ }
1359
1480
  return clean || "_";
1360
1481
  }
1361
1482
 
@@ -1375,8 +1496,9 @@ function watch(options = {}) {
1375
1496
  const target = _existing(options.path, "any");
1376
1497
  if (!target) return undefined;
1377
1498
  let paused = false;
1499
+ const debounce = options.debounce === undefined ? 0 : toMs(options.debounce, "debounce");
1378
1500
  /** @type {Record<string, Function[]>} */
1379
- const listeners = { change: [], rename: [], all: [] };
1501
+ const listeners = { change: [], rename: [], all: [], error: [] };
1380
1502
  /** @type {Map<string, NodeJS.Timeout>} */
1381
1503
  const pending = new Map();
1382
1504
 
@@ -1394,18 +1516,38 @@ function watch(options = {}) {
1394
1516
  const isDir = fs.statSync(target).isDirectory();
1395
1517
  // Watching a short (8.3) or symlinked path crashes libuv on Windows, so
1396
1518
  // watch the real one and keep reporting paths under the one you passed.
1397
- const watcher = fs.watch(_realPath(target), { recursive: options.recursive ?? false }, (event, fileName) => {
1519
+ const real = _realPath(target);
1520
+ let gone = false;
1521
+ const watcher = fs.watch(real, { recursive: options.recursive ?? false }, (event, fileName) => {
1522
+ if (gone) return;
1398
1523
  const file = fileName && isDir ? nodePath.join(target, String(fileName)) : target;
1399
1524
  const kind = event === "rename" ? "rename" : "change";
1400
- if (!options.debounce) return dispatch(kind, file);
1525
+ // A folder that was deleted keeps sending events on some systems, without
1526
+ // end and without an error: say it once, then stop listening.
1527
+ if (kind === "rename" && isDir && !fs.existsSync(real)) {
1528
+ gone = true;
1529
+ controller.stop();
1530
+ const error = Object.assign(new Error(`The watched folder no longer exists: ${target}`), { code: "ENOENT", path: target });
1531
+ for (const cb of listeners.error) cb(error);
1532
+ return;
1533
+ }
1534
+ if (!(debounce > 0)) return dispatch(kind, file);
1401
1535
  const key = `${kind}:${file}`;
1402
1536
  clearTimeout(pending.get(key));
1403
1537
  pending.set(key, setTimeout(() => {
1404
1538
  pending.delete(key);
1405
1539
  dispatch(kind, file);
1406
- }, options.debounce));
1540
+ }, debounce));
1541
+ });
1542
+ watcher.on("error", (error) => {
1543
+ if (gone) return;
1544
+ // Some systems report the deleted folder here: it is the same news, told once.
1545
+ if (isDir && !fs.existsSync(real)) {
1546
+ gone = true;
1547
+ controller.stop();
1548
+ }
1549
+ for (const cb of listeners.error) cb(error);
1407
1550
  });
1408
- watcher.on("error", () => {});
1409
1551
 
1410
1552
  /** @type {Watcher} */
1411
1553
  const controller = {
@@ -1443,6 +1585,7 @@ module.exports = {
1443
1585
  getFilesIn,
1444
1586
  glob,
1445
1587
  find,
1588
+ findUp,
1446
1589
  readFile,
1447
1590
  readLines,
1448
1591
  readJSON,