typescript-virtual-container 1.2.4 → 1.2.6

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 (267) hide show
  1. package/README.md +1056 -1239
  2. package/benchmark-results.txt +20 -20
  3. package/dist/SSHMimic/exec.js +2 -2
  4. package/dist/SSHMimic/executor.d.ts +6 -7
  5. package/dist/SSHMimic/executor.d.ts.map +1 -1
  6. package/dist/SSHMimic/executor.js +77 -60
  7. package/dist/SSHMimic/index.d.ts +19 -2
  8. package/dist/SSHMimic/index.d.ts.map +1 -1
  9. package/dist/SSHMimic/index.js +106 -24
  10. package/dist/SSHMimic/sftp.d.ts.map +1 -1
  11. package/dist/SSHMimic/sftp.js +14 -0
  12. package/dist/VirtualFileSystem/index.d.ts +115 -88
  13. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  14. package/dist/VirtualFileSystem/index.js +389 -264
  15. package/dist/VirtualShell/index.d.ts +3 -4
  16. package/dist/VirtualShell/index.d.ts.map +1 -1
  17. package/dist/VirtualShell/index.js +4 -6
  18. package/dist/VirtualShell/shell.d.ts.map +1 -1
  19. package/dist/VirtualShell/shell.js +19 -2
  20. package/dist/VirtualShell/shellParser.d.ts +20 -2
  21. package/dist/VirtualShell/shellParser.d.ts.map +1 -1
  22. package/dist/VirtualShell/shellParser.js +229 -120
  23. package/dist/VirtualUserManager/index.d.ts +25 -0
  24. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  25. package/dist/VirtualUserManager/index.js +33 -0
  26. package/dist/commands/adduser.d.ts.map +1 -1
  27. package/dist/commands/adduser.js +2 -0
  28. package/dist/commands/awk.d.ts +3 -0
  29. package/dist/commands/awk.d.ts.map +1 -0
  30. package/dist/commands/awk.js +29 -0
  31. package/dist/commands/base64.d.ts +3 -0
  32. package/dist/commands/base64.d.ts.map +1 -0
  33. package/dist/commands/base64.js +20 -0
  34. package/dist/commands/cat.d.ts.map +1 -1
  35. package/dist/commands/cat.js +2 -0
  36. package/dist/commands/cd.d.ts.map +1 -1
  37. package/dist/commands/cd.js +2 -0
  38. package/dist/commands/chmod.d.ts +3 -0
  39. package/dist/commands/chmod.d.ts.map +1 -0
  40. package/dist/commands/chmod.js +33 -0
  41. package/dist/commands/clear.d.ts.map +1 -1
  42. package/dist/commands/clear.js +4 -1
  43. package/dist/commands/cp.d.ts +3 -0
  44. package/dist/commands/cp.d.ts.map +1 -0
  45. package/dist/commands/cp.js +70 -0
  46. package/dist/commands/curl.d.ts.map +1 -1
  47. package/dist/commands/curl.js +2 -0
  48. package/dist/commands/cut.d.ts +3 -0
  49. package/dist/commands/cut.d.ts.map +1 -0
  50. package/dist/commands/cut.js +27 -0
  51. package/dist/commands/date.d.ts +3 -0
  52. package/dist/commands/date.d.ts.map +1 -0
  53. package/dist/commands/date.js +22 -0
  54. package/dist/commands/deluser.d.ts.map +1 -1
  55. package/dist/commands/deluser.js +2 -0
  56. package/dist/commands/df.d.ts +3 -0
  57. package/dist/commands/df.d.ts.map +1 -0
  58. package/dist/commands/df.js +16 -0
  59. package/dist/commands/diff.d.ts +3 -0
  60. package/dist/commands/diff.d.ts.map +1 -0
  61. package/dist/commands/diff.js +40 -0
  62. package/dist/commands/du.d.ts +3 -0
  63. package/dist/commands/du.d.ts.map +1 -0
  64. package/dist/commands/du.js +39 -0
  65. package/dist/commands/echo.d.ts.map +1 -1
  66. package/dist/commands/echo.js +2 -0
  67. package/dist/commands/env.d.ts.map +1 -1
  68. package/dist/commands/env.js +6 -14
  69. package/dist/commands/export.d.ts.map +1 -1
  70. package/dist/commands/export.js +11 -21
  71. package/dist/commands/find.d.ts +3 -0
  72. package/dist/commands/find.d.ts.map +1 -0
  73. package/dist/commands/find.js +50 -0
  74. package/dist/commands/grep.d.ts.map +1 -1
  75. package/dist/commands/grep.js +58 -35
  76. package/dist/commands/groups.d.ts +3 -0
  77. package/dist/commands/groups.d.ts.map +1 -0
  78. package/dist/commands/groups.js +12 -0
  79. package/dist/commands/gzip.d.ts +4 -0
  80. package/dist/commands/gzip.d.ts.map +1 -0
  81. package/dist/commands/gzip.js +40 -0
  82. package/dist/commands/head.d.ts +3 -0
  83. package/dist/commands/head.d.ts.map +1 -0
  84. package/dist/commands/head.js +32 -0
  85. package/dist/commands/help.d.ts +1 -1
  86. package/dist/commands/help.d.ts.map +1 -1
  87. package/dist/commands/help.js +75 -3
  88. package/dist/commands/hostname.d.ts.map +1 -1
  89. package/dist/commands/hostname.js +2 -0
  90. package/dist/commands/htop.d.ts.map +1 -1
  91. package/dist/commands/htop.js +2 -0
  92. package/dist/commands/id.d.ts +3 -0
  93. package/dist/commands/id.d.ts.map +1 -0
  94. package/dist/commands/id.js +14 -0
  95. package/dist/commands/index.d.ts +5 -2
  96. package/dist/commands/index.d.ts.map +1 -1
  97. package/dist/commands/index.js +104 -87
  98. package/dist/commands/kill.d.ts +3 -0
  99. package/dist/commands/kill.d.ts.map +1 -0
  100. package/dist/commands/kill.js +13 -0
  101. package/dist/commands/ln.d.ts +3 -0
  102. package/dist/commands/ln.d.ts.map +1 -0
  103. package/dist/commands/ln.js +44 -0
  104. package/dist/commands/ls.d.ts.map +1 -1
  105. package/dist/commands/ls.js +2 -0
  106. package/dist/commands/mkdir.d.ts.map +1 -1
  107. package/dist/commands/mkdir.js +2 -0
  108. package/dist/commands/mv.d.ts +3 -0
  109. package/dist/commands/mv.d.ts.map +1 -0
  110. package/dist/commands/mv.js +37 -0
  111. package/dist/commands/nano.d.ts.map +1 -1
  112. package/dist/commands/nano.js +2 -0
  113. package/dist/commands/neofetch.d.ts.map +1 -1
  114. package/dist/commands/neofetch.js +2 -0
  115. package/dist/commands/passwd.d.ts.map +1 -1
  116. package/dist/commands/passwd.js +2 -0
  117. package/dist/commands/ping.d.ts +3 -0
  118. package/dist/commands/ping.d.ts.map +1 -0
  119. package/dist/commands/ping.js +18 -0
  120. package/dist/commands/ps.d.ts +3 -0
  121. package/dist/commands/ps.d.ts.map +1 -0
  122. package/dist/commands/ps.js +17 -0
  123. package/dist/commands/pwd.d.ts.map +1 -1
  124. package/dist/commands/pwd.js +2 -0
  125. package/dist/commands/rm.d.ts.map +1 -1
  126. package/dist/commands/rm.js +2 -0
  127. package/dist/commands/sed.d.ts +3 -0
  128. package/dist/commands/sed.d.ts.map +1 -0
  129. package/dist/commands/sed.js +47 -0
  130. package/dist/commands/set.d.ts +3 -0
  131. package/dist/commands/set.d.ts.map +1 -1
  132. package/dist/commands/set.js +19 -46
  133. package/dist/commands/sh.d.ts +0 -1
  134. package/dist/commands/sh.d.ts.map +1 -1
  135. package/dist/commands/sh.js +228 -35
  136. package/dist/commands/sleep.d.ts +3 -0
  137. package/dist/commands/sleep.d.ts.map +1 -0
  138. package/dist/commands/sleep.js +13 -0
  139. package/dist/commands/sort.d.ts +3 -0
  140. package/dist/commands/sort.d.ts.map +1 -0
  141. package/dist/commands/sort.js +37 -0
  142. package/dist/commands/su.d.ts.map +1 -1
  143. package/dist/commands/su.js +2 -0
  144. package/dist/commands/sudo.d.ts.map +1 -1
  145. package/dist/commands/sudo.js +2 -0
  146. package/dist/commands/tail.d.ts +3 -0
  147. package/dist/commands/tail.d.ts.map +1 -0
  148. package/dist/commands/tail.js +35 -0
  149. package/dist/commands/tar.d.ts +3 -0
  150. package/dist/commands/tar.d.ts.map +1 -0
  151. package/dist/commands/tar.js +64 -0
  152. package/dist/commands/tee.d.ts +3 -0
  153. package/dist/commands/tee.d.ts.map +1 -0
  154. package/dist/commands/tee.js +29 -0
  155. package/dist/commands/touch.d.ts.map +1 -1
  156. package/dist/commands/touch.js +2 -0
  157. package/dist/commands/tr.d.ts +3 -0
  158. package/dist/commands/tr.d.ts.map +1 -0
  159. package/dist/commands/tr.js +24 -0
  160. package/dist/commands/tree.d.ts.map +1 -1
  161. package/dist/commands/tree.js +2 -0
  162. package/dist/commands/uname.d.ts +3 -0
  163. package/dist/commands/uname.d.ts.map +1 -0
  164. package/dist/commands/uname.js +21 -0
  165. package/dist/commands/uniq.d.ts +3 -0
  166. package/dist/commands/uniq.d.ts.map +1 -0
  167. package/dist/commands/uniq.js +33 -0
  168. package/dist/commands/unset.d.ts.map +1 -1
  169. package/dist/commands/unset.js +6 -10
  170. package/dist/commands/wc.d.ts +3 -0
  171. package/dist/commands/wc.d.ts.map +1 -0
  172. package/dist/commands/wc.js +50 -0
  173. package/dist/commands/wget.d.ts.map +1 -1
  174. package/dist/commands/wget.js +2 -0
  175. package/dist/commands/who.d.ts.map +1 -1
  176. package/dist/commands/who.js +2 -0
  177. package/dist/commands/whoami.d.ts.map +1 -1
  178. package/dist/commands/whoami.js +2 -0
  179. package/dist/commands/xargs.d.ts +3 -0
  180. package/dist/commands/xargs.d.ts.map +1 -0
  181. package/dist/commands/xargs.js +16 -0
  182. package/dist/index.d.ts +1 -0
  183. package/dist/index.d.ts.map +1 -1
  184. package/dist/types/commands.d.ts +13 -0
  185. package/dist/types/commands.d.ts.map +1 -1
  186. package/dist/types/pipeline.d.ts +20 -0
  187. package/dist/types/pipeline.d.ts.map +1 -1
  188. package/package.json +5 -2
  189. package/scripts/publish-package.sh +70 -0
  190. package/src/SSHMimic/exec.ts +2 -2
  191. package/src/SSHMimic/executor.ts +95 -98
  192. package/src/SSHMimic/index.ts +138 -57
  193. package/src/SSHMimic/sftp.ts +15 -0
  194. package/src/VirtualFileSystem/index.ts +464 -292
  195. package/src/VirtualShell/index.ts +4 -6
  196. package/src/VirtualShell/shell.ts +19 -2
  197. package/src/VirtualShell/shellParser.ts +202 -168
  198. package/src/VirtualUserManager/index.ts +36 -0
  199. package/src/commands/adduser.ts +2 -0
  200. package/src/commands/awk.ts +30 -0
  201. package/src/commands/base64.ts +18 -0
  202. package/src/commands/cat.ts +2 -0
  203. package/src/commands/cd.ts +2 -0
  204. package/src/commands/chmod.ts +35 -0
  205. package/src/commands/clear.ts +4 -1
  206. package/src/commands/cp.ts +78 -0
  207. package/src/commands/curl.ts +2 -0
  208. package/src/commands/cut.ts +29 -0
  209. package/src/commands/date.ts +24 -0
  210. package/src/commands/deluser.ts +2 -0
  211. package/src/commands/df.ts +18 -0
  212. package/src/commands/diff.ts +29 -0
  213. package/src/commands/du.ts +39 -0
  214. package/src/commands/echo.ts +2 -0
  215. package/src/commands/env.ts +6 -16
  216. package/src/commands/export.ts +11 -24
  217. package/src/commands/find.ts +63 -0
  218. package/src/commands/grep.ts +51 -38
  219. package/src/commands/groups.ts +14 -0
  220. package/src/commands/gzip.ts +31 -0
  221. package/src/commands/head.ts +37 -0
  222. package/src/commands/help.ts +81 -3
  223. package/src/commands/hostname.ts +2 -0
  224. package/src/commands/htop.ts +2 -0
  225. package/src/commands/id.ts +16 -0
  226. package/src/commands/index.ts +114 -133
  227. package/src/commands/kill.ts +14 -0
  228. package/src/commands/ln.ts +49 -0
  229. package/src/commands/ls.ts +2 -0
  230. package/src/commands/mkdir.ts +2 -0
  231. package/src/commands/mv.ts +45 -0
  232. package/src/commands/nano.ts +2 -0
  233. package/src/commands/neofetch.ts +2 -0
  234. package/src/commands/passwd.ts +2 -0
  235. package/src/commands/ping.ts +20 -0
  236. package/src/commands/ps.ts +19 -0
  237. package/src/commands/pwd.ts +2 -0
  238. package/src/commands/rm.ts +2 -0
  239. package/src/commands/sed.ts +45 -0
  240. package/src/commands/set.ts +19 -50
  241. package/src/commands/sh.ts +192 -43
  242. package/src/commands/sleep.ts +14 -0
  243. package/src/commands/sort.ts +37 -0
  244. package/src/commands/su.ts +2 -0
  245. package/src/commands/sudo.ts +2 -0
  246. package/src/commands/tail.ts +39 -0
  247. package/src/commands/tar.ts +58 -0
  248. package/src/commands/tee.ts +25 -0
  249. package/src/commands/touch.ts +2 -0
  250. package/src/commands/tr.ts +24 -0
  251. package/src/commands/tree.ts +2 -0
  252. package/src/commands/uname.ts +20 -0
  253. package/src/commands/uniq.ts +28 -0
  254. package/src/commands/unset.ts +5 -12
  255. package/src/commands/wc.ts +50 -0
  256. package/src/commands/wget.ts +2 -0
  257. package/src/commands/who.ts +2 -0
  258. package/src/commands/whoami.ts +2 -0
  259. package/src/commands/xargs.ts +17 -0
  260. package/src/index.ts +1 -0
  261. package/src/types/commands.ts +14 -0
  262. package/src/types/pipeline.ts +23 -0
  263. package/standalone.js +93 -55
  264. package/standalone.js.map +4 -4
  265. package/tests/bun-test-shim.ts +1 -0
  266. package/tests/sftp.test.ts +115 -191
  267. package/tests/users.test.ts +42 -88
