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.
- package/.vscode/settings.json +2 -0
- package/README.md +5 -1
- package/benchmark-virtualshell.ts +3 -11
- package/builds/self-standalone.js +230 -346
- package/builds/self-standalone.js.map +3 -3
- package/builds/standalone-wo-sftp.js +156 -272
- package/builds/standalone-wo-sftp.js.map +3 -3
- package/builds/standalone.js +151 -267
- package/builds/standalone.js.map +3 -3
- package/dist/VirtualPackageManager/index.d.ts.map +1 -1
- package/dist/VirtualPackageManager/index.js +29 -1
- 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/curl.d.ts.map +1 -1
- package/dist/commands/curl.js +2 -1
- package/dist/commands/gzip.d.ts.map +1 -1
- package/dist/commands/gzip.js +6 -0
- package/dist/commands/helpers.js +1 -1
- package/dist/commands/history.js +2 -2
- package/dist/commands/man.d.ts.map +1 -1
- package/dist/commands/man.js +30 -136
- package/dist/commands/neofetch.d.ts.map +1 -1
- package/dist/commands/neofetch.js +6 -0
- package/dist/commands/wget.d.ts.map +1 -1
- package/dist/commands/wget.js +11 -1
- 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 +2 -2
- package/src/VirtualPackageManager/index.ts +29 -1
- package/src/VirtualShell/shell.ts +6 -11
- package/src/VirtualUserManager/index.ts +4 -4
- package/src/commands/curl.ts +2 -1
- package/src/commands/gzip.ts +7 -0
- package/src/commands/helpers.ts +1 -1
- package/src/commands/history.ts +2 -2
- package/src/commands/man.ts +38 -143
- package/src/commands/manuals/adduser.txt +11 -0
- package/src/commands/manuals/apt-cache.txt +12 -0
- package/src/commands/manuals/apt.txt +20 -0
- package/src/commands/manuals/awk.txt +13 -0
- package/src/commands/manuals/cat.txt +14 -0
- package/src/commands/manuals/cd.txt +16 -0
- package/src/commands/manuals/chmod.txt +16 -0
- package/src/commands/manuals/clear.txt +10 -0
- package/src/commands/manuals/cp.txt +10 -0
- package/src/commands/manuals/curl.txt +20 -0
- package/src/commands/manuals/date.txt +14 -0
- package/src/commands/manuals/declare.txt +12 -0
- package/src/commands/manuals/deluser.txt +10 -0
- package/src/commands/manuals/df.txt +10 -0
- package/src/commands/manuals/dpkg-query.txt +11 -0
- package/src/commands/manuals/dpkg.txt +14 -0
- package/src/commands/manuals/du.txt +11 -0
- package/src/commands/manuals/echo.txt +11 -0
- package/src/commands/manuals/false.txt +10 -0
- package/src/commands/manuals/find.txt +11 -0
- package/src/commands/manuals/free.txt +12 -0
- package/src/commands/manuals/grep.txt +13 -0
- package/src/commands/manuals/groups.txt +10 -0
- package/src/commands/manuals/gzip.txt +11 -0
- package/src/commands/manuals/head.txt +10 -0
- package/src/commands/manuals/help.txt +11 -0
- package/src/commands/manuals/history.txt +11 -0
- package/src/commands/manuals/hostname.txt +10 -0
- package/src/commands/manuals/id.txt +10 -0
- package/src/commands/manuals/kill.txt +13 -0
- package/src/commands/manuals/ls.txt +20 -0
- package/src/commands/manuals/lsb_release.txt +14 -0
- package/src/commands/manuals/mkdir.txt +10 -0
- package/src/commands/manuals/mv.txt +10 -0
- package/src/commands/manuals/nano.txt +11 -0
- package/src/commands/manuals/neofetch.txt +10 -0
- package/src/commands/manuals/node.txt +13 -0
- package/src/commands/manuals/npm.txt +13 -0
- package/src/commands/manuals/npx.txt +13 -0
- package/src/commands/manuals/passwd.txt +11 -0
- package/src/commands/manuals/ping.txt +10 -0
- package/src/commands/manuals/printf.txt +11 -0
- package/src/commands/manuals/ps.txt +10 -0
- package/src/commands/manuals/pwd.txt +10 -0
- package/src/commands/manuals/python3.txt +13 -0
- package/src/commands/manuals/readlink.txt +10 -0
- package/src/commands/manuals/return.txt +10 -0
- package/src/commands/manuals/rm.txt +10 -0
- package/src/commands/manuals/sed.txt +11 -0
- package/src/commands/manuals/set.txt +11 -0
- package/src/commands/manuals/shift.txt +10 -0
- package/src/commands/manuals/sleep.txt +10 -0
- package/src/commands/manuals/sort.txt +12 -0
- package/src/commands/manuals/source.txt +11 -0
- package/src/commands/manuals/ssh.txt +11 -0
- package/src/commands/manuals/stat.txt +10 -0
- package/src/commands/manuals/su.txt +13 -0
- package/src/commands/manuals/sudo.txt +11 -0
- package/src/commands/manuals/tail.txt +10 -0
- package/src/commands/manuals/tar.txt +19 -0
- package/src/commands/manuals/tee.txt +10 -0
- package/src/commands/manuals/test.txt +11 -0
- package/src/commands/manuals/touch.txt +11 -0
- package/src/commands/manuals/tr.txt +10 -0
- package/src/commands/manuals/trap.txt +10 -0
- package/src/commands/manuals/true.txt +10 -0
- package/src/commands/manuals/type.txt +10 -0
- package/src/commands/manuals/uname.txt +12 -0
- package/src/commands/manuals/uniq.txt +12 -0
- package/src/commands/manuals/unset.txt +10 -0
- package/src/commands/manuals/uptime.txt +11 -0
- package/src/commands/manuals/wc.txt +12 -0
- package/src/commands/manuals/wget.txt +12 -0
- package/src/commands/manuals/which.txt +10 -0
- package/src/commands/manuals/whoami.txt +10 -0
- package/src/commands/manuals/xargs.txt +10 -0
- package/src/commands/neofetch.ts +7 -0
- package/src/commands/wget.ts +12 -1
- package/src/modules/linuxRootfs.ts +6 -6
- package/src/self-standalone.ts +190 -141
- package/tests/helpers.test.ts +3 -3
- package/tests/new-features.test.ts +2 -2
package/dist/self-standalone.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
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 } from "node:readline";
|
|
6
|
+
import { getCommandNames } from "./commands/registry";
|
|
5
7
|
import { makeDefaultEnv, runCommand } from "./commands/runtime";
|
|
6
8
|
import { spawnNanoEditorProcess } from "./modules/shellInteractive";
|
|
9
|
+
import { resolvePath } from "./modules/shellRuntime";
|
|
7
10
|
import { buildLoginBanner } from "./SSHMimic/loginBanner";
|
|
8
11
|
import { buildPrompt } from "./SSHMimic/prompt";
|
|
9
12
|
import { VirtualShell } from "./VirtualShell";
|
|
10
13
|
const hostname = process.env.SSH_MIMIC_HOSTNAME ?? "typescript-vm";
|
|
11
14
|
const argv = process.argv.slice(2);
|
|
15
|
+
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
12
16
|
function readUserArg() {
|
|
13
17
|
for (let index = 0; index < argv.length; index += 1) {
|
|
14
18
|
const current = argv[index];
|
|
@@ -30,11 +34,11 @@ const virtualShell = new VirtualShell(hostname, undefined, {
|
|
|
30
34
|
mode: "fs",
|
|
31
35
|
snapshotPath: ".vfs",
|
|
32
36
|
});
|
|
37
|
+
// ── VFS helpers ───────────────────────────────────────────────────────────────
|
|
33
38
|
function readLastLogin(username) {
|
|
34
|
-
const lastlogPath = `/
|
|
35
|
-
if (!virtualShell.vfs.exists(lastlogPath))
|
|
39
|
+
const lastlogPath = `/home/${username}/.lastlog`;
|
|
40
|
+
if (!virtualShell.vfs.exists(lastlogPath))
|
|
36
41
|
return null;
|
|
37
|
-
}
|
|
38
42
|
try {
|
|
39
43
|
return JSON.parse(virtualShell.vfs.readFile(lastlogPath));
|
|
40
44
|
}
|
|
@@ -42,6 +46,63 @@ function readLastLogin(username) {
|
|
|
42
46
|
return null;
|
|
43
47
|
}
|
|
44
48
|
}
|
|
49
|
+
function writeLastLogin(username, from) {
|
|
50
|
+
virtualShell.vfs.writeFile(`/home/${username}/.lastlog`, JSON.stringify({ at: new Date().toISOString(), from }));
|
|
51
|
+
}
|
|
52
|
+
async function flushVfs() {
|
|
53
|
+
await virtualShell.vfs.flushMirror();
|
|
54
|
+
}
|
|
55
|
+
function loadHistory(authUser) {
|
|
56
|
+
const historyPath = `/home/${authUser}/.bash_history`;
|
|
57
|
+
if (!virtualShell.vfs.exists(historyPath)) {
|
|
58
|
+
virtualShell.vfs.writeFile(historyPath, "");
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
return virtualShell.vfs
|
|
62
|
+
.readFile(historyPath)
|
|
63
|
+
.split("\n")
|
|
64
|
+
.map((line) => line.trim())
|
|
65
|
+
.filter((line) => line.length > 0);
|
|
66
|
+
}
|
|
67
|
+
function saveHistory(history, authUser) {
|
|
68
|
+
const data = history.length > 0 ? `${history.join("\n")}\n` : "";
|
|
69
|
+
virtualShell.vfs.writeFile(`/home/${authUser}/.bash_history`, data);
|
|
70
|
+
}
|
|
71
|
+
// ── Tab completion ────────────────────────────────────────────────────────────
|
|
72
|
+
function listPathCompletions(vfs, cwd, prefix) {
|
|
73
|
+
const slashIndex = prefix.lastIndexOf("/");
|
|
74
|
+
const dirPart = slashIndex >= 0 ? prefix.slice(0, slashIndex + 1) : "";
|
|
75
|
+
const namePart = slashIndex >= 0 ? prefix.slice(slashIndex + 1) : prefix;
|
|
76
|
+
const basePath = resolvePath(cwd, dirPart || ".");
|
|
77
|
+
try {
|
|
78
|
+
return vfs
|
|
79
|
+
.list(basePath)
|
|
80
|
+
.filter((e) => !e.startsWith(".") && e.startsWith(namePart))
|
|
81
|
+
.map((e) => {
|
|
82
|
+
const fullPath = path.posix.join(basePath, e);
|
|
83
|
+
const st = vfs.stat(fullPath);
|
|
84
|
+
return `${dirPart}${e}${st.type === "directory" ? "/" : ""}`;
|
|
85
|
+
})
|
|
86
|
+
.sort();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function makeCompleter(getState) {
|
|
93
|
+
const commandNames = Array.from(new Set(getCommandNames())).sort();
|
|
94
|
+
return (line, cb) => {
|
|
95
|
+
const { cwd } = getState();
|
|
96
|
+
// Extract the token under/before cursor (last whitespace-separated word)
|
|
97
|
+
const token = line.split(/\s+/).at(-1) ?? "";
|
|
98
|
+
const isFirstToken = line.trimStart() === token;
|
|
99
|
+
const cmdHits = isFirstToken ? commandNames.filter((n) => n.startsWith(token)) : [];
|
|
100
|
+
const pathHits = listPathCompletions(virtualShell.vfs, cwd, token);
|
|
101
|
+
const hits = Array.from(new Set([...cmdHits, ...pathHits])).sort();
|
|
102
|
+
cb(null, [hits, token]);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// ── Hidden password input ─────────────────────────────────────────────────────
|
|
45
106
|
function askHiddenQuestion(rl, promptText) {
|
|
46
107
|
return new Promise((resolve) => {
|
|
47
108
|
if (!stdin.isTTY || !stdout.isTTY) {
|
|
@@ -52,10 +113,8 @@ function askHiddenQuestion(rl, promptText) {
|
|
|
52
113
|
let buffer = "";
|
|
53
114
|
const cleanup = () => {
|
|
54
115
|
stdin.off("data", onData);
|
|
55
|
-
if (!wasRawMode)
|
|
116
|
+
if (!wasRawMode)
|
|
56
117
|
stdin.setRawMode(false);
|
|
57
|
-
}
|
|
58
|
-
rl.resume();
|
|
59
118
|
};
|
|
60
119
|
const finish = (value) => {
|
|
61
120
|
cleanup();
|
|
@@ -64,8 +123,8 @@ function askHiddenQuestion(rl, promptText) {
|
|
|
64
123
|
};
|
|
65
124
|
const onData = (chunk) => {
|
|
66
125
|
const input = chunk.toString("utf8");
|
|
67
|
-
for (let
|
|
68
|
-
const ch = input[
|
|
126
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
127
|
+
const ch = input[i];
|
|
69
128
|
if (ch === "\r" || ch === "\n") {
|
|
70
129
|
finish(buffer);
|
|
71
130
|
return;
|
|
@@ -74,46 +133,20 @@ function askHiddenQuestion(rl, promptText) {
|
|
|
74
133
|
buffer = buffer.slice(0, -1);
|
|
75
134
|
continue;
|
|
76
135
|
}
|
|
77
|
-
if (ch >= " ")
|
|
136
|
+
if (ch >= " ")
|
|
78
137
|
buffer += ch;
|
|
79
|
-
}
|
|
80
138
|
}
|
|
81
139
|
};
|
|
140
|
+
// Pause readline so it doesn't eat our raw keystrokes
|
|
82
141
|
rl.pause();
|
|
83
142
|
stdout.write(promptText);
|
|
84
|
-
if (!wasRawMode)
|
|
143
|
+
if (!wasRawMode)
|
|
85
144
|
stdin.setRawMode(true);
|
|
86
|
-
}
|
|
87
145
|
stdin.resume();
|
|
88
146
|
stdin.on("data", onData);
|
|
89
147
|
});
|
|
90
148
|
}
|
|
91
|
-
|
|
92
|
-
const dir = "/virtual-env-js/.lastlog";
|
|
93
|
-
if (!virtualShell.vfs.exists(dir)) {
|
|
94
|
-
virtualShell.vfs.mkdir(dir, 0o700);
|
|
95
|
-
}
|
|
96
|
-
virtualShell.vfs.writeFile(`/virtual-env-js/.lastlog/${username}.json`, JSON.stringify({ at: new Date().toISOString(), from }));
|
|
97
|
-
}
|
|
98
|
-
async function flushVfs() {
|
|
99
|
-
await virtualShell.vfs.flushMirror();
|
|
100
|
-
}
|
|
101
|
-
function loadHistory() {
|
|
102
|
-
const historyPath = "/virtual-env-js/.bash_history";
|
|
103
|
-
if (!virtualShell.vfs.exists(historyPath)) {
|
|
104
|
-
virtualShell.vfs.writeFile(historyPath, "");
|
|
105
|
-
return [];
|
|
106
|
-
}
|
|
107
|
-
return virtualShell.vfs
|
|
108
|
-
.readFile(historyPath)
|
|
109
|
-
.split("\n")
|
|
110
|
-
.map((line) => line.trim())
|
|
111
|
-
.filter((line) => line.length > 0);
|
|
112
|
-
}
|
|
113
|
-
function saveHistory(history) {
|
|
114
|
-
const data = history.length > 0 ? `${history.join("\n")}\n` : "";
|
|
115
|
-
virtualShell.vfs.writeFile("/virtual-env-js/.bash_history", data);
|
|
116
|
-
}
|
|
149
|
+
// ── Session state helper ──────────────────────────────────────────────────────
|
|
117
150
|
function applySessionState(authUserState, cwdState, result, shellEnvState) {
|
|
118
151
|
let authUser = authUserState;
|
|
119
152
|
let cwd = cwdState;
|
|
@@ -131,21 +164,16 @@ function applySessionState(authUserState, cwdState, result, shellEnvState) {
|
|
|
131
164
|
}
|
|
132
165
|
return { authUser, cwd };
|
|
133
166
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
167
|
+
// ── Demo command ──────────────────────────────────────────────────────────────
|
|
168
|
+
virtualShell.addCommand("demo", [], () => ({
|
|
169
|
+
stdout: "This is a demo command. It does nothing useful.",
|
|
170
|
+
exitCode: 0,
|
|
171
|
+
}));
|
|
172
|
+
// ── Main shell ────────────────────────────────────────────────────────────────
|
|
140
173
|
async function runReadlineShell() {
|
|
141
|
-
const rl = createInterface({ input: stdin, output: stdout, terminal: true });
|
|
142
174
|
await virtualShell.ensureInitialized();
|
|
143
|
-
let history = loadHistory();
|
|
144
|
-
const rlWithHistory = rl;
|
|
145
|
-
rlWithHistory.history = [...history].reverse();
|
|
146
175
|
const selectedUser = initialUser.trim() || "root";
|
|
147
|
-
|
|
148
|
-
if (!userExists) {
|
|
176
|
+
if (virtualShell.users.getPasswordHash(selectedUser) === null) {
|
|
149
177
|
process.stderr.write(`self-standalone: user '${selectedUser}' does not exist\n`);
|
|
150
178
|
process.exit(1);
|
|
151
179
|
}
|
|
@@ -154,10 +182,19 @@ async function runReadlineShell() {
|
|
|
154
182
|
let cwd = `/home/${authUser}`;
|
|
155
183
|
shellEnv.vars.PWD = cwd;
|
|
156
184
|
const remoteAddress = "localhost";
|
|
157
|
-
const terminalSize = {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
185
|
+
const terminalSize = { cols: stdout.columns ?? 80, rows: stdout.rows ?? 24 };
|
|
186
|
+
let history = loadHistory(authUser);
|
|
187
|
+
// completer reads cwd via closure — always current
|
|
188
|
+
const rl = createInterface({
|
|
189
|
+
input: stdin,
|
|
190
|
+
output: stdout,
|
|
191
|
+
terminal: true,
|
|
192
|
+
completer: makeCompleter(() => ({ cwd })),
|
|
193
|
+
});
|
|
194
|
+
// Sync readline's internal history with our VFS history
|
|
195
|
+
const rlWithHistory = rl;
|
|
196
|
+
rlWithHistory.history = [...history].reverse();
|
|
197
|
+
// ── nano editor ────────────────────────────────────────────────────────────
|
|
161
198
|
async function startNanoEditor(targetPath, initialContent, tempPath) {
|
|
162
199
|
if (virtualShell.vfs.exists(targetPath)) {
|
|
163
200
|
await writeFile(tempPath, initialContent, "utf8");
|
|
@@ -169,20 +206,16 @@ async function runReadlineShell() {
|
|
|
169
206
|
end: () => undefined,
|
|
170
207
|
});
|
|
171
208
|
const wasRawMode = Boolean(stdin.isRaw);
|
|
172
|
-
const forwardInput = (chunk) => {
|
|
173
|
-
editor.stdin.write(chunk);
|
|
174
|
-
};
|
|
209
|
+
const forwardInput = (chunk) => { editor.stdin.write(chunk); };
|
|
175
210
|
stdin.resume();
|
|
176
|
-
if (!wasRawMode)
|
|
211
|
+
if (!wasRawMode)
|
|
177
212
|
stdin.setRawMode(true);
|
|
178
|
-
}
|
|
179
213
|
stdin.on("data", forwardInput);
|
|
180
214
|
await new Promise((resolve) => {
|
|
181
215
|
const cleanup = () => {
|
|
182
216
|
stdin.off("data", forwardInput);
|
|
183
|
-
if (!wasRawMode)
|
|
217
|
+
if (!wasRawMode)
|
|
184
218
|
stdin.setRawMode(false);
|
|
185
|
-
}
|
|
186
219
|
rl.resume();
|
|
187
220
|
};
|
|
188
221
|
editor.on("error", (error) => {
|
|
@@ -199,7 +232,7 @@ async function runReadlineShell() {
|
|
|
199
232
|
await flushVfs();
|
|
200
233
|
}
|
|
201
234
|
catch {
|
|
202
|
-
//
|
|
235
|
+
// save skipped or temp file missing
|
|
203
236
|
}
|
|
204
237
|
await unlink(tempPath).catch(() => undefined);
|
|
205
238
|
stdout.write("\r\n");
|
|
@@ -207,6 +240,7 @@ async function runReadlineShell() {
|
|
|
207
240
|
});
|
|
208
241
|
});
|
|
209
242
|
}
|
|
243
|
+
// ── challenge handlers ─────────────────────────────────────────────────────
|
|
210
244
|
async function handleSudoChallenge(challenge) {
|
|
211
245
|
if (challenge.onPassword) {
|
|
212
246
|
let promptText = challenge.prompt;
|
|
@@ -275,6 +309,7 @@ async function runReadlineShell() {
|
|
|
275
309
|
break;
|
|
276
310
|
}
|
|
277
311
|
}
|
|
312
|
+
// handleCommandResult must be declared before the "line" handler
|
|
278
313
|
async function handleCommandResult(result) {
|
|
279
314
|
if (result.openEditor) {
|
|
280
315
|
await startNanoEditor(result.openEditor.targetPath, result.openEditor.initialContent, result.openEditor.tempPath);
|
|
@@ -288,32 +323,26 @@ async function runReadlineShell() {
|
|
|
288
323
|
await handlePasswordChallenge(result.passwordChallenge);
|
|
289
324
|
return;
|
|
290
325
|
}
|
|
326
|
+
if (result.clearScreen) {
|
|
327
|
+
stdout.write("\u001b[2J\u001b[H");
|
|
328
|
+
console.clear();
|
|
329
|
+
}
|
|
291
330
|
if (result.stdout) {
|
|
292
331
|
stdout.write(result.stdout.endsWith("\n") ? result.stdout : `${result.stdout}\n`);
|
|
293
332
|
}
|
|
294
333
|
if (result.stderr) {
|
|
295
334
|
process.stderr.write(result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`);
|
|
296
335
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
const updatedState = applySessionState(authUser, cwd, result, shellEnv);
|
|
302
|
-
authUser = updatedState.authUser;
|
|
303
|
-
cwd = updatedState.cwd;
|
|
336
|
+
const updated = applySessionState(authUser, cwd, result, shellEnv);
|
|
337
|
+
authUser = updated.authUser;
|
|
338
|
+
cwd = updated.cwd;
|
|
304
339
|
if (result.closeSession) {
|
|
305
340
|
await flushVfs();
|
|
306
341
|
rl.close();
|
|
307
342
|
process.exit(result.exitCode ?? 0);
|
|
308
343
|
}
|
|
309
344
|
}
|
|
310
|
-
|
|
311
|
-
const password = await askHiddenQuestion(rl, `Password for ${authUser}: `);
|
|
312
|
-
if (!virtualShell.users.verifyPassword(authUser, password)) {
|
|
313
|
-
process.stderr.write("self-standalone: authentication failed\n");
|
|
314
|
-
process.exit(1);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
345
|
+
// ── Prompt helper ──────────────────────────────────────────────────────────
|
|
317
346
|
const renderPrompt = () => {
|
|
318
347
|
const cwdLabel = cwd === `/home/${authUser}` ? "~" : basename(cwd) || "/";
|
|
319
348
|
return buildPrompt(authUser, hostname, cwdLabel);
|
|
@@ -322,50 +351,68 @@ async function runReadlineShell() {
|
|
|
322
351
|
rl.setPrompt(renderPrompt());
|
|
323
352
|
rl.prompt();
|
|
324
353
|
};
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
process.exit(0);
|
|
335
|
-
})();
|
|
336
|
-
});
|
|
354
|
+
// ── Auth (password gate) ───────────────────────────────────────────────────
|
|
355
|
+
if (process.env.USER !== "root" && virtualShell.users.hasPassword(authUser)) {
|
|
356
|
+
const password = await askHiddenQuestion(rl, `Password for ${authUser}: `);
|
|
357
|
+
if (!virtualShell.users.verifyPassword(authUser, password)) {
|
|
358
|
+
process.stderr.write("self-standalone: authentication failed\n");
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// ── Login banner ───────────────────────────────────────────────────────────
|
|
337
363
|
stdout.write(buildLoginBanner(hostname, virtualShell.properties, readLastLogin(authUser)));
|
|
338
364
|
writeLastLogin(authUser, remoteAddress);
|
|
339
365
|
await flushVfs();
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
366
|
+
// ── Event-driven line handler (enables completer) ──────────────────────────
|
|
367
|
+
//
|
|
368
|
+
// Key insight: readline's completer only fires when readline itself owns
|
|
369
|
+
// stdin (i.e. rl is not paused). We use the event-driven "line" pattern
|
|
370
|
+
// instead of a while(true)+rl.once("line") loop so readline stays active
|
|
371
|
+
// between commands. We pause only while awaiting async work, then resume
|
|
372
|
+
// immediately before re-prompting so the next Tab press is caught.
|
|
373
|
+
let busy = false;
|
|
374
|
+
rl.on("line", async (inputLine) => {
|
|
375
|
+
if (busy)
|
|
376
|
+
return; // shouldn't happen but guard re-entrancy
|
|
377
|
+
busy = true;
|
|
345
378
|
rl.pause();
|
|
346
|
-
|
|
379
|
+
const trimmed = inputLine.trim();
|
|
380
|
+
if (trimmed.length > 0) {
|
|
347
381
|
history.push(inputLine);
|
|
348
|
-
if (history.length > 500)
|
|
382
|
+
if (history.length > 500)
|
|
349
383
|
history = history.slice(history.length - 500);
|
|
350
|
-
|
|
351
|
-
saveHistory(history);
|
|
384
|
+
saveHistory(history, authUser);
|
|
352
385
|
rlWithHistory.history = [...history].reverse();
|
|
353
386
|
}
|
|
354
387
|
const result = await runCommand(inputLine, authUser, hostname, "shell", cwd, virtualShell, undefined, shellEnv);
|
|
355
388
|
await handleCommandResult(result);
|
|
356
389
|
await flushVfs();
|
|
357
|
-
|
|
390
|
+
busy = false;
|
|
391
|
+
// Resume before prompt so readline can handle Tab on the next input
|
|
358
392
|
rl.resume();
|
|
359
|
-
|
|
393
|
+
prompt();
|
|
394
|
+
});
|
|
395
|
+
rl.on("SIGINT", () => {
|
|
396
|
+
stdout.write("^C\n");
|
|
397
|
+
rl.write("", { ctrl: true, name: "u" });
|
|
398
|
+
prompt();
|
|
399
|
+
});
|
|
400
|
+
rl.on("close", () => {
|
|
401
|
+
void flushVfs().then(() => {
|
|
402
|
+
console.log("");
|
|
403
|
+
process.exit(0);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
// Initial prompt — readline is already active, completer live from first keystroke
|
|
407
|
+
prompt();
|
|
360
408
|
}
|
|
361
409
|
runReadlineShell().catch((error) => {
|
|
362
410
|
console.error("Failed to start readline SSH emulation:", error);
|
|
363
411
|
process.exit(1);
|
|
364
412
|
});
|
|
365
413
|
process.on("uncaughtException", (error) => {
|
|
366
|
-
console.
|
|
414
|
+
console.error("Uncaught exception:", error);
|
|
367
415
|
});
|
|
368
416
|
process.on("unhandledRejection", (error, promise) => {
|
|
369
|
-
console.
|
|
370
|
-
console.log(" The error was: ", error);
|
|
417
|
+
console.error("Unhandled rejection at:", promise, "error:", error);
|
|
371
418
|
});
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typescript-virtual-container",
|
|
3
|
-
"description": "
|
|
3
|
+
"description": "Scalable Linux emulator with included SSH/SFTP server, virtual filesystem and typed programmatic API for testing, automation, and interactive shell scripting in TypeScript/JavaScript.",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"version": "1.4.
|
|
7
|
+
"version": "1.4.2",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
@@ -556,7 +556,35 @@ const PACKAGE_REGISTRY: PackageDefinition[] = [
|
|
|
556
556
|
mode: 0o755,
|
|
557
557
|
},
|
|
558
558
|
],
|
|
559
|
-
},
|
|
559
|
+
},{
|
|
560
|
+
name: "gzip",
|
|
561
|
+
version: "1.12-2",
|
|
562
|
+
section: "utils",
|
|
563
|
+
description: "GNU compression utility",
|
|
564
|
+
shortDesc: "compression utility",
|
|
565
|
+
installedSizeKb: 128,
|
|
566
|
+
files: [
|
|
567
|
+
{
|
|
568
|
+
path: "/usr/bin/gzip",
|
|
569
|
+
content: "#!/bin/sh\necho 'gzip: virtual stub'\n",
|
|
570
|
+
mode: 0o755,
|
|
571
|
+
},
|
|
572
|
+
]
|
|
573
|
+
}, {
|
|
574
|
+
name: "neofetch",
|
|
575
|
+
version: "7.1.0-1",
|
|
576
|
+
section: "utils",
|
|
577
|
+
description: "A command-line system information tool written in bash 3.2+",
|
|
578
|
+
shortDesc: "command-line system information tool",
|
|
579
|
+
installedSizeKb: 256,
|
|
580
|
+
files: [
|
|
581
|
+
{
|
|
582
|
+
path: "/usr/bin/neofetch",
|
|
583
|
+
content: "#!/bin/sh\necho 'neofetch: virtual stub'\n",
|
|
584
|
+
mode: 0o755,
|
|
585
|
+
},
|
|
586
|
+
],
|
|
587
|
+
}
|
|
560
588
|
];
|
|
561
589
|
|
|
562
590
|
/**
|
|
@@ -52,7 +52,7 @@ export function startShell(
|
|
|
52
52
|
): void {
|
|
53
53
|
let lineBuffer = "";
|
|
54
54
|
let cursorPos = 0;
|
|
55
|
-
let history = loadHistory(shell.vfs);
|
|
55
|
+
let history = loadHistory(shell.vfs, authUser);
|
|
56
56
|
let historyIndex: number | null = null;
|
|
57
57
|
let historyDraft = "";
|
|
58
58
|
let cwd = `/home/${authUser}`;
|
|
@@ -388,11 +388,11 @@ export function startShell(
|
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
const data = history.length > 0 ? `${history.join("\n")}\n` : "";
|
|
391
|
-
shell.vfs.writeFile(
|
|
391
|
+
shell.vfs.writeFile(`/home/${authUser}/.bash_history`, data);
|
|
392
392
|
}
|
|
393
393
|
|
|
394
394
|
function readLastLogin(): { at: string; from: string } | null {
|
|
395
|
-
const lastlogPath = `/
|
|
395
|
+
const lastlogPath = `/home/${authUser}/.lastlog.json`;
|
|
396
396
|
if (!shell.vfs.exists(lastlogPath)) {
|
|
397
397
|
return null;
|
|
398
398
|
}
|
|
@@ -408,12 +408,7 @@ export function startShell(
|
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
function writeLastLogin(nowIso: string): void {
|
|
411
|
-
const
|
|
412
|
-
if (!shell.vfs.exists(dir)) {
|
|
413
|
-
shell.vfs.mkdir(dir, 0o700);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const lastlogPath = `${dir}/${authUser}.json`;
|
|
411
|
+
const lastlogPath = `/home/${authUser}/.lastlog`;
|
|
417
412
|
shell.vfs.writeFile(
|
|
418
413
|
lastlogPath,
|
|
419
414
|
JSON.stringify({ at: nowIso, from: remoteAddress }),
|
|
@@ -690,8 +685,8 @@ export function startShell(
|
|
|
690
685
|
});
|
|
691
686
|
}
|
|
692
687
|
|
|
693
|
-
function loadHistory(vfs: VirtualFileSystem): string[] {
|
|
694
|
-
const historyPath =
|
|
688
|
+
function loadHistory(vfs: VirtualFileSystem, authUser: string): string[] {
|
|
689
|
+
const historyPath = `/home/${authUser}/.bash_history`;
|
|
695
690
|
if (!vfs.exists(historyPath)) {
|
|
696
691
|
vfs.writeFile(historyPath, "");
|
|
697
692
|
return [];
|
|
@@ -47,10 +47,10 @@ const perf: PerfLogger = createPerfLogger("VirtualUserManager");
|
|
|
47
47
|
export class VirtualUserManager extends EventEmitter {
|
|
48
48
|
private static readonly recordCache = new Map<string, VirtualUserRecord>();
|
|
49
49
|
private static readonly fastPasswordHash = resolveFastPasswordHash();
|
|
50
|
-
private readonly usersPath = "/
|
|
51
|
-
private readonly sudoersPath = "/
|
|
52
|
-
private readonly quotasPath = "/
|
|
53
|
-
private readonly authDirPath = "
|
|
50
|
+
private readonly usersPath = "/etc/htpasswd";
|
|
51
|
+
private readonly sudoersPath = "/etc/sudoers";
|
|
52
|
+
private readonly quotasPath = "/etc/quotas";
|
|
53
|
+
private readonly authDirPath = "/.virtual-env-js/.auth";
|
|
54
54
|
private readonly users = new Map<string, VirtualUserRecord>();
|
|
55
55
|
private readonly sudoers = new Set<string>();
|
|
56
56
|
private readonly quotas = new Map<string, number>();
|
package/src/commands/curl.ts
CHANGED
|
@@ -97,7 +97,8 @@ export const curlCommand: ShellModule = {
|
|
|
97
97
|
|
|
98
98
|
let response: Response;
|
|
99
99
|
try {
|
|
100
|
-
|
|
100
|
+
const urlWithHttp = url.startsWith("http://") || url.startsWith("https://") ? url : `http://${url}`;
|
|
101
|
+
response = await fetch(urlWithHttp, fetchOpts);
|
|
101
102
|
} catch (err) {
|
|
102
103
|
const msg = err instanceof Error ? err.message : String(err);
|
|
103
104
|
return {
|
package/src/commands/gzip.ts
CHANGED
|
@@ -11,6 +11,13 @@ export const gzipCommand: ShellModule = {
|
|
|
11
11
|
category: "archive",
|
|
12
12
|
params: ["[-k] [-d] <file>"],
|
|
13
13
|
run: ({ shell, cwd, args }) => {
|
|
14
|
+
if (!shell.packageManager.isInstalled("gzip")) {
|
|
15
|
+
return {
|
|
16
|
+
stderr:
|
|
17
|
+
"bash: gzip: command not found\nHint: install it with: apt install gzip\n",
|
|
18
|
+
exitCode: 127,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
14
21
|
const keepOrig = args.includes("-k") || args.includes("--keep");
|
|
15
22
|
const decompress = args.includes("-d");
|
|
16
23
|
const file = args.find((a) => !a.startsWith("-"));
|
package/src/commands/helpers.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type VirtualFileSystem from "../VirtualFileSystem";
|
|
|
4
4
|
import type { VirtualPackageManager } from "../VirtualPackageManager";
|
|
5
5
|
import type { VirtualShell } from "../VirtualShell";
|
|
6
6
|
|
|
7
|
-
const PROTECTED_PREFIXES = ["
|
|
7
|
+
const PROTECTED_PREFIXES = ["/.virtual-env-js/.auth", "/etc/htpasswd"] as const;
|
|
8
8
|
|
|
9
9
|
function normalizeFetchUrl(input: string): string {
|
|
10
10
|
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input)) {
|
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
|
}
|