typescript-virtual-container 1.4.5 → 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.
- package/.vscode/settings.json +1 -0
- package/README.md +1 -1
- package/builds/self-standalone.js +296 -296
- package/builds/self-standalone.js.map +4 -4
- package/builds/standalone-wo-sftp.js +161 -160
- package/builds/standalone-wo-sftp.js.map +4 -4
- package/builds/{standalone.js → standalone.cjs} +158 -157
- package/builds/{standalone.js.map → standalone.cjs.map} +4 -4
- package/dist/SSHMimic/exec.d.ts.map +1 -1
- package/dist/SSHMimic/exec.js +0 -1
- package/dist/SSHMimic/exec.js.map +1 -1
- package/dist/SSHMimic/index.js +1 -1
- package/dist/SSHMimic/index.js.map +1 -1
- package/dist/SSHMimic/sftp.d.ts.map +1 -1
- package/dist/SSHMimic/sftp.js +0 -6
- package/dist/SSHMimic/sftp.js.map +1 -1
- package/dist/VirtualFileSystem/index.d.ts +68 -0
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +213 -3
- package/dist/VirtualFileSystem/index.js.map +1 -1
- package/dist/VirtualFileSystem/internalTypes.d.ts +4 -0
- package/dist/VirtualFileSystem/internalTypes.d.ts.map +1 -1
- package/dist/VirtualFileSystem/journal.d.ts +47 -0
- package/dist/VirtualFileSystem/journal.d.ts.map +1 -0
- package/dist/VirtualFileSystem/journal.js +178 -0
- package/dist/VirtualFileSystem/journal.js.map +1 -0
- package/dist/VirtualShell/shell.js +4 -4
- package/dist/VirtualShell/shell.js.map +1 -1
- package/dist/commands/man.d.ts.map +1 -1
- package/dist/commands/man.js +1 -0
- package/dist/commands/man.js.map +1 -1
- package/dist/self-standalone.js +2 -1
- package/dist/self-standalone.js.map +1 -1
- package/dist/standalone.js +7 -3
- package/dist/standalone.js.map +1 -1
- package/docs/classes/HoneyPot.html +8 -8
- package/docs/classes/SshClient.html +18 -18
- package/docs/classes/VirtualFileSystem.html +29 -29
- package/docs/classes/VirtualPackageManager.html +12 -12
- package/docs/classes/VirtualSftpServer.html +3 -3
- package/docs/classes/VirtualShell.html +22 -22
- package/docs/classes/VirtualSshServer.html +5 -5
- package/docs/classes/VirtualUserManager.html +26 -26
- package/docs/functions/assertDiff.html +1 -1
- package/docs/functions/diffSnapshots.html +1 -1
- package/docs/functions/formatDiff.html +1 -1
- package/docs/functions/getArg.html +1 -1
- package/docs/functions/getFlag.html +1 -1
- package/docs/functions/ifFlag.html +1 -1
- package/docs/index.html +1 -1
- package/docs/interfaces/AuditLogEntry.html +2 -2
- package/docs/interfaces/CommandContext.html +11 -11
- package/docs/interfaces/CommandResult.html +12 -12
- package/docs/interfaces/ExecStream.html +5 -5
- package/docs/interfaces/HoneyPotStats.html +2 -2
- package/docs/interfaces/InstalledPackage.html +10 -10
- package/docs/interfaces/NanoEditorSession.html +4 -4
- package/docs/interfaces/PackageDefinition.html +13 -13
- package/docs/interfaces/PackageFile.html +4 -4
- package/docs/interfaces/RemoveOptions.html +2 -2
- package/docs/interfaces/ShellEnv.html +3 -3
- package/docs/interfaces/ShellModule.html +7 -7
- package/docs/interfaces/ShellProperties.html +4 -4
- package/docs/interfaces/ShellStream.html +6 -6
- package/docs/interfaces/SudoChallenge.html +8 -8
- package/docs/interfaces/VfsBaseNode.html +6 -6
- package/docs/interfaces/VfsDiff.html +5 -5
- package/docs/interfaces/VfsDiffEntry.html +3 -3
- package/docs/interfaces/VfsDiffModified.html +5 -5
- package/docs/interfaces/VfsDirectoryNode.html +7 -7
- package/docs/interfaces/VfsFileNode.html +8 -8
- package/docs/interfaces/VfsOptions.html +3 -3
- package/docs/interfaces/VfsSnapshot.html +2 -2
- package/docs/interfaces/VfsSnapshotBaseNode.html +3 -3
- package/docs/interfaces/VfsSnapshotDirectoryNode.html +4 -4
- package/docs/interfaces/VfsSnapshotFileNode.html +5 -5
- package/docs/interfaces/WriteFileOptions.html +3 -3
- package/docs/types/CommandMode.html +1 -1
- package/docs/types/CommandOutcome.html +1 -1
- package/docs/types/VfsNodeStats.html +1 -1
- package/docs/types/VfsNodeType.html +1 -1
- package/docs/types/VfsPersistenceMode.html +1 -1
- package/docs/types/VfsSnapshotNode.html +1 -1
- package/package.json +4 -4
- package/src/SSHMimic/exec.ts +0 -1
- package/src/SSHMimic/index.ts +1 -1
- package/src/SSHMimic/sftp.ts +0 -6
- package/src/VirtualFileSystem/index.ts +222 -3
- package/src/VirtualFileSystem/internalTypes.ts +4 -0
- package/src/VirtualFileSystem/journal.ts +163 -0
- package/src/VirtualShell/shell.ts +4 -4
- package/src/commands/man.ts +2 -0
- package/src/self-standalone.ts +2 -2
- package/src/standalone.ts +8 -3
- package/docs/docs/.nojekyll +0 -1
- package/docs/docs/assets/hierarchy.js +0 -1
- package/docs/docs/assets/highlight.css +0 -162
- package/docs/docs/assets/icons.js +0 -18
- package/docs/docs/assets/icons.svg +0 -1
- package/docs/docs/assets/main.js +0 -60
- package/docs/docs/assets/navigation.js +0 -1
- package/docs/docs/assets/search.js +0 -1
- package/docs/docs/assets/style.css +0 -1633
- package/docs/docs/hierarchy.html +0 -1
- package/docs/docs/index.html +0 -1842
- package/docs/docs/media/LICENSE +0 -21
- package/docs/docs/modules.html +0 -1
- package/typedoc.json +0 -8
|
@@ -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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
+
// WAL: checkpoint handled by auto-flush timer
|
|
661
661
|
}
|
|
662
662
|
|
|
663
663
|
renderLine();
|
package/src/commands/man.ts
CHANGED
package/src/self-standalone.ts
CHANGED
|
@@ -18,7 +18,7 @@ const hostname = process.env.SSH_MIMIC_HOSTNAME ?? "typescript-vm";
|
|
|
18
18
|
const argv = process.argv.slice(2);
|
|
19
19
|
|
|
20
20
|
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
21
|
-
|
|
21
|
+
console.clear();
|
|
22
22
|
function readUserArg(): string {
|
|
23
23
|
for (let index = 0; index < argv.length; index += 1) {
|
|
24
24
|
const current = argv[index];
|
|
@@ -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.
|
|
65
|
+
await virtualShell.vfs.stopAutoFlush();
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function loadHistory(authUser: string): string[] {
|
package/src/standalone.ts
CHANGED
|
@@ -32,13 +32,18 @@ new VirtualSftpServer({ port: 2223, hostname, shell: virtualShell })
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
process.on("uncaughtException", (error) => {
|
|
35
|
-
console.
|
|
35
|
+
console.debug("Oh my god, something terrible happened: ", error);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
process.on("unhandledRejection", (error, promise) => {
|
|
39
|
-
console.
|
|
39
|
+
console.debug(
|
|
40
40
|
" Oh Lord! We forgot to handle a promise rejection here: ",
|
|
41
41
|
promise,
|
|
42
42
|
);
|
|
43
|
-
console.
|
|
43
|
+
console.debug(" The error was: ", error);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
setInterval(() => {
|
|
47
|
+
const rss = process.memoryUsage().rss; // Just keep the event loop alive and prevent exit
|
|
48
|
+
console.debug(`Current memory usage: ${Math.round(rss / 1024 / 1024)} MB`);
|
|
49
|
+
}, 1000);
|
package/docs/docs/.nojekyll
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
window.hierarchyData = "eJyrVirKzy8pVrKKjtVRKkpNy0lNLsnMzytWsqqurQUAmx4Kpg=="
|