@@ -1,435 +1,607 @@
1
1
  import { EventEmitter } from "node:events";
2
- import * as fs from "node:fs";
2
+ import * as fsSync from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { gunzipSync, gzipSync } from "node:zlib";
5
+ import type {
6
+ InternalDirectoryNode,
7
+ InternalFileNode,
8
+ InternalNode,
9
+ } from "./internalTypes";
10
+ import { getNode, getParentDirectory, normalizePath } from "./path";
5
11
  import type {
6
12
  RemoveOptions,
7
13
  VfsNodeStats,
14
+ VfsSnapshot,
15
+ VfsSnapshotDirectoryNode,
16
+ VfsSnapshotFileNode,
17
+ VfsSnapshotNode,
8
18
  WriteFileOptions,
9
19
  } from "../types/vfs";
10
- import type { PerfLogger } from "../utils/perfLogger";
11
- import { createPerfLogger } from "../utils/perfLogger";
12
- import { normalizePath } from "./path";
20
+
21
+ // ── Persistence options ───────────────────────────────────────────────────────
13
22
 
14
23
  /**
15
- * In-memory virtual filesystem with tar.gz mirror persistence.
24
+ * "memory" pure in-memory, no disk I/O (default).
16
25
  *
17
- * Paths are normalized to POSIX-like absolute paths. Use
18
- * {@link VirtualFileSystem.restoreMirror} on startup and
19
- * {@link VirtualFileSystem.flushMirror} to persist pending changes.
26
+ * "fs" — mirrors the VFS tree to a directory on the host filesystem.
27
+ * `snapshotPath` must be set to the directory where the JSON
28
+ * snapshot file will be read/written.
20
29
  */
