typescript-virtual-container 1.4.0 → 1.4.2

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 (122) hide show
  1. package/.vscode/settings.json +2 -0
  2. package/README.md +5 -1
  3. package/benchmark-virtualshell.ts +3 -11
  4. package/builds/self-standalone.js +230 -346
  5. package/builds/self-standalone.js.map +3 -3
  6. package/builds/standalone-wo-sftp.js +156 -272
  7. package/builds/standalone-wo-sftp.js.map +3 -3
  8. package/builds/standalone.js +151 -267
  9. package/builds/standalone.js.map +3 -3
  10. package/dist/VirtualPackageManager/index.d.ts.map +1 -1
  11. package/dist/VirtualPackageManager/index.js +29 -1
  12. package/dist/VirtualShell/shell.d.ts.map +1 -1
  13. package/dist/VirtualShell/shell.js +6 -10
  14. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  15. package/dist/VirtualUserManager/index.js +4 -4
  16. package/dist/commands/curl.d.ts.map +1 -1
  17. package/dist/commands/curl.js +2 -1
  18. package/dist/commands/gzip.d.ts.map +1 -1
  19. package/dist/commands/gzip.js +6 -0
  20. package/dist/commands/helpers.js +1 -1
  21. package/dist/commands/history.js +2 -2
  22. package/dist/commands/man.d.ts.map +1 -1
  23. package/dist/commands/man.js +30 -136
  24. package/dist/commands/neofetch.d.ts.map +1 -1
  25. package/dist/commands/neofetch.js +6 -0
  26. package/dist/commands/wget.d.ts.map +1 -1
  27. package/dist/commands/wget.js +11 -1
  28. package/dist/modules/linuxRootfs.d.ts +1 -1
  29. package/dist/modules/linuxRootfs.d.ts.map +1 -1
  30. package/dist/modules/linuxRootfs.js +5 -5
  31. package/dist/self-standalone.js +149 -102
  32. package/package.json +2 -2
  33. package/src/VirtualPackageManager/index.ts +29 -1
  34. package/src/VirtualShell/shell.ts +6 -11
  35. package/src/VirtualUserManager/index.ts +4 -4
  36. package/src/commands/curl.ts +2 -1
  37. package/src/commands/gzip.ts +7 -0
  38. package/src/commands/helpers.ts +1 -1
  39. package/src/commands/history.ts +2 -2
  40. package/src/commands/man.ts +38 -143
  41. package/src/commands/manuals/adduser.txt +11 -0
  42. package/src/commands/manuals/apt-cache.txt +12 -0
  43. package/src/commands/manuals/apt.txt +20 -0
  44. package/src/commands/manuals/awk.txt +13 -0
  45. package/src/commands/manuals/cat.txt +14 -0
  46. package/src/commands/manuals/cd.txt +16 -0
  47. package/src/commands/manuals/chmod.txt +16 -0
  48. package/src/commands/manuals/clear.txt +10 -0
  49. package/src/commands/manuals/cp.txt +10 -0
  50. package/src/commands/manuals/curl.txt +20 -0
  51. package/src/commands/manuals/date.txt +14 -0
  52. package/src/commands/manuals/declare.txt +12 -0
  53. package/src/commands/manuals/deluser.txt +10 -0
  54. package/src/commands/manuals/df.txt +10 -0
  55. package/src/commands/manuals/dpkg-query.txt +11 -0
  56. package/src/commands/manuals/dpkg.txt +14 -0
  57. package/src/commands/manuals/du.txt +11 -0
  58. package/src/commands/manuals/echo.txt +11 -0
  59. package/src/commands/manuals/false.txt +10 -0
  60. package/src/commands/manuals/find.txt +11 -0
  61. package/src/commands/manuals/free.txt +12 -0
  62. package/src/commands/manuals/grep.txt +13 -0
  63. package/src/commands/manuals/groups.txt +10 -0
  64. package/src/commands/manuals/gzip.txt +11 -0
  65. package/src/commands/manuals/head.txt +10 -0
  66. package/src/commands/manuals/help.txt +11 -0
  67. package/src/commands/manuals/history.txt +11 -0
  68. package/src/commands/manuals/hostname.txt +10 -0
  69. package/src/commands/manuals/id.txt +10 -0
  70. package/src/commands/manuals/kill.txt +13 -0
  71. package/src/commands/manuals/ls.txt +20 -0
  72. package/src/commands/manuals/lsb_release.txt +14 -0
  73. package/src/commands/manuals/mkdir.txt +10 -0
  74. package/src/commands/manuals/mv.txt +10 -0
  75. package/src/commands/manuals/nano.txt +11 -0
  76. package/src/commands/manuals/neofetch.txt +10 -0
  77. package/src/commands/manuals/node.txt +13 -0
  78. package/src/commands/manuals/npm.txt +13 -0
  79. package/src/commands/manuals/npx.txt +13 -0
  80. package/src/commands/manuals/passwd.txt +11 -0
  81. package/src/commands/manuals/ping.txt +10 -0
  82. package/src/commands/manuals/printf.txt +11 -0
  83. package/src/commands/manuals/ps.txt +10 -0
  84. package/src/commands/manuals/pwd.txt +10 -0
  85. package/src/commands/manuals/python3.txt +13 -0
  86. package/src/commands/manuals/readlink.txt +10 -0
  87. package/src/commands/manuals/return.txt +10 -0
  88. package/src/commands/manuals/rm.txt +10 -0
  89. package/src/commands/manuals/sed.txt +11 -0
  90. package/src/commands/manuals/set.txt +11 -0
  91. package/src/commands/manuals/shift.txt +10 -0
  92. package/src/commands/manuals/sleep.txt +10 -0
  93. package/src/commands/manuals/sort.txt +12 -0
  94. package/src/commands/manuals/source.txt +11 -0
  95. package/src/commands/manuals/ssh.txt +11 -0
  96. package/src/commands/manuals/stat.txt +10 -0
  97. package/src/commands/manuals/su.txt +13 -0
  98. package/src/commands/manuals/sudo.txt +11 -0
  99. package/src/commands/manuals/tail.txt +10 -0
  100. package/src/commands/manuals/tar.txt +19 -0
  101. package/src/commands/manuals/tee.txt +10 -0
  102. package/src/commands/manuals/test.txt +11 -0
  103. package/src/commands/manuals/touch.txt +11 -0
  104. package/src/commands/manuals/tr.txt +10 -0
  105. package/src/commands/manuals/trap.txt +10 -0
  106. package/src/commands/manuals/true.txt +10 -0
  107. package/src/commands/manuals/type.txt +10 -0
  108. package/src/commands/manuals/uname.txt +12 -0
  109. package/src/commands/manuals/uniq.txt +12 -0
  110. package/src/commands/manuals/unset.txt +10 -0
  111. package/src/commands/manuals/uptime.txt +11 -0
  112. package/src/commands/manuals/wc.txt +12 -0
  113. package/src/commands/manuals/wget.txt +12 -0
  114. package/src/commands/manuals/which.txt +10 -0
  115. package/src/commands/manuals/whoami.txt +10 -0
  116. package/src/commands/manuals/xargs.txt +10 -0
  117. package/src/commands/neofetch.ts +7 -0
  118. package/src/commands/wget.ts +12 -1
  119. package/src/modules/linuxRootfs.ts +6 -6
  120. package/src/self-standalone.ts +190 -141
  121. package/tests/helpers.test.ts +3 -3
  122. package/tests/new-features.test.ts +2 -2
