@ix-xs/node-comfort 2.1.1 → 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 (68) hide show
  1. package/CHANGELOG.md +135 -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 +366 -56
  12. package/src/Http.js +263 -72
  13. package/src/Id.js +53 -15
  14. package/src/Logger.js +100 -22
  15. package/src/Num.js +2 -1
  16. package/src/Obj.js +225 -36
  17. package/src/SQLite.js +154 -97
  18. package/src/Schema.js +281 -40
  19. package/src/Str.js +77 -29
  20. package/src/Sys.js +123 -22
  21. package/src/Time.js +96 -22
  22. package/src/Utils.js +18 -3
  23. package/src/errors.js +75 -4
  24. package/src/internal/shared.js +68 -14
  25. package/src/internal/terminal.js +49 -0
  26. package/types/index.d.ts +28 -5
  27. package/types/src/Arr.d.ts +4 -2
  28. package/types/src/Arr.d.ts.map +1 -1
  29. package/types/src/Checker.d.ts +29 -3
  30. package/types/src/Checker.d.ts.map +1 -1
  31. package/types/src/Cli.d.ts +6 -4
  32. package/types/src/Cli.d.ts.map +1 -1
  33. package/types/src/Color.d.ts +2 -2
  34. package/types/src/Color.d.ts.map +1 -1
  35. package/types/src/Crypto.d.ts +126 -28
  36. package/types/src/Crypto.d.ts.map +1 -1
  37. package/types/src/Emitter.d.ts +6 -6
  38. package/types/src/Emitter.d.ts.map +1 -1
  39. package/types/src/Env.d.ts +19 -1
  40. package/types/src/Env.d.ts.map +1 -1
  41. package/types/src/FS.d.ts +27 -20
  42. package/types/src/FS.d.ts.map +1 -1
  43. package/types/src/Http.d.ts +42 -5
  44. package/types/src/Http.d.ts.map +1 -1
  45. package/types/src/Id.d.ts.map +1 -1
  46. package/types/src/Logger.d.ts +11 -3
  47. package/types/src/Logger.d.ts.map +1 -1
  48. package/types/src/Num.d.ts.map +1 -1
  49. package/types/src/Obj.d.ts +15 -2
  50. package/types/src/Obj.d.ts.map +1 -1
  51. package/types/src/SQLite.d.ts +104 -12
  52. package/types/src/SQLite.d.ts.map +1 -1
  53. package/types/src/Schema.d.ts +61 -5
  54. package/types/src/Schema.d.ts.map +1 -1
  55. package/types/src/Str.d.ts +32 -7
  56. package/types/src/Str.d.ts.map +1 -1
  57. package/types/src/Sys.d.ts +42 -9
  58. package/types/src/Sys.d.ts.map +1 -1
  59. package/types/src/Time.d.ts +10 -5
  60. package/types/src/Time.d.ts.map +1 -1
  61. package/types/src/Utils.d.ts +16 -6
  62. package/types/src/Utils.d.ts.map +1 -1
  63. package/types/src/errors.d.ts +6 -2
  64. package/types/src/errors.d.ts.map +1 -1
  65. package/types/src/internal/shared.d.ts +7 -0
  66. package/types/src/internal/shared.d.ts.map +1 -1
  67. package/types/src/internal/terminal.d.ts +13 -0
  68. package/types/src/internal/terminal.d.ts.map +1 -0
package/src/FS.js CHANGED
@@ -135,8 +135,12 @@ const { sleepSync, toMs } = require("./internal/shared.js");
135
135
  */
136
136
 
