typescript-virtual-container 1.4.6 → 1.4.7

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 (44) hide show
  1. package/.vscode/settings.json +1 -0
  2. package/builds/self-standalone.js +296 -296
  3. package/builds/self-standalone.js.map +4 -4
  4. package/builds/standalone-wo-sftp.js +160 -160
  5. package/builds/standalone-wo-sftp.js.map +4 -4
  6. package/builds/standalone.cjs +156 -156
  7. package/builds/standalone.cjs.map +4 -4
  8. package/dist/SSHMimic/exec.d.ts.map +1 -1
  9. package/dist/SSHMimic/exec.js +0 -1
  10. package/dist/SSHMimic/exec.js.map +1 -1
  11. package/dist/SSHMimic/index.js +1 -1
  12. package/dist/SSHMimic/index.js.map +1 -1
  13. package/dist/SSHMimic/sftp.d.ts.map +1 -1
  14. package/dist/SSHMimic/sftp.js +0 -6
  15. package/dist/SSHMimic/sftp.js.map +1 -1
  16. package/dist/VirtualFileSystem/index.d.ts +68 -0
  17. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  18. package/dist/VirtualFileSystem/index.js +213 -3
  19. package/dist/VirtualFileSystem/index.js.map +1 -1
  20. package/dist/VirtualFileSystem/internalTypes.d.ts +4 -0
  21. package/dist/VirtualFileSystem/internalTypes.d.ts.map +1 -1
  22. package/dist/VirtualFileSystem/journal.d.ts +47 -0
  23. package/dist/VirtualFileSystem/journal.d.ts.map +1 -0
  24. package/dist/VirtualFileSystem/journal.js +178 -0
  25. package/dist/VirtualFileSystem/journal.js.map +1 -0
  26. package/dist/VirtualShell/shell.js +4 -4
  27. package/dist/VirtualShell/shell.js.map +1 -1
  28. package/dist/commands/man.d.ts.map +1 -1
  29. package/dist/commands/man.js +2 -1
  30. package/dist/commands/man.js.map +1 -1
  31. package/dist/self-standalone.js +1 -1
  32. package/dist/self-standalone.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/SSHMimic/exec.ts +0 -1
  35. package/src/SSHMimic/index.ts +1 -1
  36. package/src/SSHMimic/sftp.ts +0 -6
  37. package/src/VirtualFileSystem/index.ts +222 -3
  38. package/src/VirtualFileSystem/internalTypes.ts +4 -0
  39. package/src/VirtualFileSystem/journal.ts +163 -0
  40. package/src/VirtualShell/shell.ts +4 -4
  41. package/src/commands/man.ts +3 -1
  42. package/src/self-standalone.ts +1 -1
  43. package/builds/standalone.js +0 -491
  44. package/builds/standalone.js.map +0 -7
@@ -1,3 +1,4 @@
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";
@@ -19,6 +20,7 @@ import type {
19
20
  InternalFileNode,
20
21
  InternalNode,
21
22
  } from "./internalTypes";
23
+ import { appendJournalEntry, JournalOp, readJournal, truncateJournal } from "./journal";
22
24
  import { getNode, getParentDirectory, normalizePath } from "./path";
23
25
 
24
26
  // ── Persistence options ───────────────────────────────────────────────────────
@@ -46,6 +48,26 @@ export interface VfsOptions {
46
48
  * Required when `mode` is `"fs"`.
47
49
  */
48
50
  snapshotPath?: string;
51
+ /**
52
+ * Interval in milliseconds between automatic checkpoints in `"fs"` mode.
53
+ * Set to `0` to disable automatic flushing (manual `flushMirror()` only).
54
+ * Default: 30_000 (30 seconds).
55
+ */
56
+ flushIntervalMs?: number;
57
+ /**
58
+ * Trigger a checkpoint after this many write operations, regardless of the
59
+ * timer interval. Prevents unbounded journal growth during bulk operations
60
+ * (e.g. a 15 000-file SFTP transfer). Default: 500.
61
+ * Set to `0` to disable write-count flushing.
62
+ */
63
+ flushAfterNWrites?: number;
64
+ /**
65
+ * Files larger than this threshold (bytes) are evicted from RAM after each
66
+ * `flushMirror()` and reloaded on demand from the snapshot.
67
+ * Default: 65536 (64 KB). Set to `0` to disable eviction.
68
+ * Only applies to `"fs"` mode.
69
+ */
70
+ evictionThresholdBytes?: number;
49
71
  }