21
- const perf: PerfLogger = createPerfLogger("VirtualFileSystem");
22
-
23
- class VirtualFileSystem extends EventEmitter {
24
- private readonly mirrorRoot: string;
30
+ export type VfsPersistenceMode = "memory" | "fs";
25
31
 
26
- private ensureMirrorRoot(): void {
27
- fs.mkdirSync(this.mirrorRoot, { recursive: true, mode: 0o755 });
28
- }
29
-
30
- private resolveFsPath(targetPath: string): string {
31
- const normalized = normalizePath(targetPath);
32
- const relativePath = normalized.slice(1);
33
- const resolved = path.resolve(this.mirrorRoot, relativePath || ".");
34
- const relative = path.relative(this.mirrorRoot, resolved);
32
+ export interface VfsOptions {
33
+ /**
34
+ * Persistence mode.
35
+ * - `"memory"` (default): no disk access, snapshot via `toSnapshot()`.
36
+ * - `"fs"`: auto-save JSON snapshot to `snapshotPath` on every
37
+ * `flushMirror()` call, and restore from it on `restoreMirror()`.
38
+ */
39
+ mode?: VfsPersistenceMode;
40
+ /**
41
+ * Directory used by `"fs"` mode.
42
+ * The snapshot file will be written to `<snapshotPath>/vfs-snapshot.json`.
43
+ * Required when `mode` is `"fs"`.
44
+ */
45
+ snapshotPath?: string;
46
+ }
35
47
 
36
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
37
- throw new Error(`Invalid path '${targetPath}'.`);
38
- }
48
+ // ── VirtualFileSystem ─────────────────────────────────────────────────────────
39
49
 
40
- return resolved;
41
- }
50
+ /**
51
+ * In-memory virtual filesystem with optional JSON-snapshot persistence.
52
+ *
53
+ * **Memory mode** (default) — all state lives in a fast recursive tree.
54
+ * Use `toSnapshot()` / `fromSnapshot()` / `importSnapshot()` for serialisation.
55
+ *
56
+ * **FS mode** — same in-memory tree, but `restoreMirror()` loads a JSON
57
+ * snapshot from disk and `flushMirror()` writes it back. This gives you
58
+ * persistent VFS state across process restarts without any real POSIX filesystem
59
+ * semantics leaking through.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * // Pure in-memory (default)
64
+ * const vfs = new VirtualFileSystem();
65
+ *
66
+ * // With disk persistence
67
+ * const vfs = new VirtualFileSystem({ mode: "fs", snapshotPath: "./data" });
68
+ * await vfs.restoreMirror(); // load from disk (no-op if no snapshot yet)
69
+ * // ... use vfs ...
70
+ * await vfs.flushMirror(); // persist to disk
71
+ * ```
72
+ */
73
+ class VirtualFileSystem extends EventEmitter {
74
+ private root: InternalDirectoryNode;
75
+ private readonly mode: VfsPersistenceMode;
76
+ private readonly snapshotFile: string | null;
42
77
 
43
- private detectGzipFile(targetPath: string): boolean {
44
- const fd = fs.openSync(targetPath, "r");
45
- try {
46
- const header = Buffer.alloc(2);
47
- const bytesRead = fs.readSync(fd, header, 0, 2, 0);
48
- return bytesRead === 2 && header[0] === 0x1f && header[1] === 0x8b;
49
- } finally {
50
- fs.closeSync(fd);
78
+ constructor(options: VfsOptions = {}) {
79
+ super();
80
+ this.mode = options.mode ?? "memory";
81
+ if (this.mode === "fs") {
82
+ if (!options.snapshotPath) {
83
+ throw new Error(
84
+ 'VirtualFileSystem: "snapshotPath" is required when mode is "fs".',
85
+ );
86
+ }
87
+ this.snapshotFile = path.resolve(
88
+ options.snapshotPath,
89
+ "vfs-snapshot.json",
90
+ );
91
+ } else {
92
+ this.snapshotFile = null;
51
93
  }
94
+ this.root = this.makeDir("", 0o755);
52
95
  }
53
96
 
54
- private computeDiskUsageBytes(targetPath: string): number {
55
- const stats = fs.statSync(targetPath);
56
- if (stats.isFile()) {
57
- return stats.size;
58
- }
97
+ // ── Internal helpers ──────────────────────────────────────────────────────
59
98
 
60
- let total = 0;
61
- for (const entry of fs.readdirSync(targetPath)) {
62
- total += this.computeDiskUsageBytes(path.join(targetPath, entry));
63
- }
64
- return total;
99
+ private makeDir(name: string, mode: number): InternalDirectoryNode {
100
+ const now = new Date();
101
+ return {
102
+ type: "directory",
103
+ name,
104
+ mode,
105
+ createdAt: now,
106
+ updatedAt: now,
107
+ children: new Map(),
108
+ };
65
109
  }
66
110
 
67
- private renderTreeLines(targetPath: string, label: string): string {
68
- const lines = [label];
69
-
70
- const walk = (currentPath: string, prefix: string): void => {
71
- const entries = fs
72
- .readdirSync(currentPath, { withFileTypes: true })
73
- .map((entry) => entry.name)
74
- .sort((left, right) => left.localeCompare(right));
75
-
76
- for (let i = 0; i < entries.length; i += 1) {
77
- const name = entries[i]!;
78
- const isLast = i === entries.length - 1;
79
- const connector = isLast ? "└── " : "├── ";
80
- const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
81
- const entryPath = path.join(currentPath, name);
82
- const isDirectory = fs.statSync(entryPath).isDirectory();
83
-
84
- lines.push(`${prefix}${connector}${name}`);
85
- if (isDirectory) {
86
- walk(entryPath, nextPrefix);
87
- }
88
- }
111
+ private makeFile(
112
+ name: string,
113
+ content: Buffer,
114
+ mode: number,
115
+ compressed: boolean,
116
+ ): InternalFileNode {
117
+ const now = new Date();
118
+ return {
119
+ type: "file",
120
+ name,
121
+ content,
122
+ mode,
123
+ compressed,
124
+ createdAt: now,
125
+ updatedAt: now,
89
126
  };
90
-
91
- walk(targetPath, "");
92
- return lines.join("\n");
93
127
  }
94
128
 
95
- /**
96
- * Creates a virtual filesystem instance.
97
- *
98
- * @param baseDir Base directory used to resolve mirror archive location.
99
- */
100
- constructor(baseDir: string = process.cwd()) {
101
- super();
102
- perf.mark("constructor");
103
- this.mirrorRoot = path.resolve(baseDir, ".vfs", "mirror");
129
+ private mkdirRecursive(targetPath: string, mode: number): void {
130
+ const normalized = normalizePath(targetPath);
131
+ if (normalized === "/") return;
132
+ const parts = normalized.split("/").filter(Boolean);
133
+ let current = this.root;
134
+ let builtPath = "";
135
+ for (const part of parts) {
136
+ builtPath += `/${part}`;
137
+ let child = current.children.get(part);
138
+ if (!child) {
139
+ child = this.makeDir(part, mode);
140
+ current.children.set(part, child);
141
+ this.emit("dir:create", { path: builtPath, mode });
142
+ } else if (child.type !== "directory") {
143
+ throw new Error(
144
+ `Cannot create directory '${builtPath}': path is a file.`,
145
+ );
146
+ }
147
+ current = child as InternalDirectoryNode;
148
+ }
104
149
  }
105
150
 
151
+ // ── Persistence ───────────────────────────────────────────────────────────
152
+
106
153
  /**
107
- * Restores filesystem state from mirror archive.
154
+ * In `"fs"` mode: reads the JSON snapshot from disk and hydrates the tree.
155
+ * Silently succeeds when the snapshot file does not exist yet.
108
156
  *
109
- * If archive does not exist or cannot be read, creates fresh mirror file.
157
+ * In `"memory"` mode: no-op (kept for API compatibility).
110
158
  */
111
159
  public async restoreMirror(): Promise<void> {
112
- perf.mark("restoreMirror");
113
- this.ensureMirrorRoot();
160
+ if (this.mode !== "fs" || !this.snapshotFile) return;
161
+
162
+ if (!fsSync.existsSync(this.snapshotFile)) return;
163
+
164
+ try {
165
+ const raw = fsSync.readFileSync(this.snapshotFile, "utf8");
166
+ const snapshot: VfsSnapshot = JSON.parse(raw);
167
+ this.root = this.deserializeDir(snapshot.root, "");
168
+ this.emit("snapshot:restore", { path: this.snapshotFile });
169
+ } catch (err) {
170
+ // Corrupt or unreadable snapshot — start fresh and warn
171
+ console.warn(
172
+ `[VirtualFileSystem] Could not restore snapshot from ${this.snapshotFile}:`,
173
+ err instanceof Error ? err.message : String(err),
174
+ );
175
+ }
114
176
  }
115
177
 
116
178
  /**
117
- * Persists current filesystem state to mirror archive.
179
+ * In `"fs"` mode: serialises the in-memory tree to a JSON snapshot on disk.
180
+ * The directory is created if it does not exist.
118
181
  *
119
- * No-op when nothing changed and archive already exists.
182
+ * In `"memory"` mode: emits `"mirror:flush"` and returns (no disk write).
120
183
  */
121
184
  public async flushMirror(): Promise<void> {
122
- perf.mark("flushMirror");
123
- this.ensureMirrorRoot();
124
- this.emit("mirror:flush");
185
+ if (this.mode !== "fs" || !this.snapshotFile) {
186
+ this.emit("mirror:flush");
187
+ return;
188
+ }
189
+
190
+ const dir = path.dirname(this.snapshotFile);
191
+ fsSync.mkdirSync(dir, { recursive: true });
192
+ const snapshot = this.toSnapshot();
193
+ fsSync.writeFileSync(this.snapshotFile, JSON.stringify(snapshot), "utf8");
194
+ this.emit("mirror:flush", { path: this.snapshotFile });
125
195
  }
126
196
 
127
- /**
128
- * Creates directory and any missing parent directories.
129
- *
130
- * @param targetPath Absolute or relative path to directory.
131
- * @param mode POSIX-like mode bits for new directories.
132
- */
197
+ /** Returns the current persistence mode. */
198
+ public getMode(): VfsPersistenceMode {
199
+ return this.mode;
200
+ }
201
+
202
+ /** Returns the snapshot file path used in `"fs"` mode, or `null`. */
203
+ public getSnapshotPath(): string | null {
204
+ return this.snapshotFile;
205
+ }
206
+
207
+ // ── Public filesystem API ─────────────────────────────────────────────────
208
+
209
+ /** Creates a directory (and any missing parents). */
133
210
  public mkdir(targetPath: string, mode: number = 0o755): void {
134
- perf.mark("mkdir");
135
- this.ensureMirrorRoot();
136
- const fsPath = this.resolveFsPath(targetPath);
137
- if (fs.existsSync(fsPath) && !fs.statSync(fsPath).isDirectory()) {
211
+ const normalized = normalizePath(targetPath);
212
+ const existing = (() => {
213
+ try { return getNode(this.root, normalized); } catch { return null; }
214
+ })();
215
+ if (existing && existing.type !== "directory") {
138
216
  throw new Error(
139
- `Cannot create directory '${normalizePath(targetPath)}': path is a file.`,
217
+ `Cannot create directory '${normalized}': path is a file.`,
140
218
  );
141
219
  }
142
- fs.mkdirSync(fsPath, { recursive: true, mode });
143
- this.emit("dir:create", { path: normalizePath(targetPath), mode });
220
+ this.mkdirRecursive(normalized, mode);
144
221
  }
145
222
 
146
223
  /**
147
- * Writes UTF-8 text or binary content into file.
148
- *
224
+ * Writes UTF-8 text or binary content into a file.
149
225
  * Parent directories are created when missing.
150
- *
151
- * @param targetPath Destination file path.
152
- * @param content File content as string or Buffer.
153
- * @param options Optional write behavior (mode, compression).
154
226
  */
155
227
  public writeFile(
156
228
  targetPath: string,
157
229
  content: string | Buffer,
158
230
  options: WriteFileOptions = {},
159
231
  ): void {
160
- perf.mark("writeFile");
161
- this.ensureMirrorRoot();
162
232
  const normalized = normalizePath(targetPath);
163
- const fsPath = this.resolveFsPath(normalized);
164
- const parentPath = path.dirname(fsPath);
165
- fs.mkdirSync(parentPath, { recursive: true, mode: 0o755 });
233
+ const { parent, name } = getParentDirectory(
234
+ this.root,
235
+ normalized,
236
+ true,
237
+ (p) => this.mkdirRecursive(p, 0o755),
238
+ );
239
+
240
+ const existing = parent.children.get(name);
241
+ if (existing?.type === "directory") {
242
+ throw new Error(
243
+ `Cannot write file '${normalized}': path is a directory.`,
244
+ );
245
+ }
166
246
 
167
247
  const rawContent = Buffer.isBuffer(content)
168
248
  ? content
169
249
  : Buffer.from(content, "utf8");
170
250
  const shouldCompress = options.compress ?? false;
171
251
  const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
172
-
173
- if (fs.existsSync(fsPath) && fs.statSync(fsPath).isDirectory()) {
174
- throw new Error(
175
- `Cannot write file '${normalized}': path is a directory.`,
252
+ const mode = options.mode ?? 0o644;
253
+
254
+ if (existing) {
255
+ const f = existing as InternalFileNode;
256
+ f.content = storedContent;
257
+ f.compressed = shouldCompress;
258
+ f.mode = mode;
259
+ f.updatedAt = new Date();
260
+ } else {
261
+ parent.children.set(
262
+ name,
263
+ this.makeFile(name, storedContent, mode, shouldCompress),
176
264
  );
177
265
  }
178
266
 
179
- fs.writeFileSync(fsPath, storedContent);
180
- fs.chmodSync(fsPath, options.mode ?? 0o644);
181
267
  this.emit("file:write", { path: normalized, size: storedContent.length });
182
268
  }
183
269
 
184
270
  /**
185
- * Reads file content as UTF-8 text.
186
- *
187
- * Compressed files are transparently decompressed.
188
- *
189
- * @param targetPath Path to file.
190
- * @returns UTF-8 string content.
271
+ * Reads file content as a UTF-8 string.
272
+ * Gzip-compressed files are transparently decompressed.
191
273
  */
192
274
  public readFile(targetPath: string): string {
193
- perf.mark("readFile");
194
- this.ensureMirrorRoot();
195
- const fsPath = this.resolveFsPath(targetPath);
196
- if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
275
+ const normalized = normalizePath(targetPath);
276
+ const node = getNode(this.root, normalized);
277
+ if (node.type !== "file") {
197
278
  throw new Error(`Cannot read '${targetPath}': not a file.`);
198
279
  }
280
+ const f = node as InternalFileNode;
281
+ const raw = f.compressed ? gunzipSync(f.content) : f.content;
282
+ this.emit("file:read", { path: normalized, size: raw.length });
283
+ return raw.toString("utf8");
284
+ }
199
285
 
200
- const stored = fs.readFileSync(fsPath);
201
- const raw = this.detectGzipFile(fsPath) ? gunzipSync(stored) : stored;
286
+ /** Reads file content as a Buffer (decompresses if needed). */
287
+ public readFileRaw(targetPath: string): Buffer {
202
288
  const normalized = normalizePath(targetPath);
289
+ const node = getNode(this.root, normalized);
290
+ if (node.type !== "file") {
291
+ throw new Error(`Cannot read '${targetPath}': not a file.`);
292
+ }
293
+ const f = node as InternalFileNode;
294
+ const raw = f.compressed ? gunzipSync(f.content) : f.content;
203
295
  this.emit("file:read", { path: normalized, size: raw.length });
204
- return raw.toString("utf8");
296
+ return raw;
205
297
  }
206
298
 
207
- /**
208
- * Checks whether node exists at path.
209
- *
210
- * @param targetPath Node path.
211
- * @returns True when file or directory exists.
212
- */
299
+ /** Returns true when a file or directory exists at path. */
213
300
  public exists(targetPath: string): boolean {
214
- perf.mark("exists");
215
301
  try {
216
- const fsPath = this.resolveFsPath(targetPath);
217
- return fs.existsSync(fsPath);
218
- } catch {
219
- return false;
220
- }
302
+ getNode(this.root, normalizePath(targetPath));
303
+ return true;
304
+ } catch { return false; }
221
305
  }
222
306
 
223
- /**
224
- * Updates mode bits for file or directory.
225
- *
226
- * @param targetPath Node path.
227
- * @param mode New POSIX-like mode.
228
- */
307
+ /** Updates mode bits on a node. */
229
308
  public chmod(targetPath: string, mode: number): void {
230
- perf.mark("chmod");
231
- const fsPath = this.resolveFsPath(targetPath);
232
- if (!fs.existsSync(fsPath)) {
233
- throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
234
- }
235
- fs.chmodSync(fsPath, mode);
309
+ getNode(this.root, normalizePath(targetPath)).mode = mode;
236
310
  }
237
311
 
238
- /**
239
- * Returns metadata for file or directory.
240
- *
241
- * @param targetPath Node path.
242
- * @returns Typed stat object based on node type.
243
- */
312
+ /** Returns metadata for a file or directory. */
244
313
  public stat(targetPath: string): VfsNodeStats {
245
- perf.mark("stat");
246
- this.ensureMirrorRoot();
247
314
  const normalized = normalizePath(targetPath);
248
- const fsPath = this.resolveFsPath(normalized);
249
-
250
- if (!fs.existsSync(fsPath)) {
251
- throw new Error(`Path '${normalized}' does not exist.`);
252
- }
253
-
254
- const stats = fs.statSync(fsPath);
255
- const mode = stats.mode & 0o777;
315
+ const node = getNode(this.root, normalized);
256
316
  const name = normalized === "/" ? "" : path.posix.basename(normalized);
257
-
258
- if (stats.isFile()) {
317
+ if (node.type === "file") {
318
+ const f = node as InternalFileNode;
259
319
  return {
260
- type: "file",
261
- name,
262
- path: normalized,
263
- mode,
264
- createdAt: stats.birthtime,
265
- updatedAt: stats.mtime,
266
- compressed: this.detectGzipFile(fsPath),
267
- size: stats.size,
320
+ type: "file", name, path: normalized, mode: f.mode,
321
+ createdAt: f.createdAt, updatedAt: f.updatedAt,
322
+ compressed: f.compressed, size: f.content.length,
268
323
  };
269
324
  }
270
-
325
+ const d = node as InternalDirectoryNode;
271
326
  return {
272
- type: "directory",
273
- name,
274
- path: normalized,
275
- mode,
276
- createdAt: stats.birthtime,
277
- updatedAt: stats.mtime,
278
- childrenCount: fs.readdirSync(fsPath).length,
327
+ type: "directory", name, path: normalized, mode: d.mode,
328
+ createdAt: d.createdAt, updatedAt: d.updatedAt,
329
+ childrenCount: d.children.size,
279
330
  };
280
331
  }
281
332
 
282
- /**
283
- * Lists direct children names of directory.
284
- *
285
- * @param dirPath Directory path, defaults to root.
286
- * @returns Sorted child names.
287
- */
333
+ /** Lists direct children names of a directory (sorted). */
288
334
  public list(dirPath: string = "/"): string[] {
289
- perf.mark("list");
290
- const fsPath = this.resolveFsPath(dirPath);
291
- if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
335
+ const normalized = normalizePath(dirPath);
336
+ const node = getNode(this.root, normalized);
337
+ if (node.type !== "directory") {
292
338
  throw new Error(`Cannot list '${dirPath}': not a directory.`);
293
339
  }
294
-
295
- return fs.readdirSync(fsPath).sort();
340
+ return Array.from((node as InternalDirectoryNode).children.keys()).sort();
296
341
  }
297
342
 
298
- /**
299
- * Renders ASCII tree view of directory hierarchy.
300
- *
301
- * @param dirPath Directory path, defaults to root.
302
- * @returns Multi-line tree string.
303
- */
343
+ /** Renders ASCII tree view of a directory hierarchy. */
304
344
  public tree(dirPath: string = "/"): string {
305
- perf.mark("tree");
306
- const fsPath = this.resolveFsPath(dirPath);
307
- if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
308
- throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
345
+ const normalized = normalizePath(dirPath);
346
+ const node = getNode(this.root, normalized);
347
+ if (node.type !== "directory") {
348
+ throw new Error(
349
+ `Cannot render tree for '${dirPath}': not a directory.`,
350
+ );
309
351
  }
352
+ const label = dirPath === "/" ? "/" : path.posix.basename(normalized);
353
+ return this.renderTreeLines(node as InternalDirectoryNode, label);
354
+ }
310
355
 
311
- const rootLabel =
312
- dirPath === "/" ? "/" : path.posix.basename(normalizePath(dirPath));
313
- return this.renderTreeLines(fsPath, rootLabel);
356
+ private renderTreeLines(dir: InternalDirectoryNode, label: string): string {
357
+ const lines = [label];
358
+ const entries = Array.from(dir.children.keys()).sort();
359
+ for (let i = 0; i < entries.length; i++) {
360
+ const name = entries[i]!;
361
+ const child = dir.children.get(name)!;
362
+ const isLast = i === entries.length - 1;
363
+ const connector = isLast ? "└── " : "├── ";
364
+ const nextPrefix = isLast ? " " : "│ ";
365
+ lines.push(`${connector}${name}`);
366
+ if (child.type === "directory") {
367
+ const sub = this.renderTreeLines(child as InternalDirectoryNode, "")
368
+ .split("\n").slice(1)
369
+ .map((l) => `${nextPrefix}${l}`);
370
+ lines.push(...sub);
371
+ }
372
+ }
373
+ return lines.join("\n");
314
374
  }
315
375
 
316
- /**
317
- * Computes total stored file bytes under a path.
318
- *
319
- * File usage is based on in-memory stored bytes, including compressed
320
- * payload size when files are marked as compressed.
321
- *
322
- * @param targetPath File or directory path to measure, defaults to root.
323
- * @returns Total byte usage for file content under target path.
324
- */
376
+ /** Computes total stored bytes under a path. */
325
377
  public getUsageBytes(targetPath: string = "/"): number {
326
- perf.mark("getUsageBytes");
327
- const fsPath = this.resolveFsPath(targetPath);
328
- if (!fs.existsSync(fsPath)) {
329
- throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
378
+ return this.computeUsage(getNode(this.root, normalizePath(targetPath)));
379
+ }
380
+
381
+ private computeUsage(node: InternalNode): number {
382
+ if (node.type === "file") return (node as InternalFileNode).content.length;
383
+ let total = 0;
384
+ for (const child of (node as InternalDirectoryNode).children.values()) {
385
+ total += this.computeUsage(child);
330
386
  }
331
- return this.computeDiskUsageBytes(fsPath);
387
+ return total;
332
388
  }
333
389
 
334
- /**
335
- * Compresses file content with gzip and flags node as compressed.
336
- *
337
- * @param targetPath Path to file.
338
- */
390
+ /** Compresses a file's content with gzip in place. */
339
391
  public compressFile(targetPath: string): void {
340
- perf.mark("compressFile");
341
- const fsPath = this.resolveFsPath(targetPath);
342
- if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
343
- throw new Error(`Cannot compress '${targetPath}': not a file.`);
392
+ const node = getNode(this.root, normalizePath(targetPath));
393
+ if (node.type !== "file") throw new Error(`Cannot compress '${targetPath}': not a file.`);
394
+ const f = node as InternalFileNode;
395
+ if (!f.compressed) {
396
+ f.content = gzipSync(f.content);
397
+ f.compressed = true;
398
+ f.updatedAt = new Date();
344
399
  }
400
+ }
345
401
 
346
- if (!this.detectGzipFile(fsPath)) {
347
- const content = fs.readFileSync(fsPath);
348
- fs.writeFileSync(fsPath, gzipSync(content));
402
+ /** Decompresses a gzip-compressed file in place. */
403
+ public decompressFile(targetPath: string): void {
404
+ const node = getNode(this.root, normalizePath(targetPath));
405
+ if (node.type !== "file") throw new Error(`Cannot decompress '${targetPath}': not a file.`);
406
+ const f = node as InternalFileNode;
407
+ if (f.compressed) {
408
+ f.content = gunzipSync(f.content);
409
+ f.compressed = false;
410
+ f.updatedAt = new Date();
349
411
  }
350
412
  }
351
413
 
352
414
  /**
353
- * Decompresses gzip-compressed file content.
354
- *
355
- * @param targetPath Path to file.
415
+ * Creates a symbolic link.
416
+ * The link node is stored with mode `0o120777` (POSIX symlink convention).
356
417
  */
357
- public decompressFile(targetPath: string): void {
358
- perf.mark("decompressFile");
359
- const fsPath = this.resolveFsPath(targetPath);
360
- if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
361
- throw new Error(`Cannot decompress '${targetPath}': not a file.`);
362
- }
418
+ public symlink(targetPath: string, linkPath: string): void {
419
+ const normalizedLink = normalizePath(linkPath);
420
+ const normalizedTarget = targetPath.startsWith("/")
421
+ ? normalizePath(targetPath)
422
+ : targetPath;
423
+ const { parent, name } = getParentDirectory(
424
+ this.root, normalizedLink, true,
425
+ (p) => this.mkdirRecursive(p, 0o755),
426
+ );
427
+ const symNode: InternalFileNode = {
428
+ type: "file", name,
429
+ content: Buffer.from(normalizedTarget, "utf8"),
430
+ mode: 0o120777,
431
+ compressed: false,
432
+ createdAt: new Date(), updatedAt: new Date(),
433
+ };
434
+ parent.children.set(name, symNode);
435
+ this.emit("symlink:create", { link: normalizedLink, target: normalizedTarget });
436
+ }
363
437
 
364
- if (this.detectGzipFile(fsPath)) {
365
- const content = fs.readFileSync(fsPath);
366
- fs.writeFileSync(fsPath, gunzipSync(content));
367
- }
438
+ /** Returns true when the path is a symbolic link node. */
439
+ public isSymlink(targetPath: string): boolean {
440
+ try {
441
+ const node = getNode(this.root, normalizePath(targetPath));
442
+ return node.type === "file" && node.mode === 0o120777;
443
+ } catch { return false; }
368
444
  }
369
445
 
370
446
  /**
371
- * Removes file or directory node.
372
- *
373
- * @param targetPath Path to remove.
374
- * @param options Removal options, including recursive delete.
447
+ * Resolves a symlink chain up to `maxDepth` hops.
448
+ * Throws when the chain is too long (circular links).
375
449
  */
376
- public remove(targetPath: string, options: RemoveOptions = {}): void {
377
- perf.mark("remove");
378
- const normalized = normalizePath(targetPath);
379
- if (normalized === "/") {
380
- throw new Error("Cannot remove root directory.");
381
- }
382
- const fsPath = this.resolveFsPath(normalized);
383
-
384
- if (!fs.existsSync(fsPath)) {
385
- throw new Error(`Path '${normalized}' does not exist.`);
450
+ public resolveSymlink(linkPath: string, maxDepth = 8): string {
451
+ let current = normalizePath(linkPath);
452
+ for (let depth = 0; depth < maxDepth; depth++) {
453
+ try {
454
+ const node = getNode(this.root, current);
455
+ if (node.type === "file" && node.mode === 0o120777) {
456
+ const target = (node as InternalFileNode).content.toString("utf8");
457
+ current = target.startsWith("/")
458
+ ? target
459
+ : normalizePath(path.posix.join(path.posix.dirname(current), target));
460
+ continue;
461
+ }
462
+ } catch { break; }
463
+ return current;
386
464
  }
465
+ throw new Error(`Too many levels of symbolic links: ${linkPath}`);
466
+ }
387
467
 
388
- const stats = fs.statSync(fsPath);
389
- if (stats.isDirectory() && !options.recursive) {
390
- const entries = fs.readdirSync(fsPath);
391
- if (entries.length > 0) {
468
+ /** Removes a file or directory node. */
469
+ public remove(targetPath: string, options: RemoveOptions = {}): void {
470
+ const normalized = normalizePath(targetPath);
471
+ if (normalized === "/") throw new Error("Cannot remove root directory.");
472
+ const node = getNode(this.root, normalized);
473
+ if (node.type === "directory") {
474
+ const dir = node as InternalDirectoryNode;
475
+ if (!options.recursive && dir.children.size > 0) {
392
476
  throw new Error(
393
477
  `Directory '${normalized}' is not empty. Use recursive option.`,
394
478
  );
395
479
  }
396
480
  }
397
-
398
- if (stats.isDirectory()) {
399
- fs.rmSync(fsPath, { recursive: options.recursive ?? false });
400
- } else {
401
- fs.rmSync(fsPath);
402
- }
481
+ const { parent, name } = getParentDirectory(this.root, normalized, false, () => {});
482
+ parent.children.delete(name);
483
+ this.emit("node:remove", { path: normalized });
403
484
  }
404
485
 
405
- /**
406
- * Moves or renames node to destination path.
407
- *
408
- * @param fromPath Existing source path.
409
- * @param toPath Destination path.
410
- */
486
+ /** Moves or renames a node. */
411
487
  public move(fromPath: string, toPath: string): void {
412
- perf.mark("move");
413
488
  const fromNormalized = normalizePath(fromPath);
414
489
  const toNormalized = normalizePath(toPath);
415
-
416
490
  if (fromNormalized === "/" || toNormalized === "/") {
417
491
  throw new Error("Cannot move root directory.");
418
492
  }
493
+ const node = getNode(this.root, fromNormalized);
494
+ if (this.exists(toNormalized)) {
495
+ throw new Error(`Destination '${toNormalized}' already exists.`);
496
+ }
497
+ this.mkdirRecursive(path.posix.dirname(toNormalized), 0o755);
498
+ const { parent: destParent, name: destName } = getParentDirectory(
499
+ this.root, toNormalized, false, () => {},
500
+ );
501
+ const { parent: srcParent, name: srcName } = getParentDirectory(
502
+ this.root, fromNormalized, false, () => {},
503
+ );
504
+ srcParent.children.delete(srcName);
505
+ node.name = destName;
506
+ destParent.children.set(destName, node);
507
+ }
419
508
 
420
- const fromFsPath = this.resolveFsPath(fromNormalized);
421
- const toFsPath = this.resolveFsPath(toNormalized);
509
+ // ── Snapshot serialisation ─────────────────────────────────────────────────
422
510
 
423
- if (!fs.existsSync(fromFsPath)) {
424
- throw new Error(`Path '${fromNormalized}' does not exist.`);
425
- }
511
+ /**
512
+ * Exports the entire filesystem as a JSON-serialisable snapshot.
513
+ *
514
+ * Works regardless of the persistence mode. Useful for test fixtures,
515
+ * manual backups, or passing VFS state between processes.
516
+ */
517
+ public toSnapshot(): VfsSnapshot {
518
+ return { root: this.serializeDir(this.root) };
519
+ }
426
520
 
427
- if (fs.existsSync(toFsPath)) {
428
- throw new Error(`Destination '${toNormalized}' already exists.`);
521
+ private serializeDir(dir: InternalDirectoryNode): VfsSnapshotDirectoryNode {
522
+ const children: VfsSnapshotNode[] = [];
523
+ for (const child of dir.children.values()) {
524
+ children.push(
525
+ child.type === "file"
526
+ ? this.serializeFile(child as InternalFileNode)
527
+ : this.serializeDir(child as InternalDirectoryNode),
528
+ );
429
529
  }
530
+ return {
531
+ type: "directory", name: dir.name, mode: dir.mode,
532
+ createdAt: dir.createdAt.toISOString(),
533
+ updatedAt: dir.updatedAt.toISOString(),
534
+ children,
535
+ };
536
+ }
537
+
538
+ private serializeFile(file: InternalFileNode): VfsSnapshotFileNode {
539
+ return {
540
+ type: "file", name: file.name, mode: file.mode,
541
+ createdAt: file.createdAt.toISOString(),
542
+ updatedAt: file.updatedAt.toISOString(),
543
+ compressed: file.compressed,
544
+ contentBase64: file.content.toString("base64"),
545
+ };
546
+ }
547
+
548
+ /**
549
+ * Creates a new `VirtualFileSystem` instance (memory mode) from a snapshot.
550
+ *
551
+ * @example
552
+ * ```ts
553
+ * const vfs = VirtualFileSystem.fromSnapshot(savedSnapshot);
554
+ * ```
555
+ */
556
+ public static fromSnapshot(snapshot: VfsSnapshot): VirtualFileSystem {
557
+ const vfs = new VirtualFileSystem();
558
+ vfs.root = vfs.deserializeDir(snapshot.root, "");
559
+ return vfs;
560
+ }
561
+
562
+ /**
563
+ * Replaces the current filesystem state with the content of a snapshot.
564
+ * The persistence mode is preserved.
565
+ *
566
+ * @example
567
+ * ```ts
568
+ * vfs.importSnapshot(savedSnapshot);
569
+ * ```
570
+ */
571
+ public importSnapshot(snapshot: VfsSnapshot): void {
572
+ this.root = this.deserializeDir(snapshot.root, "");
573
+ this.emit("snapshot:import");
574
+ }
430
575
 
431
- fs.mkdirSync(path.dirname(toFsPath), { recursive: true, mode: 0o755 });
432
- fs.renameSync(fromFsPath, toFsPath);
576
+ private deserializeDir(
577
+ snap: VfsSnapshotDirectoryNode,
578
+ name: string,
579
+ ): InternalDirectoryNode {
580
+ const dir: InternalDirectoryNode = {
581
+ type: "directory", name, mode: snap.mode,
582
+ createdAt: new Date(snap.createdAt),
583
+ updatedAt: new Date(snap.updatedAt),
584
+ children: new Map(),
585
+ };
586
+ for (const child of snap.children) {
587
+ if (child.type === "file") {
588
+ const f = child as VfsSnapshotFileNode;
589
+ dir.children.set(f.name, {
590
+ type: "file", name: f.name, mode: f.mode,
591
+ createdAt: new Date(f.createdAt),
592
+ updatedAt: new Date(f.updatedAt),
593
+ compressed: f.compressed,
594
+ content: Buffer.from(f.contentBase64, "base64"),
595
+ });
596
+ } else {
597
+ const sub = this.deserializeDir(
598
+ child as VfsSnapshotDirectoryNode,
599
+ child.name,
600
+ );
601
+ dir.children.set(child.name, sub);
602
+ }
603
+ }
604
+ return dir;
433
605
  }
434
606
  }
435
607