137
137
  /**
138
- * Returned by `watch()`.
139
- * @typedef {object} Watcher
138
+ * Returned by `watch()`. With `using`, the watch stops at the end of the block.
139
+ * @typedef {_WatcherMethods & Disposable} Watcher
140
+ */
141
+
142
+ /**
143
+ * @typedef {object} _WatcherMethods
140
144
  * @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.
141
145
  * @property {() => void} stop Stops watching.
142
146
  * @property {() => Watcher} pause Ignores events until `resume()`.
@@ -301,19 +305,195 @@ const _write = (target, content, options) => {
301
305
  mode = undefined;
302
306
  }
303
307
  }
304
- const temp = `${target}.${process.pid}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}.tmp`;
308
+ // A name nobody can guess, created only if nothing is there ("wx"): a link planted in advance can't redirect the write.
309
+ const temp = `${target}.${require("node:crypto").randomBytes(8).toString("hex")}.tmp`;
310
+ /** @type {number | undefined} */
311
+ let fd;
312
+ let created = false;
305
313
  try {
306
- /** @type {fs.WriteFileOptions} */
307
- const writeOptions = { encoding, flush: options.fsync ?? false };
308
- if (mode !== undefined) writeOptions.mode = mode;
309
- fs.writeFileSync(temp, content, writeOptions);
314
+ // Created with its final permissions (the umask applies, as for any new file), never wider.
315
+ fd = fs.openSync(temp, "wx", mode ?? 0o666);
316
+ created = true;
317
+ fs.writeFileSync(fd, content, { encoding });
318
+ if (options.fsync) fs.fsyncSync(fd);
319
+ fs.closeSync(fd);
320
+ fd = undefined;
310
321
  _rename(temp, target);
311
322
  } catch (error) {
312
- fs.rmSync(temp, { force: true });
323
+ if (fd !== undefined) fs.closeSync(fd);
324
+ if (created) fs.rmSync(temp, { force: true });
313
325
  throw error;
314
326
  }
315
327
  };
316
328
 
