typescript-virtual-container 1.4.6 → 1.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.vscode/settings.json +1 -0
  2. package/README.md +0 -1
  3. package/benchmark-results.txt +21 -21
  4. package/builds/self-standalone.js +1111 -299
  5. package/builds/self-standalone.js.map +4 -4
  6. package/builds/standalone-wo-sftp.js +993 -183
  7. package/builds/standalone-wo-sftp.js.map +4 -4
  8. package/builds/standalone.cjs +984 -173
  9. package/builds/standalone.cjs.map +4 -4
  10. package/dist/SSHMimic/exec.d.ts.map +1 -1
  11. package/dist/SSHMimic/exec.js +0 -1
  12. package/dist/SSHMimic/exec.js.map +1 -1
  13. package/dist/SSHMimic/index.d.ts.map +1 -1
  14. package/dist/SSHMimic/index.js +3 -1
  15. package/dist/SSHMimic/index.js.map +1 -1
  16. package/dist/SSHMimic/sftp.d.ts.map +1 -1
  17. package/dist/SSHMimic/sftp.js +0 -6
  18. package/dist/SSHMimic/sftp.js.map +1 -1
  19. package/dist/VirtualFileSystem/binaryPack.d.ts.map +1 -1
  20. package/dist/VirtualFileSystem/binaryPack.js +21 -9
  21. package/dist/VirtualFileSystem/binaryPack.js.map +1 -1
  22. package/dist/VirtualFileSystem/index.d.ts +93 -0
  23. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  24. package/dist/VirtualFileSystem/index.js +361 -46
  25. package/dist/VirtualFileSystem/index.js.map +1 -1
  26. package/dist/VirtualFileSystem/internalTypes.d.ts +23 -4
  27. package/dist/VirtualFileSystem/internalTypes.d.ts.map +1 -1
  28. package/dist/VirtualFileSystem/journal.d.ts +47 -0
  29. package/dist/VirtualFileSystem/journal.d.ts.map +1 -0
  30. package/dist/VirtualFileSystem/journal.js +178 -0
  31. package/dist/VirtualFileSystem/journal.js.map +1 -0
  32. package/dist/VirtualFileSystem/path.js +1 -1
  33. package/dist/VirtualFileSystem/path.js.map +1 -1
  34. package/dist/VirtualShell/idleManager.d.ts +65 -0
  35. package/dist/VirtualShell/idleManager.d.ts.map +1 -0
  36. package/dist/VirtualShell/idleManager.js +106 -0
  37. package/dist/VirtualShell/idleManager.js.map +1 -0
  38. package/dist/VirtualShell/index.d.ts +28 -0
  39. package/dist/VirtualShell/index.d.ts.map +1 -1
  40. package/dist/VirtualShell/index.js +48 -0
  41. package/dist/VirtualShell/index.js.map +1 -1
  42. package/dist/VirtualShell/shell.js +4 -4
  43. package/dist/VirtualShell/shell.js.map +1 -1
  44. package/dist/commands/man.d.ts.map +1 -1
  45. package/dist/commands/man.js +5 -27
  46. package/dist/commands/man.js.map +1 -1
  47. package/dist/commands/manuals-bundle.d.ts +11 -0
  48. package/dist/commands/manuals-bundle.d.ts.map +1 -0
  49. package/dist/commands/manuals-bundle.js +898 -0
  50. package/dist/commands/manuals-bundle.js.map +1 -0
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/modules/linuxRootfs.d.ts +8 -1
  56. package/dist/modules/linuxRootfs.d.ts.map +1 -1
  57. package/dist/modules/linuxRootfs.js +47 -14
  58. package/dist/modules/linuxRootfs.js.map +1 -1
  59. package/dist/self-standalone.js +16 -1
  60. package/dist/self-standalone.js.map +1 -1
  61. package/dist/standalone.js +22 -0
  62. package/dist/standalone.js.map +1 -1
  63. package/docs/assets/hierarchy.js +1 -1
  64. package/docs/assets/navigation.js +1 -1
  65. package/docs/assets/search.js +1 -1
  66. package/docs/classes/HoneyPot.html +8 -8
  67. package/docs/classes/IdleManager.html +159 -0
  68. package/docs/classes/SshClient.html +18 -18
  69. package/docs/classes/VirtualFileSystem.html +57 -32
  70. package/docs/classes/VirtualPackageManager.html +12 -12
  71. package/docs/classes/VirtualSftpServer.html +3 -3
  72. package/docs/classes/VirtualShell.html +40 -25
  73. package/docs/classes/VirtualSshServer.html +5 -5
  74. package/docs/classes/VirtualUserManager.html +26 -26
  75. package/docs/functions/assertDiff.html +1 -1
  76. package/docs/functions/diffSnapshots.html +1 -1
  77. package/docs/functions/formatDiff.html +1 -1
  78. package/docs/functions/getArg.html +1 -1
  79. package/docs/functions/getFlag.html +1 -1
  80. package/docs/functions/ifFlag.html +1 -1
  81. package/docs/hierarchy.html +1 -1
  82. package/docs/index.html +1 -2
  83. package/docs/interfaces/AuditLogEntry.html +2 -2
  84. package/docs/interfaces/CommandContext.html +11 -11
  85. package/docs/interfaces/CommandResult.html +12 -12
  86. package/docs/interfaces/ExecStream.html +5 -5
  87. package/docs/interfaces/HoneyPotStats.html +2 -2
  88. package/docs/interfaces/IdleManagerOptions.html +7 -0
  89. package/docs/interfaces/InstalledPackage.html +10 -10
  90. package/docs/interfaces/NanoEditorSession.html +4 -4
  91. package/docs/interfaces/PackageDefinition.html +13 -13
  92. package/docs/interfaces/PackageFile.html +4 -4
  93. package/docs/interfaces/RemoveOptions.html +2 -2
  94. package/docs/interfaces/ShellEnv.html +3 -3
  95. package/docs/interfaces/ShellModule.html +7 -7
  96. package/docs/interfaces/ShellProperties.html +4 -4
  97. package/docs/interfaces/ShellStream.html +6 -6
  98. package/docs/interfaces/SudoChallenge.html +8 -8
  99. package/docs/interfaces/VfsBaseNode.html +6 -6
  100. package/docs/interfaces/VfsDiff.html +5 -5
  101. package/docs/interfaces/VfsDiffEntry.html +3 -3
  102. package/docs/interfaces/VfsDiffModified.html +5 -5
  103. package/docs/interfaces/VfsDirectoryNode.html +7 -7
  104. package/docs/interfaces/VfsFileNode.html +8 -8
  105. package/docs/interfaces/VfsOptions.html +18 -4
  106. package/docs/interfaces/VfsSnapshot.html +2 -2
  107. package/docs/interfaces/VfsSnapshotBaseNode.html +3 -3
  108. package/docs/interfaces/VfsSnapshotDirectoryNode.html +4 -4
  109. package/docs/interfaces/VfsSnapshotFileNode.html +5 -5
  110. package/docs/interfaces/WriteFileOptions.html +3 -3
  111. package/docs/modules.html +1 -1
  112. package/docs/types/CommandMode.html +1 -1
  113. package/docs/types/CommandOutcome.html +1 -1
  114. package/docs/types/IdleState.html +1 -0
  115. package/docs/types/VfsNodeStats.html +1 -1
  116. package/docs/types/VfsNodeType.html +1 -1
  117. package/docs/types/VfsPersistenceMode.html +1 -1
  118. package/docs/types/VfsSnapshotNode.html +1 -1
  119. package/package.json +5 -4
  120. package/scripts/generate-manuals-bundle.mjs +49 -0
  121. package/src/SSHMimic/exec.ts +0 -1
  122. package/src/SSHMimic/index.ts +3 -1
  123. package/src/SSHMimic/sftp.ts +0 -6
  124. package/src/VirtualFileSystem/binaryPack.ts +21 -9
  125. package/src/VirtualFileSystem/index.ts +369 -52
  126. package/src/VirtualFileSystem/internalTypes.ts +24 -4
  127. package/src/VirtualFileSystem/journal.ts +163 -0
  128. package/src/VirtualFileSystem/path.ts +1 -1
  129. package/src/VirtualShell/idleManager.ts +133 -0
  130. package/src/VirtualShell/index.ts +48 -0
  131. package/src/VirtualShell/shell.ts +4 -4
  132. package/src/commands/man.ts +5 -35
  133. package/src/commands/manuals-bundle.ts +898 -0
  134. package/src/index.ts +2 -0
  135. package/src/modules/linuxRootfs.ts +58 -14
  136. package/src/self-standalone.ts +14 -1
  137. package/src/standalone.ts +23 -0
  138. package/builds/standalone.js +0 -491
  139. package/builds/standalone.js.map +0 -7
