@ix-xs/node-comfort 1.0.57 → 1.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.
package/src/FS.js ADDED
@@ -0,0 +1,768 @@
1
+ const { readdirSync, existsSync, mkdirSync, rmSync, writeFileSync, unlinkSync, copyFileSync, readFileSync, watch } = require("node:fs");
2
+ const { resolve, dirname, sep, isAbsolute, join, relative, basename } = require("node:path");
3
+
4
+ /**
5
+ * Normalizes a path to the current platform separator.
6
+ * @private
7
+ * @param {string} path - The path to normalize.
8
+ * @returns {string} The normalized path.
9
+ */
10
+ const _format = (path) => {
11
+ return path.replace(/\//g, sep).replace(/\\/g, sep);
12
+ };
13
+
14
+ /**
15
+ * Returns the calling file path, excluding internal and node_modules frames.
16
+ * @private
17
+ * @returns {string|undefined} The caller file path, or undefined if not found.
18
+ */
19
+ const _caller = () => {
20
+ const opst = Error.prepareStackTrace;
21
+ Error.prepareStackTrace = (_, stack) => stack;
22
+ const e = new Error();
23
+ const s = e.stack;
24
+ Error.prepareStackTrace = opst;
25
+
26
+ if (s) {
27
+ for (const cs of s) {
28
+ const name = cs.getFileName();
29
+ if (
30
+ name &&
31
+ !name.startsWith("node:internal") &&
32
+ !name.includes("node_modules") &&
33
+ name !== s[0].getFileName()
34
+ ) {
35
+ return _format(name);
36
+ }
37
+ }
38
+ }
39
+ return undefined;
40
+ };
41
+
42
+ /**
43
+ * Recursively walks folders or files from a path.
44
+ * @private
45
+ * @param {string} [path=process.cwd()] - The base path to scan.
46
+ * @param {boolean} [recursive=true] - Whether to recurse into subfolders.
47
+ * @param {"folder"|"file"} [type="folder"] - The entry type to collect.
48
+ * @returns {string[]} A list of matching absolute paths.
49
+ */
50
+ const _walk = (path = process.cwd(), recursive = true, type = "folder") => {
51
+ let result = [];
52
+ try {
53
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
54
+ if (entry.name.includes("node_modules")) continue;
55
+ const _ = resolve(path, entry.name);
56
+
57
+ if (type === "folder" && entry.isDirectory()) {
58
+ result.push(_);
59
+ if (recursive) result = result.concat(_walk(_, recursive, type));
60
+ } else if (type === "file" && entry.isFile()) {
61
+ result.push(_);
62
+ } else if (type === "file" && entry.isDirectory() && recursive) {
63
+ result = result.concat(_walk(_, recursive, type));
64
+ }
65
+ }
66
+ return result;
67
+ } catch {
68
+ return result;
69
+ }
70
+ };
71
+
72
+ /**
73
+ * Finds the first folder matching a given name.
74
+ * @private
75
+ * @param {string} path - The folder to search from.
76
+ * @param {string} name - The folder name to find.
77
+ * @returns {string|undefined} The matching folder path, or undefined if not found.
78
+ */
79
+ const _firstFolder = (path, name) => {
80
+ try {
81
+ const entries = readdirSync(path, { withFileTypes: true });
82
+ for (const entry of entries) {
83
+ if (entry.name === name && entry.isDirectory()) {
84
+ return resolve(path, name);
85
+ }
86
+ }
87
+ for (const entry of entries) {
88
+ if (entry.isDirectory() && !entry.name.includes("node_modules")) {
89
+ const result = _firstFolder(resolve(path, entry.name), name);
90
+ if (result) return result;
91
+ }
92
+ }
93
+ } catch {}
94
+ return undefined;
95
+ };
96
+
97
+ /**
98
+ * Finds the first file matching a given name.
99
+ * @private
100
+ * @param {string} path - The folder to search from.
101
+ * @param {string} name - The file name to find.
102
+ * @returns {string|undefined} The matching file path, or undefined if not found.
103
+ */
104
+ const _firstFile = (path, name) => {
105
+ try {
106
+ const entries = readdirSync(path, { withFileTypes: true });
107
+ for (const entry of entries) {
108
+ if (entry.name === name && entry.isFile()) {
109
+ return resolve(path, name);
110
+ }
111
+ }
112
+ for (const entry of entries) {
113
+ if (entry.isDirectory() && !entry.name.includes("node_modules")) {
114
+ const result = _firstFile(resolve(path, entry.name), name);
115
+ if (result) return result;
116
+ }
117
+ }
118
+ } catch {}
119
+ return undefined;
120
+ };
121
+
122
+ /**
123
+ * Resolves a user path into an existing absolute path when possible.
124
+ * @private
125
+ * @param {string} [path=process.cwd()] - The path to resolve.
126
+ * @param {"folder"|"file"} [type="folder"] - The expected target type.
127
+ * @returns {string|undefined} The resolved absolute path, or undefined if it does not exist.
128
+ */
129
+ const _target = (path = process.cwd(), type = "folder") => {
130
+ if (path === process.cwd()) return path;
131
+
132
+ let absolute;
133
+ const caller = dirname(_caller() || process.cwd());
134
+
135
+ if (
136
+ _format(path).startsWith(_format("./")) ||
137
+ _format(path).startsWith(_format("../"))
138
+ ) {
139
+ absolute = resolve(caller, path);
140
+ } else if (isAbsolute(path)) {
141
+ absolute = _format(path);
142
+ } else {
143
+ absolute = resolve(process.cwd(), _format(path));
144
+ if (!existsSync(absolute)) {
145
+ absolute =
146
+ type === "folder"
147
+ ? _firstFolder(process.cwd(), _format(path)) ?? undefined
148
+ : _firstFile(process.cwd(), _format(path)) ?? undefined;
149
+ }
150
+ }
151
+
152
+ if (absolute && existsSync(absolute)) return _format(absolute);
153
+ return undefined;
154
+ };
155
+
156
+ try {
157
+ process.loadEnvFile();
158
+ } catch {}
159
+
160
+ /**
161
+ * File-system helper with safe path resolution and high-level operations.
162
+ */
163
+ module.exports = {
164
+ /**
165
+ * Reads an environment variable from process.env.
166
+ * @param {string} value - The environment variable name.
167
+ * @param {string} [envFilePath] - Reserved for future use.
168
+ * @returns {string|undefined} The environment value, or undefined if not set.
169
+ */
170
+ getEnv(value, envFilePath) {
171
+ return process.env[value];
172
+ },
173
+
174
+ /**
175
+ * Resolves a path even when it does not already exist.
176
+ * @param {string} [path=process.cwd()] - The path to resolve.
177
+ * @returns {string} The resolved path.
178
+ */
179
+ createPath(path = process.cwd()) {
180
+ const target = _target(path);
181
+ return target
182
+ ? target
183
+ : isAbsolute(path)
184
+ ? path
185
+ : path.startsWith("./") || path.startsWith("../")
186
+ ? resolve(dirname(_caller()), path)
187
+ : resolve(process.cwd(), path);
188
+ },
189
+
190
+ /**
191
+ * Returns folders found inside a path.
192
+ * @param {string} [path=process.cwd()] - The base path to inspect.
193
+ * @param {boolean} [recursive=true] - Whether to scan recursively.
194
+ * @returns {string[]|undefined} Matching folders, or undefined if the path cannot be resolved.
195
+ */
196
+ getFoldersIn(path = process.cwd(), recursive = true) {
197
+ const target = _target(path ?? process.cwd());
198
+ return target ? _walk(target, recursive) : undefined;
199
+ },
200
+
201
+ /**
202
+ * Returns the resolved folder path.
203
+ * @param {string} [path=process.cwd()] - The folder path to resolve.
204
+ * @returns {string|undefined} The resolved folder path, or undefined if not found.
205
+ */
206
+ getFolder(path = process.cwd()) {
207
+ return _target(path ?? process.cwd());
208
+ },
209
+
210
+ /**
211
+ * Returns files found inside a path.
212
+ * @param {string} [path=process.cwd()] - The base path to inspect.
213
+ * @param {boolean} [recursive=true] - Whether to scan recursively.
214
+ * @returns {string[]|undefined} Matching files, or undefined if the path cannot be resolved.
215
+ */
216
+ getFilesIn(path = process.cwd(), recursive = true) {
217
+ const target = _target(path ?? process.cwd());
218
+ return target ? _walk(target, recursive, "file") : undefined;
219
+ },
220
+
221
+ /**
222
+ * Returns the resolved file path.
223
+ * @param {string} path - The file path to resolve.
224
+ * @returns {string|undefined} The resolved file path, or undefined if not found.
225
+ */
226
+ getFile(path) {
227
+ return path ? _target(path, "file") : path;
228
+ },
229
+
230
+ /**
231
+ * Creates a folder.
232
+ * @param {string} path - The folder path to create.
233
+ * @param {boolean} [force=false] - Whether to recreate the folder if it already exists.
234
+ * @returns {boolean|undefined} True on success, false on failure, undefined if no path was provided.
235
+ */
236
+ createFolder(path, force = false) {
237
+ if (!path) return undefined;
238
+ const target = this.createPath(path);
239
+
240
+ try {
241
+ if (this.getFolder(target)) {
242
+ if (force) {
243
+ this.deleteFolder(target);
244
+ mkdirSync(target, { recursive: true });
245
+ return true;
246
+ }
247
+ return false;
248
+ }
249
+ mkdirSync(target, { recursive: true });
250
+ return true;
251
+ } catch {
252
+ return false;
253
+ }
254
+ },
255
+
256
+ /**
257
+ * Creates a file.
258
+ * @param {string} path - The file path to create.
259
+ * @param {boolean} [force=false] - Whether to recreate the file if it already exists.
260
+ * @param {string|object|null|undefined} [data] - File content to write.
261
+ * @returns {boolean|undefined} True on success, false on failure, undefined if no path was provided.
262
+ */
263
+ createFile(path, force = false, data) {
264
+ if (!path) return undefined;
265
+ const target = this.createPath(path);
266
+ const content =
267
+ data === null || typeof data === "undefined"
268
+ ? ""
269
+ : typeof data === "string"
270
+ ? data
271
+ : JSON.stringify(data, null, 2);
272
+
273
+ try {
274
+ if (this.getFile(target)) {
275
+ if (force) {
276
+ this.deleteFile(target);
277
+ writeFileSync(target, content, "utf8");
278
+ return true;
279
+ }
280
+ return false;
281
+ }
282
+
283
+ if (!this.getFolder(dirname(target))) {
284
+ this.createFolder(dirname(target));
285
+ }
286
+
287
+ writeFileSync(target, content, "utf8");
288
+ return true;
289
+ } catch {
290
+ return false;
291
+ }
292
+ },
293
+
294
+ /**
295
+ * Deletes folders found inside a path.
296
+ * @param {string} [path=process.cwd()] - The base path to inspect.
297
+ * @param {(folder: string) => boolean} [filter=() => true] - Filter applied to each folder path.
298
+ * @returns {number|undefined} Number of deleted folders, or undefined if the path cannot be resolved.
299
+ */
300
+ deleteFoldersIn(path = process.cwd(), filter = () => true) {
301
+ const target = _target(path);
302
+ if (!target) return undefined;
303
+
304
+ const folders = this.getFoldersIn(target);
305
+ let deleted = 0;
306
+
307
+ for (const folder of folders.map(_format)) {
308
+ if (!filter(folder)) continue;
309
+ this.deleteFolder(folder);
310
+ deleted++;
311
+ }
312
+ return deleted;
313
+ },
314
+
315
+ /**
316
+ * Deletes a folder.
317
+ * @param {string} path - The folder path to delete.
318
+ * @returns {boolean|undefined} True on success, false on failure, undefined if the path is invalid.
319
+ */
320
+ deleteFolder(path) {
321
+ const target = path ? _target(path) : path;
322
+ if (!target || !this.getFolder(target)) return undefined;
323
+
324
+ try {
325
+ rmSync(target, { recursive: true, force: true });
326
+ return true;
327
+ } catch {
328
+ return false;
329
+ }
330
+ },
331
+
332
+ /**
333
+ * Deletes files found inside a path.
334
+ * @param {string} [path=process.cwd()] - The base path to inspect.
335
+ * @param {boolean} [recursive=false] - Whether to scan recursively.
336
+ * @param {(file: string) => boolean} [filter=() => true] - Filter applied to each file path.
337
+ * @returns {number|undefined} Number of deleted files, or undefined if the path cannot be resolved.
338
+ */
339
+ deleteFilesIn(path = process.cwd(), recursive = false, filter = () => true) {
340
+ const target = _target(path);
341
+ if (!target) return undefined;
342
+
343
+ const files = this.getFilesIn(target, recursive);
344
+ let deleted = 0;
345
+
346
+ for (const file of files.map(_format)) {
347
+ if (!filter(file)) continue;
348
+ this.deleteFile(file);
349
+ deleted++;
350
+ }
351
+ return deleted;
352
+ },
353
+
354
+ /**
355
+ * Deletes a file.
356
+ * @param {string} path - The file path to delete.
357
+ * @returns {boolean|undefined} True on success, false on failure, undefined if the path is invalid.
358
+ */
359
+ deleteFile(path) {
360
+ const target = path ? _target(path, "file") : path;
361
+ if (!target || !this.getFile(target)) return undefined;
362
+
363
+ try {
364
+ unlinkSync(target);
365
+ return true;
366
+ } catch {
367
+ return false;
368
+ }
369
+ },
370
+
371
+ /**
372
+ * Copies folders from a source path into a destination folder.
373
+ * @param {Object} options - Copy options.
374
+ * @param {string} options.dest - Destination path.
375
+ * @param {string} [options.path] - Source path.
376
+ * @param {boolean} [options.recursive=true] - Whether to recurse into subfolders.
377
+ * @param {boolean} [options.withFiles=false] - Whether to copy files as well.
378
+ * @param {boolean} [options.force=false] - Whether to overwrite existing folders.
379
+ * @param {(folder: string) => boolean} [options.filter=() => true] - Filter applied to each folder.
380
+ * @returns {number|undefined} Number of copied folders, or undefined if invalid input.
381
+ */
382
+ copyFoldersIn(options) {
383
+ const target = _target(options.path ?? process.cwd());
384
+ if (!target || !options.dest) return undefined;
385
+
386
+ const folders = this.getFoldersIn(target, options.recursive ?? true) ?? [];
387
+ options.filter =
388
+ typeof options.filter === "function" ? options.filter : () => true;
389
+ let copied = 0;
390
+
391
+ for (const folder of folders.map(_format)) {
392
+ if (!options.filter(folder)) continue;
393
+
394
+ const destFolder = join(
395
+ this.createPath(options.dest),
396
+ relative(target, folder),
397
+ );
398
+
399
+ if (options.force && this.getFolder(destFolder)) {
400
+ this.deleteFolder(destFolder);
401
+ }
402
+
403
+ this.createFolder(destFolder);
404
+ copied++;
405
+
406
+ if (options.withFiles) {
407
+ if (options.recursive) {
408
+ for (const file of this.getFilesIn(folder, true) ?? []) {
409
+ const destFile = join(destFolder, relative(folder, file));
410
+ this.createFolder(dirname(destFile));
411
+ copyFileSync(file, destFile);
412
+ }
413
+ } else {
414
+ for (const file of this.getFilesIn(folder, false) ?? []) {
415
+ const destFile = join(destFolder, basename(file));
416
+ copyFileSync(file, destFile);
417
+ }
418
+ }
419
+ }
420
+ }
421
+ return copied;
422
+ },
423
+
424
+ /**
425
+ * Copies a folder to a destination path.
426
+ * @param {Object} options - Copy options.
427
+ * @param {string} options.dest - Destination path.
428
+ * @param {string} [options.path] - Source path.
429
+ * @param {boolean} [options.recursive=false] - Whether to copy subfolders.
430
+ * @param {boolean} [options.withFiles=false] - Whether to copy files.
431
+ * @param {boolean} [options.force=false] - Whether to overwrite the destination.
432
+ * @returns {boolean|undefined} True on success, false on failure, undefined if invalid input.
433
+ */
434
+ copyFolder(options) {
435
+ const target = _target(options.path ?? process.cwd());
436
+ const dest = this.createPath(options.dest);
437
+
438
+ if (!target || !dest) return undefined;
439
+
440
+ if (options.force && this.getFolder(dest)) {
441
+ this.deleteFolder(dest);
442
+ }
443
+
444
+ this.createFolder(dest);
445
+
446
+ if (options.recursive) {
447
+ for (const folder of this.getFoldersIn(target, true) ?? []) {
448
+ const destFolder = join(dest, relative(target, folder));
449
+ this.createFolder(destFolder);
450
+
451
+ if (options.withFiles) {
452
+ for (const file of this.getFilesIn(folder, false) ?? []) {
453
+ const destFile = join(destFolder, basename(file));
454
+ copyFileSync(file, destFile);
455
+ }
456
+ }
457
+ }
458
+
459
+ if (options.withFiles) {
460
+ for (const file of this.getFilesIn(target, false) ?? []) {
461
+ const destFile = join(dest, basename(file));
462
+ copyFileSync(file, destFile);
463
+ }
464
+ }
465
+ } else {
466
+ if (options.withFiles) {
467
+ for (const file of this.getFilesIn(target, false) ?? []) {
468
+ const destFile = join(dest, basename(file));
469
+ copyFileSync(file, destFile);
470
+ }
471
+ }
472
+ }
473
+ return true;
474
+ },
475
+
476
+ /**
477
+ * Copies files from a source path into a destination folder.
478
+ * @param {Object} options - Copy options.
479
+ * @param {string} options.dest - Destination path.
480
+ * @param {string} [options.path] - Source path.
481
+ * @param {boolean} [options.recursive=true] - Whether to recurse into subfolders.
482
+ * @param {boolean} [options.force=false] - Whether to overwrite existing files.
483
+ * @param {(file: string) => boolean} [options.filter=() => true] - Filter applied to each file.
484
+ * @returns {number|undefined} Number of copied files, or undefined if invalid input.
485
+ */
486
+ copyFilesIn(options) {
487
+ const target = _target(options.path ?? process.cwd());
488
+ const destBase = this.createPath(options.dest);
489
+
490
+ if (!target || !options.dest) return undefined;
491
+
492
+ const recursive = options.recursive ?? true;
493
+ const files = this.getFilesIn(target, recursive) ?? [];
494
+ const filter =
495
+ typeof options.filter === "function" ? options.filter : () => true;
496
+ let copied = 0;
497
+
498
+ for (const file of files.map(_format)) {
499
+ if (!filter(file)) continue;
500
+
501
+ const destFile = join(
502
+ destBase,
503
+ recursive ? relative(target, file) : basename(file),
504
+ );
505
+
506
+ if (options.force && this.getFile(destFile)) {
507
+ this.deleteFile(destFile);
508
+ }
509
+
510
+ this.createFolder(dirname(destFile));
511
+ copyFileSync(file, destFile);
512
+ copied++;
513
+ }
514
+ return copied;
515
+ },
516
+
517
+ /**
518
+ * Copies a file to a destination path.
519
+ * @param {Object} options - Copy options.
520
+ * @param {string} options.path - Source file path.
521
+ * @param {string} options.dest - Destination path.
522
+ * @param {boolean} [options.force=false] - Whether to overwrite the destination file.
523
+ * @returns {boolean|undefined} True on success, false on failure, undefined if invalid input.
524
+ */
525
+ copyFile(options) {
526
+ const target = _target(options.path, "file");
527
+ if (!target || !options.dest) return undefined;
528
+
529
+ let destFile;
530
+ if (
531
+ !basename(options.dest) ||
532
+ options.dest.endsWith("/") ||
533
+ options.dest.endsWith("\\")
534
+ ) {
535
+ destFile = join(this.createPath(options.dest), basename(target));
536
+ } else {
537
+ destFile = this.createPath(options.dest);
538
+ }
539
+
540
+ if (options.force && this.getFile(destFile)) {
541
+ this.deleteFile(destFile);
542
+ }
543
+
544
+ this.createFolder(dirname(destFile));
545
+ copyFileSync(target, destFile);
546
+ return true;
547
+ },
548
+
549
+ /**
550
+ * Moves folders from a source path into a destination folder.
551
+ * @param {Object} options - Move options.
552
+ * @param {string} options.dest - Destination path.
553
+ * @param {string} [options.path] - Source path.
554
+ * @param {boolean} [options.recursive=true] - Whether to recurse into subfolders.
555
+ * @param {boolean} [options.withFiles=false] - Whether to move files as well.
556
+ * @param {boolean} [options.force=false] - Whether to overwrite existing folders.
557
+ * @param {(folder: string) => boolean} [options.filter=() => true] - Filter applied to each folder.
558
+ * @returns {number|undefined} Number of copied folders, or undefined if invalid input.
559
+ */
560
+ moveFoldersIn(options) {
561
+ const copied = this.copyFoldersIn(options);
562
+ if (copied === undefined || !options.path) return copied;
563
+
564
+ const target = _target(options.path);
565
+ if (!target) return copied;
566
+
567
+ const folders = this.getFoldersIn(target, options.recursive ?? true) ?? [];
568
+ options.filter =
569
+ typeof options.filter === "function" ? options.filter : () => true;
570
+
571
+ for (const folder of folders.map(_format)) {
572
+ if (!options.filter(folder)) continue;
573
+ this.deleteFolder(folder);
574
+ }
575
+ return copied;
576
+ },
577
+
578
+ /**
579
+ * Moves a folder to a destination path.
580
+ * @param {Object} options - Move options.
581
+ * @param {string} options.path - Source folder path.
582
+ * @param {string} options.dest - Destination path.
583
+ * @param {boolean} [options.recursive=false] - Whether to move subfolders.
584
+ * @param {boolean} [options.withFiles=false] - Whether to move files.
585
+ * @param {boolean} [options.force=false] - Whether to overwrite the destination.
586
+ * @returns {boolean|undefined} True on success, false on failure, undefined if invalid input.
587
+ */
588
+ moveFolder(options) {
589
+ const copied = this.copyFolder(options);
590
+ const target = _target(options.path);
591
+ if (!target) return copied;
592
+
593
+ this.deleteFolder(target);
594
+ return copied;
595
+ },
596
+
597
+ /**
598
+ * Moves files from a source path into a destination folder.
599
+ * @param {Object} options - Move options.
600
+ * @param {string} options.dest - Destination path.
601
+ * @param {string} [options.path] - Source path.
602
+ * @param {boolean} [options.recursive=true] - Whether to recurse into subfolders.
603
+ * @param {boolean} [options.force=false] - Whether to overwrite existing files.
604
+ * @param {(file: string) => boolean} [options.filter=() => true] - Filter applied to each file.
605
+ * @returns {number|undefined} Number of copied files, or undefined if invalid input.
606
+ */
607
+ moveFilesIn(options) {
608
+ const copied = this.copyFilesIn(options);
609
+ if (copied === undefined || !options.path) return copied;
610
+
611
+ const target = _target(options.path ?? process.cwd());
612
+ if (!target) return copied;
613
+
614
+ const files = this.getFilesIn(target, options.recursive ?? true) ?? [];
615
+ const filter =
616
+ typeof options.filter === "function" ? options.filter : () => true;
617
+
618
+ for (const file of files.map(_format)) {
619
+ if (!filter(file)) continue;
620
+ this.deleteFile(file);
621
+ }
622
+ return copied;
623
+ },
624
+
625
+ /**
626
+ * Moves a file to a destination path.
627
+ * @param {Object} options - Move options.
628
+ * @param {string} options.path - Source file path.
629
+ * @param {string} options.dest - Destination path.
630
+ * @param {boolean} [options.force=false] - Whether to overwrite the destination file.
631
+ * @returns {boolean|undefined} True on success, false on failure, undefined if invalid input.
632
+ */
633
+ moveFile(options) {
634
+ const moved = this.copyFile(options);
635
+ if (moved) {
636
+ const target = _target(options.path, "file");
637
+ if (target) this.deleteFile(target);
638
+ }
639
+ return moved;
640
+ },
641
+
642
+ /**
643
+ * Reads a file as UTF-8 text.
644
+ * @param {string} path - The file path to read.
645
+ * @returns {string|undefined} File content, or undefined if the file cannot be read.
646
+ */
647
+ readFile(path) {
648
+ const target = _target(path, "file");
649
+ if (!target) return undefined;
650
+
651
+ try {
652
+ return readFileSync(target, "utf8");
653
+ } catch {
654
+ return undefined;
655
+ }
656
+ },
657
+
658
+ /**
659
+ * Watches a path and exposes event listeners.
660
+ * @param {Object} [options] - Watch options.
661
+ * @param {string} [options.path=process.cwd()] - Path to watch.
662
+ * @param {boolean} [options.recursive=false] - Whether to watch recursively.
663
+ * @param {(event: "rename"|"change", file: string) => boolean} [options.filter] - Filter applied before dispatch.
664
+ * @returns {{
665
+ * on: <E extends "change"|"rename"|"all">(event: E, callback: E extends "all" ? (event: "change"|"rename", file: string) => void|Promise<void> : (file: string) => void|Promise<void>) => any,
666
+ * stop: () => void,
667
+ * pause: () => any,
668
+ * resume: () => any,
669
+ * }|undefined} Watch controller, or undefined if the path cannot be resolved.
670
+ */
671
+ watch(options = {}) {
672
+ const target = _target(options.path ?? process.cwd());
673
+ if (!target) return undefined;
674
+
675
+ let paused = false;
676
+ const listeners = {};
677
+
678
+ /**
679
+ * Dispatches an event to registered listeners.
680
+ * @private
681
+ * @param {"rename"|"change"|"all"} event - The event type.
682
+ * @param {string} file - The affected file path.
683
+ */
684
+ const handler = (event, file) => {
685
+ if (paused) return;
686
+ if (
687
+ typeof options.filter === "function" &&
688
+ !options.filter(event, file)
689
+ ) {
690
+ return;
691
+ }
692
+
693
+ if (listeners[event]) {
694
+ for (const cb of listeners[event]) cb(file);
695
+ }
696
+ if (listeners["all"]) {
697
+ for (const cb of listeners["all"]) cb(event, file);
698
+ }
699
+ };
700
+
701
+ /**
702
+ * Registers a listener for an event.
703
+ * @private
704
+ * @param {"change"|"rename"|"all"} type - The event type.
705
+ * @param {(file: string) => void|Promise<void> | (event: "change"|"rename", file: string) => void|Promise<void>} callback - The callback to register.
706
+ */
707
+ const addListener = (type, callback) => {
708
+ listeners[type] = listeners[type] ?? [];
709
+ listeners[type].push(callback);
710
+ };
711
+
712
+ const watcher = watch(
713
+ target,
714
+ { recursive: options.recursive ?? false },
715
+ (eventType, fileName) => {
716
+ handler(eventType, fileName ? join(target, fileName) : target);
717
+ },
718
+ );
719
+
720
+ /**
721
+ * @typedef {"change"|"rename"|"all"} EventNames
722
+ * @typedef {{
723
+ * change: (file: string) => void|Promise<void>,
724
+ * rename: (file: string) => void|Promise<void>,
725
+ * all: (event: EventNames, file: string) => void|Promise<void>,
726
+ * }} EventHandlers
727
+ */
728
+ return {
729
+ /**
730
+ * Registers a callback for a watch event.
731
+ * @template {EventNames} E
732
+ * @param {E} event - The event name.
733
+ * @param {EventHandlers[E]} callback - The callback to execute.
734
+ * @returns {this} The same watch controller.
735
+ */
736
+ on(event, callback) {
737
+ addListener(event, callback);
738
+ return this;
739
+ },
740
+ /**
741
+ * Stops the underlying file watcher.
742
+ */
743
+ stop() {
744
+ if (watcher) {
745
+ try {
746
+ watcher.close();
747
+ } catch {}
748
+ }
749
+ },
750
+ /**
751
+ * Pauses event dispatching without closing the watcher.
752
+ * @returns {this} The same watch controller.
753
+ */
754
+ pause() {
755
+ paused = true;
756
+ return this;
757
+ },
758
+ /**
759
+ * Resumes event dispatching after a pause.
760
+ * @returns {this} The same watch controller.
761
+ */
762
+ resume() {
763
+ paused = false;
764
+ return this;
765
+ },
766
+ };
767
+ },
768
+ };