typescript-virtual-container 1.1.2 → 1.1.4

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 (49) hide show
  1. package/dist/SSHMimic/exec.d.ts.map +1 -1
  2. package/dist/SSHMimic/exec.js +8 -2
  3. package/dist/SSHMimic/index.d.ts +1 -0
  4. package/dist/SSHMimic/index.d.ts.map +1 -1
  5. package/dist/SSHMimic/index.js +9 -3
  6. package/dist/SSHMimic/sftp.d.ts +46 -0
  7. package/dist/SSHMimic/sftp.d.ts.map +1 -0
  8. package/dist/SSHMimic/sftp.js +576 -0
  9. package/dist/VirtualFileSystem/index.d.ts +6 -4
  10. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  11. package/dist/VirtualFileSystem/index.js +144 -153
  12. package/dist/VirtualShell/index.d.ts +6 -0
  13. package/dist/VirtualShell/index.d.ts.map +1 -1
  14. package/dist/VirtualShell/index.js +16 -4
  15. package/dist/VirtualShell/shell.d.ts.map +1 -1
  16. package/dist/VirtualShell/shell.js +7 -0
  17. package/dist/VirtualUserManager/index.d.ts +8 -0
  18. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  19. package/dist/VirtualUserManager/index.js +30 -0
  20. package/dist/commands/exit.d.ts.map +1 -1
  21. package/dist/commands/exit.js +1 -0
  22. package/dist/commands/index.d.ts.map +1 -1
  23. package/dist/commands/index.js +2 -0
  24. package/dist/commands/passwd.d.ts +3 -0
  25. package/dist/commands/passwd.d.ts.map +1 -0
  26. package/dist/commands/passwd.js +21 -0
  27. package/dist/index.d.ts +2 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -2
  30. package/dist/modules/neofetch.d.ts.map +1 -1
  31. package/dist/modules/neofetch.js +0 -1
  32. package/dist/standalone.js +10 -1
  33. package/package.json +1 -1
  34. package/src/SSHMimic/exec.ts +18 -12
  35. package/src/SSHMimic/index.ts +16 -7
  36. package/src/SSHMimic/sftp.ts +833 -0
  37. package/src/VirtualFileSystem/index.ts +158 -188
  38. package/src/VirtualShell/index.ts +19 -8
  39. package/src/VirtualShell/shell.ts +7 -0
  40. package/src/VirtualUserManager/index.ts +38 -0
  41. package/src/commands/exit.ts +1 -0
  42. package/src/commands/index.ts +2 -0
  43. package/src/commands/passwd.ts +25 -0
  44. package/src/index.ts +2 -1
  45. package/src/modules/neofetch.ts +0 -2
  46. package/src/standalone.ts +11 -1
  47. package/tests/sftp.test.ts +319 -0
  48. package/tests/ssh-exec.test.ts +45 -0
  49. package/tests/users.test.ts +13 -0