@@ -1,8 +1,10 @@
1
+ /** biome-ignore-all lint/style/useNamingConvention: NW ? */
1
2
  import { EventEmitter } from "node:events";
2
3
  import * as fsSync from "node:fs";
3
4
  import * as path from "node:path";
4
5
  import { gunzipSync, gzipSync } from "node:zlib";
5
6
  import { decodeVfs, encodeVfs, isBinarySnapshot } from "./binaryPack";
7
+ import { appendJournalEntry, JournalOp, readJournal, truncateJournal } from "./journal";
6
8
  import { getNode, getParentDirectory, normalizePath } from "./path";
7
9
  // ── VirtualFileSystem ─────────────────────────────────────────────────────────
8
10
  /**
@@ -32,6 +34,18 @@ class VirtualFileSystem extends EventEmitter {
32
34
  root;
33
35
  mode;
34
36
  snapshotFile;
37
+ /** Path to the WAL journal file (null in memory mode). */
38
+ journalFile;
39
+ /** Eviction threshold in bytes (0 = disabled). Files above this are purged after flush. */
40
+ evictionThreshold;
41
+ /** Max writes between forced flushes (0 = disabled). */
42
+ flushAfterNWrites;
43
+ /** Pending write counter since last checkpoint. */
44
+ _writesSinceFlush = 0;
45
+ /** NodeJS timer handle for periodic auto-flush (null = disabled or stopped). */
46
+ _flushTimer = null;
47
+ /** True if the VFS has unflushed changes. */
48
+ _dirty = false;
35
49
  /** Active host-directory mounts: vPath → { hostPath, readOnly } */