329
+ /**
330
+ * Does something exist at this path, a broken link included?
331
+ * @param {string} path
332
+ * @returns {boolean}
333
+ */
334
+ const _lexists = (path) => {
335
+ try {
336
+ fs.lstatSync(path);
337
+ return true;
338
+ } catch {
339
+ return false;
340
+ }
341
+ };
342
+
343
+ /**
344
+ * The real path of a path that may not exist yet: the part that exists is
345
+ * resolved, the rest is added as it is.
346
+ * @param {string} path Absolute.
347
+ * @returns {string}
348
+ */
349
+ const _realTarget = (path) => {
350
+ /** @type {string[]} */
351
+ const rest = [];
352
+ let existing = path;
353
+ while (!fs.existsSync(existing)) {
354
+ const parent = nodePath.dirname(existing);
355
+ if (parent === existing) return path;
356
+ rest.unshift(nodePath.basename(existing));
357
+ existing = parent;
358
+ }
359
+ return nodePath.join(_realPath(existing), ...rest);
360
+ };
361
+
362
+ /**
363
+ * Do both paths lead to the same file or folder? Links, short names and,
364
+ * on Windows, letter case don't fool it.
365
+ * @param {string} a
366
+ * @param {string} b
367
+ * @returns {boolean}
368
+ */
369
+ const _sameEntry = (a, b) => {
370
+ let x;
371
+ let y;
372
+ try {
373
+ x = fs.statSync(a, { bigint: true });
374
+ y = fs.statSync(b, { bigint: true });
375
+ } catch {
376
+ // one of them doesn't exist
377
+ return false;
378
+ }
379
+ // some file systems have no file ids: compare the real paths then
380
+ if (x.ino !== 0n || y.ino !== 0n) return x.dev === y.dev && x.ino === y.ino;
381
+ const [ra, rb] = [_realPath(a), _realPath(b)];
382
+ return process.platform === "win32" ? ra.toLowerCase() === rb.toLowerCase() : ra === rb;
383
+ };
384
+
385
+ /**
386
+ * Is `src` a link to `dest`? Renaming it there would put the link in place
387
+ * of the real file. The link itself under another letter case is not.
388
+ * @param {string} src
389
+ * @param {string} dest
390
+ * @returns {boolean}
391
+ */
392
+ const _linkOnto = (src, dest) => {
393
+ let link;
394
+ let other;
395
+ try {
396
+ link = fs.lstatSync(src, { bigint: true });
397
+ if (!link.isSymbolicLink()) return false;
398
+ other = fs.lstatSync(dest, { bigint: true });
399
+ } catch {
400
+ // no link, or nothing at dest to lose
401
+ return false;
402
+ }
403
+ if (link.ino !== 0n && other.dev === link.dev && other.ino === link.ino) return false;
404
+ const [ra, rb] = [_realPath(src), _realPath(dest)];
405
+ return process.platform === "win32" ? ra.toLowerCase() === rb.toLowerCase() : ra === rb;
406
+ };
407
+
408
+ /**
409
+ * Is `child` the folder `parent` itself, or somewhere inside it, once links
410
+ * are resolved? Copying or moving a folder there would destroy it.
411
+ * @param {string} child Absolute, may not exist yet.
412
+ * @param {string} parent Absolute, exists.
413
+ * @returns {boolean}
414
+ */
415
+ const _within = (child, parent) => {
416
+ if (_sameEntry(child, parent)) return true;
417
+ // path.relative ignores letter case on Windows
418
+ const rel = nodePath.relative(_realPath(parent), _realTarget(child));
419
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(rel));
420
+ };
421
+
422
+ /**
423
+ * Would copying the files of `root` into `dest` copy them onto themselves,
424
+ * or into the tree being read?
425
+ * @param {string} root
426
+ * @param {string} dest
427
+ * @param {boolean} recursive
428
+ * @returns {boolean}
429
+ */
430
+ const _filesInItself = (root, dest, recursive) => (recursive ? _within(dest, root) : _sameEntry(dest, root));
431
+
432
+ /**
433
+ * Copies a link as a link, never what it points to. On Windows a link to a
434
+ * folder becomes a junction, which needs no special rights. A link that
435
+ * can't be made is left out.
436
+ * @param {string} from
437
+ * @param {string} to
438
+ * @param {boolean} overwrite
439
+ */
440
+ const _copyLink = (from, to, overwrite) => {
441
+ try {
442
+ if (_lexists(to)) {
443
+ // a real file or folder in the way is kept: only a link is replaced by a link
444
+ if (!overwrite || !fs.lstatSync(to).isSymbolicLink()) return;
445
+ fs.unlinkSync(to);
446
+ }
447
+ const target = fs.readlinkSync(from);
448
+ /** @type {"junction" | "file" | undefined} */
449
+ let type;
450
+ if (process.platform === "win32") {
451
+ try {
452
+ type = fs.statSync(from).isDirectory() ? "junction" : "file";
453
+ } catch {
454
+ type = "file";
455
+ }
456
+ }
457
+ fs.symlinkSync(target, to, type);
458
+ } catch {
459
+ // left out, never followed
460
+ }
461
+ };
462
+
463
+ /**
464
+ * `fs.cpSync()` that copies the links inside the tree as links: a junction
465
+ * or a link to a folder outside never brings that folder's files along.
466
+ * Elsewhere than on Windows, `fs.cpSync()` already copies links as links.
467
+ * @param {string} src Absolute.
468
+ * @param {string} dest Absolute.
469
+ * @param {{ force?: boolean, errorOnExist?: boolean, preserveTimestamps?: boolean, filter?: (source: string, destination: string) => boolean }} [options]
470
+ */
471
+ const _copyTree = (src, dest, options = {}) => {
472
+ const overwrite = options.force ?? true;
473
+ fs.cpSync(src, dest, {
474
+ recursive: true,
475
+ dereference: false,
476
+ force: overwrite,
477
+ errorOnExist: options.errorOnExist ?? false,
478
+ preserveTimestamps: options.preserveTimestamps ?? false,
479
+ // Every platform: cpSync follows junctions on Windows, and elsewhere refuses to copy a link over the same link.
480
+ filter: (s, d) => {
481
+ if (options.filter && !options.filter(s, d)) return false;
482
+ // the source itself was named by the caller: only links inside it are kept as links
483
+ if (nodePath.resolve(s) === src) return true;
484
+ let link;
485
+ try {
486
+ link = fs.lstatSync(s).isSymbolicLink();
487
+ } catch {
488
+ return false;
489
+ }
490
+ if (!link) return true;
491
+ _copyLink(s, d, overwrite);
492
+ return false;
493
+ },
494
+ });
495
+ };
496
+
317
497
  /**
318
498
  * Removes comments and trailing commas from JSONC.
319
499
  * @param {string} text
@@ -680,6 +860,13 @@ function find(name, options = {}) {
680
860
  return undefined;
681
861
  }
682
862
 
863
+ /**
864
+ * Does this relative path leave its base folder?
865
+ * @param {string} rel From `path.relative()`, which ignores letter case on Windows.
866
+ * @returns {boolean}
867
+ */
868
+ const _outside = (rel) => rel === ".." || rel.startsWith(`..${nodePath.sep}`) || nodePath.isAbsolute(rel);
869
+
683
870
  /**
684
871
  * Looks for a file or folder in a folder, then in its parent, and so on up
685
872
  * to the root. It's how you find the `package.json` or the `.git` a file
@@ -691,13 +878,16 @@ function find(name, options = {}) {
691
878
  * nc.findUp(["pnpm-lock.yaml", "yarn.lock", "package-lock.json"]);
692
879
  *
693
880
  * @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.
881
+ * @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: the search ends there, or as soon as it's outside of it.
695
882
  * @returns {string | undefined} The absolute path.
696
883
  */