@@ -1,4 +1,4 @@
1
- import { promises as fs } from "node:fs";
1
+ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { gunzipSync, gzipSync } from "node:zlib";
4
4
  import type {
@@ -6,11 +6,7 @@ import type {
6
6
  VfsNodeStats,
7
7
  WriteFileOptions,
8
8
  } from "../types/vfs";
9
- import { archiveExists, createTarBuffer, readSnapshotFromTar } from "./archive";
10
- import type { InternalDirectoryNode, InternalNode } from "./internalTypes";
11
- import { getNode, getParentDirectory, normalizePath, splitPath } from "./path";
12
- import { applySnapshot, createSnapshot } from "./snapshot";
13
- import { renderTree } from "./tree";
9
+ import { normalizePath } from "./path";
14
10
 
15
11
  /**
16
12
  * In-memory virtual filesystem with tar.gz mirror persistence.
@@ -20,38 +16,84 @@ import { renderTree } from "./tree";
20
16
  * {@link VirtualFileSystem.flushMirror} to persist pending changes.
21
17
  */
22
18
  class VirtualFileSystem {
23
- private readonly root: InternalDirectoryNode;
24
- private readonly archivePath: string;
25
- private dirty = false;
19
+ private readonly mirrorRoot: string;
26
20
 
27
- private computeNodeUsageBytes(node: InternalNode): number {
28
- if (node.type === "file") {
29
- return node.content.length;
21
+ private ensureMirrorRoot(): void {
22
+ fs.mkdirSync(this.mirrorRoot, { recursive: true, mode: 0o755 });
23
+ }
24
+
25
+ private resolveFsPath(targetPath: string): string {
26
+ const normalized = normalizePath(targetPath);
27
+ const relativePath = normalized.slice(1);
28
+ const resolved = path.resolve(this.mirrorRoot, relativePath || ".");
29
+ const relative = path.relative(this.mirrorRoot, resolved);
30
+
31
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
32
+ throw new Error(`Invalid path '${targetPath}'.`);
33
+ }
34
+
35
+ return resolved;
36
+ }
37
+
38
+ private detectGzipFile(targetPath: string): boolean {
39
+ const fd = fs.openSync(targetPath, "r");
40
+ try {
41
+ const header = Buffer.alloc(2);
42
+ const bytesRead = fs.readSync(fd, header, 0, 2, 0);
43
+ return bytesRead === 2 && header[0] === 0x1f && header[1] === 0x8b;
44
+ } finally {
45
+ fs.closeSync(fd);
46
+ }
47
+ }
48
+
49
+ private computeDiskUsageBytes(targetPath: string): number {
50
+ const stats = fs.statSync(targetPath);
51
+ if (stats.isFile()) {
52
+ return stats.size;
30
53
  }
31
54
 
32
55
  let total = 0;
33
- for (const child of node.children.values()) {
34
- total += this.computeNodeUsageBytes(child);
56
+ for (const entry of fs.readdirSync(targetPath)) {
57
+ total += this.computeDiskUsageBytes(path.join(targetPath, entry));
35
58
  }
36
59
  return total;
37
60
  }
38
61
 
62
+ private renderTreeLines(targetPath: string, label: string): string {
63
+ const lines = [label];
64
+
65
+ const walk = (currentPath: string, prefix: string): void => {
66
+ const entries = fs
67
+ .readdirSync(currentPath, { withFileTypes: true })
68
+ .map((entry) => entry.name)
69
+ .sort((left, right) => left.localeCompare(right));
70
+
71
+ for (let i = 0; i < entries.length; i += 1) {
72
+ const name = entries[i]!;
73
+ const isLast = i === entries.length - 1;
74
+ const connector = isLast ? "└── " : "├── ";
75
+ const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
76
+ const entryPath = path.join(currentPath, name);
77
+ const isDirectory = fs.statSync(entryPath).isDirectory();
78
+
79
+ lines.push(`${prefix}${connector}${name}`);
80
+ if (isDirectory) {
81
+ walk(entryPath, nextPrefix);
82
+ }
83
+ }
84
+ };
85
+
86
+ walk(targetPath, "");
87
+ return lines.join("\n");
88
+ }
89
+
39
90
  /**
40
91
  * Creates a virtual filesystem instance.
41
92
  *
42
93
  * @param baseDir Base directory used to resolve mirror archive location.
43
94
  */
44
95
  constructor(baseDir: string = process.cwd()) {
45
- const now = new Date();
46
- this.archivePath = path.resolve(baseDir, ".vfs", "mirror.tar.gz");
47
- this.root = {
48
- type: "directory",
49
- name: "",
50
- mode: 0o755,
51
- createdAt: now,
52
- updatedAt: now,
53
- children: new Map<string, InternalNode>(),
54
- };
96
+ this.mirrorRoot = path.resolve(baseDir, ".vfs", "mirror");
55
97
  }
56
98
 
57
99
  /**
@@ -60,20 +102,7 @@ class VirtualFileSystem {
60
102
  * If archive does not exist or cannot be read, creates fresh mirror file.
61
103
  */
62
104
  public async restoreMirror(): Promise<void> {
63
- await fs.mkdir(path.dirname(this.archivePath), { recursive: true });
64
- try {
65
- const compressed = await fs.readFile(this.archivePath);
66
- const tarBuffer = gunzipSync(compressed);
67
- const snapshot = await readSnapshotFromTar(tarBuffer);
68
- applySnapshot(this.root, snapshot);
69
- this.dirty = false;
70
- return;
71
- } catch {
72
- console.warn(
73
- `No valid mirror archive found at '${this.archivePath}'. Starting with empty filesystem.`,
74
- );
75
- await this.flushMirror();
76
- }
105
+ this.ensureMirrorRoot();
77
106
  }
78
107
 
79
108
  /**
@@ -82,16 +111,7 @@ class VirtualFileSystem {
82
111
  * No-op when nothing changed and archive already exists.
83
112
  */
84
113
  public async flushMirror(): Promise<void> {
85
- if (!this.dirty && (await archiveExists(this.archivePath))) {
86
- return;
87
- }
88
-
89
- await fs.mkdir(path.dirname(this.archivePath), { recursive: true });
90
- const snapshotJson = JSON.stringify(createSnapshot(this.root), null, 2);
91
- const tarBuffer = await createTarBuffer(snapshotJson);
92
- const compressed = gzipSync(tarBuffer);
93
- await fs.writeFile(this.archivePath, compressed);
94
- this.dirty = false;
114
+ this.ensureMirrorRoot();
95
115
  }
96
116
 
97
117
  /**
@@ -101,36 +121,14 @@ class VirtualFileSystem {
101
121
  * @param mode POSIX-like mode bits for new directories.
102
122
  */
103
123
  public mkdir(targetPath: string, mode: number = 0o755): void {
104
- const normalized = normalizePath(targetPath);
105
- const parts = splitPath(normalized);
106
-
107
- let current = this.root;
108
- for (const part of parts) {
109
- const existing = current.children.get(part);
110
- if (!existing) {
111
- const now = new Date();
112
- const nextDir: InternalDirectoryNode = {
113
- type: "directory",
114
- name: part,
115
- mode,
116
- createdAt: now,
117
- updatedAt: now,
118
- children: new Map<string, InternalNode>(),
119
- };
120
- current.children.set(part, nextDir);
121
- current.updatedAt = now;
122
- this.dirty = true;
123
- current = nextDir;
124
- continue;
125
- }
126
-
127
- if (existing.type !== "directory") {
128
- throw new Error(
129
- `Cannot create directory '${normalized}': '${part}' is a file.`,
130
- );
131
- }
132
- current = existing;
124
+ this.ensureMirrorRoot();
125
+ const fsPath = this.resolveFsPath(targetPath);
126
+ if (fs.existsSync(fsPath) && !fs.statSync(fsPath).isDirectory()) {
127
+ throw new Error(
128
+ `Cannot create directory '${normalizePath(targetPath)}': path is a file.`,
129
+ );
133
130
  }
131
+ fs.mkdirSync(fsPath, { recursive: true, mode });
134
132
  }
135
133
 
136
134
  /**
@@ -147,44 +145,26 @@ class VirtualFileSystem {
147
145
  content: string | Buffer,
148
146
  options: WriteFileOptions = {},
149
147
  ): void {
148
+ this.ensureMirrorRoot();
150
149
  const normalized = normalizePath(targetPath);
151
- const { parent, name } = getParentDirectory(
152
- this.root,
153
- normalized,
154
- true,
155
- (pathToCreate) => this.mkdir(pathToCreate),
156
- );
157
- const now = new Date();
150
+ const fsPath = this.resolveFsPath(normalized);
151
+ const parentPath = path.dirname(fsPath);
152
+ fs.mkdirSync(parentPath, { recursive: true, mode: 0o755 });
158
153
 
159
154
  const rawContent = Buffer.isBuffer(content)
160
155
  ? content
161
156
  : Buffer.from(content, "utf8");
162
157
  const shouldCompress = options.compress ?? false;
163
158
  const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
164
- const existing = parent.children.get(name);
165
159
 
166
- if (existing && existing.type === "directory") {
160
+ if (fs.existsSync(fsPath) && fs.statSync(fsPath).isDirectory()) {
167
161
  throw new Error(
168
162
  `Cannot write file '${normalized}': path is a directory.`,
169
163
  );
170
164
  }
171
165
 
172
- const createdAt = existing?.type === "file" ? existing.createdAt : now;
173
- const mode =
174
- options.mode ?? (existing?.type === "file" ? existing.mode : 0o644);
175
-
176
- parent.children.set(name, {
177
- type: "file",
178
- name,
179
- mode,
180
- createdAt,
181
- updatedAt: now,
182
- content: storedContent,
183
- compressed: shouldCompress,
184
- });
185
-
186
- parent.updatedAt = now;
187
- this.dirty = true;
166
+ fs.writeFileSync(fsPath, storedContent);
167
+ fs.chmodSync(fsPath, options.mode ?? 0o644);
188
168
  }
189
169
 
190
170
  /**
@@ -196,12 +176,14 @@ class VirtualFileSystem {
196
176
  * @returns UTF-8 string content.
197
177
  */
198
178
  public readFile(targetPath: string): string {
199
- const node = getNode(this.root, targetPath);
200
- if (node.type !== "file") {
179
+ this.ensureMirrorRoot();
180
+ const fsPath = this.resolveFsPath(targetPath);
181
+ if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
201
182
  throw new Error(`Cannot read '${targetPath}': not a file.`);
202
183
  }
203
184
 
204
- const raw = node.compressed ? gunzipSync(node.content) : node.content;
185
+ const stored = fs.readFileSync(fsPath);
186
+ const raw = this.detectGzipFile(fsPath) ? gunzipSync(stored) : stored;
205
187
  return raw.toString("utf8");
206
188
  }
207
189
 
@@ -213,8 +195,8 @@ class VirtualFileSystem {
213
195
  */
214
196
  public exists(targetPath: string): boolean {
215
197
  try {
216
- getNode(this.root, targetPath);
217
- return true;
198
+ const fsPath = this.resolveFsPath(targetPath);
199
+ return fs.existsSync(fsPath);
218
200
  } catch {
219
201
  return false;
220
202
  }
@@ -227,10 +209,11 @@ class VirtualFileSystem {
227
209
  * @param mode New POSIX-like mode.
228
210
  */
229
211
  public chmod(targetPath: string, mode: number): void {
230
- const node = getNode(this.root, targetPath);
231
- node.mode = mode;
232
- node.updatedAt = new Date();
233
- this.dirty = true;
212
+ const fsPath = this.resolveFsPath(targetPath);
213
+ if (!fs.existsSync(fsPath)) {
214
+ throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
215
+ }
216
+ fs.chmodSync(fsPath, mode);
234
217
  }
235
218
 
236
219
  /**
@@ -240,30 +223,39 @@ class VirtualFileSystem {
240
223
  * @returns Typed stat object based on node type.
241
224
  */
242
225
  public stat(targetPath: string): VfsNodeStats {
226
+ this.ensureMirrorRoot();
243
227
  const normalized = normalizePath(targetPath);
244
- const node = getNode(this.root, normalized);
228
+ const fsPath = this.resolveFsPath(normalized);
245
229
 
246
- if (node.type === "file") {
230
+ if (!fs.existsSync(fsPath)) {
231
+ throw new Error(`Path '${normalized}' does not exist.`);
232
+ }
233
+
234
+ const stats = fs.statSync(fsPath);
235
+ const mode = stats.mode & 0o777;
236
+ const name = normalized === "/" ? "" : path.posix.basename(normalized);
237
+
238
+ if (stats.isFile()) {
247
239
  return {
248
240
  type: "file",
249
- name: node.name,
241
+ name,
250
242
  path: normalized,
251
- mode: node.mode,
252
- createdAt: node.createdAt,
253
- updatedAt: node.updatedAt,
254
- compressed: node.compressed,
255
- size: node.content.length,
243
+ mode,
244
+ createdAt: stats.birthtime,
245
+ updatedAt: stats.mtime,
246
+ compressed: this.detectGzipFile(fsPath),
247
+ size: stats.size,
256
248
  };
257
249
  }
258
250
 
259
251
  return {
260
252
  type: "directory",
261
- name: node.name,
253
+ name,
262
254
  path: normalized,
263
- mode: node.mode,
264
- createdAt: node.createdAt,
265
- updatedAt: node.updatedAt,
266
- childrenCount: node.children.size,
255
+ mode,
256
+ createdAt: stats.birthtime,
257
+ updatedAt: stats.mtime,
258
+ childrenCount: fs.readdirSync(fsPath).length,
267
259
  };
268
260
  }
269
261
 
@@ -274,12 +266,12 @@ class VirtualFileSystem {
274
266
  * @returns Sorted child names.
275
267
  */
276
268
  public list(dirPath: string = "/"): string[] {
277
- const node = getNode(this.root, dirPath);
278
- if (node.type !== "directory") {
269
+ const fsPath = this.resolveFsPath(dirPath);
270
+ if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
279
271
  throw new Error(`Cannot list '${dirPath}': not a directory.`);
280
272
  }
281
273
 
282
- return Array.from(node.children.keys()).sort();
274
+ return fs.readdirSync(fsPath).sort();
283
275
  }
284
276
 
285
277
  /**
@@ -289,14 +281,14 @@ class VirtualFileSystem {
289
281
  * @returns Multi-line tree string.
290
282
  */
291
283
  public tree(dirPath: string = "/"): string {
292
- const node = getNode(this.root, dirPath);
293
- if (node.type !== "directory") {
284
+ const fsPath = this.resolveFsPath(dirPath);
285
+ if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
294
286
  throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
295
287
  }
296
288
 
297
289
  const rootLabel =
298
290
  dirPath === "/" ? "/" : path.posix.basename(normalizePath(dirPath));
299
- return renderTree(node, rootLabel);
291
+ return this.renderTreeLines(fsPath, rootLabel);
300
292
  }
301
293
 
302
294
  /**
@@ -309,8 +301,11 @@ class VirtualFileSystem {
309
301
  * @returns Total byte usage for file content under target path.
310
302
  */
311
303
  public getUsageBytes(targetPath: string = "/"): number {
312
- const node = getNode(this.root, targetPath);
313
- return this.computeNodeUsageBytes(node);
304
+ const fsPath = this.resolveFsPath(targetPath);
305
+ if (!fs.existsSync(fsPath)) {
306
+ throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
307
+ }
308
+ return this.computeDiskUsageBytes(fsPath);
314
309
  }
315
310
 
316
311
  /**
@@ -319,16 +314,14 @@ class VirtualFileSystem {
319
314
  * @param targetPath Path to file.
320
315
  */
321
316
  public compressFile(targetPath: string): void {
322
- const node = getNode(this.root, targetPath);
323
- if (node.type !== "file") {
317
+ const fsPath = this.resolveFsPath(targetPath);
318
+ if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
324
319
  throw new Error(`Cannot compress '${targetPath}': not a file.`);
325
320
  }
326
321
 
327
- if (!node.compressed) {
328
- node.content = gzipSync(node.content);
329
- node.compressed = true;
330
- node.updatedAt = new Date();
331
- this.dirty = true;
322
+ if (!this.detectGzipFile(fsPath)) {
323
+ const content = fs.readFileSync(fsPath);
324
+ fs.writeFileSync(fsPath, gzipSync(content));
332
325
  }
333
326
  }
334
327
 
@@ -338,16 +331,14 @@ class VirtualFileSystem {
338
331
  * @param targetPath Path to file.
339
332
  */
340
333
  public decompressFile(targetPath: string): void {
341
- const node = getNode(this.root, targetPath);
342
- if (node.type !== "file") {
334
+ const fsPath = this.resolveFsPath(targetPath);
335
+ if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
343
336
  throw new Error(`Cannot decompress '${targetPath}': not a file.`);
344
337
  }
345
338
 
346
- if (node.compressed) {
347
- node.content = gunzipSync(node.content);
348
- node.compressed = false;
349
- node.updatedAt = new Date();
350
- this.dirty = true;
339
+ if (this.detectGzipFile(fsPath)) {
340
+ const content = fs.readFileSync(fsPath);
341
+ fs.writeFileSync(fsPath, gunzipSync(content));
351
342
  }
352
343
  }
353
344
 
@@ -362,32 +353,27 @@ class VirtualFileSystem {
362
353
  if (normalized === "/") {
363
354
  throw new Error("Cannot remove root directory.");
364
355
  }
356
+ const fsPath = this.resolveFsPath(normalized);
365
357
 
366
- const { parent, name } = getParentDirectory(
367
- this.root,
368
- normalized,
369
- false,
370
- () => undefined,
371
- );
372
- const node = parent.children.get(name);
373
-
374
- if (!node) {
358
+ if (!fs.existsSync(fsPath)) {
375
359
  throw new Error(`Path '${normalized}' does not exist.`);
376
360
  }
377
361
 
378
- if (
379
- node.type === "directory" &&
380
- node.children.size > 0 &&
381
- !options.recursive
382
- ) {
383
- throw new Error(
384
- `Directory '${normalized}' is not empty. Use recursive option.`,
385
- );
362
+ const stats = fs.statSync(fsPath);
363
+ if (stats.isDirectory() && !options.recursive) {
364
+ const entries = fs.readdirSync(fsPath);
365
+ if (entries.length > 0) {
366
+ throw new Error(
367
+ `Directory '${normalized}' is not empty. Use recursive option.`,
368
+ );
369
+ }
386
370
  }
387
371
 
388
- parent.children.delete(name);
389
- parent.updatedAt = new Date();
390
- this.dirty = true;
372
+ if (stats.isDirectory()) {
373
+ fs.rmSync(fsPath, { recursive: options.recursive ?? false });
374
+ } else {
375
+ fs.rmSync(fsPath);
376
+ }
391
377
  }
392
378
 
393
379
  /**
@@ -404,35 +390,19 @@ class VirtualFileSystem {
404
390
  throw new Error("Cannot move root directory.");
405
391
  }
406
392
 
407
- const { parent: fromParent, name: fromName } = getParentDirectory(
408
- this.root,
409
- fromNormalized,
410
- false,
411
- () => undefined,
412
- );
413
- const node = fromParent.children.get(fromName);
393
+ const fromFsPath = this.resolveFsPath(fromNormalized);
394
+ const toFsPath = this.resolveFsPath(toNormalized);
414
395
 
415
- if (!node) {
396
+ if (!fs.existsSync(fromFsPath)) {
416
397
  throw new Error(`Path '${fromNormalized}' does not exist.`);
417
398
  }
418
399
 
419
- const { parent: toParent, name: toName } = getParentDirectory(
420
- this.root,
421
- toNormalized,
422
- true,
423
- (pathToCreate) => this.mkdir(pathToCreate),
424
- );
425
- if (toParent.children.has(toName)) {
400
+ if (fs.existsSync(toFsPath)) {
426
401
  throw new Error(`Destination '${toNormalized}' already exists.`);
427
402
  }
428
403
 
429
- fromParent.children.delete(fromName);
430
- node.name = toName;
431
- node.updatedAt = new Date();
432
- toParent.children.set(toName, node);
433
- fromParent.updatedAt = new Date();
434
- toParent.updatedAt = new Date();
435
- this.dirty = true;
404
+ fs.mkdirSync(path.dirname(toFsPath), { recursive: true, mode: 0o755 });
405
+ fs.renameSync(fromFsPath, toFsPath);
436
406
  }
437
407
  }
438
408
 
@@ -52,6 +52,7 @@ class VirtualShell {
52
52
  users: VirtualUserManager;
53
53
  hostname: string;
54
54
  properties: ShellProperties;
55
+ private initialized: Promise<void>;
55
56
 
56
57
  /**
57
58
  * Creates a new virtual shell instance.
@@ -74,14 +75,24 @@ class VirtualShell {
74
75
  resolveRootPassword(),
75
76
  resolveAutoSudoForNewUsers(),
76
77
  );
77
- this.vfs.restoreMirror().then(() => {
78
- this.users = new VirtualUserManager(
79
- this.vfs,
80
- resolveRootPassword(),
81
- resolveAutoSudoForNewUsers(),
82
- );
83
- this.users.initialize();
84
- });
78
+
79
+ // Store references to avoid TypeScript "used before assigned" errors
80
+ const vfs = this.vfs;
81
+ const users = this.users;
82
+
83
+ // Initialize both VFS mirror and users, ensuring all is ready before auth
84
+ this.initialized = (async () => {
85
+ await vfs.restoreMirror();
86
+ await users.initialize();
87
+ })();
88
+ }
89
+
90
+ /**
91
+ * Ensures initialization is complete before allowing operations.
92
+ * Call this before any authentication or command execution.
93
+ */
94
+ public async ensureInitialized(): Promise<void> {
95
+ await this.initialized;
85
96
  }
86
97
 
87
98
  /**
@@ -467,6 +467,13 @@ export function startShell(
467
467
  const ch = input[i]!;
468
468
 
469
469
  if (ch === "\u0004") {
470
+ lineBuffer = "";
471
+ cursorPos = 0;
472
+ historyIndex = null;
473
+ historyDraft = "";
474
+ stream.write("bye\r\n");
475
+ pushHistory("bye");
476
+ await shell.vfs.flushMirror();
470
477
  stream.write("logout\r\n");
471
478
  stream.exit(0);
472
479
  stream.end();
@@ -57,6 +57,7 @@ export class VirtualUserManager {
57
57
 
58
58
  /**
59
59
  * Loads users/sudoers from disk and ensures root account exists.
60
+ * Also creates the current system user if not already present.
60
61
  */
61
62
  public async initialize(): Promise<void> {
62
63
  this.loadFromVfs();
@@ -67,6 +68,25 @@ export class VirtualUserManager {
67
68
 
68
69
  this.sudoers.add("root");
69
70
 
71
+ // Auto-create current system user for easier authentication
72
+ const currentUser = process.env.USER || process.env.USERNAME;
73
+ if (currentUser && currentUser !== "root" && !this.users.has(currentUser)) {
74
+ // Use same password as root for convenience, or a generic default
75
+ const userPassword = this.defaultRootPassword;
76
+ this.users.set(currentUser, this.createRecord(currentUser, userPassword));
77
+ this.sudoers.add(currentUser);
78
+
79
+ // Create home directory for the system user
80
+ const homePath = `/home/${currentUser}`;
81
+ if (!this.vfs.exists(homePath)) {
82
+ this.vfs.mkdir(homePath, 0o755);
83
+ this.vfs.writeFile(
84
+ `${homePath}/README.txt`,
85
+ `Welcome to the virtual environment, ${currentUser}`,
86
+ );
87
+ }
88
+ }
89
+
70
90
  await this.persist();
71
91
  }
72
92
 
@@ -222,6 +242,24 @@ export class VirtualUserManager {
222
242
  await this.persist();
223
243
  }
224
244
 
245
+ /**
246
+ * Updates password for an existing user account.
247
+ *
248
+ * @param username Username to update.
249
+ * @param password New plaintext password.
250
+ */
251
+ public async setPassword(username: string, password: string): Promise<void> {
252
+ this.validateUsername(username);
253
+ this.validatePassword(password);
254
+
255
+ if (!this.users.has(username)) {
256
+ throw new Error(`passwd: user '${username}' does not exist`);
257
+ }
258
+
259
+ this.users.set(username, this.createRecord(username, password));
260
+ await this.persist();
261
+ }
262
+
225
263
  /**
226
264
  * Deletes existing non-root user account.
227
265
  *
@@ -2,6 +2,7 @@ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export const exitCommand: ShellModule = {
4
4
  name: "exit",
5
+ aliases: ["bye"],
5
6
  params: [],
6
7
  run: () => ({ closeSession: true, exitCode: 0 }),
7
8
  };
@@ -23,6 +23,7 @@ import { lsCommand } from "./ls";
23
23
  import { mkdirCommand } from "./mkdir";
24
24
  import { nanoCommand } from "./nano";
25
25
  import { neofetchCommand } from "./neofetch";
26
+ import { passwdCommand } from "./passwd";
26
27
  import { pwdCommand } from "./pwd";
27
28
  import { rmCommand } from "./rm";
28
29
  import { setCommand } from "./set";
@@ -53,6 +54,7 @@ const BASE_COMMANDS: ShellModule[] = [
53
54
  neofetchCommand,
54
55
  htopCommand,
55
56
  adduserCommand,
57
+ passwdCommand,
56
58
  deluserCommand,
57
59
  sudoCommand,
58
60
  suCommand,