36
50
  mounts = new Map();
37
51
  /** True when running in a browser environment (no host FS access). */
@@ -44,26 +58,44 @@ class VirtualFileSystem extends EventEmitter {
44
58
  throw new Error('VirtualFileSystem: "snapshotPath" is required when mode is "fs".');
45
59
  }
46
60
  this.snapshotFile = path.resolve(options.snapshotPath, "vfs-snapshot.vfsb");
61
+ this.journalFile = path.resolve(options.snapshotPath, "vfs-journal.bin");
62
+ this.evictionThreshold = options.evictionThresholdBytes ?? 64 * 1024; // 64 KB default
63
+ this.flushAfterNWrites = options.flushAfterNWrites ?? 500;
64
+ const intervalMs = options.flushIntervalMs ?? 30_000;
65
+ if (intervalMs > 0) {
66
+ this._flushTimer = setInterval(() => {
67
+ if (this._dirty)
68
+ void this._autoFlush();
69
+ }, intervalMs);
70
+ // Don't block process exit on this timer
71
+ if (typeof this._flushTimer === "object" && this._flushTimer !== null && "unref" in this._flushTimer) {
72
+ this._flushTimer.unref();
73
+ }
74
+ }
47
75
  }
48
76
  else {
49
77
  this.snapshotFile = null;
78
+ this.journalFile = null;
79
+ this.evictionThreshold = 0; // disabled in memory mode
80
+ this.flushAfterNWrites = 0;
50
81
  }
51
82
  this.root = this.makeDir("", 0o755);
52
83
  }
53
84
  // ── Internal helpers ──────────────────────────────────────────────────────
54
85
  makeDir(name, mode) {
55
- const now = new Date();
86
+ const now = Date.now();
56
87
  return {
57
88
  type: "directory",
58
89
  name,
59
90
  mode,
60
91
  createdAt: now,
61
92
  updatedAt: now,
62
- children: new Map(),
93
+ children: Object.create(null),
94
+ _childCount: 0,
63
95
  };
64
96
  }