697
884
  function findUp(name, options = {}) {
698
885
  const names = Array.isArray(name) ? name : [name];
699
886
  const type = options.type ?? "any";
700
- const stopAt = options.stopAt === undefined ? undefined : _abs(options.stopAt);
887
+ // resolve() drops a trailing separator; relative() ignores letter case on Windows
888
+ const stopAt = options.stopAt === undefined ? undefined : nodePath.resolve(_abs(options.stopAt));
889
+ // a link, a junction or /var vs /private/var can lead to stopAt another way
890
+ const realStop = stopAt === undefined ? undefined : _realPath(stopAt);
701
891
  let dir = _abs(options.cwd);
702
892
  for (;;) {
703
893
  for (const candidate of names) {
@@ -705,7 +895,14 @@ function findUp(name, options = {}) {
705
895
  if (found) return found;
706
896
  }
707
897
  const parent = nodePath.dirname(dir);
708
- if (parent === dir || dir === stopAt) return undefined;
898
+ if (parent === dir) return undefined;
899
+ if (stopAt !== undefined && realStop !== undefined) {
900
+ const rel = nodePath.relative(stopAt, dir);
901
+ const realRel = nodePath.relative(realStop, _realPath(dir));
902
+ // at stopAt by either path, or outside of it by both
903
+ if (rel === "" || realRel === "") return undefined;
904
+ if (_outside(rel) && _outside(realRel)) return undefined;
905
+ }
709
906
  dir = parent;
710
907
  }
711
908
  }
@@ -1063,15 +1260,17 @@ function deleteFilesIn(path, recursive = false, filter = () => true) {
1063
1260
  }
1064
1261
 
1065
1262
  /**
1066
- * Deletes everything inside a folder, keeping the folder.
1263
+ * Deletes everything inside a folder, keeping the folder. A link to a folder
1264
+ * (a junction on Windows) is refused: what it points to is left alone.
1067
1265
  *
1068
1266
  * @param {string} path
1069
- * @returns {boolean | undefined} `undefined` if it doesn't exist.
1267
+ * @returns {boolean | undefined} `undefined` if it doesn't exist, `false` if it's a link or something couldn't be deleted.
1070
1268
  */
1071
1269
  function emptyFolder(path) {
1072
1270
  const target = _existing(path, "folder");
1073
1271
  if (!target) return undefined;
1074
1272
  try {
1273
+ if (fs.lstatSync(target).isSymbolicLink()) return false;
1075
1274
  for (const entry of fs.readdirSync(target)) fs.rmSync(nodePath.join(target, entry), { recursive: true, force: true });
1076
1275
  return true;
1077
1276
  } catch {
@@ -1080,7 +1279,9 @@ function emptyFolder(path) {
1080
1279
  }
1081
1280
 
1082
1281
  /**
1083
- * Copies a file or a whole folder, like `cp -r`.
1282
+ * Copies a file or a whole folder, like `cp -r`. Links inside the folder
1283
+ * are copied as links (junctions on Windows), never followed: files outside
1284
+ * the folder are never brought along.
1084
1285
  *
1085
1286
  * @example
1086
1287
  * nc.copy("./templates", "./dist/templates");
@@ -1089,29 +1290,28 @@ function emptyFolder(path) {
1089
1290
  * @param {string} source
1090
1291
  * @param {string} destination
1091
1292
  * @param {CopyOptions} [options]
1092
- * @returns {boolean} `false` if the source is missing or the copy failed.
1293
+ * @returns {boolean} `false` if the source is missing, if the destination is the source or inside it, or if the copy failed.
1093
1294
  */
1094
1295
  function copy(source, destination, options = {}) {
1095
1296
  const src = _existing(source, "any");
1096
1297
  if (!src) return false;
1097
1298
  try {
1098
1299
  const dest = _abs(destination);
1300
+ if (_within(dest, src)) return false;
1099
1301
  fs.mkdirSync(nodePath.dirname(dest), { recursive: true });
1100
- fs.cpSync(src, dest, {
1101
- recursive: true,
1102
- force: options.overwrite ?? true,
1103
- errorOnExist: false,
1104
- preserveTimestamps: options.preserveTimestamps ?? false,
1105
- filter: options.filter ? (s, d) => /** @type {Function} */ (options.filter)(s, d) : undefined,
1106
- });
1302
+ _copyTree(src, dest, { force: options.overwrite ?? true, preserveTimestamps: options.preserveTimestamps, filter: options.filter });
1107
1303
  return true;
1108
1304
  } catch {
1109
1305
  return false;
1110
1306
  }
1111
1307
  }
1112
1308
 
1309
+ // what a rename says when something is in the way of the destination
1310
+ const _IN_THE_WAY = new Set(["EEXIST", "ENOTEMPTY", "EISDIR", "ENOTDIR"]);
1311
+
1113
1312
  /**
1114
- * Moves or renames a file or folder, across drives too.
1313
+ * Moves or renames a file or folder, across drives too. A destination in the
1314
+ * way is put aside first, and put back if the move fails.
1115
1315
  *
1116
1316
  * @example
1117
1317
  * nc.move("./report.pdf", "./archive/2024/report.pdf");
@@ -1119,31 +1319,56 @@ function copy(source, destination, options = {}) {
1119
1319
  * @param {string} source
1120
1320
  * @param {string} destination
1121
1321
  * @param {{ overwrite?: boolean }} [options] `overwrite` replaces an existing destination. Defaults to `true`.
1122
- * @returns {boolean}
1322
+ * @returns {boolean} `false` if the source is missing, if the destination is inside it, if the source is a link to the destination, or if the move failed.
1123
1323
  */
1124
1324
  function move(source, destination, options = {}) {
1125
1325
  const src = _existing(source, "any");
1126
1326
  if (!src) return false;
1127
1327
  const dest = _abs(destination);
1128
1328
  try {
1129
- if (options.overwrite === false && fs.existsSync(dest)) return false;
1329
+ // the link would take the place of the file it leads to
1330
+ if (_linkOnto(src, dest)) return false;
1331
+ if (_sameEntry(src, dest)) {
1332
+ // the same file: only its name can change, like its letter case on Windows
1333
+ fs.renameSync(src, dest);
1334
+ return true;
1335
+ }
1336
+ if (_within(dest, src)) return false;
1337
+ if (options.overwrite === false && _lexists(dest)) return false;
1130
1338
  fs.mkdirSync(nodePath.dirname(dest), { recursive: true });
1339
+ let crossDevice = false;
1131
1340
  try {
1132
1341
  // A rename replaces a file in one step: the destination is never missing, not even for an instant.
1133
1342
  fs.renameSync(src, dest);
1343
+ return true;
1344
+ } catch (error) {
1345
+ const code = /** @type {NodeJS.ErrnoException} */ (error).code ?? "";
1346
+ crossDevice = code === "EXDEV";
1347
+ // Windows says EPERM or EACCES for a folder in the way
1348
+ const inTheWay = _IN_THE_WAY.has(code) || (process.platform === "win32" && (code === "EPERM" || code === "EACCES"));
1349
+ if (!crossDevice && !(inTheWay && _lexists(dest))) throw error;
1350
+ }
1351
+ // Put what's in the way aside, and back if the move fails.
1352
+ const backup = _lexists(dest) ? `${dest}.${require("node:crypto").randomBytes(6).toString("hex")}.bak` : undefined;
1353
+ if (backup) _rename(dest, backup);
1354
+ try {
1355
+ // another drive: copy, and delete the source once the copy is complete
1356
+ if (crossDevice) _copyTree(src, dest);
1357
+ else _rename(src, dest);
1134
1358
  } catch (error) {
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);
1359
+ // a half-made copy is ours to remove, nothing else is
1360
+ if (crossDevice) fs.rmSync(dest, { recursive: true, force: true });
1361
+ if (backup) _rename(backup, dest);
1362
+ throw error;
1363
+ }
1364
+ if (backup) {
1365
+ try {
1366
+ fs.rmSync(backup, { recursive: true, force: true });
1367
+ } catch {
1368
+ // the move is done: a leftover of the old destination is better than an error
1145
1369
  }
1146
1370
  }
1371
+ if (crossDevice) fs.rmSync(src, { recursive: true, force: true });
1147
1372
  return true;
1148
1373
  } catch {
1149
1374
  return false;
@@ -1155,12 +1380,13 @@ function move(source, destination, options = {}) {
1155
1380
  * for most cases.
1156
1381
  *
1157
1382
  * @param {CopyFoldersOptions} options
1158
- * @returns {number | undefined} How many were copied.
1383
+ * @returns {number | undefined} How many were copied: `0` when `dest` is the source folder or inside it.
1159
1384
  */
1160
1385
  function copyFoldersIn(options) {
1161
1386
  const root = _existing(options.path, "folder");
1162
1387
  if (!root || !options.dest) return undefined;
1163
1388
  const dest = _abs(options.dest);
1389
+ if (_within(dest, root)) return 0;
1164
1390
  const recursive = options.recursive ?? true;
1165
1391
  const filter = typeof options.filter === "function" ? options.filter : () => true;
1166
1392
  let copied = 0;
@@ -1182,12 +1408,13 @@ function copyFoldersIn(options) {
1182
1408
  * folder is created; `copy()` copies everything in one call.
1183
1409
  *
1184
1410
  * @param {CopyFolderOptions} options
1185
- * @returns {boolean | undefined}
1411
+ * @returns {boolean | undefined} `false` when `dest` is the source folder or inside it.
1186
1412
  */
1187
1413
  function copyFolder(options) {
1188
1414
  const root = _existing(options.path, "folder");
1189
1415
  if (!root || !options.dest) return undefined;
1190
1416
  const dest = _abs(options.dest);
1417
+ if (_within(dest, root)) return false;
1191
1418
  try {
1192
1419
  if (options.force && fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
1193
1420
  fs.mkdirSync(dest, { recursive: true });
@@ -1212,13 +1439,14 @@ function copyFolder(options) {
1212
1439
  * nc.copyFilesIn({ path: "./src", dest: "./dist", filter: (file) => file.endsWith(".css") });
1213
1440
  *
1214
1441
  * @param {CopyFilesOptions} options
1215
- * @returns {number | undefined} How many were copied.
1442
+ * @returns {number | undefined} How many were copied: `0` when `dest` is the source folder, or inside it with `recursive`.
1216
1443
  */
1217
1444
  function copyFilesIn(options) {
1218
1445
  const root = _existing(options.path, "folder");
1219
1446
  if (!root || !options.dest) return undefined;
1220
1447
  const dest = _abs(options.dest);
1221
1448
  const recursive = options.recursive ?? true;
1449
+ if (_filesInItself(root, dest, recursive)) return 0;
1222
1450
  const filter = typeof options.filter === "function" ? options.filter : () => true;
1223
1451
  let copied = 0;
1224
1452
  for (const file of getFilesIn(root, recursive) ?? []) {
@@ -1232,6 +1460,26 @@ function copyFilesIn(options) {
1232
1460
  return copied;
1233
1461
  }
1234
1462
 
1463
+ /**
1464
+ * Where a file goes: into `dest` under its own name if `dest` ends with `/`
1465
+ * or is a folder, else `dest` itself.
1466
+ * @param {string} src
1467
+ * @param {string} dest
1468
+ * @returns {string}
1469
+ */
1470
+ const _fileDest = (src, dest) => {
1471
+ const abs = _abs(dest);
1472
+ let intoFolder = /[\\/]$/.test(dest);
1473
+ if (!intoFolder) {
1474
+ try {
1475
+ intoFolder = fs.statSync(abs).isDirectory();
1476
+ } catch {
1477
+ // nothing there yet
1478
+ }
1479
+ }
1480
+ return intoFolder ? nodePath.join(abs, nodePath.basename(src)) : abs;
1481
+ };
1482
+
1235
1483
  /**
1236
1484
  * Copies one file. If `dest` ends with `/`, the file keeps its name inside
1237
1485
  * that folder.
@@ -1240,13 +1488,14 @@ function copyFilesIn(options) {
1240
1488
  * nc.copyFile({ path: "./src/index.js", dest: "./dist/" });
1241
1489
  *
1242
1490
  * @param {CopyFileOptions} options
1243
- * @returns {boolean | undefined}
1491
+ * @returns {boolean | undefined} `false` when `dest` is the file itself.
1244
1492
  */
1245
1493
  function copyFile(options) {
1246
1494
  const src = _existing(options.path, "file");
1247
1495
  if (!src || !options.dest) return undefined;
1248
- const intoFolder = /[\\/]$/.test(options.dest) || (fs.existsSync(_abs(options.dest)) && fs.statSync(_abs(options.dest)).isDirectory());
1249
- const dest = intoFolder ? nodePath.join(_abs(options.dest), nodePath.basename(src)) : _abs(options.dest);
1496
+ const dest = _fileDest(src, options.dest);
1497
+ // with force, the copy would delete its own source
1498
+ if (_sameEntry(src, dest)) return false;
1250
1499
  try {
1251
1500
  if (options.force && fs.existsSync(dest)) fs.rmSync(dest, { force: true });
1252
1501
  fs.mkdirSync(nodePath.dirname(dest), { recursive: true });
@@ -1261,10 +1510,12 @@ function copyFile(options) {
1261
1510
  * Moves the folders inside a folder.
1262
1511
  *
1263
1512
  * @param {CopyFoldersOptions} options
1264
- * @returns {number | undefined} How many were moved.
1513
+ * @returns {number | undefined} How many were moved: `0` when `dest` is the source folder or inside it.
1265
1514
  */
1266
1515
  function moveFoldersIn(options) {
1267
1516
  const root = _existing(options.path, "folder");
1517
+ // the folders are deleted after the copy: they must not hold it
1518
+ if (root && options.dest && _within(_abs(options.dest), root)) return 0;
1268
1519
  const copied = copyFoldersIn(options);
1269
1520
  if (copied === undefined || !root) return copied;
1270
1521
  const filter = typeof options.filter === "function" ? options.filter : () => true;
@@ -1278,7 +1529,7 @@ function moveFoldersIn(options) {
1278
1529
  * Moves a folder.
1279
1530
  *
1280
1531
  * @param {CopyFolderOptions & { path: string }} options
1281
- * @returns {boolean | undefined}
1532
+ * @returns {boolean | undefined} `false` when `dest` is the source folder or inside it.
1282
1533
  */
1283
1534
  function moveFolder(options) {
1284
1535
  const root = _existing(options.path, "folder");
@@ -1288,19 +1539,40 @@ function moveFolder(options) {
1288
1539
  }
1289
1540
 
1290
1541
  /**
1291
- * Moves the files inside a folder.
1542
+ * Moves the files inside a folder. A file that can't be copied stays where
1543
+ * it is, and isn't counted.
1292
1544
  *
1293
1545
  * @param {CopyFilesOptions} options
1294
- * @returns {number | undefined} How many were moved.
1546
+ * @returns {number | undefined} How many were moved: `0` when `dest` is the source folder, or inside it with `recursive`.
1295
1547
  */
1296
1548
  function moveFilesIn(options) {
1297
1549
  const root = _existing(options.path, "folder");
1298
1550
  if (!root || !options.dest) return undefined;
1551
+ const dest = _abs(options.dest);
1552
+ const recursive = options.recursive ?? true;
1553
+ // the files are deleted after the copy: they must not be the copy
1554
+ if (_filesInItself(root, dest, recursive)) return 0;
1299
1555
  const filter = typeof options.filter === "function" ? options.filter : () => true;
1300
- const files = new Set((getFilesIn(root, options.recursive ?? true) ?? []).filter(filter));
1301
- const copied = copyFilesIn({ ...options, path: root, filter: (file) => files.has(file) });
1302
- for (const file of files) fs.rmSync(file, { force: true });
1303
- return copied;
1556
+ let moved = 0;
1557
+ for (const file of getFilesIn(root, recursive) ?? []) {
1558
+ if (!filter(file)) continue;
1559
+ const target = nodePath.join(dest, recursive ? nodePath.relative(root, file) : nodePath.basename(file));
1560
+ try {
1561
+ if (options.force && fs.existsSync(target)) fs.rmSync(target, { force: true });
1562
+ fs.mkdirSync(nodePath.dirname(target), { recursive: true });
1563
+ fs.copyFileSync(file, target);
1564
+ } catch {
1565
+ // not copied: the source stays where it is
1566
+ continue;
1567
+ }
1568
+ try {
1569
+ fs.rmSync(file, { force: true });
1570
+ moved++;
1571
+ } catch {
1572
+ // copied but still there: not moved
1573
+ }
1574
+ }
1575
+ return moved;
1304
1576
  }
1305
1577
 
1306
1578
  /**
@@ -1310,12 +1582,26 @@ function moveFilesIn(options) {
1310
1582
  * nc.moveFile({ path: "./logs/app.log", dest: "./logs/archive/" });
1311
1583
  *
1312
1584
  * @param {CopyFileOptions} options
1313
- * @returns {boolean | undefined}
1585
+ * @returns {boolean | undefined} `false` when `dest` is the file itself under the same name, or when the file is a link to `dest`.
1314
1586
  */
1315
1587
  function moveFile(options) {
1316
1588
  const src = _existing(options.path, "file");
1589
+ if (!src || !options.dest) return undefined;
1590
+ const dest = _fileDest(src, options.dest);
1591
+ // the link would take the place of the file it leads to
1592
+ if (_linkOnto(src, dest)) return false;
1593
+ if (_sameEntry(src, dest)) {
1594
+ // the same file: only its name can change, like its letter case on Windows
1595
+ if (nodePath.basename(src) === nodePath.basename(dest)) return false;
1596
+ try {
1597
+ fs.renameSync(src, dest);
1598
+ return true;
1599
+ } catch {
1600
+ return false;
1601
+ }
1602
+ }
1317
1603
  const moved = copyFile(options);
1318
- if (moved && src) fs.rmSync(src, { force: true });
1604
+ if (moved) fs.rmSync(src, { force: true });
1319
1605
  return moved;
1320
1606
  }
1321
1607
 
@@ -1448,10 +1734,24 @@ function tempFolder(prefix = "nc-") {
1448
1734
  return fs.mkdtempSync(nodePath.join(os.tmpdir(), prefix));
1449
1735
  }
1450
1736
 
1737
+ /**
1738
+ * Removes the dots and spaces at the end, in one pass: a regex like
1739
+ * `/[. ]+$/` would start again from every dot or space of a long name.
1740
+ * @param {string} text
1741
+ * @param {string} chars
1742
+ * @returns {string}
1743
+ */
1744
+ const _trimEnd = (text, chars) => {
1745
+ let end = text.length;
1746
+ while (end > 0 && chars.includes(text[end - 1])) end--;
1747
+ return text.slice(0, end);
1748
+ };
1749
+
1451
1750
  /**
1452
1751
  * Turns any text into a file name that works on Windows, macOS and Linux:
1453
- * forbidden characters are replaced, reserved names like `CON` are
1454
- * prefixed, and the length is capped.
1752
+ * forbidden characters are replaced, invisible and text direction
1753
+ * characters are removed, reserved names like `CON`, `NUL .txt` or
1754
+ * `COM¹` are prefixed, and the length is capped.
1455
1755
  *
1456
1756
  * @example
1457
1757
  * nc.sanitizeFilename('Report: Q1/Q2 "final"?.pdf'); // "Report_ Q1_Q2 _final__.pdf"
@@ -1463,8 +1763,15 @@ function tempFolder(prefix = "nc-") {
1463
1763
  */
1464
1764
  function sanitizeFilename(name, options = {}) {
1465
1765
  const replacement = options.replacement ?? "_";
1466
- let clean = String(name).replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/g, replacement).replace(/[. ]+$/, "");
1467
- if (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i.test(clean)) clean = `_${clean}`;
1766
+ let clean = _trimEnd(
1767
+ String(name)
1768
+ // invisible characters, and the ones that reverse the text so that "exe.txt" reads "txt.exe"
1769
+ .replace(/[​-‏‪-‮⁠⁦-⁩]/g, "")
1770
+ .replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/g, replacement),
1771
+ ". ",
1772
+ );
1773
+ // Windows ignores the extension and the spaces before it: "NUL .txt" is still the NUL device
1774
+ if (/^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i.test(_trimEnd(clean.split(".")[0], " "))) clean = `_${clean}`;
1468
1775
  if (clean === "." || clean === "..") clean = replacement;
1469
1776
  if (Buffer.byteLength(clean) > 255) {
1470
1777
  // File systems count bytes, and a character cut in two would be broken.
@@ -1475,7 +1782,7 @@ function sanitizeFilename(name, options = {}) {
1475
1782
  if (bytes > 255) break;
1476
1783
  kept += char;
1477
1784
  }
1478
- clean = kept.replace(/[. ]+$/, "");
1785
+ clean = _trimEnd(kept, ". ");
1479
1786
  }
1480
1787
  return clean || "_";
1481
1788
  }
@@ -1572,6 +1879,9 @@ function watch(options = {}) {
1572
1879
  paused = false;
1573
1880
  return controller;
1574
1881
  },
1882
+ [Symbol.dispose]() {
1883
+ controller.stop();
1884
+ },
1575
1885
  };
1576
1886
  return controller;
1577
1887
  }