typescript-virtual-container 1.4.1 → 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.
- package/.vscode/settings.json +2 -0
- package/README.md +5 -1
- package/benchmark-virtualshell.ts +3 -11
- package/builds/self-standalone.js +202 -202
- package/builds/self-standalone.js.map +3 -3
- package/builds/standalone-wo-sftp.js +8 -8
- package/builds/standalone-wo-sftp.js.map +3 -3
- package/builds/standalone.js +42 -42
- package/builds/standalone.js.map +3 -3
- package/dist/VirtualShell/shell.d.ts.map +1 -1
- package/dist/VirtualShell/shell.js +6 -10
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +4 -4
- package/dist/commands/helpers.js +1 -1
- package/dist/commands/history.js +2 -2
- package/dist/modules/linuxRootfs.d.ts +1 -1
- package/dist/modules/linuxRootfs.d.ts.map +1 -1
- package/dist/modules/linuxRootfs.js +5 -5
- package/dist/self-standalone.js +149 -102
- package/package.json +1 -1
- package/src/VirtualShell/shell.ts +6 -11
- package/src/VirtualUserManager/index.ts +4 -4
- package/src/commands/helpers.ts +1 -1
- package/src/commands/history.ts +2 -2
- package/src/modules/linuxRootfs.ts +6 -6
- package/src/self-standalone.ts +190 -141
- package/tests/helpers.test.ts +3 -3
package/src/commands/history.ts
CHANGED
|
@@ -10,9 +10,9 @@ export const historyCommand: ShellModule = {
|
|
|
10
10
|
description: "Display command history",
|
|
11
11
|
category: "shell",
|
|
12
12
|
params: ["[n]"],
|
|
13
|
-
run: ({ args, shell }) => {
|
|
13
|
+
run: ({ args, shell, authUser }) => {
|
|
14
14
|
// History is persisted in the VFS by the interactive shell
|
|
15
|
-
const histPath =
|
|
15
|
+
const histPath = `/home/${authUser}/.bash_history`;
|
|
16
16
|
if (!shell.vfs.exists(histPath)) {
|
|
17
17
|
return { stdout: "", exitCode: 0 };
|
|
18
18
|
}
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import * as os from "node:os";
|
|
11
|
-
import type { ShellProperties } from "../VirtualShell";
|
|
12
11
|
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
12
|
+
import type { ShellProperties } from "../VirtualShell";
|
|
13
13
|
import type { VirtualUserManager } from "../VirtualUserManager";
|
|
14
14
|
|
|
15
15
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
@@ -584,7 +584,7 @@ function bootstrapRoot(vfs: VirtualFileSystem): void {
|
|
|
584
584
|
ensureDir(vfs, "/root", 0o700);
|
|
585
585
|
ensureFile(
|
|
586
586
|
vfs,
|
|
587
|
-
"/root/.bashrc",
|
|
587
|
+
"/home/root/.bashrc",
|
|
588
588
|
`${[
|
|
589
589
|
"# root .bashrc",
|
|
590
590
|
"export PS1='\\[\\033[0;31m\\]\\u@\\h\\[\\033[0m\\]:\\[\\033[0;34m\\]\\w\\[\\033[0m\\]# '",
|
|
@@ -593,11 +593,11 @@ function bootstrapRoot(vfs: VirtualFileSystem): void {
|
|
|
593
593
|
"alias la='ls -A'",
|
|
594
594
|
].join("\n")}\n`,
|
|
595
595
|
);
|
|
596
|
-
ensureFile(vfs, "/root/.profile", "[ -f ~/.bashrc ] && . ~/.bashrc\n");
|
|
596
|
+
ensureFile(vfs, "/home/root/.profile", "[ -f ~/.bashrc ] && . ~/.bashrc\n");
|
|
597
597
|
// Fix: /home/root should map to /root for root user
|
|
598
|
-
if (!vfs.exists("/home/root")) {
|
|
599
|
-
|
|
600
|
-
}
|
|
598
|
+
// if (!vfs.exists("/home/root")) {
|
|
599
|
+
// vfs.symlink("/root", "/home/root");
|
|
600
|
+
// }
|
|
601
601
|
}
|
|
602
602
|
|
|
603
603
|
// ─── /opt + /srv + /mnt + /media ─────────────────────────────────────────────
|
package/src/self-standalone.ts
CHANGED
|
@@ -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 = `/
|
|
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
|
|
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
|
|
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
|
|
80
|
-
const ch = input[
|
|
81
|
-
if (ch === "\r" || ch === "\n") {
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
stdout.write("^C\n");
|
|
409
|
-
rl.write("", { ctrl: true, name: "u" });
|
|
410
|
-
prompt();
|
|
411
|
-
});
|
|
429
|
+
// ── Auth (password gate) ───────────────────────────────────────────────────
|
|
412
430
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
process.exit(
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
459
|
+
|
|
460
|
+
const trimmed = inputLine.trim();
|
|
461
|
+
if (trimmed.length > 0) {
|
|
433
462
|
history.push(inputLine);
|
|
434
|
-
if (history.length > 500)
|
|
435
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
510
|
+
console.error("Uncaught exception:", error);
|
|
458
511
|
});
|
|
459
512
|
|
|
460
513
|
process.on("unhandledRejection", (error, promise) => {
|
|
461
|
-
console.
|
|
462
|
-
|
|
463
|
-
promise,
|
|
464
|
-
);
|
|
465
|
-
console.log(" The error was: ", error);
|
|
466
|
-
});
|
|
514
|
+
console.error("Unhandled rejection at:", promise, "error:", error);
|
|
515
|
+
});
|
package/tests/helpers.test.ts
CHANGED
|
@@ -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", "/
|
|
8
|
-
).toThrow("cat: permission denied: /
|
|
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", "/
|
|
13
|
+
assertPathAccess("root", "/etc/htpasswd", "cat"),
|
|
14
14
|
).not.toThrow();
|
|
15
15
|
});
|
|
16
16
|
|