@@ -1,18 +1,24 @@
1
1
  import { readFile, unlink, writeFile } from "node:fs/promises";
2
+ import * as path from "node:path";
2
3
  import { basename } from "node:path";
3
4
  import { stdin, stdout } from "node:process";
4
5
  import { createInterface, type Interface } from "node:readline";
5
6
 
7
+ import { getCommandNames } from "./commands/registry";
6
8
  import { makeDefaultEnv, runCommand } from "./commands/runtime";
7
9
  import { spawnNanoEditorProcess } from "./modules/shellInteractive";
10
+ import { resolvePath } from "./modules/shellRuntime";
8
11
  import { buildLoginBanner, type LoginBannerState } from "./SSHMimic/loginBanner";
9
12
  import { buildPrompt } from "./SSHMimic/prompt";
10
13
  import type { CommandResult, PasswordChallenge, SudoChallenge } from "./types/commands";
14
+ import type VirtualFileSystem from "./VirtualFileSystem";
11
15
  import { VirtualShell } from "./VirtualShell";
12
16
 
13
17
  const hostname = process.env.SSH_MIMIC_HOSTNAME ?? "typescript-vm";
14
18
  const argv = process.argv.slice(2);
15
19
 
20
+ // ── CLI args ──────────────────────────────────────────────────────────────────
21
+
16
22
  function readUserArg(): string {
17
23
  for (let index = 0; index < argv.length; index += 1) {
18
24
  const current = argv[index];
@@ -27,7 +33,6 @@ function readUserArg(): string {
27
33
  return current.slice("--user=".length) || "root";
28
34
  }
29
35
  }
30
-
31
36
  return "root";
32
37
  }