50
72
 
51
73
  // ── VirtualFileSystem ─────────────────────────────────────────────────────────
@@ -77,6 +99,18 @@ class VirtualFileSystem extends EventEmitter {
77
99
  private root: InternalDirectoryNode;
78
100
  private readonly mode: VfsPersistenceMode;
79
101
  private readonly snapshotFile: string | null;
102
+ /** Path to the WAL journal file (null in memory mode). */
103
+ private readonly journalFile: string | null;
104
+ /** Eviction threshold in bytes (0 = disabled). Files above this are purged after flush. */
105
+ private readonly evictionThreshold: number;
106
+ /** Max writes between forced flushes (0 = disabled). */
107
+ private readonly flushAfterNWrites: number;
108
+ /** Pending write counter since last checkpoint. */
109
+ private _writesSinceFlush = 0;
110
+ /** NodeJS timer handle for periodic auto-flush (null = disabled or stopped). */
111
+ private _flushTimer: ReturnType<typeof setInterval> | null = null;
112
+ /** True if the VFS has unflushed changes. */
113
+ private _dirty = false;
80
114
  /** Active host-directory mounts: vPath → { hostPath, readOnly } */
81
115
  private readonly mounts = new Map<string, { hostPath: string; readOnly: boolean }>();
82
116
  /** True when running in a browser environment (no host FS access). */
@@ -96,8 +130,24 @@ class VirtualFileSystem extends EventEmitter {
96
130
  options.snapshotPath,
97
131
  "vfs-snapshot.vfsb",
98
132
  );
133
+ this.journalFile = path.resolve(options.snapshotPath, "vfs-journal.bin");
134
+ this.evictionThreshold = options.evictionThresholdBytes ?? 64 * 1024; // 64 KB default
135
+ this.flushAfterNWrites = options.flushAfterNWrites ?? 500;
136
+ const intervalMs = options.flushIntervalMs ?? 30_000;
137
+ if (intervalMs > 0) {
138
+ this._flushTimer = setInterval(() => {
139
+ if (this._dirty) void this._autoFlush();
140
+ }, intervalMs);
141
+ // Don't block process exit on this timer
142
+ if (typeof this._flushTimer === "object" && this._flushTimer !== null && "unref" in this._flushTimer) {
143
+ (this._flushTimer as NodeJS.Timeout).unref();
144
+ }
145
+ }
99
146
  } else {
100
147
  this.snapshotFile = null;
148
+ this.journalFile = null;
149
+ this.evictionThreshold = 0; // disabled in memory mode
150
+ this.flushAfterNWrites = 0;
101
151
  }
102
152
  this.root = this.makeDir("", 0o755);
103
153
  }
