instrumentality 0.0.1

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/road.ts ADDED
@@ -0,0 +1,827 @@
1
+ import * as rl from "node:readline"
2
+ import * as fs from "node:fs"; import { constants as fsc } from "node:fs"
3
+ import * as fp from "node:fs/promises"
4
+ import * as ph from "node:path"
5
+ import * as os from "node:os"
6
+ import * as cr from "node:crypto"
7
+ import * as sp from "node:stream/promises"
8
+ import { on } from "node:events"
9
+ import * as bs from "./base.ts"
10
+
11
+
12
+
13
+ /**
14
+ * Subclass of {@link bs.InsErr} that represents an error thrown from this specific module of the library
15
+ */
16
+ export class RdErr extends bs.InsErr { override name = "Instrumentality-Road-Error" }
17
+
18
+
19
+
20
+ /**
21
+ * Returns the constructor function corresponding to the file mode.
22
+ *
23
+ * @param statmode The file mode to check.
24
+ * @returns The constructor function corresponding to the file mode.
25
+ * @throws If the file mode is unknown, throws a {@link RdErr}.
26
+ */
27
+ export function modeCtor(statmode: number) {
28
+ switch (statmode & fsc.S_IFMT) {
29
+ case fsc.S_IFREG: return File
30
+ case fsc.S_IFDIR: return Folder
31
+ case fsc.S_IFBLK: return BlockDevice
32
+ case fsc.S_IFCHR: return CharacterDevice
33
+ case fsc.S_IFLNK: return SymbolicLink
34
+ case fsc.S_IFIFO: return Fifo
35
+ case fsc.S_IFSOCK: return Socket
36
+ default: throw new RdErr(`Unknown mode type ${statmode} (statmode is most likely corrupted)`)
37
+ }
38
+ }
39
+
40
+
41
+
42
+ /**
43
+ * Creates a new instance of the appropriate subclass of {@link Road} based on the file mode of the specified path.
44
+ *
45
+ * @param lookFor The path to check.
46
+ * @returns A new instance of the appropriate subclass of {@link Road}.
47
+ * @throws If the path does not exist, throws a fs {@link Error}.
48
+ */
49
+ export function factorySync(lookFor: string) {
50
+ fs.accessSync(lookFor, fsc.F_OK)
51
+ return new (modeCtor(fs.lstatSync(lookFor).mode))(lookFor, false)
52
+ }
53
+ /** Async version of {@link factorySync}. */
54
+ export async function factory(lookFor: string) {
55
+ await fp.access(lookFor, fsc.F_OK)
56
+ return new (modeCtor((await fp.lstat(lookFor)).mode))(lookFor, false)
57
+ }
58
+
59
+
60
+
61
+ /**
62
+ * A map that keeps track of locked roads to prevent concurrent modifications. The keys are the absolute paths of the roads, and the values are promises that resolve when the lock is released.
63
+ *
64
+ * @remarks Don't manually modify this map. Use the {@link Road.initChange} and {@link Road.initChangeSync} methods to acquire and release locks on roads.
65
+ * For read-only purposes, you should use the {@link lockFor} function to await the optional lock on a road.
66
+ */
67
+ let lockedRoads: Map<string, Promise<void>> | null = null
68
+ /**
69
+ * Getter for the locked roads map.
70
+ *
71
+ * @param roadOrPath - A {@link Road} instance or a string representing the absolute path of the road to check.
72
+ * @returns The promise associated with the locked road, or `undefined` if the road is not currently locked.
73
+ */
74
+ export function lockFor(roadOrPath: Road | string) {
75
+ return lockedRoads?.get(roadOrPath.toString())
76
+ }
77
+
78
+
79
+
80
+ export abstract class Road {
81
+ /** The absolute path to the file or directory that this Road instance represents.
82
+ * Intentionally made protected to prevent external modification, as changing this value could lead to inconsistencies and unexpected behavior. */
83
+ protected pointsTo: string
84
+ /** Indicates whether the file or directory represented by this Road instance can be modified.
85
+ * Changing this value does not affect the actual file system permissions, but rather serves as a safeguard within the application to prevent accidental modifications. */
86
+ mutable: boolean = true
87
+
88
+ // Quick accessors
89
+ /** Accessor for the absolute path to the file or directory that this Road instance represents. */
90
+ get isAt() { return this.pointsTo }
91
+ /** Accessor for the name of the file or directory that this Road instance represents. */
92
+ get name() { return ph.basename(this.isAt) }
93
+ /** Same as {@link isAt} but for compatibility with external libraries that try to convert the object to a string. */
94
+ toString() { return this.isAt }
95
+ /** Returns the OS file type of the file or directory.
96
+ * Return value (OS type) and the type of this instance are not guaranteed to be the same, as the file system may have changed since this instance was created. */
97
+ typeSync() { return (modeCtor(fs.lstatSync(this.isAt).mode)) }
98
+ /** Async version of {@link typeSync}. */
99
+ async type() { return (modeCtor((await fp.lstat(this.isAt)).mode)) }
100
+
101
+ /**
102
+ * Constructs a new Road instance representing the file or directory at the specified path.
103
+ *
104
+ * @param lookFor The path to the file or directory that this Road instance will represent.
105
+ * @param typeCheck Whether to check if the type of the file or directory at the specified path matches the type of this instance.
106
+ * This can be skipped for performance reasons if the type is known to be correct, but it is recommended to keep it enabled for safety.
107
+ * @throws If the specified path does not exist, throws a fs.{@link Error}.
108
+ * @throws If the type of the file or directory at the specified path does not match the type of this instance, throws a {@link RdErr}. Useful for subclasses.
109
+ */
110
+ constructor(lookFor: string, typeCheck: boolean) {
111
+ this.pointsTo = ph.resolve(lookFor)
112
+ if (typeCheck && !(this instanceof this.typeSync())) // `this` directly refers to the subclass
113
+ throw new RdErr(`Type missmatch: Path '${this.isAt}' is not of constructed type ${this.constructor.name}.`)
114
+ }
115
+
116
+ /**
117
+ * Verifies that the file or directory represented by this Road instance exists, is of the same type as this instance, and (optionally) is writable.
118
+ *
119
+ * @param expectMode The expected access mode for this Road other than visibility by this process.
120
+ * @returns Result of the verification.
121
+ */
122
+ async verify(expectMode: number, typeCheck: boolean): Promise<boolean> {
123
+ try {
124
+ await fp.access(this.isAt, fsc.F_OK | expectMode)
125
+ return typeCheck || this instanceof (await this.type())
126
+ } catch {
127
+ return false
128
+ }
129
+ }
130
+ /** Sync version of {@link verify}. */
131
+ verifySync(expectMode: number, typeCheck: boolean): boolean {
132
+ try {
133
+ fs.accessSync(this.isAt, fsc.F_OK | expectMode)
134
+ return typeCheck || this instanceof this.typeSync()
135
+ } catch {
136
+ return false
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Creates a disposable lock for the file or directory represented by this Road instance, preventing concurrent modifications.
142
+ *
143
+ * @returns An object with a `dispose` method that releases the lock when called. The lock is automatically released when the object is garbage collected.
144
+ * @remarks Please use this method with the `await using` statement to ensure that the lock is properly released after the operation is complete. This method is intended for internal use and shouldn't be called directly in most cases. (that's why it's protected)
145
+ */
146
+ protected async initChange() {
147
+ if (!await this.verify(fsc.R_OK | fsc.W_OK, true))
148
+ throw new RdErr(`Road to '${this.isAt}' (${this.constructor.name}) isn't the same as during construction, can't modify (OS type: ${fs.existsSync(this.isAt) ? this.typeSync().name : 'nonexistent'})`)
149
+ if (!this.mutable)
150
+ throw new RdErr(`Attempting to modify road to '${this.isAt}' of type ${this.constructor.name} which's marked as immutable (unrelated to the actual OS file permissions)`)
151
+ if (!lockedRoads)
152
+ lockedRoads = new Map<string, Promise<void>>()
153
+ const lockedPath = this.isAt
154
+ let releaseLock = () => {}
155
+ await lockFor(lockedPath)
156
+ lockedRoads.set(lockedPath, new Promise<void>(res => releaseLock = res))
157
+ return {
158
+ [Symbol.dispose]() {
159
+ releaseLock()
160
+ releaseLock = () => { throw new RdErr("Lock already released, can't dispose") }
161
+ lockedRoads!.delete(lockedPath)
162
+ },
163
+ async [Symbol.asyncDispose]() {
164
+ releaseLock()
165
+ releaseLock = () => { throw new RdErr("Lock already released, can't dispose") }
166
+ lockedRoads!.delete(lockedPath)
167
+ }
168
+ }
169
+ }
170
+ /**
171
+ * Sync version of {@link initChangeSync}.
172
+ *
173
+ * @remarks This sync version will throw if the road is already locked by another operation.
174
+ */
175
+ protected initChangeSync() {
176
+ if (!this.verifySync(fsc.R_OK | fsc.W_OK, true))
177
+ throw new RdErr(`Road to '${this.isAt}' (${this.constructor.name}) isn't the same as during construction, can't modify (OS type: ${fs.existsSync(this.isAt) ? this.typeSync().name : 'nonexistent'})`)
178
+ if (!this.mutable)
179
+ throw new RdErr(`Attempting to modify road to '${this.isAt}' of type ${this.constructor.name} which's marked as immutable (unrelated to the actual OS file permissions)`)
180
+ if (!lockedRoads)
181
+ lockedRoads = new Map<string, Promise<void>>()
182
+ const lockedPath = this.isAt
183
+ if (lockedRoads.has(lockedPath))
184
+ throw new RdErr(`Road to '${this.isAt}' is currently locked by another operation, can't modify synchronously`)
185
+ let releaseLock = () => {}
186
+ lockedRoads.set(lockedPath, new Promise<void>(res => releaseLock = res))
187
+ return {
188
+ [Symbol.dispose]() {
189
+ lockedRoads!.delete(lockedPath)
190
+ releaseLock()
191
+ releaseLock = () => { throw new RdErr("Lock already released, can't dispose") }
192
+ },
193
+ async [Symbol.asyncDispose]() {
194
+ lockedRoads!.delete(lockedPath)
195
+ releaseLock()
196
+ releaseLock = () => { throw new RdErr("Lock already released, can't dispose") }
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Checks if the file or directory represented by this Road is both visible and of the same type as expected.
203
+ */
204
+ existsSync() { return this.verifySync(fsc.F_OK, true) }
205
+ async exists() { return this.verify(fsc.F_OK, true) }
206
+ /**
207
+ * @returns The file system stats for the file or directory.
208
+ * @see {@link fs.lstatSync}
209
+ */
210
+ statsSync() { return fs.lstatSync(this.isAt) }
211
+ /**
212
+ * @returns The file system stats for the file or directory.
213
+ * @see {@link fs.promises.lstat}
214
+ */
215
+ async stats() {
216
+ return fp.lstat(this.isAt)
217
+ }
218
+
219
+ /**
220
+ * @returns The amount of path segments in the absolute path to the file or directory represented by this Road instance, minus one (i.e., the depth of the file or directory in the file system hierarchy).
221
+ * @remarks As subclasses of {@link Road} require all paths to be absolute/normalized and valid, this method is guaranteed to return a non-negative integer.
222
+ */
223
+ depth() { return this.isAt.split(ph.sep).length - 1 }
224
+ /** @returns The parent folder of the file or directory represented by this Road instance. */
225
+ parent() { return new Folder(ph.dirname(this.isAt), false) }
226
+ *ancestorsIt() {
227
+ let current: Folder = this.parent()
228
+ let parent = current.parent()
229
+ while (current.isAt !== parent.isAt) {
230
+ yield current
231
+ current = parent
232
+ parent = current.parent()
233
+ }
234
+ }
235
+ ancestors() { return [...this.ancestorsIt()] }
236
+
237
+ async untilAccessible(mode = fsc.F_OK, abs: AbortSignal, onEachAttempt?: () => unknown) {
238
+ const watcher = fs.watch(this.isAt)
239
+ try {
240
+ if (await this.verify(mode, true))
241
+ return
242
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
243
+ for await (let _ of on(watcher, 'change', { signal: abs }))
244
+ if (await this.verify(mode, true))
245
+ return
246
+ else
247
+ await onEachAttempt?.()
248
+ } finally {
249
+ watcher.close()
250
+ }
251
+ }
252
+ async onChange<T>(abs: AbortSignal, cb?: () => T) {
253
+ const watcher = fs.watch(this.isAt)
254
+ try {
255
+ for await (let _ of on(watcher, 'change', { signal: abs }))
256
+ return await cb?.() || null
257
+ return null
258
+ }
259
+ catch(e) { throw e }
260
+ finally { watcher.close() }
261
+ }
262
+
263
+ metaSync(suffixID = "tsInstrumentalityMeta") {
264
+ if (os.platform() === "win32")
265
+ return fs.readFileSync(`${this.isAt}:${suffixID}`)
266
+ else
267
+ throw new RdErr("Extended attributes are not supported on this platform")
268
+ }
269
+ async meta(suffixID = "tsInstrumentalityMeta"): Promise<Record<string, unknown>> {
270
+ if (os.platform() === "win32")
271
+ return JSON.parse(await fp.readFile(`${this.isAt}:${suffixID}`, 'utf-8'))
272
+ else
273
+ throw new RdErr("Extended attributes are not supported on this platform")
274
+ }
275
+ setMetaSync(meta: Record<string, unknown>, suffixID = "tsInstrumentalityMeta") {
276
+ using _ = this.initChangeSync()
277
+ if (os.platform() === "win32")
278
+ fs.writeFileSync(`${this.isAt}:${suffixID}`, JSON.stringify(meta), 'utf-8')
279
+ else
280
+ throw new RdErr("Extended attributes are not supported on this platform")
281
+ }
282
+ async setMeta(meta: Record<string, unknown>, suffixID = "tsInstrumentalityMeta") {
283
+ using _ = await this.initChange()
284
+ if (os.platform() === "win32")
285
+ await fp.writeFile(`${this.isAt}:${suffixID}`, JSON.stringify(meta), 'utf-8')
286
+ else
287
+ throw new RdErr("Extended attributes are not supported on this platform")
288
+ }
289
+
290
+ abstract deleteSync(): void
291
+ abstract delete(): Promise<void>
292
+ abstract moveSync(into: Folder): void
293
+ abstract move(into: Folder): Promise<void>
294
+ abstract copySync(into: Folder): this
295
+ abstract copy(into: Folder): Promise<this>
296
+ abstract renameSync(to: string): void
297
+ abstract rename(to: string): Promise<void>
298
+ abstract resurrectSync(): void
299
+ abstract resurrect(): Promise<void>
300
+ }
301
+
302
+
303
+
304
+ export class File extends Road {
305
+ get ext() { return ph.extname(this.isAt) }
306
+ get noExt() { return ph.basename(this.isAt, this.ext) }
307
+
308
+ static createSync(at: string) {
309
+ try {
310
+ fs.accessSync(at, fsc.W_OK)
311
+ } catch {
312
+ fs.writeFileSync(at, "")
313
+ }
314
+ return new File(at, false)
315
+ }
316
+ static async create(at: string) {
317
+ try {
318
+ await fp.access(at, fsc.W_OK)
319
+ } catch {
320
+ await fp.writeFile(at, "")
321
+ }
322
+ return new File(at, false)
323
+ }
324
+
325
+ readSync(): Buffer
326
+ readSync(encoding: BufferEncoding, flag?: string): string
327
+ readSync(encoding?: BufferEncoding, flag?: string): Buffer | string {
328
+ if (encoding)
329
+ return fs.readFileSync(this.isAt, { encoding: encoding, flag: flag })
330
+ else
331
+ return fs.readFileSync(this.isAt)
332
+ }
333
+ async read(): Promise<Buffer>
334
+ async read(encoding: BufferEncoding, flag?: string): Promise<string>
335
+ async read(encoding?: BufferEncoding, flag?: string): Promise<Buffer | string> {
336
+ if (encoding)
337
+ return fp.readFile(this.isAt, { encoding: encoding, flag: flag })
338
+ else
339
+ return fp.readFile(this.isAt)
340
+ }
341
+
342
+ // Bizarre reading
343
+ *itBuffSync(chunkSize: number = 64 * 1024, flags: string | number = 'r', mode?: fs.Mode) {
344
+ const fd = fs.openSync(this.isAt, flags, mode)
345
+ try {
346
+ const buffer = Buffer.alloc(chunkSize)
347
+ let bytesRead: number
348
+ do {
349
+ bytesRead = fs.readSync(fd, buffer, 0, chunkSize, null)
350
+ if (bytesRead > 0)
351
+ yield buffer.subarray(0, bytesRead)
352
+ } while (bytesRead === chunkSize)
353
+ } finally {
354
+ fs.closeSync(fd)
355
+ }
356
+ }
357
+ async *itBuff(chunkSize: number = 64 * 1024, flags: string | number = 'r', mode?: fs.Mode) {
358
+ const fd = await fp.open(this.isAt, flags, mode)
359
+ try {
360
+ const buffer = Buffer.alloc(chunkSize)
361
+ let bytesRead: number
362
+ do {
363
+ const readResult = await fd.read(buffer, 0, chunkSize, null)
364
+ bytesRead = readResult.bytesRead
365
+ if (bytesRead > 0)
366
+ yield buffer.subarray(0, bytesRead)
367
+ } while (bytesRead === chunkSize)
368
+ } finally {
369
+ await fd.close()
370
+ }
371
+ }
372
+ async *itLines(options: Parameters<typeof fs.createReadStream>[1] = { encoding: 'utf-8' }) {
373
+ const readStream = fs.createReadStream(this.isAt, options)
374
+ const rlInterface = rl.createInterface({ input: readStream, crlfDelay: Infinity })
375
+ try {
376
+ for await (const line of rlInterface)
377
+ yield line
378
+ } finally {
379
+ rlInterface.close()
380
+ readStream.destroy()
381
+ }
382
+ }
383
+ computeHashSync(algorithm?: string, options?: cr.HashOptions): Buffer
384
+ computeHashSync(algorithm?: string, options?: cr.HashOptions, encoding?: BufferEncoding): string
385
+ computeHashSync(algorithm = "sha256", options?: cr.HashOptions, encoding?: BufferEncoding): Buffer | string {
386
+ const hash = cr.createHash(algorithm, options)
387
+ for (const chunk of this.itBuffSync())
388
+ hash.update(chunk)
389
+ return encoding ? hash.digest(encoding) : hash.digest()
390
+ }
391
+ async computeHash(algorithm?: string, options?: cr.HashOptions): Promise<Buffer>
392
+ async computeHash(algorithm?: string, options?: cr.HashOptions, encoding?: BufferEncoding): Promise<string>
393
+ async computeHash(algorithm = "sha256", options?: cr.HashOptions, encoding?: BufferEncoding): Promise<Buffer | string> {
394
+ const hash = cr.createHash(algorithm, options)
395
+ for await (const chunk of this.itBuff())
396
+ hash.update(chunk)
397
+ return encoding ? hash.digest(encoding) : hash.digest()
398
+ }
399
+ async streamHash(algorithm = "sha256", options?: cr.HashOptions, encoding?: BufferEncoding): Promise<Buffer | string> {
400
+ const hash = cr.createHash(algorithm, options)
401
+ await sp.pipeline(fs.createReadStream(this.isAt), hash)
402
+ return encoding ? hash.digest(encoding) : hash.digest()
403
+ }
404
+
405
+ writeSync(data: Buffer | string, options?: fs.WriteFileOptions) {
406
+ using _ = this.initChangeSync()
407
+ fs.writeFileSync(this.isAt, data, options)
408
+ }
409
+ async write(data: Buffer | string, options?: fs.WriteFileOptions) {
410
+ using _ = await this.initChange()
411
+ await fp.writeFile(this.isAt, data, options)
412
+ }
413
+ appendSync(data: Buffer | string, options?: fs.WriteFileOptions) {
414
+ using _ = this.initChangeSync()
415
+ fs.appendFileSync(this.isAt, data, options)
416
+ }
417
+ async append(data: Buffer | string, options?: fs.WriteFileOptions) {
418
+ using _ = await this.initChange()
419
+ await fp.appendFile(this.isAt, data, options)
420
+ }
421
+
422
+ async sameAs(other: File) {
423
+ if (this.isAt === other.isAt)
424
+ return true
425
+ else if ((await fp.lstat(this.isAt)).size !== (await fp.lstat(other.isAt)).size)
426
+ return false
427
+ const thisIter = this.itBuff()
428
+ const otherIter = other.itBuff()
429
+ while (true) {
430
+ const [a, b] = await Promise.all([thisIter.next(), otherIter.next()])
431
+ if (a.done && b.done) return true
432
+ if (a.done !== b.done) return false
433
+ if (!a.value!.equals(b.value!)) return false
434
+ }
435
+ }
436
+ sameAsSync(other: File) {
437
+ if (this.isAt === other.isAt)
438
+ return true
439
+ else if (fs.statSync(this.isAt).size !== fs.statSync(other.isAt).size)
440
+ return false
441
+ const thisIter = this.itBuffSync()
442
+ const otherIter = other.itBuffSync()
443
+ while (true) {
444
+ const a = thisIter.next()
445
+ const b = otherIter.next()
446
+ if (a.done && b.done) return true
447
+ if (a.done !== b.done) return false
448
+ if (!a.value!.equals(b.value!)) return false
449
+ }
450
+ }
451
+
452
+ deleteSync() {
453
+ using _ = this.initChangeSync()
454
+ fs.rmSync(this.isAt, { force: true })
455
+ }
456
+ async delete() {
457
+ using _ = await this.initChange()
458
+ await fp.rm(this.isAt, { force: true })
459
+ }
460
+ moveSync(into: Folder) {
461
+ using _ = this.initChangeSync()
462
+ const newPath = into.join(this.name)
463
+ fs.renameSync(this.isAt, newPath)
464
+ this.pointsTo = newPath
465
+ }
466
+ async move(into: Folder) {
467
+ using _ = await this.initChange()
468
+ const newPath = into.join(this.name)
469
+ await fp.rename(this.isAt, newPath)
470
+ this.pointsTo = newPath
471
+ }
472
+ copySync(into: Folder): this {
473
+ const newPath = into.join(this.name)
474
+ fs.copyFileSync(this.isAt, newPath)
475
+ return new File(newPath, false) as this
476
+ }
477
+ async copy(into: Folder): Promise<this> {
478
+ const newPath = into.join(this.name)
479
+ await fp.copyFile(this.isAt, newPath)
480
+ return new File(newPath, false) as this
481
+ }
482
+ renameSync(to: string) {
483
+ using _ = this.initChangeSync()
484
+ const newPath = this.parent().join(to)
485
+ fs.renameSync(this.isAt, newPath)
486
+ this.pointsTo = newPath
487
+ }
488
+ async rename(to: string) {
489
+ using _ = await this.initChange()
490
+ const newPath = this.parent().join(to)
491
+ await fp.rename(this.isAt, newPath)
492
+ this.pointsTo = newPath
493
+ }
494
+ resurrectSync() {
495
+ using _ = this.initChangeSync()
496
+ fs.writeFileSync(this.isAt, "")
497
+ }
498
+ async resurrect() {
499
+ using _ = await this.initChange()
500
+ await fp.writeFile(this.isAt, "")
501
+ }
502
+ }
503
+
504
+ export function entry() { return new File(process.argv[1]!, false) }
505
+
506
+
507
+
508
+ export class Folder extends Road {
509
+ static async create(at: string): Promise<Folder> {
510
+ try {
511
+ await fp.access(at, fs.constants.F_OK)
512
+ } catch {
513
+ await fp.mkdir(at, { recursive: true })
514
+ }
515
+ return new Folder(at, false)
516
+ }
517
+ static createSync(at: string): Folder {
518
+ try {
519
+ fs.accessSync(at, fs.constants.F_OK)
520
+ } catch {
521
+ fs.mkdirSync(at, { recursive: true })
522
+ }
523
+ return new Folder(at, false)
524
+ }
525
+
526
+ join(...paths: string[]) {
527
+ return ph.join(this.isAt, ...paths)
528
+ }
529
+
530
+ itSync(): Iterable<Road>
531
+ itSync<T extends Road>(expectedType: new () => T): Iterable<T>
532
+ *itSync<T extends Road>(expectedType?: new () => T): Iterable<Road> | Iterable<T> {
533
+ for (const entry of fs.readdirSync(this.isAt)) {
534
+ const road = factorySync(this.join(entry))
535
+ if (!expectedType || road instanceof expectedType)
536
+ yield road
537
+ }
538
+ }
539
+ it(): AsyncIterable<Road>
540
+ it<T extends Road>(expectedType: new () => T): AsyncIterable<T>
541
+ async *it<T extends Road>(expectedType?: new () => T): AsyncIterable<Road> | AsyncIterable<T> {
542
+ for (const entry of await fp.readdir(this.isAt)) {
543
+ const road = await factory(this.join(entry))
544
+ if (!expectedType || road instanceof expectedType)
545
+ yield road
546
+ }
547
+ }
548
+ listSync(): Road[]
549
+ listSync<T extends Road>(expectedType: new () => T): T[]
550
+ listSync<T extends Road>(expectedType?: new () => T): Road[] | T[] {
551
+ const entries = fs.readdirSync(this.isAt).map(entry => factorySync(this.join(entry)))
552
+ if (!expectedType)
553
+ return entries
554
+ return entries.filter(entry => entry instanceof expectedType) as unknown as T[]
555
+ }
556
+ async list(): Promise<Road[]>
557
+ async list<T extends Road>(_expectedType: new () => T): Promise<T[]>
558
+ async list<T extends Road>(_expectedType?: new () => T): Promise<Road[] | T[]> {
559
+ const entries = (await fp.readdir(this.isAt)).map(async entry => factory(this.join(entry)))
560
+ const resolvedEntries = await Promise.all(entries)
561
+ if (!_expectedType)
562
+ return resolvedEntries
563
+ return resolvedEntries.filter(entry => entry instanceof _expectedType) as unknown as T[]
564
+ }
565
+
566
+ findSync(name: string): Road | null
567
+ findSync<T extends Road>(name: string, _expectedType: new () => T): T | null
568
+ findSync<T extends Road>(name: string, _expectedType?: new () => T): Road | T | null {
569
+ try {
570
+ const found = factorySync(this.join(name))
571
+ if (!_expectedType)
572
+ return found
573
+ if (found instanceof _expectedType)
574
+ return found as T
575
+ return null
576
+ } catch {
577
+ return null
578
+ }
579
+ }
580
+
581
+ async find(name: string): Promise<Road | null>
582
+ async find<T extends Road>(name: string, _expectedType: new () => T): Promise<T | null>
583
+ async find<T extends Road>(name: string, _expectedType?: new () => T): Promise<Road | T | null> {
584
+ try {
585
+ await fp.access(this.join(name), fs.constants.F_OK)
586
+ const found = await factory(this.join(name))
587
+ if (!_expectedType)
588
+ return found
589
+ if (found instanceof _expectedType)
590
+ return found as T
591
+ return null
592
+ } catch {
593
+ return null
594
+ }
595
+ }
596
+
597
+ addSync<T extends Road>(name: string, createable: { createSync: (at: string) => T }): T {
598
+ const newPath = this.join(name)
599
+ createable.createSync(newPath)
600
+ return factorySync(newPath) as unknown as T
601
+ }
602
+ async add<T extends Road>(name: string, createable: { create: (at: string) => Promise<T> }): Promise<T> {
603
+ const newPath = this.join(name)
604
+ await createable.create(newPath)
605
+ return factory(newPath) as unknown as Promise<T>
606
+ }
607
+
608
+ deleteSync(options: fs.RmOptions = { recursive: true }) {
609
+ using _ = this.initChangeSync()
610
+ fs.rmSync(this.isAt, options)
611
+ }
612
+ async delete(options: fs.RmOptions = { recursive: true }) {
613
+ using _ = await this.initChange()
614
+ await fp.rm(this.isAt, options)
615
+ }
616
+ moveSync(into: Folder) {
617
+ using _ = this.initChangeSync()
618
+ const newPath = into.join(this.name)
619
+ fs.renameSync(this.isAt, newPath)
620
+ this.pointsTo = newPath
621
+ }
622
+ async move(into: Folder) {
623
+ using _ = await this.initChange()
624
+ const newPath = into.join(this.name)
625
+ await fp.rename(this.isAt, newPath)
626
+ this.pointsTo = newPath
627
+ }
628
+ copySync(into: Folder): this {
629
+ const newPath = into.join(this.name)
630
+ fs.cpSync(this.isAt, newPath, { recursive: true })
631
+ return new Folder(newPath, false) as this
632
+ }
633
+ async copy(into: Folder): Promise<this> {
634
+ const newPath = into.join(this.name)
635
+ await fp.cp(this.isAt, newPath, { recursive: true })
636
+ return new Folder(newPath, false) as this
637
+ }
638
+ renameSync(to: string) {
639
+ using _ = this.initChangeSync()
640
+ const newPath = this.parent().join(to)
641
+ fs.renameSync(this.isAt, newPath)
642
+ this.pointsTo = newPath
643
+ }
644
+ async rename(to: string) {
645
+ using _ = await this.initChange()
646
+ const newPath = this.parent().join(to)
647
+ await fp.rename(this.isAt, newPath)
648
+ this.pointsTo = newPath
649
+ }
650
+ resurrectSync() {
651
+ using _ = this.initChangeSync()
652
+ fs.mkdirSync(this.isAt, { recursive: true })
653
+ }
654
+ async resurrect() {
655
+ using _ = await this.initChange()
656
+ await fp.mkdir(this.isAt, { recursive: true })
657
+ }
658
+ }
659
+
660
+ export function sysRoot() { return new Folder(ph.parse(process.cwd()).root, false) }
661
+ export function home() { return new Folder(os.homedir(), false) }
662
+ export function tmp() { return new Folder(os.tmpdir(), false) }
663
+ export function here() { return new Folder(process.cwd(), false) }
664
+ export { Folder as Dir, Folder as Directory, Folder as Dict, Folder as Dictionary }
665
+
666
+
667
+
668
+ export class SymbolicLink extends Road {
669
+ static async create(at: string, target: Road) {
670
+ try {
671
+ await fp.access(at, fs.constants.F_OK)
672
+ } catch {
673
+ await fp.symlink(target.isAt, at)
674
+ }
675
+ return new SymbolicLink(at, false)
676
+ }
677
+ static createSync(_at: string, _target: Road) {
678
+ try {
679
+ fs.accessSync(_at, fs.constants.F_OK)
680
+ } catch {
681
+ fs.symlinkSync(_target.isAt, _at)
682
+ }
683
+ return new SymbolicLink(_at, false)
684
+ }
685
+
686
+ targetSync() {
687
+ return factorySync(ph.resolve(ph.dirname(this.isAt), fs.readlinkSync(this.isAt)))
688
+ }
689
+ async target() {
690
+ return factory(ph.resolve(ph.dirname(this.isAt), await fp.readlink(this.isAt)))
691
+ }
692
+ retargetSync(_newTarget: Road) {
693
+ this.deleteSync()
694
+ fs.symlinkSync(_newTarget.isAt, this.isAt)
695
+ }
696
+ async retarget(_newTarget: Road) {
697
+ await this.delete()
698
+ return fp.symlink(_newTarget.isAt, this.isAt)
699
+ }
700
+
701
+ deleteSync() {
702
+ using _ = this.initChangeSync()
703
+ fs.unlinkSync(this.isAt)
704
+ }
705
+ async delete() {
706
+ using _ = await this.initChange()
707
+ await fp.unlink(this.isAt)
708
+ }
709
+ moveSync(_into: Folder) {
710
+ using _ = this.initChangeSync()
711
+ const newPath = _into.join(this.name)
712
+ fs.renameSync(this.isAt, newPath)
713
+ this.pointsTo = newPath
714
+ }
715
+ async move(_into: Folder) {
716
+ using _ = await this.initChange()
717
+ const newPath = _into.join(this.name)
718
+ await fp.rename(this.isAt, newPath)
719
+ this.pointsTo = newPath
720
+ }
721
+ copySync(_into: Folder): this {
722
+ const newPath = _into.join(this.name)
723
+ const target = this.targetSync()
724
+ fs.symlinkSync(target.isAt, newPath)
725
+ return new SymbolicLink(newPath, false) as this
726
+ }
727
+ async copy(_into: Folder): Promise<this> {
728
+ const newPath = _into.join(this.name)
729
+ const target = await this.target()
730
+ await fp.symlink(target.isAt, newPath)
731
+ return new SymbolicLink(newPath, false) as this
732
+ }
733
+ renameSync(_to: string) {
734
+ using _ = this.initChangeSync()
735
+ const newPath = this.parent().join(_to)
736
+ fs.renameSync(this.isAt, newPath)
737
+ this.pointsTo = newPath
738
+ }
739
+ async rename(_to: string) {
740
+ using _ = await this.initChange()
741
+ const newPath = this.parent().join(_to)
742
+ await fp.rename(this.isAt, newPath)
743
+ this.pointsTo = newPath
744
+ }
745
+ resurrectSync() {
746
+ using _ = this.initChangeSync()
747
+ const target = this.targetSync()
748
+ fs.symlinkSync(target.isAt, this.isAt)
749
+ }
750
+ async resurrect() {
751
+ using _ = await this.initChange()
752
+ const target = await this.target()
753
+ await fp.symlink(target.isAt, this.isAt)
754
+ }
755
+ }
756
+ export { SymbolicLink as Symlink }
757
+
758
+
759
+
760
+ export abstract class UnusableRoad extends Road {
761
+ override readonly mutable: boolean = false // Modification is most likely to cause system issues (e.g. deleting a device file)
762
+ constructor(_at: string, typeCheck: boolean) {
763
+ super(_at, typeCheck)
764
+ Object.freeze(this)
765
+ }
766
+ error(): never { throw new RdErr(`${this.constructor.name} at '${this.isAt}' is a system-level resource thus intentionally made immutable.`) }
767
+ override initChangeSync(): never { return this.error() }
768
+ override async initChange(): Promise<never> { return this.error() }
769
+ override deleteSync(): never { return this.error() }
770
+ override async delete(): Promise<never> { return this.error() }
771
+ override moveSync(): never { return this.error() }
772
+ override async move(): Promise<never> { return this.error() }
773
+ override copySync(): never { return this.error() }
774
+ override async copy(): Promise<never> { return this.error() }
775
+ override renameSync(): never { return this.error() }
776
+ override async rename(): Promise<never> { return this.error() }
777
+ override resurrectSync(): never { return this.error() }
778
+ override async resurrect(): Promise<never> { return this.error() }
779
+ }
780
+ export class BlockDevice extends UnusableRoad { }
781
+ export class CharacterDevice extends UnusableRoad { }
782
+ export class Fifo extends UnusableRoad { }
783
+ export class Socket extends UnusableRoad { }
784
+
785
+
786
+
787
+ export let finalizer: FinalizationRegistry<string> | null = null
788
+ export let toDelete: Set<string> | null = null
789
+ let exitHandlerRegistered = false
790
+ /**
791
+ * Forcefully cleans up all files and folders registered for cleanup on exit.
792
+ *
793
+ * @remarks This function is not recommended to be called manually, as it will delete all files and folders registered for cleanup on exit, which may lead to data loss if called at the wrong time. This function is intended to be called automatically when the process exits.
794
+ */
795
+ export function forceCleanupToDelete() {
796
+ for (const path of toDelete ?? [])
797
+ try { fs.rmSync(path, { force: true, recursive: true }) } catch {}
798
+ toDelete?.clear()
799
+ toDelete = null
800
+ finalizer = null
801
+ if (exitHandlerRegistered)
802
+ process.off('exit', forceCleanupToDelete)
803
+ exitHandlerRegistered = false
804
+ }
805
+ export function registerToCleanup(self: Road) {
806
+ if (!finalizer)
807
+ finalizer = new FinalizationRegistry<string>(p => { try { fs.rmSync(p, { force: true, recursive: true }) } catch {}; toDelete?.delete(p) })
808
+ if (!toDelete)
809
+ toDelete = new Set()
810
+ if (!exitHandlerRegistered) {
811
+ process.once('exit', forceCleanupToDelete)
812
+ exitHandlerRegistered = true
813
+ }
814
+ toDelete.add(self.isAt)
815
+ finalizer.register(self, self.isAt, self)
816
+ }
817
+
818
+
819
+ export function Temp<T extends Road>(createable: { createSync: (at: string) => T }, autoCleanup: boolean): T & Disposable & AsyncDisposable {
820
+ const t = createable.createSync(tmp().join(`instrumentality@${cr.randomUUID()}`))
821
+ if (autoCleanup)
822
+ registerToCleanup(t)
823
+ return Object.freeze(Object.assign(t, {
824
+ [Symbol.dispose]() { try { t.deleteSync() } catch {} toDelete?.delete(t.isAt); finalizer?.unregister(t) },
825
+ async [Symbol.asyncDispose]() { try { await t.delete() } catch {} toDelete?.delete(t.isAt); finalizer?.unregister(t) }
826
+ }))
827
+ }