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.
- package/README.md +1056 -1239
- package/benchmark-results.txt +20 -20
- package/dist/SSHMimic/exec.js +2 -2
- package/dist/SSHMimic/executor.d.ts +6 -7
- package/dist/SSHMimic/executor.d.ts.map +1 -1
- package/dist/SSHMimic/executor.js +77 -60
- package/dist/SSHMimic/index.d.ts +19 -2
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +106 -24
- package/dist/SSHMimic/sftp.d.ts.map +1 -1
- package/dist/SSHMimic/sftp.js +14 -0
- package/dist/VirtualFileSystem/index.d.ts +115 -88
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +389 -264
- package/dist/VirtualShell/index.d.ts +3 -4
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +4 -6
- package/dist/VirtualShell/shell.d.ts.map +1 -1
- package/dist/VirtualShell/shell.js +19 -2
- package/dist/VirtualShell/shellParser.d.ts +20 -2
- package/dist/VirtualShell/shellParser.d.ts.map +1 -1
- package/dist/VirtualShell/shellParser.js +229 -120
- package/dist/VirtualUserManager/index.d.ts +25 -0
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +33 -0
- package/dist/commands/adduser.d.ts.map +1 -1
- package/dist/commands/adduser.js +2 -0
- package/dist/commands/awk.d.ts +3 -0
- package/dist/commands/awk.d.ts.map +1 -0
- package/dist/commands/awk.js +29 -0
- package/dist/commands/base64.d.ts +3 -0
- package/dist/commands/base64.d.ts.map +1 -0
- package/dist/commands/base64.js +20 -0
- package/dist/commands/cat.d.ts.map +1 -1
- package/dist/commands/cat.js +2 -0
- package/dist/commands/cd.d.ts.map +1 -1
- package/dist/commands/cd.js +2 -0
- package/dist/commands/chmod.d.ts +3 -0
- package/dist/commands/chmod.d.ts.map +1 -0
- package/dist/commands/chmod.js +33 -0
- package/dist/commands/clear.d.ts.map +1 -1
- package/dist/commands/clear.js +4 -1
- package/dist/commands/cp.d.ts +3 -0
- package/dist/commands/cp.d.ts.map +1 -0
- package/dist/commands/cp.js +70 -0
- package/dist/commands/curl.d.ts.map +1 -1
- package/dist/commands/curl.js +2 -0
- package/dist/commands/cut.d.ts +3 -0
- package/dist/commands/cut.d.ts.map +1 -0
- package/dist/commands/cut.js +27 -0
- package/dist/commands/date.d.ts +3 -0
- package/dist/commands/date.d.ts.map +1 -0
- package/dist/commands/date.js +22 -0
- package/dist/commands/deluser.d.ts.map +1 -1
- package/dist/commands/deluser.js +2 -0
- package/dist/commands/df.d.ts +3 -0
- package/dist/commands/df.d.ts.map +1 -0
- package/dist/commands/df.js +16 -0
- package/dist/commands/diff.d.ts +3 -0
- package/dist/commands/diff.d.ts.map +1 -0
- package/dist/commands/diff.js +40 -0
- package/dist/commands/du.d.ts +3 -0
- package/dist/commands/du.d.ts.map +1 -0
- package/dist/commands/du.js +39 -0
- package/dist/commands/echo.d.ts.map +1 -1
- package/dist/commands/echo.js +2 -0
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +6 -14
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +11 -21
- package/dist/commands/find.d.ts +3 -0
- package/dist/commands/find.d.ts.map +1 -0
- package/dist/commands/find.js +50 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +58 -35
- package/dist/commands/groups.d.ts +3 -0
- package/dist/commands/groups.d.ts.map +1 -0
- package/dist/commands/groups.js +12 -0
- package/dist/commands/gzip.d.ts +4 -0
- package/dist/commands/gzip.d.ts.map +1 -0
- package/dist/commands/gzip.js +40 -0
- package/dist/commands/head.d.ts +3 -0
- package/dist/commands/head.d.ts.map +1 -0
- package/dist/commands/head.js +32 -0
- package/dist/commands/help.d.ts +1 -1
- package/dist/commands/help.d.ts.map +1 -1
- package/dist/commands/help.js +75 -3
- package/dist/commands/hostname.d.ts.map +1 -1
- package/dist/commands/hostname.js +2 -0
- package/dist/commands/htop.d.ts.map +1 -1
- package/dist/commands/htop.js +2 -0
- package/dist/commands/id.d.ts +3 -0
- package/dist/commands/id.d.ts.map +1 -0
- package/dist/commands/id.js +14 -0
- package/dist/commands/index.d.ts +5 -2
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +104 -87
- package/dist/commands/kill.d.ts +3 -0
- package/dist/commands/kill.d.ts.map +1 -0
- package/dist/commands/kill.js +13 -0
- package/dist/commands/ln.d.ts +3 -0
- package/dist/commands/ln.d.ts.map +1 -0
- package/dist/commands/ln.js +44 -0
- package/dist/commands/ls.d.ts.map +1 -1
- package/dist/commands/ls.js +2 -0
- package/dist/commands/mkdir.d.ts.map +1 -1
- package/dist/commands/mkdir.js +2 -0
- package/dist/commands/mv.d.ts +3 -0
- package/dist/commands/mv.d.ts.map +1 -0
- package/dist/commands/mv.js +37 -0
- package/dist/commands/nano.d.ts.map +1 -1
- package/dist/commands/nano.js +2 -0
- package/dist/commands/neofetch.d.ts.map +1 -1
- package/dist/commands/neofetch.js +2 -0
- package/dist/commands/passwd.d.ts.map +1 -1
- package/dist/commands/passwd.js +2 -0
- package/dist/commands/ping.d.ts +3 -0
- package/dist/commands/ping.d.ts.map +1 -0
- package/dist/commands/ping.js +18 -0
- package/dist/commands/ps.d.ts +3 -0
- package/dist/commands/ps.d.ts.map +1 -0
- package/dist/commands/ps.js +17 -0
- package/dist/commands/pwd.d.ts.map +1 -1
- package/dist/commands/pwd.js +2 -0
- package/dist/commands/rm.d.ts.map +1 -1
- package/dist/commands/rm.js +2 -0
- package/dist/commands/sed.d.ts +3 -0
- package/dist/commands/sed.d.ts.map +1 -0
- package/dist/commands/sed.js +47 -0
- package/dist/commands/set.d.ts +3 -0
- package/dist/commands/set.d.ts.map +1 -1
- package/dist/commands/set.js +19 -46
- package/dist/commands/sh.d.ts +0 -1
- package/dist/commands/sh.d.ts.map +1 -1
- package/dist/commands/sh.js +228 -35
- package/dist/commands/sleep.d.ts +3 -0
- package/dist/commands/sleep.d.ts.map +1 -0
- package/dist/commands/sleep.js +13 -0
- package/dist/commands/sort.d.ts +3 -0
- package/dist/commands/sort.d.ts.map +1 -0
- package/dist/commands/sort.js +37 -0
- package/dist/commands/su.d.ts.map +1 -1
- package/dist/commands/su.js +2 -0
- package/dist/commands/sudo.d.ts.map +1 -1
- package/dist/commands/sudo.js +2 -0
- package/dist/commands/tail.d.ts +3 -0
- package/dist/commands/tail.d.ts.map +1 -0
- package/dist/commands/tail.js +35 -0
- package/dist/commands/tar.d.ts +3 -0
- package/dist/commands/tar.d.ts.map +1 -0
- package/dist/commands/tar.js +64 -0
- package/dist/commands/tee.d.ts +3 -0
- package/dist/commands/tee.d.ts.map +1 -0
- package/dist/commands/tee.js +29 -0
- package/dist/commands/touch.d.ts.map +1 -1
- package/dist/commands/touch.js +2 -0
- package/dist/commands/tr.d.ts +3 -0
- package/dist/commands/tr.d.ts.map +1 -0
- package/dist/commands/tr.js +24 -0
- package/dist/commands/tree.d.ts.map +1 -1
- package/dist/commands/tree.js +2 -0
- package/dist/commands/uname.d.ts +3 -0
- package/dist/commands/uname.d.ts.map +1 -0
- package/dist/commands/uname.js +21 -0
- package/dist/commands/uniq.d.ts +3 -0
- package/dist/commands/uniq.d.ts.map +1 -0
- package/dist/commands/uniq.js +33 -0
- package/dist/commands/unset.d.ts.map +1 -1
- package/dist/commands/unset.js +6 -10
- package/dist/commands/wc.d.ts +3 -0
- package/dist/commands/wc.d.ts.map +1 -0
- package/dist/commands/wc.js +50 -0
- package/dist/commands/wget.d.ts.map +1 -1
- package/dist/commands/wget.js +2 -0
- package/dist/commands/who.d.ts.map +1 -1
- package/dist/commands/who.js +2 -0
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +2 -0
- package/dist/commands/xargs.d.ts +3 -0
- package/dist/commands/xargs.d.ts.map +1 -0
- package/dist/commands/xargs.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/types/commands.d.ts +13 -0
- package/dist/types/commands.d.ts.map +1 -1
- package/dist/types/pipeline.d.ts +20 -0
- package/dist/types/pipeline.d.ts.map +1 -1
- package/package.json +5 -2
- package/scripts/publish-package.sh +70 -0
- package/src/SSHMimic/exec.ts +2 -2
- package/src/SSHMimic/executor.ts +95 -98
- package/src/SSHMimic/index.ts +138 -57
- package/src/SSHMimic/sftp.ts +15 -0
- package/src/VirtualFileSystem/index.ts +464 -292
- package/src/VirtualShell/index.ts +4 -6
- package/src/VirtualShell/shell.ts +19 -2
- package/src/VirtualShell/shellParser.ts +202 -168
- package/src/VirtualUserManager/index.ts +36 -0
- package/src/commands/adduser.ts +2 -0
- package/src/commands/awk.ts +30 -0
- package/src/commands/base64.ts +18 -0
- package/src/commands/cat.ts +2 -0
- package/src/commands/cd.ts +2 -0
- package/src/commands/chmod.ts +35 -0
- package/src/commands/clear.ts +4 -1
- package/src/commands/cp.ts +78 -0
- package/src/commands/curl.ts +2 -0
- package/src/commands/cut.ts +29 -0
- package/src/commands/date.ts +24 -0
- package/src/commands/deluser.ts +2 -0
- package/src/commands/df.ts +18 -0
- package/src/commands/diff.ts +29 -0
- package/src/commands/du.ts +39 -0
- package/src/commands/echo.ts +2 -0
- package/src/commands/env.ts +6 -16
- package/src/commands/export.ts +11 -24
- package/src/commands/find.ts +63 -0
- package/src/commands/grep.ts +51 -38
- package/src/commands/groups.ts +14 -0
- package/src/commands/gzip.ts +31 -0
- package/src/commands/head.ts +37 -0
- package/src/commands/help.ts +81 -3
- package/src/commands/hostname.ts +2 -0
- package/src/commands/htop.ts +2 -0
- package/src/commands/id.ts +16 -0
- package/src/commands/index.ts +114 -133
- package/src/commands/kill.ts +14 -0
- package/src/commands/ln.ts +49 -0
- package/src/commands/ls.ts +2 -0
- package/src/commands/mkdir.ts +2 -0
- package/src/commands/mv.ts +45 -0
- package/src/commands/nano.ts +2 -0
- package/src/commands/neofetch.ts +2 -0
- package/src/commands/passwd.ts +2 -0
- package/src/commands/ping.ts +20 -0
- package/src/commands/ps.ts +19 -0
- package/src/commands/pwd.ts +2 -0
- package/src/commands/rm.ts +2 -0
- package/src/commands/sed.ts +45 -0
- package/src/commands/set.ts +19 -50
- package/src/commands/sh.ts +192 -43
- package/src/commands/sleep.ts +14 -0
- package/src/commands/sort.ts +37 -0
- package/src/commands/su.ts +2 -0
- package/src/commands/sudo.ts +2 -0
- package/src/commands/tail.ts +39 -0
- package/src/commands/tar.ts +58 -0
- package/src/commands/tee.ts +25 -0
- package/src/commands/touch.ts +2 -0
- package/src/commands/tr.ts +24 -0
- package/src/commands/tree.ts +2 -0
- package/src/commands/uname.ts +20 -0
- package/src/commands/uniq.ts +28 -0
- package/src/commands/unset.ts +5 -12
- package/src/commands/wc.ts +50 -0
- package/src/commands/wget.ts +2 -0
- package/src/commands/who.ts +2 -0
- package/src/commands/whoami.ts +2 -0
- package/src/commands/xargs.ts +17 -0
- package/src/index.ts +1 -0
- package/src/types/commands.ts +14 -0
- package/src/types/pipeline.ts +23 -0
- package/standalone.js +93 -55
- package/standalone.js.map +4 -4
- package/tests/bun-test-shim.ts +1 -0
- package/tests/sftp.test.ts +115 -191
- package/tests/users.test.ts +42 -88
|
@@ -1,435 +1,607 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import * as
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
import { normalizePath } from "./path";
|
|
20
|
+
|
|
21
|
+
// ── Persistence options ───────────────────────────────────────────────────────
|
|
13
22
|
|
|
14
23
|
/**
|
|
15
|
-
*
|
|
24
|
+
* "memory" — pure in-memory, no disk I/O (default).
|
|
16
25
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
class VirtualFileSystem extends EventEmitter {
|
|
24
|
-
private readonly mirrorRoot: string;
|
|
30
|
+
export type VfsPersistenceMode = "memory" | "fs";
|
|
25
31
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
throw new Error(`Invalid path '${targetPath}'.`);
|
|
38
|
-
}
|
|
48
|
+
// ── VirtualFileSystem ─────────────────────────────────────────────────────────
|
|
39
49
|
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
const stats = fs.statSync(targetPath);
|
|
56
|
-
if (stats.isFile()) {
|
|
57
|
-
return stats.size;
|
|
58
|
-
}
|
|
97
|
+
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
59
98
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
157
|
+
* In `"memory"` mode: no-op (kept for API compatibility).
|
|
110
158
|
*/
|
|
111
159
|
public async restoreMirror(): Promise<void> {
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
182
|
+
* In `"memory"` mode: emits `"mirror:flush"` and returns (no disk write).
|
|
120
183
|
*/
|
|
121
184
|
public async flushMirror(): Promise<void> {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 '${
|
|
217
|
+
`Cannot create directory '${normalized}': path is a file.`,
|
|
140
218
|
);
|
|
141
219
|
}
|
|
142
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
this.
|
|
195
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
317
|
+
if (node.type === "file") {
|
|
318
|
+
const f = node as InternalFileNode;
|
|
259
319
|
return {
|
|
260
|
-
type: "file",
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
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
|
-
|
|
306
|
-
const
|
|
307
|
-
if (
|
|
308
|
-
throw new Error(
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
*
|
|
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
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
*
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
421
|
-
const toFsPath = this.resolveFsPath(toNormalized);
|
|
509
|
+
// ── Snapshot serialisation ─────────────────────────────────────────────────
|
|
422
510
|
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
|