@@ -147,6 +197,7 @@ class VirtualFileSystem extends EventEmitter {
147
197
  child = this.makeDir(part, mode);
148
198
  current.children.set(part, child);
149
199
  this.emit("dir:create", { path: builtPath, mode });
200
+ this._journal({ op: JournalOp.MKDIR, path: builtPath, mode });
150
201
  } else if (child.type !== "directory") {
151
202
  throw new Error(
152
203
  `Cannot create directory '${builtPath}': path is a file.`,
@@ -168,7 +219,14 @@ class VirtualFileSystem extends EventEmitter {
168
219
  public async restoreMirror(): Promise<void> {
169
220
  if (this.mode !== "fs" || !this.snapshotFile) return;
170
221
 
171
- if (!fsSync.existsSync(this.snapshotFile)) return;
222
+ if (!fsSync.existsSync(this.snapshotFile)) {
223
+ // No snapshot yet — but replay journal if it exists (crash after writes, before first flush)
224
+ if (this.journalFile) {
225
+ const entries = readJournal(this.journalFile);
226
+ if (entries.length > 0) this._replayJournal(entries);
227
+ }
228
+ return;
229
+ }
172
230
 
173
231
  try {
174
232
  const raw = fsSync.readFileSync(this.snapshotFile);
@@ -184,6 +242,11 @@ class VirtualFileSystem extends EventEmitter {
184
242
  );
185
243
  }
186
244
  this.emit("snapshot:restore", { path: this.snapshotFile });
245
+ // Replay WAL journal on top of the loaded snapshot
246
+ if (this.journalFile) {
247
+ const entries = readJournal(this.journalFile);
248
+ if (entries.length > 0) this._replayJournal(entries);
249
+ }
187
250
  } catch (err) {
188
251
  // Corrupt or unreadable snapshot — start fresh and warn
189
252
  console.warn(
@@ -210,7 +273,13 @@ class VirtualFileSystem extends EventEmitter {
210
273
  fsSync.mkdirSync(dir, { recursive: true });
211
274
  const binary = encodeVfs(this.root);
212
275
  fsSync.writeFileSync(this.snapshotFile, binary);
276
+ // Checkpoint complete — truncate the journal (entries are now in the snapshot)
277
+ if (this.journalFile) truncateJournal(this.journalFile);
278
+ this._dirty = false;
279
+ this._writesSinceFlush = 0;
213
280
  this.emit("mirror:flush", { path: this.snapshotFile });
281
+ // Evict large files from RAM now that the snapshot is on disk
282
+ this.evictLargeFiles();
214
283
  }
215
284
 
216
285
  /** Returns the current persistence mode. */
@@ -227,6 +296,147 @@ class VirtualFileSystem extends EventEmitter {
227
296
 
228
297
  /** Creates a directory (and any missing parents). */
229
298
 
299
+
300
+
301
+ // ── Auto-flush scheduler ──────────────────────────────────────────────────
302
+
303
+ /** Internal: flush triggered by timer or write-count threshold. */
304
+ private async _autoFlush(): Promise<void> {
305
+ if (!this._dirty) return;
306
+ await this.flushMirror();
307
+ }
308
+
309
+ /** Mark VFS as having unflushed writes and trigger threshold flush if needed. */
310
+ private _markDirty(): void {
311
+ this._dirty = true;
312
+ if (this.flushAfterNWrites > 0) {
313
+ this._writesSinceFlush++;
314
+ if (this._writesSinceFlush >= this.flushAfterNWrites) {
315
+ this._writesSinceFlush = 0;
316
+ void this._autoFlush();
317
+ }
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Stop the automatic flush timer and perform a final checkpoint.
323
+ * Call this when shutting down to ensure all data is persisted.
324
+ *
325
+ * @example
326
+ * ```ts
327
+ * process.on("SIGINT", async () => {
328
+ * await shell.vfs.stopAutoFlush();
329
+ * process.exit(0);
330
+ * });
331
+ * ```
332
+ */
333
+ public async stopAutoFlush(): Promise<void> {
334
+ if (this._flushTimer !== null) {
335
+ clearInterval(this._flushTimer);
336
+ this._flushTimer = null;
337
+ }
338
+ if (this._dirty) await this.flushMirror();
339
+ }
340
+
341
+ // ── WAL Journal helpers ───────────────────────────────────────────────────
342
+
343
+ /** Set to true during journal replay to suppress re-journaling. */
344
+ private _replayMode = false;
345
+
346
+ /** Append a journal entry if in fs mode and not replaying. */
347
+ private _journal(entry: Parameters<typeof appendJournalEntry>[1]): void {
348
+ if (this.journalFile && !this._replayMode) {
349
+ appendJournalEntry(this.journalFile, entry);
350
+ this._markDirty();
351
+ }
352
+ }
353
+
354
+ /** Replay a list of journal entries onto the in-memory tree. */
355
+ private _replayJournal(entries: ReturnType<typeof readJournal>): void {
356
+ this._replayMode = true;
357
+ try {
358
+ for (const e of entries) {
359
+ try {
360
+ if (e.op === JournalOp.WRITE) {
361
+ this.writeFile(e.path, e.content ?? Buffer.alloc(0), { mode: e.mode });
362
+ } else if (e.op === JournalOp.MKDIR) {
363
+ this.mkdir(e.path, e.mode);
364
+ } else if (e.op === JournalOp.REMOVE) {
365
+ if (this.exists(e.path)) this.remove(e.path, { recursive: true });
366
+ } else if (e.op === JournalOp.CHMOD) {
367
+ if (this.exists(e.path)) this.chmod(e.path, e.mode ?? 0o644);
368
+ } else if (e.op === JournalOp.MOVE) {
369
+ if (this.exists(e.path) && e.dest) this.move(e.path, e.dest);
370
+ } else if (e.op === JournalOp.SYMLINK) {
371
+ if (e.dest) this.symlink(e.dest, e.path);
372
+ }
373
+ } catch { /* ignore individual replay errors — best-effort */ }
374
+ }
375
+ } finally {
376
+ this._replayMode = false;
377
+ }
378
+ }
379
+
380
+
381
+ // ── RAM eviction ──────────────────────────────────────────────────────────
382
+
383
+ /**
384
+ * Walk the in-memory tree and evict file contents that exceed
385
+ * `evictionThreshold`. Called automatically after `flushMirror()`.
386
+ * Safe to call at any time — evicted files are reloaded on demand.
387
+ */
388
+ public evictLargeFiles(): void {
389
+ if (!this.snapshotFile || this.evictionThreshold === 0) return;
390
+ if (!fsSync.existsSync(this.snapshotFile)) return;
391
+ this._evictDir(this.root);
392
+ }
393
+
394
+ private _evictDir(dir: InternalDirectoryNode): void {
395
+ for (const node of dir.children.values()) {
396
+ if (node.type === "directory") {
397
+ this._evictDir(node);
398
+ } else if (!node.evicted) {
399
+ const rawSize = node.compressed
400
+ ? (node.size ?? node.content.length * 2) // estimate uncompressed
401
+ : node.content.length;
402
+ if (rawSize > this.evictionThreshold) {
403
+ node.size = rawSize;
404
+ node.content = Buffer.alloc(0); // free heap
405
+ node.evicted = true;
406
+ }
407
+ }
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Reload a single evicted file node's content from the current snapshot.
413
+ * No-op if the node is not evicted.
414
+ */
415
+ private _reloadEvicted(node: InternalFileNode, normalizedPath: string): void {
416
+ if (!node.evicted || !this.snapshotFile) return;
417
+ if (!fsSync.existsSync(this.snapshotFile)) return;
418
+ try {
419
+ // Load and parse the snapshot to find this specific node
420
+ const raw = fsSync.readFileSync(this.snapshotFile);
421
+ const tmpRoot = decodeVfs(raw);
422
+ const parts = normalizedPath.split("/").filter(Boolean);
423
+ let cur: InternalNode = tmpRoot;
424
+ for (const part of parts) {
425
+ if (cur.type !== "directory") return;
426
+ const next = cur.children.get(part);
427
+ if (!next) return;
428
+ cur = next;
429
+ }
430
+ if (cur.type === "file") {
431
+ node.content = cur.content;
432
+ node.compressed = cur.compressed;
433
+ node.evicted = undefined;
434
+ }
435
+ } catch {
436
+ // Snapshot unreadable — leave evicted; caller will get empty content
437
+ }
438
+ }
439
+
230
440
  // ── Mount API ─────────────────────────────────────────────────────────────
231
441
 
232
442
  /**
@@ -387,6 +597,7 @@ class VirtualFileSystem extends EventEmitter {
387
597
  }
388
598
 
389
599
  this.emit("file:write", { path: normalized, size: storedContent.length });
600
+ this._journal({ op: JournalOp.WRITE, path: normalized, content: rawContent, mode });
390
601
  }
391
602
 
392
603
  /**
@@ -405,6 +616,7 @@ class VirtualFileSystem extends EventEmitter {
405
616
  throw new Error(`Cannot read '${targetPath}': not a file.`);
406
617
  }
407
618
  const f = node as InternalFileNode;
619
+ if (f.evicted) this._reloadEvicted(f, normalized);
408
620
  const raw = f.compressed ? gunzipSync(f.content) : f.content;
409
621
  this.emit("file:read", { path: normalized, size: raw.length });
410
622
  return raw.toString("utf8");
@@ -423,6 +635,7 @@ class VirtualFileSystem extends EventEmitter {
423
635
  throw new Error(`Cannot read '${targetPath}': not a file.`);
424
636
  }
425
637
  const f = node as InternalFileNode;
638
+ if (f.evicted) this._reloadEvicted(f, normalized);
426
639
  const raw = f.compressed ? gunzipSync(f.content) : f.content;
427
640
  this.emit("file:read", { path: normalized, size: raw.length });
428
641
  return raw;
@@ -442,7 +655,9 @@ class VirtualFileSystem extends EventEmitter {
442
655
 
443
656
  /** Updates mode bits on a node. */
444
657
  public chmod(targetPath: string, mode: number): void {
445
- getNode(this.root, normalizePath(targetPath)).mode = mode;
658
+ const normalized = normalizePath(targetPath);
659
+ getNode(this.root, normalized).mode = mode;
660
+ this._journal({ op: JournalOp.CHMOD, path: normalized, mode });
446
661
  }
447
662
 
448
663
  /** Returns metadata for a file or directory. */
@@ -488,7 +703,7 @@ class VirtualFileSystem extends EventEmitter {
488
703
  createdAt: f.createdAt,
489
704
  updatedAt: f.updatedAt,
490
705
  compressed: f.compressed,
491
- size: f.content.length,
706
+ size: f.evicted ? (f.size ?? 0) : f.content.length,
492
707
  };
493
708
  }
494
709
  const d = node as InternalDirectoryNode;
@@ -617,6 +832,8 @@ class VirtualFileSystem extends EventEmitter {
617
832
  updatedAt: new Date(),
618
833
  };
619
834
  parent.children.set(name, symNode);
835
+ // Journal before emit
836
+ this._journal({ op: JournalOp.SYMLINK, path: normalizedLink, dest: normalizedTarget });
620
837
  this.emit("symlink:create", {
621
838
  link: normalizedLink,
622
839
  target: normalizedTarget,
@@ -692,6 +909,7 @@ class VirtualFileSystem extends EventEmitter {
692
909
  );
693
910
  parent.children.delete(name);
694
911
  this.emit("node:remove", { path: normalized });
912
+ this._journal({ op: JournalOp.REMOVE, path: normalized });
695
913
  }
696
914
 
697
915
  /** Moves or renames a node. */
@@ -721,6 +939,7 @@ class VirtualFileSystem extends EventEmitter {
721
939
  srcParent.children.delete(srcName);
722
940
  node.name = destName;
723
941
  destParent.children.set(destName, node);
942
+ this._journal({ op: JournalOp.MOVE, path: fromNormalized, dest: toNormalized });
724
943
  }
725
944
 
726
945
  // ── Snapshot serialisation ─────────────────────────────────────────────────
@@ -11,6 +11,10 @@ export interface InternalFileNode extends InternalBaseNode {
11
11
  type: "file";
12
12
  content: Buffer;
13
13
  compressed: boolean;
14
+ /** When true, content has been purged from RAM. Reloaded from snapshot on demand. */
15
+ evicted?: true;
16
+ /** Byte length of the original (uncompressed) content — preserved when evicted. */
17
+ size?: number;
14
18
  }
15
19
 
16
20
  export interface InternalDirectoryNode extends InternalBaseNode {
@@ -0,0 +1,163 @@
1
+ /**
2
+ * journal.ts — Write-Ahead Log for VirtualFileSystem "fs" mode.
3
+ *
4
+ * Each mutating VFS operation is appended to `vfs-journal.bin` immediately.
5
+ * On `restoreMirror()` the base snapshot is loaded first, then the journal
6
+ * is replayed on top. On `flushMirror()` a new checkpoint is written and
7
+ * the journal is truncated.
8
+ *
9
+ * Entry format (binary, little-endian):
10
+ * [1B op] [2B path_len] [path bytes] [payload per op]
11
+ *
12
+ * Op codes:
13
+ * 0x01 WRITE — [4B content_len] [content bytes] [4B mode]
14
+ * 0x02 MKDIR — [4B mode]
15
+ * 0x03 REMOVE — (no payload)
16
+ * 0x04 CHMOD — [4B mode]
17
+ * 0x05 MOVE — [2B dest_len] [dest bytes]
18
+ * 0x06 SYMLINK — [2B target_len] [target bytes]
19
+ */
20
+ /** biome-ignore-all lint/style/useNamingConvention: JournalOp modes */
21
+
22
+ import * as fsSync from "node:fs";
23
+
24
+ export const JournalOp = {
25
+ WRITE: 0x01,
26
+ MKDIR: 0x02,
27
+ REMOVE: 0x03,
28
+ CHMOD: 0x04,
29
+ MOVE: 0x05,
30
+ SYMLINK: 0x06,
31
+ } as const;
32
+
33
+ export type JournalOp = typeof JournalOp[keyof typeof JournalOp];
34
+
35
+ export interface JournalEntry {
36
+ op: JournalOp;
37
+ path: string;
38
+ content?: Buffer;
39
+ mode?: number;
40
+ dest?: string; // MOVE destination, SYMLINK target
41
+ }
42
+
43
+ const ENC = "utf8" as const;
44
+
45
+ function writeString2(buf: Buffer, offset: number, s: string): number {
46
+ const b = Buffer.from(s, ENC);
47
+ buf.writeUInt16LE(b.length, offset);
48
+ b.copy(buf, offset + 2);
49
+ return 2 + b.length;
50
+ }
51
+
52
+ /** Serialise one entry to a Buffer. */
53
+ export function encodeEntry(e: JournalEntry): Buffer {
54
+ const pathBuf = Buffer.from(e.path, ENC);
55
+ let payloadLen = 0;
56
+
57
+ if (e.op === JournalOp.WRITE) {
58
+ payloadLen = 4 + (e.content?.length ?? 0) + 4;
59
+ } else if (e.op === JournalOp.MKDIR) {
60
+ payloadLen = 4;
61
+ } else if (e.op === JournalOp.REMOVE) {
62
+ payloadLen = 0;
63
+ } else if (e.op === JournalOp.CHMOD) {
64
+ payloadLen = 4;
65
+ } else if (e.op === JournalOp.MOVE || e.op === JournalOp.SYMLINK) {
66
+ payloadLen = 2 + Buffer.byteLength(e.dest ?? "", ENC);
67
+ }
68
+
69
+ const total = 1 + 2 + pathBuf.length + payloadLen;
70
+ const buf = Buffer.allocUnsafe(total);
71
+ let off = 0;
72
+
73
+ buf.writeUInt8(e.op, off++);
74
+ buf.writeUInt16LE(pathBuf.length, off); off += 2;
75
+ pathBuf.copy(buf, off); off += pathBuf.length;
76
+
77
+ if (e.op === JournalOp.WRITE) {
78
+ const c = e.content ?? Buffer.alloc(0);
79
+ buf.writeUInt32LE(c.length, off); off += 4;
80
+ c.copy(buf, off); off += c.length;
81
+ buf.writeUInt32LE(e.mode ?? 0o644, off); off += 4;
82
+ } else if (e.op === JournalOp.MKDIR) {
83
+ buf.writeUInt32LE(e.mode ?? 0o755, off); off += 4;
84
+ } else if (e.op === JournalOp.CHMOD) {
85
+ buf.writeUInt32LE(e.mode ?? 0o644, off); off += 4;
86
+ } else if (e.op === JournalOp.MOVE || e.op === JournalOp.SYMLINK) {
87
+ off += writeString2(buf, off, e.dest ?? "");
88
+ }
89
+
90
+ return buf;
91
+ }
92
+
93
+ /** Parse all entries from a journal Buffer. Returns empty array on corrupt data. */
94
+ export function decodeJournal(buf: Buffer): JournalEntry[] {
95
+ const entries: JournalEntry[] = [];
96
+ let off = 0;
97
+
98
+ try {
99
+ while (off < buf.length) {
100
+ if (off + 3 > buf.length) break;
101
+ const op = buf.readUInt8(off++) as JournalOp;
102
+ const pathLen = buf.readUInt16LE(off); off += 2;
103
+ if (off + pathLen > buf.length) break;
104
+ const path = buf.subarray(off, off + pathLen).toString(ENC); off += pathLen;
105
+
106
+ if (op === JournalOp.WRITE) {
107
+ if (off + 4 > buf.length) break;
108
+ const cLen = buf.readUInt32LE(off); off += 4;
109
+ if (off + cLen + 4 > buf.length) break;
110
+ const content = Buffer.from(buf.subarray(off, off + cLen)); off += cLen;
111
+ const mode = buf.readUInt32LE(off); off += 4;
112
+ entries.push({ op, path, content, mode });
113
+ } else if (op === JournalOp.MKDIR) {
114
+ if (off + 4 > buf.length) break;
115
+ const mode = buf.readUInt32LE(off); off += 4;
116
+ entries.push({ op, path, mode });
117
+ } else if (op === JournalOp.REMOVE) {
118
+ entries.push({ op, path });
119
+ } else if (op === JournalOp.CHMOD) {
120
+ if (off + 4 > buf.length) break;
121
+ const mode = buf.readUInt32LE(off); off += 4;
122
+ entries.push({ op, path, mode });
123
+ } else if (op === JournalOp.MOVE || op === JournalOp.SYMLINK) {
124
+ if (off + 2 > buf.length) break;
125
+ const dLen = buf.readUInt16LE(off); off += 2;
126
+ if (off + dLen > buf.length) break;
127
+ const dest = buf.subarray(off, off + dLen).toString(ENC); off += dLen;
128
+ entries.push({ op, path, dest });
129
+ } else {
130
+ // Unknown op — skip rest of buffer to avoid corrupt replay
131
+ break;
132
+ }
133
+ }
134
+ } catch {
135
+ // Truncated or corrupt entry — return what we have
136
+ }
137
+
138
+ return entries;
139
+ }
140
+
141
+ /** Append a single entry to the journal file (O_APPEND, atomic write). */
142
+ export function appendJournalEntry(journalPath: string, entry: JournalEntry): void {
143
+ const buf = encodeEntry(entry);
144
+ const fd = fsSync.openSync(journalPath, fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_APPEND);
145
+ try {
146
+ fsSync.writeSync(fd, buf);
147
+ } finally {
148
+ fsSync.closeSync(fd);
149
+ }
150
+ }
151
+
152
+ /** Read and decode all entries from a journal file. Returns [] if file is absent/empty. */
153
+ export function readJournal(journalPath: string): JournalEntry[] {
154
+ if (!fsSync.existsSync(journalPath)) return [];
155
+ const buf = fsSync.readFileSync(journalPath);
156
+ if (buf.length === 0) return [];
157
+ return decodeJournal(buf);
158
+ }
159
+
160
+ /** Delete the journal file (after a successful checkpoint). */
161
+ export function truncateJournal(journalPath: string): void {
162
+ if (fsSync.existsSync(journalPath)) fsSync.unlinkSync(journalPath);
163
+ }
@@ -202,7 +202,7 @@ export function startShell(
202
202
  cwd = result.nextCwd;
203
203
  }
204
204
 
205
- await shell.vfs.flushMirror();
205
+ // WAL: checkpoint handled by auto-flush timer
206
206
  renderLine();
207
207
  }
208
208
 
@@ -221,7 +221,7 @@ export function startShell(
221
221
  activeSession.targetPath,
222
222
  updatedContent,
223
223
  );
224
- await shell.vfs.flushMirror();
224
+ // WAL: checkpoint handled by auto-flush timer
225
225
  } catch {
226
226
  // If temp file does not exist, nano exited without writing.
227
227
  }
@@ -498,7 +498,7 @@ export function startShell(
498
498
  historyDraft = "";
499
499
  stream.write("bye\r\n");
500
500
  pushHistory("bye");
501
- await shell.vfs.flushMirror();
501
+ // WAL: checkpoint handled by auto-flush timer
502
502
  stream.write("logout\r\n");
503
503
  stream.exit(0);
504
504
  stream.end();
@@ -657,7 +657,7 @@ export function startShell(
657
657
  cursorPos = 0;
658
658
  }
659
659
 
660
- await shell.vfs.flushMirror();
660
+ // WAL: checkpoint handled by auto-flush timer
661
661
  }
662
662
 
663
663
  renderLine();
@@ -4,8 +4,10 @@ const MANUAL_ALIASES: Record<string, string> = {
4
4
  gunzip: "gzip",
5
5
  };
6
6
 
7
+ const __dirname = import.meta.dirname;
8
+
7
9
  const manualCache = new Map<string, string | null>();
8
- const manualsBaseUrl = `${__dirname}/manuals/`;
10
+ const manualsBaseUrl = new URL("./manuals/", import.meta.url);
9
11
 
10
12
  async function dynamicImport(specifier: string): Promise<unknown> {
11
13
  const importer = new Function(
@@ -62,7 +62,7 @@ function writeLastLogin(username: string, from: string): void {
62
62
  }
63
63
 
64
64
  async function flushVfs(): Promise<void> {
65
- await virtualShell.vfs.flushMirror();
65
+ await virtualShell.vfs.stopAutoFlush();
66
66
  }
67
67
 
68
68
  function loadHistory(authUser: string): string[] {