65
97
  makeFile(name, content, mode, compressed) {
66
- const now = new Date();
98
+ const now = Date.now();
67
99
  return {
68
100
  type: "file",
69
101
  name,
@@ -74,6 +106,30 @@ class VirtualFileSystem extends EventEmitter {
74
106
  updatedAt: now,
75
107
  };
76
108
  }
109
+ makeStub(name, content, mode) {
110
+ const now = Date.now();
111
+ return { type: "stub", name, stubContent: content, mode, createdAt: now, updatedAt: now };
112
+ }
113
+ /**
114
+ * Write a lazy stub — stores content as a plain string with no Buffer allocation.
115
+ * Use for static rootfs files that may never be read. On first `writeFile()`,
116
+ * the stub is promoted to a real `InternalFileNode`.
117
+ * Parent directories are created when missing.
118
+ */
119
+ writeStub(targetPath, content, mode = 0o644) {
120
+ const normalized = normalizePath(targetPath);
121
+ const { parent, name } = getParentDirectory(this.root, normalized, true, (p) => this.mkdirRecursive(p, 0o755));
122
+ const existing = parent.children[name];
123
+ if (existing?.type === "directory") {
124
+ throw new Error(`Cannot write stub '${normalized}': path is a directory.`);
125
+ }
126
+ // Don't overwrite a real file or an already-promoted node
127
+ if (existing?.type === "file")
128
+ return;
129
+ if (!existing)
130
+ parent._childCount++;
131
+ parent.children[name] = this.makeStub(name, content, mode);
132
+ }
77
133
  mkdirRecursive(targetPath, mode) {
78
134
  const normalized = normalizePath(targetPath);
79
135
  if (normalized === "/")
@@ -83,11 +139,13 @@ class VirtualFileSystem extends EventEmitter {
83
139
  let builtPath = "";
84
140
  for (const part of parts) {
85
141
  builtPath += `/${part}`;
86
- let child = current.children.get(part);
142
+ let child = current.children[part];
87
143
  if (!child) {
88
144
  child = this.makeDir(part, mode);
89
- current.children.set(part, child);
145
+ current.children[part] = child;
146
+ current._childCount++;
90
147
  this.emit("dir:create", { path: builtPath, mode });
148
+ this._journal({ op: JournalOp.MKDIR, path: builtPath, mode });
91
149
  }
92
150
  else if (child.type !== "directory") {
93
151
  throw new Error(`Cannot create directory '${builtPath}': path is a file.`);
@@ -106,8 +164,15 @@ class VirtualFileSystem extends EventEmitter {
106
164
  async restoreMirror() {
107
165
  if (this.mode !== "fs" || !this.snapshotFile)
108
166
  return;
109
- if (!fsSync.existsSync(this.snapshotFile))
167
+ if (!fsSync.existsSync(this.snapshotFile)) {
168
+ // No snapshot yet — but replay journal if it exists (crash after writes, before first flush)
169
+ if (this.journalFile) {
170
+ const entries = readJournal(this.journalFile);
171
+ if (entries.length > 0)
172
+ this._replayJournal(entries);
173
+ }
110
174
  return;
175
+ }
111
176
  try {
112
177
  const raw = fsSync.readFileSync(this.snapshotFile);
113
178
  if (isBinarySnapshot(raw)) {
@@ -121,6 +186,12 @@ class VirtualFileSystem extends EventEmitter {
121
186
  console.info("[VirtualFileSystem] Migrating legacy JSON snapshot to binary format.");
122
187
  }
123
188
  this.emit("snapshot:restore", { path: this.snapshotFile });
189
+ // Replay WAL journal on top of the loaded snapshot
190
+ if (this.journalFile) {
191
+ const entries = readJournal(this.journalFile);
192
+ if (entries.length > 0)
193
+ this._replayJournal(entries);
194
+ }
124
195
  }
125
196
  catch (err) {
126
197
  // Corrupt or unreadable snapshot — start fresh and warn
@@ -143,7 +214,14 @@ class VirtualFileSystem extends EventEmitter {
143
214
  fsSync.mkdirSync(dir, { recursive: true });
144
215
  const binary = encodeVfs(this.root);
145
216
  fsSync.writeFileSync(this.snapshotFile, binary);
217
+ // Checkpoint complete — truncate the journal (entries are now in the snapshot)
218
+ if (this.journalFile)
219
+ truncateJournal(this.journalFile);
220
+ this._dirty = false;
221
+ this._writesSinceFlush = 0;
146
222
  this.emit("mirror:flush", { path: this.snapshotFile });
223
+ // Evict large files from RAM now that the snapshot is on disk
224
+ this.evictLargeFiles();
147
225
  }
148
226
  /** Returns the current persistence mode. */
149
227
  getMode() {
@@ -155,6 +233,183 @@ class VirtualFileSystem extends EventEmitter {
155
233
  }
156
234
  // ── Public filesystem API ─────────────────────────────────────────────────
157
235
  /** Creates a directory (and any missing parents). */
236
+ // ── Auto-flush scheduler ──────────────────────────────────────────────────
237
+ /** Internal: flush triggered by timer or write-count threshold. */
238
+ async _autoFlush() {
239
+ if (!this._dirty)
240
+ return;
241
+ await this.flushMirror();
242
+ }
243
+ /** Mark VFS as having unflushed writes and trigger threshold flush if needed. */
244
+ _markDirty() {
245
+ this._dirty = true;
246
+ if (this.flushAfterNWrites > 0) {
247
+ this._writesSinceFlush++;
248
+ if (this._writesSinceFlush >= this.flushAfterNWrites) {
249
+ this._writesSinceFlush = 0;
250
+ void this._autoFlush();
251
+ }
252
+ }
253
+ }
254
+ /**
255
+ * Stop the automatic flush timer and perform a final checkpoint.
256
+ * Call this when shutting down to ensure all data is persisted.
257
+ *
258
+ * @example
259
+ * ```ts
260
+ * process.on("SIGINT", async () => {
261
+ * await shell.vfs.stopAutoFlush();
262
+ * process.exit(0);
263
+ * });
264
+ * ```
265
+ */
266
+ async stopAutoFlush() {
267
+ if (this._flushTimer !== null) {
268
+ clearInterval(this._flushTimer);
269
+ this._flushTimer = null;
270
+ }
271
+ if (this._dirty)
272
+ await this.flushMirror();
273
+ }
274
+ /**
275
+ * Replace the entire root tree — used internally by `bootstrapLinuxRootfs`
276
+ * to hot-swap the static rootfs snapshot without going through importSnapshot
277
+ * (which would re-journal every node in fs mode).
278
+ * @internal
279
+ */
280
+ importRootTree(root) {
281
+ const prev = this._replayMode;
282
+ this._replayMode = true;
283
+ try {
284
+ this.root = root;
285
+ }
286
+ finally {
287
+ this._replayMode = prev;
288
+ }
289
+ }
290
+ /** Serialise current tree to VFSB binary. Used for the static rootfs cache. */
291
+ encodeBinary() {
292
+ return encodeVfs(this.root);
293
+ }
294
+ /**
295
+ * Release the in-memory VFS tree, freeing all InternalNode objects for GC.
296
+ * The tree MUST be restored via `importRootTree()` before any VFS operation.
297
+ * Called by IdleManager when freezing an idle shell.
298
+ * @internal
299
+ */
300
+ releaseTree() {
301
+ // Replace root with a minimal stub — keeps the object alive but frees all children
302
+ this.root = this.makeDir("", 0o755);
303
+ }
304
+ /** Set to true during journal replay to suppress re-journaling. */
305
+ _replayMode = false;
306
+ /** Append a journal entry if in fs mode and not replaying. */
307
+ _journal(entry) {
308
+ if (this.journalFile && !this._replayMode) {
309
+ appendJournalEntry(this.journalFile, entry);
310
+ this._markDirty();
311
+ }
312
+ }
313
+ /** Replay a list of journal entries onto the in-memory tree. */
314
+ _replayJournal(entries) {
315
+ this._replayMode = true;
316
+ try {
317
+ for (const e of entries) {
318
+ try {
319
+ if (e.op === JournalOp.WRITE) {
320
+ this.writeFile(e.path, e.content ?? Buffer.alloc(0), { mode: e.mode });
321
+ }
322
+ else if (e.op === JournalOp.MKDIR) {
323
+ this.mkdir(e.path, e.mode);
324
+ }
325
+ else if (e.op === JournalOp.REMOVE) {
326
+ if (this.exists(e.path))
327
+ this.remove(e.path, { recursive: true });
328
+ }
329
+ else if (e.op === JournalOp.CHMOD) {
330
+ if (this.exists(e.path))
331
+ this.chmod(e.path, e.mode ?? 0o644);
332
+ }
333
+ else if (e.op === JournalOp.MOVE) {
334
+ if (this.exists(e.path) && e.dest)
335
+ this.move(e.path, e.dest);
336
+ }
337
+ else if (e.op === JournalOp.SYMLINK) {
338
+ if (e.dest)
339
+ this.symlink(e.dest, e.path);
340
+ }
341
+ }
342
+ catch { /* ignore individual replay errors — best-effort */ }
343
+ }
344
+ }
345
+ finally {
346
+ this._replayMode = false;
347
+ }
348
+ }
349
+ // ── RAM eviction ──────────────────────────────────────────────────────────
350
+ /**
351
+ * Walk the in-memory tree and evict file contents that exceed
352
+ * `evictionThreshold`. Called automatically after `flushMirror()`.
353
+ * Safe to call at any time — evicted files are reloaded on demand.
354
+ */
355
+ evictLargeFiles() {
356
+ if (!this.snapshotFile || this.evictionThreshold === 0)
357
+ return;
358
+ if (!fsSync.existsSync(this.snapshotFile))
359
+ return;
360
+ this._evictDir(this.root);
361
+ }
362
+ _evictDir(dir) {
363
+ for (const node of Object.values(dir.children)) {
364
+ if (node.type === "directory") {
365
+ this._evictDir(node);
366
+ }
367
+ else if (node.type === "file" && !node.evicted) {
368
+ const rawSize = node.compressed
369
+ ? (node.size ?? node.content.length * 2) // estimate uncompressed
370
+ : node.content.length;
371
+ if (rawSize > this.evictionThreshold) {
372
+ node.size = rawSize;
373
+ node.content = Buffer.alloc(0); // free heap
374
+ node.evicted = true;
375
+ }
376
+ }
377
+ // stubs: nothing to evict — content is already a plain string, not a Buffer
378
+ }
379
+ }
380
+ /**
381
+ * Reload a single evicted file node's content from the current snapshot.
382
+ * No-op if the node is not evicted.
383
+ */
384
+ _reloadEvicted(node, normalizedPath) {
385
+ if (!node.evicted || !this.snapshotFile)
386
+ return;
387
+ if (!fsSync.existsSync(this.snapshotFile))
388
+ return;
389
+ try {
390
+ // Load and parse the snapshot to find this specific node
391
+ const raw = fsSync.readFileSync(this.snapshotFile);
392
+ const tmpRoot = decodeVfs(raw);
393
+ const parts = normalizedPath.split("/").filter(Boolean);
394
+ let cur = tmpRoot;
395
+ for (const part of parts) {
396
+ if (cur.type !== "directory")
397
+ return;
398
+ const next = cur.children[part];
399
+ if (!next)
400
+ return;
401
+ cur = next;
402
+ }
403
+ if (cur.type === "file") {
404
+ node.content = cur.content;
405
+ node.compressed = cur.compressed;
406
+ node.evicted = undefined;
407
+ }
408
+ }
409
+ catch {
410
+ // Snapshot unreadable — leave evicted; caller will get empty content
411
+ }
412
+ }
158
413
  // ── Mount API ─────────────────────────────────────────────────────────────
159
414
  /**
160
415
  * Mount a host directory into the VFS at `vPath`.
@@ -262,7 +517,7 @@ class VirtualFileSystem extends EventEmitter {
262
517
  }
263
518
  const normalized = normalizePath(targetPath);
264
519
  const { parent, name } = getParentDirectory(this.root, normalized, true, (p) => this.mkdirRecursive(p, 0o755));
265
- const existing = parent.children.get(name);
520
+ const existing = parent.children[name];
266
521
  if (existing?.type === "directory") {
267
522
  throw new Error(`Cannot write file '${normalized}': path is a directory.`);
268
523
  }
@@ -272,17 +527,22 @@ class VirtualFileSystem extends EventEmitter {
272
527
  const shouldCompress = options.compress ?? false;
273
528
  const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
274
529
  const mode = options.mode ?? 0o644;
275
- if (existing) {
530
+ if (existing && existing.type === "file") {
531
+ // Update real file in place
276
532
  const f = existing;
277
533
  f.content = storedContent;
278
534
  f.compressed = shouldCompress;
279
535
  f.mode = mode;
280
- f.updatedAt = new Date();
536
+ f.updatedAt = Date.now();
281
537
  }
282
538
  else {
283
- parent.children.set(name, this.makeFile(name, storedContent, mode, shouldCompress));
539
+ // Create new real file — also promotes stubs (no _childCount change for stubs)
540
+ if (!existing)
541
+ parent._childCount++;
542
+ parent.children[name] = this.makeFile(name, storedContent, mode, shouldCompress);
284
543
  }
285
544
  this.emit("file:write", { path: normalized, size: storedContent.length });
545
+ this._journal({ op: JournalOp.WRITE, path: normalized, content: rawContent, mode });
286
546
  }
287
547
  /**
288
548
  * Reads file content as a UTF-8 string.
@@ -297,10 +557,16 @@ class VirtualFileSystem extends EventEmitter {
297
557
  }
298
558
  const normalized = normalizePath(targetPath);
299
559
  const node = getNode(this.root, normalized);
560
+ if (node.type === "stub") {
561
+ this.emit("file:read", { path: normalized, size: node.stubContent.length });
562
+ return node.stubContent;
563
+ }
300
564
  if (node.type !== "file") {
301
565
  throw new Error(`Cannot read '${targetPath}': not a file.`);
302
566
  }
303
567
  const f = node;
568
+ if (f.evicted)
569
+ this._reloadEvicted(f, normalized);
304
570
  const raw = f.compressed ? gunzipSync(f.content) : f.content;
305
571
  this.emit("file:read", { path: normalized, size: raw.length });
306
572
  return raw.toString("utf8");
@@ -315,10 +581,17 @@ class VirtualFileSystem extends EventEmitter {
315
581
  }
316
582
  const normalized = normalizePath(targetPath);
317
583
  const node = getNode(this.root, normalized);
584
+ if (node.type === "stub") {
585
+ const buf = Buffer.from(node.stubContent, "utf8");
586
+ this.emit("file:read", { path: normalized, size: buf.length });
587
+ return buf;
588
+ }
318
589
  if (node.type !== "file") {
319
590
  throw new Error(`Cannot read '${targetPath}': not a file.`);
320
591
  }
321
592
  const f = node;
593
+ if (f.evicted)
594
+ this._reloadEvicted(f, normalized);
322
595
  const raw = f.compressed ? gunzipSync(f.content) : f.content;
323
596
  this.emit("file:read", { path: normalized, size: raw.length });
324
597
  return raw;
@@ -338,7 +611,9 @@ class VirtualFileSystem extends EventEmitter {
338
611
  }
339
612
  /** Updates mode bits on a node. */
340
613
  chmod(targetPath, mode) {
341
- getNode(this.root, normalizePath(targetPath)).mode = mode;
614
+ const normalized = normalizePath(targetPath);
615
+ getNode(this.root, normalized).mode = mode;
616
+ this._journal({ op: JournalOp.CHMOD, path: normalized, mode });
342
617
  }
343
618
  /** Returns metadata for a file or directory. */
344
619
  stat(targetPath) {
@@ -374,6 +649,19 @@ class VirtualFileSystem extends EventEmitter {
374
649
  const normalized = normalizePath(targetPath);
375
650
  const node = getNode(this.root, normalized);
376
651
  const name = normalized === "/" ? "" : path.posix.basename(normalized);
652
+ if (node.type === "stub") {
653
+ const s = node;
654
+ return {
655
+ type: "file",
656
+ name,
657
+ path: normalized,
658
+ mode: s.mode,
659
+ createdAt: new Date(s.createdAt),
660
+ updatedAt: new Date(s.updatedAt),
661
+ compressed: false,
662
+ size: s.stubContent.length,
663
+ };
664
+ }
377
665
  if (node.type === "file") {
378
666
  const f = node;
379
667
  return {
@@ -381,10 +669,10 @@ class VirtualFileSystem extends EventEmitter {
381
669
  name,
382
670
  path: normalized,
383
671
  mode: f.mode,
384
- createdAt: f.createdAt,
385
- updatedAt: f.updatedAt,
672
+ createdAt: new Date(f.createdAt),
673
+ updatedAt: new Date(f.updatedAt),
386
674
  compressed: f.compressed,
387
- size: f.content.length,
675
+ size: f.evicted ? (f.size ?? 0) : f.content.length,
388
676
  };
389
677
  }
390
678
  const d = node;
@@ -393,9 +681,9 @@ class VirtualFileSystem extends EventEmitter {
393
681
  name,
394
682
  path: normalized,
395
683
  mode: d.mode,
396
- createdAt: d.createdAt,
397
- updatedAt: d.updatedAt,
398
- childrenCount: d.children.size,
684
+ createdAt: new Date(d.createdAt),
685
+ updatedAt: new Date(d.updatedAt),
686
+ childrenCount: d._childCount,
399
687
  };
400
688
  }
401
689
  /** Lists direct children names of a directory (sorted). */
@@ -416,7 +704,7 @@ class VirtualFileSystem extends EventEmitter {
416
704
  if (node.type !== "directory") {
417
705
  throw new Error(`Cannot list '${dirPath}': not a directory.`);
418
706
  }
419
- return Array.from(node.children.keys()).sort();
707
+ return Object.keys(node.children).sort();
420
708
  }
421
709
  /** Renders ASCII tree view of a directory hierarchy. */
422
710
  tree(dirPath = "/") {
@@ -430,10 +718,10 @@ class VirtualFileSystem extends EventEmitter {
430
718
  }
431
719
  renderTreeLines(dir, label) {
432
720
  const lines = [label];
433
- const entries = Array.from(dir.children.keys()).sort();
721
+ const entries = Object.keys(dir.children).sort();
434
722
  for (let i = 0; i < entries.length; i++) {
435
723
  const name = entries[i];
436
- const child = dir.children.get(name);
724
+ const child = dir.children[name];
437
725
  const isLast = i === entries.length - 1;
438
726
  const connector = isLast ? "└── " : "├── ";
439
727
  const nextPrefix = isLast ? " " : "│ ";
@@ -455,8 +743,10 @@ class VirtualFileSystem extends EventEmitter {
455
743
  computeUsage(node) {
456
744
  if (node.type === "file")
457
745
  return node.content.length;
746
+ if (node.type === "stub")
747
+ return node.stubContent.length;
458
748
  let total = 0;
459
- for (const child of node.children.values()) {
749
+ for (const child of Object.values(node.children)) {
460
750
  total += this.computeUsage(child);
461
751
  }
462
752
  return total;
@@ -470,7 +760,7 @@ class VirtualFileSystem extends EventEmitter {
470
760
  if (!f.compressed) {
471
761
  f.content = gzipSync(f.content);
472
762
  f.compressed = true;
473
- f.updatedAt = new Date();
763
+ f.updatedAt = Date.now();
474
764
  }
475
765
  }
476
766
  /** Decompresses a gzip-compressed file in place. */
@@ -482,7 +772,7 @@ class VirtualFileSystem extends EventEmitter {
482
772
  if (f.compressed) {
483
773
  f.content = gunzipSync(f.content);
484
774
  f.compressed = false;
485
- f.updatedAt = new Date();
775
+ f.updatedAt = Date.now();
486
776
  }
487
777
  }
488
778
  /**
@@ -501,10 +791,13 @@ class VirtualFileSystem extends EventEmitter {
501
791
  content: Buffer.from(normalizedTarget, "utf8"),
502
792
  mode: 0o120777,
503
793
  compressed: false,
504
- createdAt: new Date(),
505
- updatedAt: new Date(),
794
+ createdAt: Date.now(),
795
+ updatedAt: Date.now(),
506
796
  };
507
- parent.children.set(name, symNode);
797
+ parent.children[name] = symNode;
798
+ parent._childCount++;
799
+ // Journal before emit
800
+ this._journal({ op: JournalOp.SYMLINK, path: normalizedLink, dest: normalizedTarget });
508
801
  this.emit("symlink:create", {
509
802
  link: normalizedLink,
510
803
  target: normalizedTarget,
@@ -567,13 +860,15 @@ class VirtualFileSystem extends EventEmitter {
567
860
  const node = getNode(this.root, normalized);
568
861
  if (node.type === "directory") {
569
862
  const dir = node;
570
- if (!options.recursive && dir.children.size > 0) {
863
+ if (!options.recursive && dir._childCount > 0) {
571
864
  throw new Error(`Directory '${normalized}' is not empty. Use recursive option.`);
572
865
  }
573
866
  }
574
867
  const { parent, name } = getParentDirectory(this.root, normalized, false, () => { });
575
- parent.children.delete(name);
868
+ delete parent.children[name];
869
+ parent._childCount--;
576
870
  this.emit("node:remove", { path: normalized });
871
+ this._journal({ op: JournalOp.REMOVE, path: normalized });
577
872
  }
578
873
  /** Moves or renames a node. */
579
874
  move(fromPath, toPath) {
@@ -589,9 +884,12 @@ class VirtualFileSystem extends EventEmitter {
589
884
  this.mkdirRecursive(path.posix.dirname(toNormalized), 0o755);
590
885
  const { parent: destParent, name: destName } = getParentDirectory(this.root, toNormalized, false, () => { });
591
886
  const { parent: srcParent, name: srcName } = getParentDirectory(this.root, fromNormalized, false, () => { });
592
- srcParent.children.delete(srcName);
887
+ delete srcParent.children[srcName];
888
+ srcParent._childCount--;
593
889
  node.name = destName;
594
- destParent.children.set(destName, node);
890
+ destParent.children[destName] = node;
891
+ destParent._childCount++;
892
+ this._journal({ op: JournalOp.MOVE, path: fromNormalized, dest: toNormalized });
595
893
  }
596
894
  // ── Snapshot serialisation ─────────────────────────────────────────────────
597
895
  /**
@@ -605,17 +903,32 @@ class VirtualFileSystem extends EventEmitter {
605
903
  }
606
904
  serializeDir(dir) {
607
905
  const children = [];
608
- for (const child of dir.children.values()) {
609
- children.push(child.type === "file"
610
- ? this.serializeFile(child)
611
- : this.serializeDir(child));
906
+ for (const child of Object.values(dir.children)) {
907
+ if (child.type === "stub") {
908
+ // Serialize stub as a regular file node
909
+ children.push({
910
+ type: "file",
911
+ name: child.name,
912
+ mode: child.mode,
913
+ createdAt: new Date(child.createdAt).toISOString(),
914
+ updatedAt: new Date(child.updatedAt).toISOString(),
915
+ compressed: false,
916
+ contentBase64: Buffer.from(child.stubContent, "utf8").toString("base64"),
917
+ });
918
+ }
919
+ else if (child.type === "file") {
920
+ children.push(this.serializeFile(child));
921
+ }
922
+ else {
923
+ children.push(this.serializeDir(child));
924
+ }
612
925
  }
613
926
  return {
614
927
  type: "directory",
615
928
  name: dir.name,
616
929
  mode: dir.mode,
617
- createdAt: dir.createdAt.toISOString(),
618
- updatedAt: dir.updatedAt.toISOString(),
930
+ createdAt: new Date(dir.createdAt).toISOString(),
931
+ updatedAt: new Date(dir.updatedAt).toISOString(),
619
932
  children,
620
933
  };
621
934
  }
@@ -624,8 +937,8 @@ class VirtualFileSystem extends EventEmitter {
624
937
  type: "file",
625
938
  name: file.name,
626
939
  mode: file.mode,
627
- createdAt: file.createdAt.toISOString(),
628
- updatedAt: file.updatedAt.toISOString(),
940
+ createdAt: new Date(file.createdAt).toISOString(),
941
+ updatedAt: new Date(file.updatedAt).toISOString(),
629
942
  compressed: file.compressed,
630
943
  contentBase64: file.content.toString("base64"),
631
944
  };
@@ -661,27 +974,29 @@ class VirtualFileSystem extends EventEmitter {
661
974
  type: "directory",
662
975
  name,
663
976
  mode: snap.mode,
664
- createdAt: new Date(snap.createdAt),
665
- updatedAt: new Date(snap.updatedAt),
666
- children: new Map(),
977
+ createdAt: Date.parse(snap.createdAt),
978
+ updatedAt: Date.parse(snap.updatedAt),
979
+ children: Object.create(null),
980
+ _childCount: 0,
667
981
  };
668
982
  for (const child of snap.children) {
669
983
  if (child.type === "file") {
670
984
  const f = child;
671
- dir.children.set(f.name, {
985
+ dir.children[f.name] = {
672
986
  type: "file",
673
987
  name: f.name,
674
988
  mode: f.mode,
675
- createdAt: new Date(f.createdAt),
676
- updatedAt: new Date(f.updatedAt),
989
+ createdAt: Date.parse(f.createdAt),
990
+ updatedAt: Date.parse(f.updatedAt),
677
991
  compressed: f.compressed,
678
992
  content: Buffer.from(f.contentBase64, "base64"),
679
- });
993
+ };
680
994
  }
681
995
  else {
682
996
  const sub = this.deserializeDir(child, child.name);
683
- dir.children.set(child.name, sub);
997
+ dir.children[child.name] = sub;
684
998
  }
999
+ dir._childCount++;
685
1000
  }
686
1001
  return dir;
687
1002
  }