33
38
 
@@ -37,19 +42,85 @@ const virtualShell = new VirtualShell(hostname, undefined, {
37
42
  snapshotPath: ".vfs",
38
43
  });
39
44
 
45
+ // ── VFS helpers ───────────────────────────────────────────────────────────────
46
+
40
47
  function readLastLogin(username: string): LoginBannerState | null {
41
- const lastlogPath = `/virtual-env-js/.lastlog/${username}.json`;
42
- if (!virtualShell.vfs.exists(lastlogPath)) {
48
+ const lastlogPath = `/home/${username}/.lastlog`;
49
+ if (!virtualShell.vfs.exists(lastlogPath)) return null;
50
+ try {
51
+ return JSON.parse(virtualShell.vfs.readFile(lastlogPath)) as LoginBannerState;
52
+ } catch {
43
53
  return null;
44
54
  }
55
+ }
45
56
 
57
+ function writeLastLogin(username: string, from: string): void {
58
+ virtualShell.vfs.writeFile(
59
+ `/home/${username}/.lastlog`,
60
+ JSON.stringify({ at: new Date().toISOString(), from }),
61
+ );
62
+ }
63
+
64
+ async function flushVfs(): Promise<void> {
65
+ await virtualShell.vfs.flushMirror();
66
+ }
67
+
68
+ function loadHistory(authUser: string): string[] {
69
+ const historyPath = `/home/${authUser}/.bash_history`;
70
+ if (!virtualShell.vfs.exists(historyPath)) {
71
+ virtualShell.vfs.writeFile(historyPath, "");
72
+ return [];
73
+ }
74
+ return virtualShell.vfs
75
+ .readFile(historyPath)
76
+ .split("\n")
77
+ .map((line) => line.trim())
78
+ .filter((line) => line.length > 0);
79
+ }
80
+
81
+ function saveHistory(history: string[], authUser: string): void {
82
+ const data = history.length > 0 ? `${history.join("\n")}\n` : "";
83
+ virtualShell.vfs.writeFile(`/home/${authUser}/.bash_history`, data);
84
+ }
85
+
86
+ // ── Tab completion ────────────────────────────────────────────────────────────
87
+
88
+ function listPathCompletions(vfs: VirtualFileSystem, cwd: string, prefix: string): string[] {
89
+ const slashIndex = prefix.lastIndexOf("/");
90
+ const dirPart = slashIndex >= 0 ? prefix.slice(0, slashIndex + 1) : "";
91
+ const namePart = slashIndex >= 0 ? prefix.slice(slashIndex + 1) : prefix;
92
+ const basePath = resolvePath(cwd, dirPart || ".");
46
93
  try {
47
- return JSON.parse(virtualShell.vfs.readFile(lastlogPath)) as LoginBannerState;
94
+ return vfs
95
+ .list(basePath)
96
+ .filter((e) => !e.startsWith(".") && e.startsWith(namePart))
97
+ .map((e) => {
98
+ const fullPath = path.posix.join(basePath, e);
99
+ const st = vfs.stat(fullPath);
100
+ return `${dirPart}${e}${st.type === "directory" ? "/" : ""}`;
101
+ })
102
+ .sort();
48
103
  } catch {
49
- return null;
104
+ return [];
50
105
  }
51
106
  }
52
107
 
108
+ function makeCompleter(getState: () => { cwd: string }) {
109
+ const commandNames = Array.from(new Set(getCommandNames())).sort();
110
+ return (line: string, cb: (err: null, result: [string[], string]) => void): void => {
111
+ const { cwd } = getState();
112
+ // Extract the token under/before cursor (last whitespace-separated word)
113
+ const token = line.split(/\s+/).at(-1) ?? "";
114
+ const isFirstToken = line.trimStart() === token;
115
+ const cmdHits = isFirstToken ? commandNames.filter((n) => n.startsWith(token)) : [];
116
+ const pathHits = listPathCompletions(virtualShell.vfs, cwd, token);
117
+ const hits = Array.from(new Set([...cmdHits, ...pathHits])).sort();
118
+ cb(null, [hits, token]);
119
+ };
120
+ }
121
+
122
+ // ── Hidden password input ─────────────────────────────────────────────────────
123
+
53
124
  function askHiddenQuestion(rl: Interface, promptText: string): Promise<string> {
54
125
  return new Promise((resolve) => {
55
126
  if (!stdin.isTTY || !stdout.isTTY) {
@@ -62,10 +133,7 @@ function askHiddenQuestion(rl: Interface, promptText: string): Promise<string> {
62
133
 
63
134
  const cleanup = (): void => {
64
135
  stdin.off("data", onData);
65
- if (!wasRawMode) {
66
- stdin.setRawMode(false);
67
- }
68
- rl.resume();
136
+ if (!wasRawMode) stdin.setRawMode(false);
69
137
  };
70
138
 
71
139
  const finish = (value: string): void => {
@@ -76,66 +144,24 @@ function askHiddenQuestion(rl: Interface, promptText: string): Promise<string> {
76
144
 
77
145
  const onData = (chunk: Buffer): void => {
78
146
  const input = chunk.toString("utf8");
79
- for (let index = 0; index < input.length; index += 1) {
80
- const ch = input[index]!;
81
- if (ch === "\r" || ch === "\n") {
82
- finish(buffer);
83
- return;
84
- }
85
- if (ch === "\u007f" || ch === "\b") {
86
- buffer = buffer.slice(0, -1);
87
- continue;
88
- }
89
- if (ch >= " ") {
90
- buffer += ch;
91
- }
147
+ for (let i = 0; i < input.length; i += 1) {
148
+ const ch = input[i]!;
149
+ if (ch === "\r" || ch === "\n") { finish(buffer); return; }
150
+ if (ch === "\u007f" || ch === "\b") { buffer = buffer.slice(0, -1); continue; }
151
+ if (ch >= " ") buffer += ch;
92
152
  }
93
153
  };
94
154
 
155
+ // Pause readline so it doesn't eat our raw keystrokes
95
156
  rl.pause();
96
157
  stdout.write(promptText);
97
- if (!wasRawMode) {
98
- stdin.setRawMode(true);
99
- }
158
+ if (!wasRawMode) stdin.setRawMode(true);
100
159
  stdin.resume();
101
160
  stdin.on("data", onData);
102
161
  });
103
162
  }
104
163
 
105
- function writeLastLogin(username: string, from: string): void {
106
- const dir = "/virtual-env-js/.lastlog";
107
- if (!virtualShell.vfs.exists(dir)) {
108
- virtualShell.vfs.mkdir(dir, 0o700);
109
- }
110
-
111
- virtualShell.vfs.writeFile(
112
- `/virtual-env-js/.lastlog/${username}.json`,
113
- JSON.stringify({ at: new Date().toISOString(), from }),
114
- );
115
- }
116
-
117
- async function flushVfs(): Promise<void> {
118
- await virtualShell.vfs.flushMirror();
119
- }
120
-
121
- function loadHistory(): string[] {
122
- const historyPath = "/virtual-env-js/.bash_history";
123
- if (!virtualShell.vfs.exists(historyPath)) {
124
- virtualShell.vfs.writeFile(historyPath, "");
125
- return [];
126
- }
127
-
128
- return virtualShell.vfs
129
- .readFile(historyPath)
130
- .split("\n")
131
- .map((line) => line.trim())
132
- .filter((line) => line.length > 0);
133
- }
134
-
135
- function saveHistory(history: string[]): void {
136
- const data = history.length > 0 ? `${history.join("\n")}\n` : "";
137
- virtualShell.vfs.writeFile("/virtual-env-js/.bash_history", data);
138
- }
164
+ // ── Session state helper ──────────────────────────────────────────────────────
139
165
 
140
166
  function applySessionState(
141
167
  authUserState: string,
@@ -145,7 +171,6 @@ function applySessionState(
145
171
  ): { authUser: string; cwd: string } {
146
172
  let authUser = authUserState;
147
173
  let cwd = cwdState;
148
-
149
174
  if (result.switchUser) {
150
175
  authUser = result.switchUser;
151
176
  cwd = result.nextCwd ?? `/home/${authUser}`;
@@ -157,27 +182,23 @@ function applySessionState(
157
182
  cwd = result.nextCwd;
158
183
  shellEnvState.vars.PWD = cwd;
159
184
  }
160
-
161
185
  return { authUser, cwd };
162
186
  }
163
187
 
164
- virtualShell.addCommand("demo", [], () => {
165
- return {
166
- stdout: "This is a demo command. It does nothing useful.",
167
- exitCode: 0,
168
- };
169
- });
188
+ // ── Demo command ──────────────────────────────────────────────────────────────
189
+
190
+ virtualShell.addCommand("demo", [], () => ({
191
+ stdout: "This is a demo command. It does nothing useful.",
192
+ exitCode: 0,
193
+ }));
170
194
 
171
- async function runReadlineShell() {
172
- const rl = createInterface({ input: stdin, output: stdout, terminal: true });
195
+ // ── Main shell ────────────────────────────────────────────────────────────────
196
+
197
+ async function runReadlineShell(): Promise<void> {
173
198
  await virtualShell.ensureInitialized();
174
- let history = loadHistory();
175
- const rlWithHistory = rl as Interface & { history: string[] };
176
- rlWithHistory.history = [...history].reverse();
177
199
 
178
200
  const selectedUser = initialUser.trim() || "root";
179
- const userExists = virtualShell.users.getPasswordHash(selectedUser) !== null;
180
- if (!userExists) {
201
+ if (virtualShell.users.getPasswordHash(selectedUser) === null) {
181
202
  process.stderr.write(`self-standalone: user '${selectedUser}' does not exist\n`);
182
203
  process.exit(1);
183
204
  }
@@ -187,10 +208,23 @@ async function runReadlineShell() {
187
208
  let cwd = `/home/${authUser}`;
188
209
  shellEnv.vars.PWD = cwd;
189
210
  const remoteAddress = "localhost";
190
- const terminalSize = {
191
- cols: stdout.columns ?? 80,
192
- rows: stdout.rows ?? 24,
193
- };
211
+ const terminalSize = { cols: stdout.columns ?? 80, rows: stdout.rows ?? 24 };
212
+
213
+ let history = loadHistory(authUser);
214
+
215
+ // completer reads cwd via closure — always current
216
+ const rl = createInterface({
217
+ input: stdin,
218
+ output: stdout,
219
+ terminal: true,
220
+ completer: makeCompleter(() => ({ cwd })),
221
+ });
222
+
223
+ // Sync readline's internal history with our VFS history
224
+ const rlWithHistory = rl as Interface & { history: string[] };
225
+ rlWithHistory.history = [...history].reverse();
226
+
227
+ // ── nano editor ────────────────────────────────────────────────────────────
194
228
 
195
229
  async function startNanoEditor(
196
230
  targetPath: string,
@@ -202,6 +236,7 @@ async function runReadlineShell() {
202
236
  }
203
237
 
204
238
  rl.pause();
239
+
205
240
  const editor = spawnNanoEditorProcess(
206
241
  tempPath,
207
242
  terminalSize,
@@ -213,22 +248,16 @@ async function runReadlineShell() {
213
248
  );
214
249
 
215
250
  const wasRawMode = Boolean(stdin.isRaw);
216
- const forwardInput = (chunk: Buffer): void => {
217
- editor.stdin.write(chunk);
218
- };
251
+ const forwardInput = (chunk: Buffer): void => { editor.stdin.write(chunk); };
219
252
 
220
253
  stdin.resume();
221
- if (!wasRawMode) {
222
- stdin.setRawMode(true);
223
- }
254
+ if (!wasRawMode) stdin.setRawMode(true);
224
255
  stdin.on("data", forwardInput);
225
256
 
226
257
  await new Promise<void>((resolve) => {
227
258
  const cleanup = (): void => {
228
259
  stdin.off("data", forwardInput);
229
- if (!wasRawMode) {
230
- stdin.setRawMode(false);
231
- }
260
+ if (!wasRawMode) stdin.setRawMode(false);
232
261
  rl.resume();
233
262
  };
234
263
 
@@ -246,9 +275,8 @@ async function runReadlineShell() {
246
275
  virtualShell.writeFileAsUser(authUser, targetPath, updatedContent);
247
276
  await flushVfs();
248
277
  } catch {
249
- // Save skipped or temp file missing.
278
+ // save skipped or temp file missing
250
279
  }
251
-
252
280
  await unlink(tempPath).catch(() => undefined);
253
281
  stdout.write("\r\n");
254
282
  resolve();
@@ -256,6 +284,8 @@ async function runReadlineShell() {
256
284
  });
257
285
  }
258
286
 
287
+ // ── challenge handlers ─────────────────────────────────────────────────────
288
+
259
289
  async function handleSudoChallenge(challenge: SudoChallenge): Promise<void> {
260
290
  if (challenge.onPassword) {
261
291
  let promptText = challenge.prompt;
@@ -266,7 +296,6 @@ async function runReadlineShell() {
266
296
  promptText = step.nextPrompt ?? promptText;
267
297
  continue;
268
298
  }
269
-
270
299
  await handleCommandResult(step.result);
271
300
  return;
272
301
  }
@@ -302,9 +331,7 @@ async function runReadlineShell() {
302
331
  await handleCommandResult(nestedResult);
303
332
  }
304
333
 
305
- async function handlePasswordChallenge(
306
- challenge: PasswordChallenge,
307
- ): Promise<void> {
334
+ async function handlePasswordChallenge(challenge: PasswordChallenge): Promise<void> {
308
335
  const first = await askHiddenQuestion(rl, challenge.prompt);
309
336
  if (challenge.confirmPrompt) {
310
337
  const second = await askHiddenQuestion(rl, challenge.confirmPrompt);
@@ -342,6 +369,7 @@ async function runReadlineShell() {
342
369
  }
343
370
  }
344
371
 
372
+ // handleCommandResult must be declared before the "line" handler
345
373
  async function handleCommandResult(result: CommandResult): Promise<void> {
346
374
  if (result.openEditor) {
347
375
  await startNanoEditor(
@@ -362,6 +390,11 @@ async function runReadlineShell() {
362
390
  return;
363
391
  }
364
392
 
393
+ if (result.clearScreen) {
394
+ stdout.write("\u001b[2J\u001b[H");
395
+ console.clear();
396
+ }
397
+
365
398
  if (result.stdout) {
366
399
  stdout.write(result.stdout.endsWith("\n") ? result.stdout : `${result.stdout}\n`);
367
400
  }
@@ -370,14 +403,9 @@ async function runReadlineShell() {
370
403
  process.stderr.write(result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`);
371
404
  }
372
405
 
373
- if (result.clearScreen) {
374
- stdout.write("\u001b[2J\u001b[H");
375
- console.clear();
376
- }
377
-
378
- const updatedState = applySessionState(authUser, cwd, result, shellEnv);
379
- authUser = updatedState.authUser;
380
- cwd = updatedState.cwd;
406
+ const updated = applySessionState(authUser, cwd, result, shellEnv);
407
+ authUser = updated.authUser;
408
+ cwd = updated.cwd;
381
409
 
382
410
  if (result.closeSession) {
383
411
  await flushVfs();
@@ -386,13 +414,7 @@ async function runReadlineShell() {
386
414
  }
387
415
  }
388
416
 
389
- if (process.env.USER !== "root" && virtualShell.users.hasPassword(authUser)) {
390
- const password = await askHiddenQuestion(rl, `Password for ${authUser}: `);
391
- if (!virtualShell.users.verifyPassword(authUser, password)) {
392
- process.stderr.write("self-standalone: authentication failed\n");
393
- process.exit(1);
394
- }
395
- }
417
+ // ── Prompt helper ──────────────────────────────────────────────────────────
396
418
 
397
419
  const renderPrompt = (): string => {
398
420
  const cwdLabel = cwd === `/home/${authUser}` ? "~" : basename(cwd) || "/";
@@ -404,48 +426,79 @@ async function runReadlineShell() {
404
426
  rl.prompt();
405
427
  };
406
428
 
407
- rl.on("SIGINT", () => {
408
- stdout.write("^C\n");
409
- rl.write("", { ctrl: true, name: "u" });
410
- prompt();
411
- });
429
+ // ── Auth (password gate) ───────────────────────────────────────────────────
412
430
 
413
- rl.on("close", () => {
414
- void (async () => {
415
- await flushVfs();
416
- console.log("");
417
- process.exit(0);
418
- })();
419
- });
431
+ if (process.env.USER !== "root" && virtualShell.users.hasPassword(authUser)) {
432
+ const password = await askHiddenQuestion(rl, `Password for ${authUser}: `);
433
+ if (!virtualShell.users.verifyPassword(authUser, password)) {
434
+ process.stderr.write("self-standalone: authentication failed\n");
435
+ process.exit(1);
436
+ }
437
+ }
438
+
439
+ // ── Login banner ───────────────────────────────────────────────────────────
420
440
 
421
441
  stdout.write(buildLoginBanner(hostname, virtualShell.properties, readLastLogin(authUser)));
422
442
  writeLastLogin(authUser, remoteAddress);
423
443
  await flushVfs();
424
- prompt();
425
444
 
426
- while (true) {
427
- const inputLine = await new Promise<string>((resolve) => {
428
- rl.once("line", (line) => resolve(line));
429
- });
445
+ // ── Event-driven line handler (enables completer) ──────────────────────────
446
+ //
447
+ // Key insight: readline's completer only fires when readline itself owns
448
+ // stdin (i.e. rl is not paused). We use the event-driven "line" pattern
449
+ // instead of a while(true)+rl.once("line") loop so readline stays active
450
+ // between commands. We pause only while awaiting async work, then resume
451
+ // immediately before re-prompting so the next Tab press is caught.
430
452
 
453
+ let busy = false;
454
+
455
+ rl.on("line", async (inputLine: string) => {
456
+ if (busy) return; // shouldn't happen but guard re-entrancy
457
+ busy = true;
431
458
  rl.pause();
432
- if (inputLine.trim().length > 0) {
459
+
460
+ const trimmed = inputLine.trim();
461
+ if (trimmed.length > 0) {
433
462
  history.push(inputLine);
434
- if (history.length > 500) {
435
- history = history.slice(history.length - 500);
436
- }
437
- saveHistory(history);
463
+ if (history.length > 500) history = history.slice(history.length - 500);
464
+ saveHistory(history, authUser);
438
465
  rlWithHistory.history = [...history].reverse();
439
466
  }
440
467
 
441
- const result = await runCommand(inputLine, authUser, hostname, "shell", cwd, virtualShell, undefined, shellEnv);
468
+ const result = await runCommand(
469
+ inputLine,
470
+ authUser,
471
+ hostname,
472
+ "shell",
473
+ cwd,
474
+ virtualShell,
475
+ undefined,
476
+ shellEnv,
477
+ );
442
478
  await handleCommandResult(result);
443
-
444
479
  await flushVfs();
445
480
 
446
- prompt();
481
+ busy = false;
482
+ // Resume before prompt so readline can handle Tab on the next input
447
483
  rl.resume();
448
- }
484
+ prompt();
485
+ });
486
+
487
+ rl.on("SIGINT", () => {
488
+ stdout.write("^C\n");
489
+ rl.write("", { ctrl: true, name: "u" });
490
+ prompt();
491
+ });
492
+
493
+ rl.on("close", () => {
494
+ void flushVfs().then(() => {
495
+ console.log("");
496
+ process.exit(0);
497
+ });
498
+ });
499
+
500
+ // Initial prompt — readline is already active, completer live from first keystroke
501
+ prompt();
449
502
  }
450
503
 
451
504
  runReadlineShell().catch((error: unknown) => {
@@ -454,13 +507,9 @@ runReadlineShell().catch((error: unknown) => {
454
507
  });
455
508
 
456
509
  process.on("uncaughtException", (error) => {
457
- console.log("Oh my god, something terrible happened: ", error);
510
+ console.error("Uncaught exception:", error);
458
511
  });
459
512
 
460
513
  process.on("unhandledRejection", (error, promise) => {
461
- console.log(
462
- " Oh Lord! We forgot to handle a promise rejection here: ",
463
- promise,
464
- );
465
- console.log(" The error was: ", error);
466
- });
514
+ console.error("Unhandled rejection at:", promise, "error:", error);
515
+ });
@@ -4,13 +4,13 @@ import { assertPathAccess } from "../src/commands/helpers";
4
4
  describe("assertPathAccess", () => {
5
5
  test("blocks non-root access to auth store", () => {
6
6
  expect(() =>
7
- assertPathAccess("alice", "/virtual-env-js/.auth/htpasswd", "cat"),
8
- ).toThrow("cat: permission denied: /virtual-env-js/.auth/htpasswd");
7
+ assertPathAccess("alice", "/etc/htpasswd", "cat"),
8
+ ).toThrow("cat: permission denied: /etc/htpasswd");
9
9
  });
10
10
 
11
11
  test("allows root access to auth store", () => {
12
12
  expect(() =>
13
- assertPathAccess("root", "/virtual-env-js/.auth/htpasswd", "cat"),
13
+ assertPathAccess("root", "/etc/htpasswd", "cat"),
14
14
  ).not.toThrow();
15
15
  });
16
16
 
@@ -223,7 +223,7 @@ describe("Package manager (apt/dpkg)", () => {
223
223
  });
224
224
 
225
225
  test("neofetch shows package count after installs", async () => {
226
- await client.exec("apt install curl wget htop");
226
+ await client.exec("apt install curl wget htop neofetch");
227
227
  const r = await client.exec("neofetch");
228
228
  expect(r.exitCode).toBe(0);
229
229
  expect(r.stdout).toContain("(dpkg)");
@@ -675,7 +675,7 @@ describe("/proc/self and /proc/<pid>", () => {
675
675
  });
676
676
  });
677
677
 
678
- import { diffSnapshots, formatDiff, assertDiff } from "../src";
678
+ import { assertDiff, diffSnapshots, formatDiff } from "../src";
679
679
 
680
680
  describe("VFS snapshot diff tooling", () => {
681
681
  let shell5: VirtualShell;