@catmint-fs/core 0.0.0-prealpha.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/dist/index.js ADDED
@@ -0,0 +1,1835 @@
1
+ // src/types.ts
2
+ var TransactionError = class extends Error {
3
+ rootCause;
4
+ sourceError;
5
+ rollbackErrors;
6
+ constructor(message, rootCause, sourceError, rollbackErrors) {
7
+ super(message);
8
+ this.name = "TransactionError";
9
+ this.rootCause = rootCause;
10
+ this.sourceError = sourceError;
11
+ this.rollbackErrors = rollbackErrors;
12
+ }
13
+ };
14
+
15
+ // src/errors.ts
16
+ var FsError = class extends Error {
17
+ code;
18
+ constructor(code, message) {
19
+ super(`${code}: ${message}`);
20
+ this.name = "FsError";
21
+ this.code = code;
22
+ }
23
+ };
24
+
25
+ // src/ledger.ts
26
+ var DEFAULT_FILE_MODE = 438;
27
+ var DEFAULT_DIR_MODE = 511;
28
+ var Ledger = class {
29
+ records = /* @__PURE__ */ new Map();
30
+ // When chmod and chown coexist on the same path, we track both
31
+ // using a separate map keyed by path to hold the secondary operation.
32
+ // E.g., if chmod is the primary, chown data is stored in chmodChownPairs.
33
+ chmodChownPairs = /* @__PURE__ */ new Map();
34
+ nextOrder = 0;
35
+ caseSensitive;
36
+ constructor(caseSensitive = true) {
37
+ this.caseSensitive = caseSensitive;
38
+ }
39
+ normalizePath(p) {
40
+ return this.caseSensitive ? p : p.toLowerCase();
41
+ }
42
+ has(path2) {
43
+ return this.records.has(this.normalizePath(path2));
44
+ }
45
+ get(path2) {
46
+ return this.records.get(this.normalizePath(path2))?.change;
47
+ }
48
+ getDetail(path2) {
49
+ const key = this.normalizePath(path2);
50
+ const rec = this.records.get(key);
51
+ if (!rec) return null;
52
+ return this.recordToDetail(rec, key);
53
+ }
54
+ recordToDetail(rec, key) {
55
+ const { change } = rec;
56
+ switch (change.type) {
57
+ case "create": {
58
+ if (change.entryType === "directory") {
59
+ return {
60
+ type: "create",
61
+ entryType: "directory",
62
+ path: change.path,
63
+ mode: rec.mode ?? DEFAULT_DIR_MODE,
64
+ uid: rec.uid ?? 0,
65
+ gid: rec.gid ?? 0
66
+ };
67
+ }
68
+ return {
69
+ type: "create",
70
+ entryType: "file",
71
+ path: change.path,
72
+ content: rec.content ?? new Uint8Array(0),
73
+ mode: rec.mode ?? DEFAULT_FILE_MODE,
74
+ uid: rec.uid ?? 0,
75
+ gid: rec.gid ?? 0
76
+ };
77
+ }
78
+ case "update":
79
+ return {
80
+ type: "update",
81
+ path: change.path,
82
+ content: rec.content ?? new Uint8Array(0),
83
+ mode: rec.mode ?? DEFAULT_FILE_MODE,
84
+ uid: rec.uid ?? 0,
85
+ gid: rec.gid ?? 0
86
+ };
87
+ case "delete":
88
+ return {
89
+ type: "delete",
90
+ entryType: change.entryType,
91
+ path: change.path
92
+ };
93
+ case "rename":
94
+ return {
95
+ type: "rename",
96
+ from: change.from,
97
+ to: change.to
98
+ };
99
+ case "chmod": {
100
+ const pair = this.chmodChownPairs.get(key);
101
+ if (pair && pair.uid !== void 0 && pair.gid !== void 0) {
102
+ }
103
+ return {
104
+ type: "chmod",
105
+ path: change.path,
106
+ mode: change.mode
107
+ };
108
+ }
109
+ case "chown": {
110
+ const pair = this.chmodChownPairs.get(key);
111
+ if (pair && pair.mode !== void 0) {
112
+ }
113
+ return {
114
+ type: "chown",
115
+ path: change.path,
116
+ uid: change.uid,
117
+ gid: change.gid
118
+ };
119
+ }
120
+ case "symlink":
121
+ return {
122
+ type: "symlink",
123
+ path: change.path,
124
+ target: change.target
125
+ };
126
+ }
127
+ }
128
+ getAll() {
129
+ const entries = [];
130
+ const sorted = [...this.records.values()].sort(
131
+ (a, b) => a.order - b.order
132
+ );
133
+ for (const r of sorted) {
134
+ entries.push(r.change);
135
+ }
136
+ return entries;
137
+ }
138
+ getAllDetails() {
139
+ const sorted = [...this.records.entries()].sort(
140
+ ([, a], [, b]) => a.order - b.order
141
+ );
142
+ return sorted.map(([key, rec]) => this.recordToDetail(rec, key));
143
+ }
144
+ recordCreate(path2, entryType, content, mode, uid, gid) {
145
+ const key = this.normalizePath(path2);
146
+ const existing = this.records.get(key);
147
+ if (existing && existing.change.type === "delete") {
148
+ this.records.set(key, {
149
+ order: this.nextOrder++,
150
+ change: { type: "update", path: path2 },
151
+ content,
152
+ mode,
153
+ uid,
154
+ gid
155
+ });
156
+ this.chmodChownPairs.delete(key);
157
+ return;
158
+ }
159
+ if (existing && (existing.change.type === "create" || existing.change.type === "update")) {
160
+ existing.order = this.nextOrder++;
161
+ if (content !== void 0) existing.content = content;
162
+ if (mode !== void 0) existing.mode = mode;
163
+ if (uid !== void 0) existing.uid = uid;
164
+ if (gid !== void 0) existing.gid = gid;
165
+ return;
166
+ }
167
+ this.records.set(key, {
168
+ order: this.nextOrder++,
169
+ change: { type: "create", path: path2, entryType },
170
+ content,
171
+ mode,
172
+ uid,
173
+ gid
174
+ });
175
+ this.chmodChownPairs.delete(key);
176
+ }
177
+ recordUpdate(path2, content, mode, uid, gid) {
178
+ const key = this.normalizePath(path2);
179
+ const existing = this.records.get(key);
180
+ if (existing && existing.change.type === "create") {
181
+ existing.order = this.nextOrder++;
182
+ if (content !== void 0) existing.content = content;
183
+ if (mode !== void 0) existing.mode = mode;
184
+ if (uid !== void 0) existing.uid = uid;
185
+ if (gid !== void 0) existing.gid = gid;
186
+ return;
187
+ }
188
+ if (existing && existing.change.type === "update") {
189
+ existing.order = this.nextOrder++;
190
+ if (content !== void 0) existing.content = content;
191
+ if (mode !== void 0) existing.mode = mode;
192
+ if (uid !== void 0) existing.uid = uid;
193
+ if (gid !== void 0) existing.gid = gid;
194
+ return;
195
+ }
196
+ this.records.set(key, {
197
+ order: this.nextOrder++,
198
+ change: { type: "update", path: path2 },
199
+ content,
200
+ mode,
201
+ uid,
202
+ gid
203
+ });
204
+ this.chmodChownPairs.delete(key);
205
+ }
206
+ recordDelete(path2, entryType = "file") {
207
+ const key = this.normalizePath(path2);
208
+ const existing = this.records.get(key);
209
+ if (existing && existing.change.type === "create") {
210
+ this.records.delete(key);
211
+ this.chmodChownPairs.delete(key);
212
+ return;
213
+ }
214
+ if (existing && existing.change.type === "symlink") {
215
+ this.records.delete(key);
216
+ this.chmodChownPairs.delete(key);
217
+ return;
218
+ }
219
+ this.records.set(key, {
220
+ order: this.nextOrder++,
221
+ change: { type: "delete", entryType, path: path2 }
222
+ });
223
+ this.chmodChownPairs.delete(key);
224
+ }
225
+ recordRename(from, to) {
226
+ const fromKey = this.normalizePath(from);
227
+ const toKey = this.normalizePath(to);
228
+ const existing = this.records.get(fromKey);
229
+ if (existing && existing.change.type === "create") {
230
+ this.records.delete(fromKey);
231
+ this.chmodChownPairs.delete(fromKey);
232
+ this.records.set(toKey, {
233
+ order: this.nextOrder++,
234
+ change: { type: "create", path: to, entryType: existing.change.entryType },
235
+ content: existing.content,
236
+ mode: existing.mode,
237
+ uid: existing.uid,
238
+ gid: existing.gid,
239
+ target: existing.target
240
+ });
241
+ this.chmodChownPairs.delete(toKey);
242
+ return;
243
+ }
244
+ if (existing && existing.change.type === "symlink") {
245
+ this.records.delete(fromKey);
246
+ this.chmodChownPairs.delete(fromKey);
247
+ this.records.set(toKey, {
248
+ order: this.nextOrder++,
249
+ change: { type: "symlink", path: to, target: existing.change.target },
250
+ target: existing.change.target
251
+ });
252
+ this.chmodChownPairs.delete(toKey);
253
+ return;
254
+ }
255
+ this.records.set(fromKey, {
256
+ order: this.nextOrder++,
257
+ change: { type: "delete", entryType: "file", path: from }
258
+ });
259
+ this.chmodChownPairs.delete(fromKey);
260
+ this.records.set(toKey, {
261
+ order: this.nextOrder++,
262
+ change: { type: "rename", from, to }
263
+ });
264
+ this.chmodChownPairs.delete(toKey);
265
+ }
266
+ recordChmod(path2, mode) {
267
+ const key = this.normalizePath(path2);
268
+ const existing = this.records.get(key);
269
+ if (existing && (existing.change.type === "create" || existing.change.type === "update" || existing.change.type === "symlink")) {
270
+ existing.mode = mode;
271
+ return;
272
+ }
273
+ if (existing && existing.change.type === "chmod") {
274
+ existing.change = { type: "chmod", path: path2, mode };
275
+ existing.mode = mode;
276
+ return;
277
+ }
278
+ if (existing && existing.change.type === "chown") {
279
+ const chownData = {
280
+ uid: existing.change.uid,
281
+ gid: existing.change.gid
282
+ };
283
+ this.records.set(key, {
284
+ order: this.nextOrder++,
285
+ change: { type: "chmod", path: path2, mode },
286
+ mode,
287
+ uid: existing.uid,
288
+ gid: existing.gid
289
+ });
290
+ this.chmodChownPairs.set(key, chownData);
291
+ return;
292
+ }
293
+ this.records.set(key, {
294
+ order: this.nextOrder++,
295
+ change: { type: "chmod", path: path2, mode },
296
+ mode
297
+ });
298
+ }
299
+ recordChown(path2, uid, gid) {
300
+ const key = this.normalizePath(path2);
301
+ const existing = this.records.get(key);
302
+ if (existing && (existing.change.type === "create" || existing.change.type === "update" || existing.change.type === "symlink")) {
303
+ existing.uid = uid;
304
+ existing.gid = gid;
305
+ return;
306
+ }
307
+ if (existing && existing.change.type === "chown") {
308
+ existing.change = { type: "chown", path: path2, uid, gid };
309
+ existing.uid = uid;
310
+ existing.gid = gid;
311
+ return;
312
+ }
313
+ if (existing && existing.change.type === "chmod") {
314
+ const chmodData = { mode: existing.change.mode };
315
+ this.records.set(key, {
316
+ order: this.nextOrder++,
317
+ change: { type: "chown", path: path2, uid, gid },
318
+ mode: existing.mode,
319
+ uid,
320
+ gid
321
+ });
322
+ this.chmodChownPairs.set(key, chmodData);
323
+ return;
324
+ }
325
+ this.records.set(key, {
326
+ order: this.nextOrder++,
327
+ change: { type: "chown", path: path2, uid, gid },
328
+ uid,
329
+ gid
330
+ });
331
+ }
332
+ recordSymlink(path2, target) {
333
+ const key = this.normalizePath(path2);
334
+ this.records.set(key, {
335
+ order: this.nextOrder++,
336
+ change: { type: "symlink", path: path2, target },
337
+ target
338
+ });
339
+ this.chmodChownPairs.delete(key);
340
+ }
341
+ /**
342
+ * Returns the virtual mode for a path if it has been chmod'd (or created
343
+ * with a mode) in this ledger.
344
+ */
345
+ getVirtualMode(path2) {
346
+ const rec = this.records.get(this.normalizePath(path2));
347
+ if (!rec) return void 0;
348
+ return rec.mode;
349
+ }
350
+ /**
351
+ * Returns virtual ownership for a path if it has been chown'd (or created
352
+ * with ownership) in this ledger.
353
+ */
354
+ getVirtualOwnership(path2) {
355
+ const rec = this.records.get(this.normalizePath(path2));
356
+ if (!rec) return void 0;
357
+ if (rec.uid !== void 0 && rec.gid !== void 0) {
358
+ return { uid: rec.uid, gid: rec.gid };
359
+ }
360
+ return void 0;
361
+ }
362
+ /**
363
+ * Returns the content stored for a path, if any.
364
+ */
365
+ getContent(path2) {
366
+ const rec = this.records.get(this.normalizePath(path2));
367
+ if (!rec) return void 0;
368
+ return rec.content;
369
+ }
370
+ clear() {
371
+ this.records.clear();
372
+ this.chmodChownPairs.clear();
373
+ this.nextOrder = 0;
374
+ }
375
+ };
376
+
377
+ // src/permissions.ts
378
+ async function checkPermission(adapter, ledger, capabilities, path2, op) {
379
+ if (!capabilities.permissions) {
380
+ return;
381
+ }
382
+ const virtualMode = ledger.getVirtualMode(path2);
383
+ if (virtualMode !== void 0) {
384
+ const ownership = ledger.getVirtualOwnership(path2);
385
+ checkModePermission(
386
+ virtualMode,
387
+ op,
388
+ ownership?.uid,
389
+ ownership?.gid
390
+ );
391
+ return;
392
+ }
393
+ if (ledger.has(path2)) {
394
+ return;
395
+ }
396
+ await adapter.checkPermission(path2, op);
397
+ }
398
+ function checkModePermission(mode, op, fileUid, fileGid) {
399
+ let bit;
400
+ switch (op) {
401
+ case "read":
402
+ bit = 4;
403
+ break;
404
+ case "write":
405
+ bit = 2;
406
+ break;
407
+ case "execute":
408
+ bit = 1;
409
+ break;
410
+ }
411
+ const ownerBits = mode >> 6 & 7;
412
+ const groupBits = mode >> 3 & 7;
413
+ const otherBits = mode & 7;
414
+ if ((otherBits & bit) !== 0) {
415
+ return;
416
+ }
417
+ if ((groupBits & bit) !== 0) {
418
+ return;
419
+ }
420
+ if ((ownerBits & bit) !== 0) {
421
+ return;
422
+ }
423
+ const opName = op === "read" ? "reading" : op === "write" ? "writing" : "executing";
424
+ throw new FsError("EACCES", `permission denied for ${opName}`);
425
+ }
426
+
427
+ // src/layer.ts
428
+ var encoder = new TextEncoder();
429
+ var MAX_SYMLINK_DEPTH = 40;
430
+ var DEFAULT_FILE_MODE2 = 438;
431
+ var DEFAULT_DIR_MODE2 = 511;
432
+ var Layer = class {
433
+ adapter;
434
+ ledger;
435
+ capabilities;
436
+ root;
437
+ disposed = false;
438
+ constructor(root, adapter, capabilities) {
439
+ this.root = root;
440
+ this.adapter = adapter;
441
+ this.capabilities = capabilities;
442
+ this.ledger = new Ledger(capabilities.caseSensitive);
443
+ }
444
+ assertNotDisposed() {
445
+ if (this.disposed) {
446
+ throw new FsError("DISPOSED", "layer has been disposed");
447
+ }
448
+ }
449
+ /**
450
+ * Enforce ENOSYS for operations that require symlink support.
451
+ */
452
+ assertSymlinksSupported() {
453
+ if (!this.capabilities.symlinks) {
454
+ throw new FsError("ENOSYS", "operation not supported: symlinks are disabled");
455
+ }
456
+ }
457
+ resolvePath(p) {
458
+ if (p.startsWith("/")) return p;
459
+ return this.root + (this.root.endsWith("/") ? "" : "/") + p;
460
+ }
461
+ /**
462
+ * Follow symlinks to resolve the final path. Used by operations that
463
+ * follow symlinks (readFile, stat, writeFile, chmod, chown, exists).
464
+ */
465
+ async resolveSymlinks(path2, depth = 0) {
466
+ if (depth > MAX_SYMLINK_DEPTH) {
467
+ throw new FsError("ELOOP", `too many levels of symbolic links: ${path2}`);
468
+ }
469
+ const change = this.ledger.get(path2);
470
+ if (change && change.type === "symlink") {
471
+ const target = change.target;
472
+ const resolved = target.startsWith("/") ? target : this.resolveRelativeTarget(path2, target);
473
+ return this.resolveSymlinks(resolved, depth + 1);
474
+ }
475
+ if (change && change.type === "delete") {
476
+ throw new FsError("ENOENT", `no such file or directory: ${path2}`);
477
+ }
478
+ if (change && (change.type === "create" || change.type === "update")) {
479
+ return path2;
480
+ }
481
+ if (change && change.type === "rename") {
482
+ return path2;
483
+ }
484
+ try {
485
+ const lstatResult = await this.adapter.lstat(path2);
486
+ if (lstatResult.isSymbolicLink()) {
487
+ const target = await this.adapter.readlink(path2);
488
+ const resolved = target.startsWith("/") ? target : this.resolveRelativeTarget(path2, target);
489
+ return this.resolveSymlinks(resolved, depth + 1);
490
+ }
491
+ return path2;
492
+ } catch {
493
+ return path2;
494
+ }
495
+ }
496
+ resolveRelativeTarget(linkPath, target) {
497
+ const dir = linkPath.substring(0, linkPath.lastIndexOf("/")) || "/";
498
+ const parts = dir.split("/").filter(Boolean);
499
+ const targetParts = target.split("/");
500
+ for (const part of targetParts) {
501
+ if (part === "..") {
502
+ parts.pop();
503
+ } else if (part !== "." && part !== "") {
504
+ parts.push(part);
505
+ }
506
+ }
507
+ return "/" + parts.join("/");
508
+ }
509
+ // --- Reading ---
510
+ async readFile(path2) {
511
+ this.assertNotDisposed();
512
+ const absPath = this.resolvePath(path2);
513
+ const resolved = await this.resolveSymlinks(absPath);
514
+ await checkPermission(
515
+ this.adapter,
516
+ this.ledger,
517
+ this.capabilities,
518
+ resolved,
519
+ "read"
520
+ );
521
+ const change = this.ledger.get(resolved);
522
+ if (change) {
523
+ if (change.type === "delete") {
524
+ throw new FsError(
525
+ "ENOENT",
526
+ `no such file or directory: ${resolved}`
527
+ );
528
+ }
529
+ if (change.type === "create" && change.entryType === "directory") {
530
+ throw new FsError("EISDIR", `illegal operation on a directory: ${resolved}`);
531
+ }
532
+ const content = this.ledger.getContent(resolved);
533
+ if (content !== void 0) {
534
+ return content;
535
+ }
536
+ if (change.type === "rename") {
537
+ return this.adapter.readFile(change.from);
538
+ }
539
+ }
540
+ return this.adapter.readFile(resolved);
541
+ }
542
+ createReadStream(path2) {
543
+ this.assertNotDisposed();
544
+ const absPath = this.resolvePath(path2);
545
+ const self = this;
546
+ return new ReadableStream({
547
+ async start(controller) {
548
+ try {
549
+ const resolved = await self.resolveSymlinks(absPath);
550
+ await checkPermission(
551
+ self.adapter,
552
+ self.ledger,
553
+ self.capabilities,
554
+ resolved,
555
+ "read"
556
+ );
557
+ const change = self.ledger.get(resolved);
558
+ if (change) {
559
+ if (change.type === "delete") {
560
+ controller.error(
561
+ new FsError(
562
+ "ENOENT",
563
+ `no such file or directory: ${resolved}`
564
+ )
565
+ );
566
+ return;
567
+ }
568
+ if (change.type === "create" && change.entryType === "directory") {
569
+ controller.error(
570
+ new FsError("EISDIR", `illegal operation on a directory: ${resolved}`)
571
+ );
572
+ return;
573
+ }
574
+ const content = self.ledger.getContent(resolved);
575
+ if (content !== void 0) {
576
+ controller.enqueue(content);
577
+ controller.close();
578
+ return;
579
+ }
580
+ if (change.type === "rename") {
581
+ const reader2 = self.adapter.createReadStream(change.from).getReader();
582
+ try {
583
+ while (true) {
584
+ const { done, value } = await reader2.read();
585
+ if (done) break;
586
+ controller.enqueue(value);
587
+ }
588
+ controller.close();
589
+ } catch (err) {
590
+ controller.error(err);
591
+ }
592
+ return;
593
+ }
594
+ }
595
+ const reader = self.adapter.createReadStream(resolved).getReader();
596
+ try {
597
+ while (true) {
598
+ const { done, value } = await reader.read();
599
+ if (done) break;
600
+ controller.enqueue(value);
601
+ }
602
+ controller.close();
603
+ } catch (err) {
604
+ controller.error(err);
605
+ }
606
+ } catch (err) {
607
+ controller.error(err);
608
+ }
609
+ }
610
+ });
611
+ }
612
+ async readdir(path2) {
613
+ this.assertNotDisposed();
614
+ const absPath = this.resolvePath(path2);
615
+ const resolved = await this.resolveSymlinks(absPath);
616
+ const change = this.ledger.get(resolved);
617
+ if (change && change.type === "delete") {
618
+ throw new FsError(
619
+ "ENOENT",
620
+ `no such file or directory: ${resolved}`
621
+ );
622
+ }
623
+ let entries = [];
624
+ let isVirtualDir = false;
625
+ if (change && change.type === "create" && change.entryType === "directory") {
626
+ isVirtualDir = true;
627
+ } else {
628
+ try {
629
+ entries = await this.adapter.readdir(resolved);
630
+ } catch (err) {
631
+ if (!isVirtualDir) throw err;
632
+ }
633
+ }
634
+ const entryMap = /* @__PURE__ */ new Map();
635
+ for (const entry of entries) {
636
+ const key = this.capabilities.caseSensitive ? entry.name : entry.name.toLowerCase();
637
+ entryMap.set(key, entry);
638
+ }
639
+ const allChanges = this.ledger.getAll();
640
+ const dirPrefix = resolved.endsWith("/") ? resolved : resolved + "/";
641
+ for (const ch of allChanges) {
642
+ const changePath = this.getChangePath(ch);
643
+ if (!changePath) continue;
644
+ if (!changePath.startsWith(dirPrefix)) continue;
645
+ const relative = changePath.substring(dirPrefix.length);
646
+ if (relative.includes("/")) continue;
647
+ const name = relative;
648
+ const key = this.capabilities.caseSensitive ? name : name.toLowerCase();
649
+ if (ch.type === "delete") {
650
+ entryMap.delete(key);
651
+ } else if (ch.type === "create") {
652
+ const entryType = ch.entryType;
653
+ entryMap.set(key, {
654
+ name,
655
+ isFile: () => entryType === "file",
656
+ isDirectory: () => entryType === "directory",
657
+ isSymbolicLink: () => false
658
+ });
659
+ } else if (ch.type === "symlink") {
660
+ entryMap.set(key, {
661
+ name,
662
+ isFile: () => false,
663
+ isDirectory: () => false,
664
+ isSymbolicLink: () => true
665
+ });
666
+ } else if (ch.type === "update" || ch.type === "rename") {
667
+ if (!entryMap.has(key)) {
668
+ entryMap.set(key, {
669
+ name,
670
+ isFile: () => true,
671
+ isDirectory: () => false,
672
+ isSymbolicLink: () => false
673
+ });
674
+ }
675
+ }
676
+ }
677
+ return [...entryMap.values()];
678
+ }
679
+ getChangePath(ch) {
680
+ switch (ch.type) {
681
+ case "create":
682
+ case "update":
683
+ case "delete":
684
+ case "chmod":
685
+ case "chown":
686
+ case "symlink":
687
+ return ch.path;
688
+ case "rename":
689
+ return ch.to;
690
+ default:
691
+ return void 0;
692
+ }
693
+ }
694
+ async stat(path2) {
695
+ this.assertNotDisposed();
696
+ const absPath = this.resolvePath(path2);
697
+ const resolved = await this.resolveSymlinks(absPath);
698
+ return this.statResolved(resolved, true);
699
+ }
700
+ async lstat(path2) {
701
+ this.assertNotDisposed();
702
+ this.assertSymlinksSupported();
703
+ const absPath = this.resolvePath(path2);
704
+ return this.statResolved(absPath, false);
705
+ }
706
+ async statResolved(absPath, followSymlinks) {
707
+ const change = this.ledger.get(absPath);
708
+ if (change) {
709
+ if (change.type === "delete") {
710
+ throw new FsError(
711
+ "ENOENT",
712
+ `no such file or directory: ${absPath}`
713
+ );
714
+ }
715
+ if (change.type === "create") {
716
+ const entryType = change.entryType;
717
+ const now = Date.now();
718
+ const detail = this.ledger.getDetail(absPath);
719
+ const content = this.ledger.getContent(absPath);
720
+ const size = entryType === "file" ? content?.byteLength ?? 0 : 0;
721
+ return {
722
+ mode: detail && "mode" in detail ? detail.mode : entryType === "directory" ? DEFAULT_DIR_MODE2 : DEFAULT_FILE_MODE2,
723
+ uid: detail && "uid" in detail ? detail.uid : 0,
724
+ gid: detail && "gid" in detail ? detail.gid : 0,
725
+ size,
726
+ atimeMs: now,
727
+ mtimeMs: now,
728
+ ctimeMs: now,
729
+ birthtimeMs: now,
730
+ isFile: () => entryType === "file",
731
+ isDirectory: () => entryType === "directory",
732
+ isSymbolicLink: () => false
733
+ };
734
+ }
735
+ if (change.type === "symlink") {
736
+ const now = Date.now();
737
+ return {
738
+ mode: 511,
739
+ uid: 0,
740
+ gid: 0,
741
+ size: change.target.length,
742
+ atimeMs: now,
743
+ mtimeMs: now,
744
+ ctimeMs: now,
745
+ birthtimeMs: now,
746
+ isFile: () => false,
747
+ isDirectory: () => false,
748
+ isSymbolicLink: () => true
749
+ };
750
+ }
751
+ if (change.type === "update" || change.type === "rename") {
752
+ const detail = this.ledger.getDetail(absPath);
753
+ const content = this.ledger.getContent(absPath);
754
+ const virtualMode = this.ledger.getVirtualMode(absPath);
755
+ const virtualOwnership = this.ledger.getVirtualOwnership(absPath);
756
+ let baseStat;
757
+ try {
758
+ if (change.type === "rename") {
759
+ baseStat = followSymlinks ? await this.adapter.stat(change.from) : await this.adapter.lstat(change.from);
760
+ } else {
761
+ baseStat = followSymlinks ? await this.adapter.stat(absPath) : await this.adapter.lstat(absPath);
762
+ }
763
+ } catch {
764
+ const now = Date.now();
765
+ baseStat = {
766
+ mode: DEFAULT_FILE_MODE2,
767
+ uid: 0,
768
+ gid: 0,
769
+ size: 0,
770
+ atimeMs: now,
771
+ mtimeMs: now,
772
+ ctimeMs: now,
773
+ birthtimeMs: now,
774
+ isFile: () => true,
775
+ isDirectory: () => false,
776
+ isSymbolicLink: () => false
777
+ };
778
+ }
779
+ return {
780
+ ...baseStat,
781
+ mode: virtualMode ?? baseStat.mode,
782
+ uid: virtualOwnership?.uid ?? baseStat.uid,
783
+ gid: virtualOwnership?.gid ?? baseStat.gid,
784
+ size: content?.byteLength ?? baseStat.size,
785
+ isFile: baseStat.isFile,
786
+ isDirectory: baseStat.isDirectory,
787
+ isSymbolicLink: baseStat.isSymbolicLink
788
+ };
789
+ }
790
+ if (change.type === "chmod" || change.type === "chown") {
791
+ const virtualMode = this.ledger.getVirtualMode(absPath);
792
+ const virtualOwnership = this.ledger.getVirtualOwnership(absPath);
793
+ const baseStat = followSymlinks ? await this.adapter.stat(absPath) : await this.adapter.lstat(absPath);
794
+ return {
795
+ ...baseStat,
796
+ mode: virtualMode ?? baseStat.mode,
797
+ uid: virtualOwnership?.uid ?? baseStat.uid,
798
+ gid: virtualOwnership?.gid ?? baseStat.gid,
799
+ isFile: baseStat.isFile,
800
+ isDirectory: baseStat.isDirectory,
801
+ isSymbolicLink: baseStat.isSymbolicLink
802
+ };
803
+ }
804
+ }
805
+ return followSymlinks ? this.adapter.stat(absPath) : this.adapter.lstat(absPath);
806
+ }
807
+ async readlink(path2) {
808
+ this.assertNotDisposed();
809
+ this.assertSymlinksSupported();
810
+ const absPath = this.resolvePath(path2);
811
+ const change = this.ledger.get(absPath);
812
+ if (change) {
813
+ if (change.type === "delete") {
814
+ throw new FsError(
815
+ "ENOENT",
816
+ `no such file or directory: ${absPath}`
817
+ );
818
+ }
819
+ if (change.type === "symlink") {
820
+ return change.target;
821
+ }
822
+ throw new FsError("EINVAL", `invalid argument: ${absPath} is not a symlink`);
823
+ }
824
+ return this.adapter.readlink(absPath);
825
+ }
826
+ async exists(path2) {
827
+ this.assertNotDisposed();
828
+ const absPath = this.resolvePath(path2);
829
+ try {
830
+ const resolved = await this.resolveSymlinks(absPath);
831
+ const change = this.ledger.get(resolved);
832
+ if (change) {
833
+ return change.type !== "delete";
834
+ }
835
+ return this.adapter.exists(resolved);
836
+ } catch {
837
+ return false;
838
+ }
839
+ }
840
+ // --- Writing ---
841
+ async writeFile(path2, data, options) {
842
+ this.assertNotDisposed();
843
+ const absPath = this.resolvePath(path2);
844
+ const parentDir = absPath.substring(0, absPath.lastIndexOf("/")) || "/";
845
+ await checkPermission(
846
+ this.adapter,
847
+ this.ledger,
848
+ this.capabilities,
849
+ parentDir,
850
+ "write"
851
+ );
852
+ let resolved;
853
+ try {
854
+ resolved = await this.resolveSymlinks(absPath);
855
+ } catch (err) {
856
+ if (err instanceof FsError && err.code === "ENOENT") {
857
+ resolved = absPath;
858
+ } else {
859
+ throw err;
860
+ }
861
+ }
862
+ const content = typeof data === "string" ? encoder.encode(data) : data;
863
+ const change = this.ledger.get(resolved);
864
+ if (change) {
865
+ if (change.type === "create" && change.entryType === "directory") {
866
+ throw new FsError(
867
+ "EISDIR",
868
+ `illegal operation on a directory: ${resolved}`
869
+ );
870
+ }
871
+ if (change.type === "delete") {
872
+ this.ledger.recordCreate(
873
+ resolved,
874
+ "file",
875
+ content,
876
+ options?.mode,
877
+ options?.uid,
878
+ options?.gid
879
+ );
880
+ return;
881
+ }
882
+ this.ledger.recordUpdate(
883
+ resolved,
884
+ content,
885
+ options?.mode,
886
+ options?.uid,
887
+ options?.gid
888
+ );
889
+ return;
890
+ }
891
+ const hostExists = await this.adapter.exists(resolved);
892
+ if (hostExists) {
893
+ try {
894
+ const s = await this.adapter.stat(resolved);
895
+ if (s.isDirectory()) {
896
+ throw new FsError(
897
+ "EISDIR",
898
+ `illegal operation on a directory: ${resolved}`
899
+ );
900
+ }
901
+ } catch (err) {
902
+ if (err instanceof FsError) throw err;
903
+ }
904
+ this.ledger.recordUpdate(
905
+ resolved,
906
+ content,
907
+ options?.mode,
908
+ options?.uid,
909
+ options?.gid
910
+ );
911
+ } else {
912
+ this.ledger.recordCreate(
913
+ resolved,
914
+ "file",
915
+ content,
916
+ options?.mode,
917
+ options?.uid,
918
+ options?.gid
919
+ );
920
+ }
921
+ }
922
+ async mkdir(path2, options) {
923
+ this.assertNotDisposed();
924
+ const absPath = this.resolvePath(path2);
925
+ if (options?.recursive) {
926
+ const parts = absPath.split("/").filter(Boolean);
927
+ let current = "";
928
+ for (const part of parts) {
929
+ current += "/" + part;
930
+ const change = this.ledger.get(current);
931
+ const existsOnHost = await this.adapter.exists(current);
932
+ if (change) {
933
+ if (change.type === "delete") {
934
+ this.ledger.recordCreate(
935
+ current,
936
+ "directory",
937
+ void 0,
938
+ options?.mode,
939
+ options?.uid,
940
+ options?.gid
941
+ );
942
+ }
943
+ continue;
944
+ }
945
+ if (!existsOnHost) {
946
+ this.ledger.recordCreate(
947
+ current,
948
+ "directory",
949
+ void 0,
950
+ options?.mode,
951
+ options?.uid,
952
+ options?.gid
953
+ );
954
+ }
955
+ }
956
+ } else {
957
+ const parentDir = absPath.substring(0, absPath.lastIndexOf("/")) || "/";
958
+ const parentChange = this.ledger.get(parentDir);
959
+ let parentExists = false;
960
+ if (parentChange) {
961
+ parentExists = parentChange.type !== "delete";
962
+ } else {
963
+ parentExists = await this.adapter.exists(parentDir);
964
+ }
965
+ if (!parentExists) {
966
+ throw new FsError(
967
+ "ENOENT",
968
+ `no such file or directory: ${parentDir}`
969
+ );
970
+ }
971
+ await checkPermission(
972
+ this.adapter,
973
+ this.ledger,
974
+ this.capabilities,
975
+ parentDir,
976
+ "write"
977
+ );
978
+ const change = this.ledger.get(absPath);
979
+ if (change && change.type !== "delete") {
980
+ throw new FsError("EEXIST", `file already exists: ${absPath}`);
981
+ }
982
+ const existsOnHost = !change && await this.adapter.exists(absPath);
983
+ if (existsOnHost) {
984
+ throw new FsError("EEXIST", `file already exists: ${absPath}`);
985
+ }
986
+ this.ledger.recordCreate(
987
+ absPath,
988
+ "directory",
989
+ void 0,
990
+ options?.mode,
991
+ options?.uid,
992
+ options?.gid
993
+ );
994
+ }
995
+ }
996
+ // --- Deleting ---
997
+ async rm(path2, options) {
998
+ this.assertNotDisposed();
999
+ const absPath = this.resolvePath(path2);
1000
+ const change = this.ledger.get(absPath);
1001
+ if (change && change.type === "delete") {
1002
+ if (options?.force) return;
1003
+ throw new FsError(
1004
+ "ENOENT",
1005
+ `no such file or directory: ${absPath}`
1006
+ );
1007
+ }
1008
+ const existsOnHost = !change && await this.adapter.exists(absPath);
1009
+ if (!change && !existsOnHost) {
1010
+ if (options?.force) return;
1011
+ throw new FsError(
1012
+ "ENOENT",
1013
+ `no such file or directory: ${absPath}`
1014
+ );
1015
+ }
1016
+ const parentDir = absPath.substring(0, absPath.lastIndexOf("/")) || "/";
1017
+ await checkPermission(
1018
+ this.adapter,
1019
+ this.ledger,
1020
+ this.capabilities,
1021
+ parentDir,
1022
+ "write"
1023
+ );
1024
+ let entryType = "file";
1025
+ let isDir = false;
1026
+ if (change) {
1027
+ if (change.type === "create" && change.entryType === "directory") {
1028
+ isDir = true;
1029
+ entryType = "directory";
1030
+ } else if (change.type === "symlink") {
1031
+ entryType = "symlink";
1032
+ }
1033
+ } else if (existsOnHost) {
1034
+ try {
1035
+ const s = await this.adapter.lstat(absPath);
1036
+ if (s.isDirectory()) {
1037
+ isDir = true;
1038
+ entryType = "directory";
1039
+ } else if (s.isSymbolicLink()) {
1040
+ entryType = "symlink";
1041
+ }
1042
+ } catch {
1043
+ }
1044
+ }
1045
+ if (isDir && !options?.recursive) {
1046
+ throw new FsError("EISDIR", `is a directory: ${absPath}`);
1047
+ }
1048
+ if (isDir && options?.recursive) {
1049
+ const allChanges = this.ledger.getAll();
1050
+ const dirPrefix = absPath + "/";
1051
+ for (const ch of allChanges) {
1052
+ const chPath = this.getChangePath(ch);
1053
+ if (chPath && chPath.startsWith(dirPrefix)) {
1054
+ let childType = "file";
1055
+ if (ch.type === "create" && ch.entryType === "directory") {
1056
+ childType = "directory";
1057
+ } else if (ch.type === "symlink") {
1058
+ childType = "symlink";
1059
+ }
1060
+ this.ledger.recordDelete(chPath, childType);
1061
+ }
1062
+ }
1063
+ }
1064
+ this.ledger.recordDelete(absPath, entryType);
1065
+ }
1066
+ async rmdir(path2) {
1067
+ this.assertNotDisposed();
1068
+ const absPath = this.resolvePath(path2);
1069
+ const change = this.ledger.get(absPath);
1070
+ if (change && change.type === "delete") {
1071
+ throw new FsError(
1072
+ "ENOENT",
1073
+ `no such file or directory: ${absPath}`
1074
+ );
1075
+ }
1076
+ let isDir = false;
1077
+ if (change) {
1078
+ isDir = change.type === "create" && change.entryType === "directory";
1079
+ } else {
1080
+ try {
1081
+ const s = await this.adapter.lstat(absPath);
1082
+ isDir = s.isDirectory();
1083
+ } catch {
1084
+ throw new FsError(
1085
+ "ENOENT",
1086
+ `no such file or directory: ${absPath}`
1087
+ );
1088
+ }
1089
+ }
1090
+ if (!isDir) {
1091
+ throw new FsError("ENOTDIR", `not a directory: ${absPath}`);
1092
+ }
1093
+ const entries = await this.readdir(absPath);
1094
+ if (entries.length > 0) {
1095
+ throw new FsError("ENOTEMPTY", `directory not empty: ${absPath}`);
1096
+ }
1097
+ this.ledger.recordDelete(absPath, "directory");
1098
+ }
1099
+ // --- Renaming ---
1100
+ async rename(from, to) {
1101
+ this.assertNotDisposed();
1102
+ const absFrom = this.resolvePath(from);
1103
+ const absTo = this.resolvePath(to);
1104
+ const srcChange = this.ledger.get(absFrom);
1105
+ if (srcChange && srcChange.type === "delete") {
1106
+ throw new FsError(
1107
+ "ENOENT",
1108
+ `no such file or directory: ${absFrom}`
1109
+ );
1110
+ }
1111
+ if (!srcChange) {
1112
+ const srcExists = await this.adapter.exists(absFrom);
1113
+ if (!srcExists) {
1114
+ throw new FsError(
1115
+ "ENOENT",
1116
+ `no such file or directory: ${absFrom}`
1117
+ );
1118
+ }
1119
+ }
1120
+ const destChange = this.ledger.get(absTo);
1121
+ if (destChange && destChange.type !== "delete") {
1122
+ let srcIsDir = false;
1123
+ let destIsDir = false;
1124
+ if (srcChange && srcChange.type === "create") {
1125
+ srcIsDir = srcChange.entryType === "directory";
1126
+ } else if (!srcChange) {
1127
+ try {
1128
+ const s = await this.adapter.lstat(absFrom);
1129
+ srcIsDir = s.isDirectory();
1130
+ } catch {
1131
+ }
1132
+ }
1133
+ if (destChange.type === "create") {
1134
+ destIsDir = destChange.entryType === "directory";
1135
+ }
1136
+ if (srcIsDir !== destIsDir) {
1137
+ if (destIsDir) {
1138
+ throw new FsError("EISDIR", `cannot overwrite directory with non-directory: ${absTo}`);
1139
+ } else {
1140
+ throw new FsError("ENOTDIR", `cannot overwrite non-directory with directory: ${absTo}`);
1141
+ }
1142
+ }
1143
+ }
1144
+ this.ledger.recordRename(absFrom, absTo);
1145
+ }
1146
+ // --- Symlinks ---
1147
+ async symlink(target, path2) {
1148
+ this.assertNotDisposed();
1149
+ this.assertSymlinksSupported();
1150
+ const absPath = this.resolvePath(path2);
1151
+ const parentDir = absPath.substring(0, absPath.lastIndexOf("/")) || "/";
1152
+ await checkPermission(
1153
+ this.adapter,
1154
+ this.ledger,
1155
+ this.capabilities,
1156
+ parentDir,
1157
+ "write"
1158
+ );
1159
+ const change = this.ledger.get(absPath);
1160
+ if (change && change.type !== "delete") {
1161
+ throw new FsError("EEXIST", `file already exists: ${absPath}`);
1162
+ }
1163
+ if (!change) {
1164
+ const existsOnHost = await this.adapter.exists(absPath);
1165
+ if (existsOnHost) {
1166
+ throw new FsError("EEXIST", `file already exists: ${absPath}`);
1167
+ }
1168
+ }
1169
+ this.ledger.recordSymlink(absPath, target);
1170
+ }
1171
+ // --- Permissions ---
1172
+ async chmod(path2, mode) {
1173
+ this.assertNotDisposed();
1174
+ const absPath = this.resolvePath(path2);
1175
+ const resolved = await this.resolveSymlinks(absPath);
1176
+ const change = this.ledger.get(resolved);
1177
+ if (change && change.type === "delete") {
1178
+ throw new FsError(
1179
+ "ENOENT",
1180
+ `no such file or directory: ${resolved}`
1181
+ );
1182
+ }
1183
+ if (!change) {
1184
+ const existsOnHost = await this.adapter.exists(resolved);
1185
+ if (!existsOnHost) {
1186
+ throw new FsError(
1187
+ "ENOENT",
1188
+ `no such file or directory: ${resolved}`
1189
+ );
1190
+ }
1191
+ }
1192
+ this.ledger.recordChmod(resolved, mode);
1193
+ }
1194
+ async chown(path2, uid, gid) {
1195
+ this.assertNotDisposed();
1196
+ const absPath = this.resolvePath(path2);
1197
+ const resolved = await this.resolveSymlinks(absPath);
1198
+ const change = this.ledger.get(resolved);
1199
+ if (change && change.type === "delete") {
1200
+ throw new FsError(
1201
+ "ENOENT",
1202
+ `no such file or directory: ${resolved}`
1203
+ );
1204
+ }
1205
+ if (!change) {
1206
+ const existsOnHost = await this.adapter.exists(resolved);
1207
+ if (!existsOnHost) {
1208
+ throw new FsError(
1209
+ "ENOENT",
1210
+ `no such file or directory: ${resolved}`
1211
+ );
1212
+ }
1213
+ }
1214
+ this.ledger.recordChown(resolved, uid, gid);
1215
+ }
1216
+ async lchown(path2, uid, gid) {
1217
+ this.assertNotDisposed();
1218
+ this.assertSymlinksSupported();
1219
+ const absPath = this.resolvePath(path2);
1220
+ const change = this.ledger.get(absPath);
1221
+ if (change && change.type === "delete") {
1222
+ throw new FsError(
1223
+ "ENOENT",
1224
+ `no such file or directory: ${absPath}`
1225
+ );
1226
+ }
1227
+ if (!change) {
1228
+ const existsOnHost = await this.adapter.exists(absPath);
1229
+ if (!existsOnHost) {
1230
+ throw new FsError(
1231
+ "ENOENT",
1232
+ `no such file or directory: ${absPath}`
1233
+ );
1234
+ }
1235
+ }
1236
+ this.ledger.recordChown(absPath, uid, gid);
1237
+ }
1238
+ async getOwner(path2) {
1239
+ this.assertNotDisposed();
1240
+ const absPath = this.resolvePath(path2);
1241
+ const resolved = await this.resolveSymlinks(absPath);
1242
+ const ownership = this.ledger.getVirtualOwnership(resolved);
1243
+ if (ownership) return ownership;
1244
+ const s = await this.adapter.stat(resolved);
1245
+ return { uid: s.uid, gid: s.gid };
1246
+ }
1247
+ // --- Changes ---
1248
+ getChanges() {
1249
+ this.assertNotDisposed();
1250
+ return this.ledger.getAll();
1251
+ }
1252
+ async getChangeDetail(path2) {
1253
+ this.assertNotDisposed();
1254
+ const absPath = this.resolvePath(path2);
1255
+ return this.ledger.getDetail(absPath);
1256
+ }
1257
+ // --- Apply ---
1258
+ async apply(options) {
1259
+ this.assertNotDisposed();
1260
+ const transaction = options?.transaction ?? false;
1261
+ const allDetails = this.ledger.getAllDetails();
1262
+ if (allDetails.length === 0) {
1263
+ return { applied: 0, errors: [] };
1264
+ }
1265
+ const dirCreates = [];
1266
+ const fileCreatesUpdates = [];
1267
+ const renames = [];
1268
+ const permChanges = [];
1269
+ const fileSymlinkDeletes = [];
1270
+ const dirDeletes = [];
1271
+ for (const detail of allDetails) {
1272
+ switch (detail.type) {
1273
+ case "create":
1274
+ if (detail.entryType === "directory") {
1275
+ dirCreates.push(detail);
1276
+ } else {
1277
+ fileCreatesUpdates.push(detail);
1278
+ }
1279
+ break;
1280
+ case "update":
1281
+ fileCreatesUpdates.push(detail);
1282
+ break;
1283
+ case "rename":
1284
+ renames.push(detail);
1285
+ break;
1286
+ case "chmod":
1287
+ case "chown":
1288
+ permChanges.push(detail);
1289
+ break;
1290
+ case "delete":
1291
+ if (detail.entryType === "directory") {
1292
+ dirDeletes.push(detail);
1293
+ } else {
1294
+ fileSymlinkDeletes.push(detail);
1295
+ }
1296
+ break;
1297
+ case "symlink":
1298
+ fileCreatesUpdates.push(detail);
1299
+ break;
1300
+ }
1301
+ }
1302
+ dirCreates.sort((a, b) => {
1303
+ const aPath = a.type === "create" ? a.path : "";
1304
+ const bPath = b.type === "create" ? b.path : "";
1305
+ return aPath.split("/").length - bPath.split("/").length;
1306
+ });
1307
+ dirDeletes.sort((a, b) => {
1308
+ const aPath = a.type === "delete" ? a.path : "";
1309
+ const bPath = b.type === "delete" ? b.path : "";
1310
+ return bPath.split("/").length - aPath.split("/").length;
1311
+ });
1312
+ const plan = [
1313
+ ...dirCreates,
1314
+ ...fileCreatesUpdates,
1315
+ ...renames,
1316
+ ...permChanges,
1317
+ ...fileSymlinkDeletes,
1318
+ ...dirDeletes
1319
+ ];
1320
+ if (transaction) {
1321
+ return this.applyTransaction(plan);
1322
+ } else {
1323
+ return this.applyBestEffort(plan);
1324
+ }
1325
+ }
1326
+ async applyBestEffort(plan) {
1327
+ let applied = 0;
1328
+ const errors = [];
1329
+ for (const detail of plan) {
1330
+ try {
1331
+ await this.applyOne(detail);
1332
+ applied++;
1333
+ } catch (err) {
1334
+ errors.push({
1335
+ change: this.detailToChangeEntry(detail),
1336
+ error: err instanceof Error ? err : new Error(String(err))
1337
+ });
1338
+ }
1339
+ }
1340
+ this.ledger.clear();
1341
+ return { applied, errors };
1342
+ }
1343
+ async applyTransaction(plan) {
1344
+ const originals = [];
1345
+ let applied = 0;
1346
+ for (const detail of plan) {
1347
+ const path2 = this.getDetailPath(detail);
1348
+ if (path2) {
1349
+ try {
1350
+ const existed = await this.adapter.exists(path2);
1351
+ let content;
1352
+ let stat2;
1353
+ if (existed) {
1354
+ try {
1355
+ stat2 = await this.adapter.lstat(path2);
1356
+ if (stat2.isFile()) {
1357
+ content = await this.adapter.readFile(path2);
1358
+ }
1359
+ } catch {
1360
+ }
1361
+ }
1362
+ originals.push({
1363
+ detail,
1364
+ backup: { existed, content, stat: stat2 }
1365
+ });
1366
+ } catch {
1367
+ originals.push({
1368
+ detail,
1369
+ backup: { existed: false }
1370
+ });
1371
+ }
1372
+ }
1373
+ try {
1374
+ await this.applyOne(detail);
1375
+ applied++;
1376
+ } catch (err) {
1377
+ const rollbackErrors = [];
1378
+ for (let i = originals.length - 2; i >= 0; i--) {
1379
+ const orig = originals[i];
1380
+ try {
1381
+ await this.rollbackOne(orig.detail, orig.backup);
1382
+ } catch (rbErr) {
1383
+ rollbackErrors.push({
1384
+ change: this.detailToChangeEntry(orig.detail),
1385
+ error: rbErr instanceof Error ? rbErr : new Error(String(rbErr))
1386
+ });
1387
+ }
1388
+ }
1389
+ throw new TransactionError(
1390
+ `Transaction failed: ${err instanceof Error ? err.message : String(err)}`,
1391
+ this.detailToChangeEntry(detail),
1392
+ err instanceof Error ? err : new Error(String(err)),
1393
+ rollbackErrors
1394
+ );
1395
+ }
1396
+ }
1397
+ this.ledger.clear();
1398
+ return { applied, errors: [] };
1399
+ }
1400
+ /**
1401
+ * Convert a flat ChangeDetail back to a ChangeEntry for error reporting.
1402
+ */
1403
+ detailToChangeEntry(detail) {
1404
+ switch (detail.type) {
1405
+ case "create":
1406
+ return { type: "create", entryType: detail.entryType, path: detail.path };
1407
+ case "update":
1408
+ return { type: "update", path: detail.path };
1409
+ case "delete":
1410
+ return { type: "delete", entryType: detail.entryType, path: detail.path };
1411
+ case "rename":
1412
+ return { type: "rename", from: detail.from, to: detail.to };
1413
+ case "chmod":
1414
+ return { type: "chmod", path: detail.path, mode: detail.mode };
1415
+ case "chown":
1416
+ return { type: "chown", path: detail.path, uid: detail.uid, gid: detail.gid };
1417
+ case "symlink":
1418
+ return { type: "symlink", path: detail.path, target: detail.target };
1419
+ }
1420
+ }
1421
+ /**
1422
+ * Get the primary path from a ChangeDetail.
1423
+ */
1424
+ getDetailPath(detail) {
1425
+ switch (detail.type) {
1426
+ case "create":
1427
+ case "update":
1428
+ case "delete":
1429
+ case "chmod":
1430
+ case "chown":
1431
+ case "symlink":
1432
+ return detail.path;
1433
+ case "rename":
1434
+ return detail.to;
1435
+ default:
1436
+ return void 0;
1437
+ }
1438
+ }
1439
+ async rollbackOne(detail, backup) {
1440
+ const path2 = this.getDetailPath(detail);
1441
+ if (!path2) return;
1442
+ if (backup.existed) {
1443
+ if (backup.content !== void 0) {
1444
+ await this.adapter.writeFile(path2, backup.content, {
1445
+ mode: backup.stat?.mode
1446
+ });
1447
+ }
1448
+ if (backup.stat) {
1449
+ try {
1450
+ await this.adapter.chmod(path2, backup.stat.mode);
1451
+ } catch {
1452
+ }
1453
+ try {
1454
+ await this.adapter.chown(path2, backup.stat.uid, backup.stat.gid);
1455
+ } catch {
1456
+ }
1457
+ }
1458
+ } else {
1459
+ try {
1460
+ await this.adapter.rm(path2, { recursive: true, force: true });
1461
+ } catch {
1462
+ }
1463
+ }
1464
+ }
1465
+ async applyOne(detail) {
1466
+ switch (detail.type) {
1467
+ case "create": {
1468
+ if (detail.entryType === "directory") {
1469
+ await this.adapter.mkdir(detail.path, {
1470
+ recursive: true,
1471
+ mode: detail.mode,
1472
+ uid: detail.uid,
1473
+ gid: detail.gid
1474
+ });
1475
+ } else {
1476
+ await this.adapter.writeFile(
1477
+ detail.path,
1478
+ detail.content,
1479
+ {
1480
+ mode: detail.mode,
1481
+ uid: detail.uid,
1482
+ gid: detail.gid
1483
+ }
1484
+ );
1485
+ }
1486
+ break;
1487
+ }
1488
+ case "update":
1489
+ await this.adapter.writeFile(
1490
+ detail.path,
1491
+ detail.content,
1492
+ {
1493
+ mode: detail.mode,
1494
+ uid: detail.uid,
1495
+ gid: detail.gid
1496
+ }
1497
+ );
1498
+ break;
1499
+ case "delete":
1500
+ await this.adapter.rm(detail.path, {
1501
+ recursive: true,
1502
+ force: true
1503
+ });
1504
+ break;
1505
+ case "rename":
1506
+ await this.adapter.rename(detail.from, detail.to);
1507
+ break;
1508
+ case "chmod":
1509
+ await this.adapter.chmod(detail.path, detail.mode);
1510
+ break;
1511
+ case "chown":
1512
+ await this.adapter.chown(detail.path, detail.uid, detail.gid);
1513
+ break;
1514
+ case "symlink":
1515
+ await this.adapter.symlink(detail.target, detail.path);
1516
+ break;
1517
+ }
1518
+ }
1519
+ // --- Lifecycle ---
1520
+ dispose() {
1521
+ this.disposed = true;
1522
+ this.ledger.clear();
1523
+ }
1524
+ reset() {
1525
+ this.assertNotDisposed();
1526
+ this.ledger.clear();
1527
+ }
1528
+ };
1529
+
1530
+ // src/adapters/local.ts
1531
+ import * as fs from "fs/promises";
1532
+ import * as nodeFs from "fs";
1533
+ import * as path from "path";
1534
+ var LocalAdapter = class {
1535
+ _capabilities = {
1536
+ permissions: true,
1537
+ symlinks: true,
1538
+ caseSensitive: true
1539
+ };
1540
+ async initialize(root) {
1541
+ const testName = `.catmint-case-probe-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1542
+ const testPath = path.join(root, testName);
1543
+ const testPathUpper = path.join(root, testName.toUpperCase());
1544
+ try {
1545
+ await fs.writeFile(testPath, "");
1546
+ let caseSensitive = true;
1547
+ try {
1548
+ await fs.access(testPathUpper);
1549
+ caseSensitive = false;
1550
+ } catch {
1551
+ caseSensitive = true;
1552
+ }
1553
+ this._capabilities = {
1554
+ permissions: true,
1555
+ symlinks: true,
1556
+ caseSensitive
1557
+ };
1558
+ } finally {
1559
+ try {
1560
+ await fs.unlink(testPath);
1561
+ } catch {
1562
+ }
1563
+ }
1564
+ }
1565
+ capabilities() {
1566
+ return this._capabilities;
1567
+ }
1568
+ async readFile(filePath) {
1569
+ try {
1570
+ const buffer = await fs.readFile(filePath);
1571
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
1572
+ } catch (err) {
1573
+ throw this.mapError(err, filePath);
1574
+ }
1575
+ }
1576
+ createReadStream(filePath) {
1577
+ const nodeStream = nodeFs.createReadStream(filePath);
1578
+ return new ReadableStream({
1579
+ start(controller) {
1580
+ nodeStream.on("data", (chunk) => {
1581
+ controller.enqueue(
1582
+ new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
1583
+ );
1584
+ });
1585
+ nodeStream.on("end", () => {
1586
+ controller.close();
1587
+ });
1588
+ nodeStream.on("error", (err) => {
1589
+ controller.error(err);
1590
+ });
1591
+ },
1592
+ cancel() {
1593
+ nodeStream.destroy();
1594
+ }
1595
+ });
1596
+ }
1597
+ async readdir(dirPath) {
1598
+ try {
1599
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
1600
+ return entries.map((entry) => ({
1601
+ name: entry.name,
1602
+ isFile: () => entry.isFile(),
1603
+ isDirectory: () => entry.isDirectory(),
1604
+ isSymbolicLink: () => entry.isSymbolicLink()
1605
+ }));
1606
+ } catch (err) {
1607
+ throw this.mapError(err, dirPath);
1608
+ }
1609
+ }
1610
+ async stat(filePath) {
1611
+ try {
1612
+ const s = await fs.stat(filePath);
1613
+ return {
1614
+ mode: s.mode,
1615
+ uid: s.uid,
1616
+ gid: s.gid,
1617
+ size: s.size,
1618
+ atimeMs: s.atimeMs,
1619
+ mtimeMs: s.mtimeMs,
1620
+ ctimeMs: s.ctimeMs,
1621
+ birthtimeMs: s.birthtimeMs,
1622
+ isFile: () => s.isFile(),
1623
+ isDirectory: () => s.isDirectory(),
1624
+ isSymbolicLink: () => s.isSymbolicLink()
1625
+ };
1626
+ } catch (err) {
1627
+ throw this.mapError(err, filePath);
1628
+ }
1629
+ }
1630
+ async lstat(filePath) {
1631
+ try {
1632
+ const s = await fs.lstat(filePath);
1633
+ return {
1634
+ mode: s.mode,
1635
+ uid: s.uid,
1636
+ gid: s.gid,
1637
+ size: s.size,
1638
+ atimeMs: s.atimeMs,
1639
+ mtimeMs: s.mtimeMs,
1640
+ ctimeMs: s.ctimeMs,
1641
+ birthtimeMs: s.birthtimeMs,
1642
+ isFile: () => s.isFile(),
1643
+ isDirectory: () => s.isDirectory(),
1644
+ isSymbolicLink: () => s.isSymbolicLink()
1645
+ };
1646
+ } catch (err) {
1647
+ throw this.mapError(err, filePath);
1648
+ }
1649
+ }
1650
+ async readlink(filePath) {
1651
+ try {
1652
+ return await fs.readlink(filePath);
1653
+ } catch (err) {
1654
+ throw this.mapError(err, filePath);
1655
+ }
1656
+ }
1657
+ async exists(filePath) {
1658
+ try {
1659
+ await fs.access(filePath);
1660
+ return true;
1661
+ } catch {
1662
+ return false;
1663
+ }
1664
+ }
1665
+ async writeFile(filePath, data, options) {
1666
+ try {
1667
+ await fs.writeFile(filePath, data, { mode: options?.mode });
1668
+ if (options?.uid !== void 0 && options?.gid !== void 0 && (options.uid !== process.getuid?.() || options.gid !== process.getgid?.())) {
1669
+ try {
1670
+ await fs.chown(filePath, options.uid, options.gid);
1671
+ } catch {
1672
+ }
1673
+ }
1674
+ } catch (err) {
1675
+ throw this.mapError(err, filePath);
1676
+ }
1677
+ }
1678
+ async mkdir(dirPath, options) {
1679
+ try {
1680
+ await fs.mkdir(dirPath, {
1681
+ recursive: options?.recursive,
1682
+ mode: options?.mode
1683
+ });
1684
+ if (options?.uid !== void 0 && options?.gid !== void 0) {
1685
+ await fs.chown(dirPath, options.uid, options.gid);
1686
+ }
1687
+ } catch (err) {
1688
+ throw this.mapError(err, dirPath);
1689
+ }
1690
+ }
1691
+ async rm(filePath, options) {
1692
+ try {
1693
+ await fs.rm(filePath, {
1694
+ recursive: options?.recursive,
1695
+ force: options?.force
1696
+ });
1697
+ } catch (err) {
1698
+ throw this.mapError(err, filePath);
1699
+ }
1700
+ }
1701
+ async rmdir(dirPath) {
1702
+ try {
1703
+ await fs.rmdir(dirPath);
1704
+ } catch (err) {
1705
+ throw this.mapError(err, dirPath);
1706
+ }
1707
+ }
1708
+ async rename(from, to) {
1709
+ try {
1710
+ await fs.rename(from, to);
1711
+ } catch (err) {
1712
+ throw this.mapError(err, from);
1713
+ }
1714
+ }
1715
+ async symlink(target, linkPath) {
1716
+ try {
1717
+ await fs.symlink(target, linkPath);
1718
+ } catch (err) {
1719
+ throw this.mapError(err, linkPath);
1720
+ }
1721
+ }
1722
+ async chmod(filePath, mode) {
1723
+ try {
1724
+ await fs.chmod(filePath, mode);
1725
+ } catch (err) {
1726
+ throw this.mapError(err, filePath);
1727
+ }
1728
+ }
1729
+ async chown(filePath, uid, gid) {
1730
+ try {
1731
+ await fs.chown(filePath, uid, gid);
1732
+ } catch (err) {
1733
+ throw this.mapError(err, filePath);
1734
+ }
1735
+ }
1736
+ async lchown(filePath, uid, gid) {
1737
+ try {
1738
+ await fs.lchown(filePath, uid, gid);
1739
+ } catch (err) {
1740
+ throw this.mapError(err, filePath);
1741
+ }
1742
+ }
1743
+ async checkPermission(filePath, op) {
1744
+ let mode;
1745
+ switch (op) {
1746
+ case "read":
1747
+ mode = nodeFs.constants.R_OK;
1748
+ break;
1749
+ case "write":
1750
+ mode = nodeFs.constants.W_OK;
1751
+ break;
1752
+ case "execute":
1753
+ mode = nodeFs.constants.X_OK;
1754
+ break;
1755
+ }
1756
+ try {
1757
+ await fs.access(filePath, mode);
1758
+ } catch (err) {
1759
+ throw this.mapError(err, filePath);
1760
+ }
1761
+ }
1762
+ mapError(err, filePath) {
1763
+ if (err instanceof FsError) return err;
1764
+ const nodeErr = err;
1765
+ const code = nodeErr?.code;
1766
+ switch (code) {
1767
+ case "ENOENT":
1768
+ return new FsError(
1769
+ "ENOENT",
1770
+ `no such file or directory: ${filePath}`
1771
+ );
1772
+ case "EACCES":
1773
+ return new FsError("EACCES", `permission denied: ${filePath}`);
1774
+ case "EPERM":
1775
+ return new FsError(
1776
+ "EPERM",
1777
+ `operation not permitted: ${filePath}`
1778
+ );
1779
+ case "EEXIST":
1780
+ return new FsError(
1781
+ "EEXIST",
1782
+ `file already exists: ${filePath}`
1783
+ );
1784
+ case "ENOTDIR":
1785
+ return new FsError("ENOTDIR", `not a directory: ${filePath}`);
1786
+ case "EISDIR":
1787
+ return new FsError(
1788
+ "EISDIR",
1789
+ `is a directory: ${filePath}`
1790
+ );
1791
+ case "ELOOP":
1792
+ return new FsError(
1793
+ "ELOOP",
1794
+ `too many symbolic links: ${filePath}`
1795
+ );
1796
+ case "EINVAL":
1797
+ return new FsError(
1798
+ "EINVAL",
1799
+ `invalid argument: ${filePath}`
1800
+ );
1801
+ case "ENOTEMPTY":
1802
+ return new FsError(
1803
+ "ENOTEMPTY",
1804
+ `directory not empty: ${filePath}`
1805
+ );
1806
+ default:
1807
+ return new FsError(
1808
+ "ENOSYS",
1809
+ `${code ?? "unknown error"}: ${filePath}: ${nodeErr?.message ?? ""}`
1810
+ );
1811
+ }
1812
+ }
1813
+ };
1814
+
1815
+ // src/index.ts
1816
+ async function createLayer(options) {
1817
+ const { root, adapter: userAdapter } = options;
1818
+ if (!root.startsWith("/")) {
1819
+ throw new FsError("EINVAL", "root must be an absolute path");
1820
+ }
1821
+ const adapter = userAdapter ?? new LocalAdapter();
1822
+ if (adapter.initialize) {
1823
+ await adapter.initialize(root);
1824
+ }
1825
+ const capabilities = adapter.capabilities();
1826
+ return new Layer(root, adapter, capabilities);
1827
+ }
1828
+ export {
1829
+ FsError,
1830
+ Layer,
1831
+ Ledger,
1832
+ LocalAdapter,
1833
+ TransactionError,
1834
+ createLayer
1835
+ };