typescript-virtual-container 1.1.1 → 1.1.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/README.md +1 -1
- package/dist/SSHClient/index.d.ts +138 -0
- package/dist/SSHClient/index.d.ts.map +1 -0
- package/dist/SSHClient/index.js +216 -0
- package/dist/SSHMimic/exec.d.ts +4 -0
- package/dist/SSHMimic/exec.d.ts.map +1 -0
- package/dist/SSHMimic/exec.js +21 -0
- package/dist/SSHMimic/executor.d.ts +9 -0
- package/dist/SSHMimic/executor.d.ts.map +1 -0
- package/dist/SSHMimic/executor.js +131 -0
- package/dist/SSHMimic/hostKey.d.ts +2 -0
- package/dist/SSHMimic/hostKey.d.ts.map +1 -0
- package/dist/SSHMimic/hostKey.js +17 -0
- package/dist/SSHMimic/index.d.ts +39 -0
- package/dist/SSHMimic/index.d.ts.map +1 -0
- package/dist/SSHMimic/index.js +113 -0
- package/dist/SSHMimic/loginFormat.d.ts +2 -0
- package/dist/SSHMimic/loginFormat.d.ts.map +1 -0
- package/dist/SSHMimic/loginFormat.js +10 -0
- package/dist/SSHMimic/prompt.d.ts +2 -0
- package/dist/SSHMimic/prompt.d.ts.map +1 -0
- package/dist/SSHMimic/prompt.js +9 -0
- package/dist/VirtualFileSystem/archive.d.ts +5 -0
- package/dist/VirtualFileSystem/archive.d.ts.map +1 -0
- package/dist/VirtualFileSystem/archive.js +56 -0
- package/dist/VirtualFileSystem/index.d.ts +131 -0
- package/dist/VirtualFileSystem/index.d.ts.map +1 -0
- package/dist/VirtualFileSystem/index.js +355 -0
- package/dist/VirtualFileSystem/internalTypes.d.ts +18 -0
- package/dist/VirtualFileSystem/internalTypes.d.ts.map +1 -0
- package/dist/VirtualFileSystem/internalTypes.js +0 -0
- package/dist/VirtualFileSystem/path.d.ts +9 -0
- package/dist/VirtualFileSystem/path.d.ts.map +1 -0
- package/dist/VirtualFileSystem/path.js +49 -0
- package/dist/VirtualFileSystem/snapshot.d.ts +5 -0
- package/dist/VirtualFileSystem/snapshot.d.ts.map +1 -0
- package/dist/VirtualFileSystem/snapshot.js +59 -0
- package/dist/VirtualFileSystem/tree.d.ts +3 -0
- package/dist/VirtualFileSystem/tree.d.ts.map +1 -0
- package/dist/VirtualFileSystem/tree.js +19 -0
- package/dist/VirtualShell/index.d.ts +86 -0
- package/dist/VirtualShell/index.d.ts.map +1 -0
- package/dist/VirtualShell/index.js +129 -0
- package/dist/VirtualShell/shell.d.ts +5 -0
- package/dist/VirtualShell/shell.d.ts.map +1 -0
- package/dist/VirtualShell/shell.js +473 -0
- package/dist/VirtualShell/shellParser.d.ts +4 -0
- package/dist/VirtualShell/shellParser.d.ts.map +1 -0
- package/dist/VirtualShell/shellParser.js +207 -0
- package/dist/VirtualUserManager/index.d.ts +168 -0
- package/dist/VirtualUserManager/index.d.ts.map +1 -0
- package/dist/VirtualUserManager/index.js +375 -0
- package/dist/commands/adduser.d.ts +3 -0
- package/dist/commands/adduser.d.ts.map +1 -0
- package/dist/commands/adduser.js +18 -0
- package/dist/commands/cat.d.ts +3 -0
- package/dist/commands/cat.d.ts.map +1 -0
- package/dist/commands/cat.js +15 -0
- package/dist/commands/cd.d.ts +3 -0
- package/dist/commands/cd.d.ts.map +1 -0
- package/dist/commands/cd.js +17 -0
- package/dist/commands/clear.d.ts +3 -0
- package/dist/commands/clear.d.ts.map +1 -0
- package/dist/commands/clear.js +5 -0
- package/dist/commands/command-helpers.d.ts +23 -0
- package/dist/commands/command-helpers.d.ts.map +1 -0
- package/dist/commands/command-helpers.js +139 -0
- package/dist/commands/curl.d.ts +3 -0
- package/dist/commands/curl.d.ts.map +1 -0
- package/dist/commands/curl.js +44 -0
- package/dist/commands/deluser.d.ts +3 -0
- package/dist/commands/deluser.d.ts.map +1 -0
- package/dist/commands/deluser.js +15 -0
- package/dist/commands/echo.d.ts +3 -0
- package/dist/commands/echo.d.ts.map +1 -0
- package/dist/commands/echo.js +22 -0
- package/dist/commands/env.d.ts +3 -0
- package/dist/commands/env.d.ts.map +1 -0
- package/dist/commands/env.js +18 -0
- package/dist/commands/exit.d.ts +3 -0
- package/dist/commands/exit.d.ts.map +1 -0
- package/dist/commands/exit.js +5 -0
- package/dist/commands/export.d.ts +3 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +34 -0
- package/dist/commands/grep.d.ts +3 -0
- package/dist/commands/grep.d.ts.map +1 -0
- package/dist/commands/grep.js +69 -0
- package/dist/commands/help.d.ts +3 -0
- package/dist/commands/help.d.ts.map +1 -0
- package/dist/commands/help.js +7 -0
- package/dist/commands/helpers.d.ts +26 -0
- package/dist/commands/helpers.d.ts.map +1 -0
- package/dist/commands/helpers.js +160 -0
- package/dist/commands/hostname.d.ts +3 -0
- package/dist/commands/hostname.d.ts.map +1 -0
- package/dist/commands/hostname.js +5 -0
- package/dist/commands/htop.d.ts +3 -0
- package/dist/commands/htop.d.ts.map +1 -0
- package/dist/commands/htop.js +10 -0
- package/dist/commands/index.d.ts +8 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +212 -0
- package/dist/commands/ls.d.ts +3 -0
- package/dist/commands/ls.d.ts.map +1 -0
- package/dist/commands/ls.js +47 -0
- package/dist/commands/mkdir.d.ts +3 -0
- package/dist/commands/mkdir.d.ts.map +1 -0
- package/dist/commands/mkdir.js +21 -0
- package/dist/commands/nano.d.ts +3 -0
- package/dist/commands/nano.d.ts.map +1 -0
- package/dist/commands/nano.js +27 -0
- package/dist/commands/neofetch.d.ts +3 -0
- package/dist/commands/neofetch.d.ts.map +1 -0
- package/dist/commands/neofetch.js +32 -0
- package/dist/commands/pwd.d.ts +3 -0
- package/dist/commands/pwd.d.ts.map +1 -0
- package/dist/commands/pwd.js +5 -0
- package/dist/commands/rm.d.ts +3 -0
- package/dist/commands/rm.d.ts.map +1 -0
- package/dist/commands/rm.js +29 -0
- package/dist/commands/set.d.ts +7 -0
- package/dist/commands/set.d.ts.map +1 -0
- package/dist/commands/set.js +64 -0
- package/dist/commands/sh.d.ts +4 -0
- package/dist/commands/sh.d.ts.map +1 -0
- package/dist/commands/sh.js +45 -0
- package/dist/commands/su.d.ts +3 -0
- package/dist/commands/su.d.ts.map +1 -0
- package/dist/commands/su.js +24 -0
- package/dist/commands/sudo.d.ts +3 -0
- package/dist/commands/sudo.d.ts.map +1 -0
- package/dist/commands/sudo.js +47 -0
- package/dist/commands/touch.d.ts +3 -0
- package/dist/commands/touch.d.ts.map +1 -0
- package/dist/commands/touch.js +18 -0
- package/dist/commands/tree.d.ts +3 -0
- package/dist/commands/tree.d.ts.map +1 -0
- package/dist/commands/tree.js +11 -0
- package/dist/commands/unset.d.ts +3 -0
- package/dist/commands/unset.d.ts.map +1 -0
- package/dist/commands/unset.js +15 -0
- package/dist/commands/wget.d.ts +3 -0
- package/dist/commands/wget.d.ts.map +1 -0
- package/dist/commands/wget.js +113 -0
- package/dist/commands/who.d.ts +3 -0
- package/dist/commands/who.d.ts.map +1 -0
- package/dist/commands/who.js +15 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +5 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/modules/neofetch.d.ts +19 -0
- package/dist/modules/neofetch.d.ts.map +1 -0
- package/dist/modules/neofetch.js +284 -0
- package/dist/modules/shellInteractive.d.ts +6 -0
- package/dist/modules/shellInteractive.d.ts.map +1 -0
- package/dist/modules/shellInteractive.js +26 -0
- package/dist/modules/shellRuntime.d.ts +11 -0
- package/dist/modules/shellRuntime.d.ts.map +1 -0
- package/dist/modules/shellRuntime.js +52 -0
- package/dist/standalone.d.ts +2 -0
- package/dist/standalone.d.ts.map +1 -0
- package/dist/standalone.js +25 -0
- package/dist/types/commands.d.ts +89 -0
- package/dist/types/commands.d.ts.map +1 -0
- package/dist/types/commands.js +0 -0
- package/dist/types/pipeline.d.ts +23 -0
- package/dist/types/pipeline.d.ts.map +1 -0
- package/dist/types/pipeline.js +0 -0
- package/dist/types/streams.d.ts +32 -0
- package/dist/types/streams.d.ts.map +1 -0
- package/dist/types/streams.js +0 -0
- package/dist/types/vfs.d.ts +71 -0
- package/dist/types/vfs.d.ts.map +1 -0
- package/dist/types/vfs.js +0 -0
- package/package.json +4 -2
- package/src/VirtualShell/shell.ts +3 -3
- package/src/commands/neofetch.ts +1 -1
- package/{modules → src/modules}/neofetch.ts +56 -51
- package/{modules → src/modules}/shellInteractive.ts +16 -4
- package/tsconfig.json +20 -8
- /package/{modules → src/modules}/shellRuntime.ts +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createCustomCommand, registerCommand, runCommand } from "../commands";
|
|
3
|
+
import VirtualFileSystem from "../VirtualFileSystem";
|
|
4
|
+
import { VirtualUserManager } from "../VirtualUserManager";
|
|
5
|
+
import { startShell } from "./shell";
|
|
6
|
+
const defaultShellProperties = {
|
|
7
|
+
kernel: "1.0.0+itsrealfortune+1-amd64",
|
|
8
|
+
os: "Fortune GNU/Linux x64",
|
|
9
|
+
arch: "x86_64",
|
|
10
|
+
};
|
|
11
|
+
function resolveRootPassword() {
|
|
12
|
+
const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
|
|
13
|
+
if (configured && configured.trim().length > 0) {
|
|
14
|
+
return configured;
|
|
15
|
+
}
|
|
16
|
+
const generated = randomBytes(18).toString("base64url");
|
|
17
|
+
console.warn(`[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`);
|
|
18
|
+
return generated;
|
|
19
|
+
}
|
|
20
|
+
function resolveAutoSudoForNewUsers() {
|
|
21
|
+
const configured = process.env.SSH_MIMIC_AUTO_SUDO_NEW_USERS;
|
|
22
|
+
if (!configured) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
return !["0", "false", "no", "off"].includes(configured.toLowerCase());
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Coordinates the virtual filesystem, user manager, and command runtime.
|
|
29
|
+
*
|
|
30
|
+
* Instances are used both by the SSH server facade and by the programmatic
|
|
31
|
+
* client API.
|
|
32
|
+
*/
|
|
33
|
+
class VirtualShell {
|
|
34
|
+
basePath = ".";
|
|
35
|
+
vfs;
|
|
36
|
+
users;
|
|
37
|
+
hostname;
|
|
38
|
+
properties;
|
|
39
|
+
/**
|
|
40
|
+
* Creates a new virtual shell instance.
|
|
41
|
+
*
|
|
42
|
+
* @param hostname Virtual hostname used for prompts and idents.
|
|
43
|
+
* @param properties Customizable properties shown in `uname -a` and similar commands.
|
|
44
|
+
* @param basePath Optional base path for the virtual filesystem (defaults to process.cwd()).
|
|
45
|
+
*/
|
|
46
|
+
constructor(hostname, properties, basePath) {
|
|
47
|
+
this.hostname = hostname;
|
|
48
|
+
this.properties = properties || defaultShellProperties;
|
|
49
|
+
this.basePath = basePath || ".";
|
|
50
|
+
this.vfs = new VirtualFileSystem(this.basePath);
|
|
51
|
+
this.users = new VirtualUserManager(this.vfs, resolveRootPassword(), resolveAutoSudoForNewUsers());
|
|
52
|
+
this.vfs.restoreMirror().then(() => {
|
|
53
|
+
this.users = new VirtualUserManager(this.vfs, resolveRootPassword(), resolveAutoSudoForNewUsers());
|
|
54
|
+
this.users.initialize();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Registers a new command in the shell runtime.
|
|
59
|
+
*
|
|
60
|
+
* @param name Case-insensitive command name (no spaces).
|
|
61
|
+
* @param params List of parameter names for help text (no validation).
|
|
62
|
+
* @param callback Function invoked with command context on execution.
|
|
63
|
+
*/
|
|
64
|
+
addCommand(name, params, callback) {
|
|
65
|
+
const normalized = name.trim().toLowerCase();
|
|
66
|
+
if (normalized.length === 0 || /\s/.test(normalized)) {
|
|
67
|
+
throw new Error("Command name must be non-empty and contain no spaces");
|
|
68
|
+
}
|
|
69
|
+
registerCommand(createCustomCommand(normalized, params, callback));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Executes a command line string in the context of this shell instance.
|
|
73
|
+
*
|
|
74
|
+
* @param rawInput
|
|
75
|
+
* @param authUser
|
|
76
|
+
* @param cwd
|
|
77
|
+
*/
|
|
78
|
+
executeCommand(rawInput, authUser, cwd) {
|
|
79
|
+
runCommand(rawInput, authUser, this.hostname, "shell", cwd, this);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Starts an interactive session with the shell.
|
|
83
|
+
*
|
|
84
|
+
* @param stream The stream for the interactive session.
|
|
85
|
+
* @param authUser The authenticated user for the session.
|
|
86
|
+
* @param sessionId The ID of the session.
|
|
87
|
+
* @param remoteAddress The address of the remote client.
|
|
88
|
+
*/
|
|
89
|
+
startInteractiveSession(stream, authUser, sessionId, remoteAddress, terminalSize) {
|
|
90
|
+
// Interactive shell logic
|
|
91
|
+
startShell(this.properties, stream, authUser, this.hostname, sessionId, remoteAddress, terminalSize, this);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Returns virtual filesystem instance after server started.
|
|
95
|
+
*
|
|
96
|
+
* @returns VirtualFileSystem or null when not started.
|
|
97
|
+
*/
|
|
98
|
+
getVfs() {
|
|
99
|
+
return this?.vfs ?? null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Returns user manager instance after server started.
|
|
103
|
+
*
|
|
104
|
+
* @returns VirtualUserManager or null when not started.
|
|
105
|
+
*/
|
|
106
|
+
getUsers() {
|
|
107
|
+
return this?.users ?? null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Returns hostname shown in prompts and idents.
|
|
111
|
+
*
|
|
112
|
+
* @returns Configured hostname label.
|
|
113
|
+
*/
|
|
114
|
+
getHostname() {
|
|
115
|
+
return this?.hostname;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Writes a file on behalf of a user with quota enforcement.
|
|
119
|
+
*
|
|
120
|
+
* @param authUser User performing the write.
|
|
121
|
+
* @param targetPath Destination path.
|
|
122
|
+
* @param content File content.
|
|
123
|
+
*/
|
|
124
|
+
writeFileAsUser(authUser, targetPath, content) {
|
|
125
|
+
this.users.assertWriteWithinQuota(authUser, targetPath, content);
|
|
126
|
+
this.vfs.writeFile(targetPath, content);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export { VirtualShell };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ShellProperties, VirtualShell } from ".";
|
|
2
|
+
import { type TerminalSize } from "../modules/shellRuntime";
|
|
3
|
+
import type { ShellStream } from "../types/streams";
|
|
4
|
+
export declare function startShell(properties: ShellProperties, stream: ShellStream, authUser: string, hostname: string, sessionId: string | null, remoteAddress: string | undefined, terminalSize: TerminalSize | undefined, shell: VirtualShell): void;
|
|
5
|
+
//# sourceMappingURL=shell.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../../src/VirtualShell/shell.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,GAAG,CAAC;AAMvD,OAAO,EAGN,KAAK,YAAY,EAEjB,MAAM,yBAAyB,CAAC;AAGjC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAmBpD,wBAAgB,UAAU,CACzB,UAAU,EAAE,eAAe,EAC3B,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,aAAa,oBAAY,EACzB,YAAY,EAAE,YAAY,YAAyB,EACnD,KAAK,EAAE,YAAY,GACjB,IAAI,CAulBN"}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getCommandNames, runCommand } from "../commands";
|
|
4
|
+
import { spawnHtopProcess, spawnNanoEditorProcess, } from "../modules/shellInteractive";
|
|
5
|
+
import { getVisibleHtopPidList, resolvePath, toTtyLines, } from "../modules/shellRuntime";
|
|
6
|
+
import { formatLoginDate } from "../SSHMimic/loginFormat";
|
|
7
|
+
import { buildPrompt } from "../SSHMimic/prompt";
|
|
8
|
+
export function startShell(properties, stream, authUser, hostname, sessionId, remoteAddress = "unknown", terminalSize = { cols: 80, rows: 24 }, shell) {
|
|
9
|
+
let lineBuffer = "";
|
|
10
|
+
let cursorPos = 0;
|
|
11
|
+
let history = loadHistory(shell.vfs);
|
|
12
|
+
let historyIndex = null;
|
|
13
|
+
let historyDraft = "";
|
|
14
|
+
let cwd = `/home/${authUser}`;
|
|
15
|
+
let nanoSession = null;
|
|
16
|
+
let pendingSudo = null;
|
|
17
|
+
const buildCurrentPrompt = () => {
|
|
18
|
+
const homePath = `/home/${authUser}`;
|
|
19
|
+
const cwdLabel = cwd === homePath ? "~" : path.posix.basename(cwd) || "/";
|
|
20
|
+
return buildPrompt(authUser, hostname, cwdLabel);
|
|
21
|
+
};
|
|
22
|
+
const commandNames = Array.from(new Set(getCommandNames())).sort();
|
|
23
|
+
console.log(`[${sessionId}] Shell started for user '${authUser}' at ${remoteAddress}`);
|
|
24
|
+
function renderLine() {
|
|
25
|
+
const prompt = buildCurrentPrompt();
|
|
26
|
+
stream.write(`\r${prompt}${lineBuffer}\u001b[K`);
|
|
27
|
+
const moveLeft = lineBuffer.length - cursorPos;
|
|
28
|
+
if (moveLeft > 0) {
|
|
29
|
+
stream.write(`\u001b[${moveLeft}D`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function clearCurrentLine() {
|
|
33
|
+
stream.write("\r\u001b[K");
|
|
34
|
+
}
|
|
35
|
+
function startSudoPrompt(challenge) {
|
|
36
|
+
pendingSudo = {
|
|
37
|
+
...challenge,
|
|
38
|
+
buffer: "",
|
|
39
|
+
};
|
|
40
|
+
clearCurrentLine();
|
|
41
|
+
stream.write(challenge.prompt);
|
|
42
|
+
}
|
|
43
|
+
async function finishSudoPrompt(success) {
|
|
44
|
+
if (!pendingSudo) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const challenge = pendingSudo;
|
|
48
|
+
pendingSudo = null;
|
|
49
|
+
if (!success) {
|
|
50
|
+
stream.write("\r\nSorry, try again.\r\n");
|
|
51
|
+
renderLine();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!challenge.commandLine) {
|
|
55
|
+
authUser = challenge.targetUser;
|
|
56
|
+
cwd = `/home/${authUser}`;
|
|
57
|
+
shell.users.updateSession(sessionId, authUser, remoteAddress);
|
|
58
|
+
stream.write("\r\n");
|
|
59
|
+
renderLine();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const runCwd = challenge.loginShell ? `/home/${challenge.targetUser}` : cwd;
|
|
63
|
+
const result = await Promise.resolve(runCommand(challenge.commandLine, challenge.targetUser, hostname, "shell", runCwd, shell));
|
|
64
|
+
stream.write("\r\n");
|
|
65
|
+
if (result.openEditor) {
|
|
66
|
+
await startNanoEditor(result.openEditor.targetPath, result.openEditor.initialContent, result.openEditor.tempPath);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (result.openHtop) {
|
|
70
|
+
await startHtop();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (result.clearScreen) {
|
|
74
|
+
stream.write("\u001b[2J\u001b[H");
|
|
75
|
+
}
|
|
76
|
+
if (result.stdout) {
|
|
77
|
+
stream.write(`${toTtyLines(result.stdout)}\r\n`);
|
|
78
|
+
}
|
|
79
|
+
if (result.stderr) {
|
|
80
|
+
stream.write(`${toTtyLines(result.stderr)}\r\n`);
|
|
81
|
+
}
|
|
82
|
+
if (result.switchUser) {
|
|
83
|
+
authUser = result.switchUser;
|
|
84
|
+
cwd = result.nextCwd ?? `/home/${authUser}`;
|
|
85
|
+
shell.users.updateSession(sessionId, authUser, remoteAddress);
|
|
86
|
+
}
|
|
87
|
+
else if (result.nextCwd) {
|
|
88
|
+
cwd = result.nextCwd;
|
|
89
|
+
}
|
|
90
|
+
await shell.vfs.flushMirror();
|
|
91
|
+
renderLine();
|
|
92
|
+
}
|
|
93
|
+
async function finishNanoEditor() {
|
|
94
|
+
if (!nanoSession) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const activeSession = nanoSession;
|
|
98
|
+
if (activeSession.kind === "nano") {
|
|
99
|
+
try {
|
|
100
|
+
const updatedContent = await readFile(activeSession.tempPath, "utf8");
|
|
101
|
+
shell.writeFileAsUser(authUser, activeSession.targetPath, updatedContent);
|
|
102
|
+
await shell.vfs.flushMirror();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// If temp file does not exist, nano exited without writing.
|
|
106
|
+
}
|
|
107
|
+
await unlink(activeSession.tempPath).catch(() => undefined);
|
|
108
|
+
}
|
|
109
|
+
nanoSession = null;
|
|
110
|
+
lineBuffer = "";
|
|
111
|
+
cursorPos = 0;
|
|
112
|
+
stream.write("\r\n");
|
|
113
|
+
renderLine();
|
|
114
|
+
}
|
|
115
|
+
async function startNanoEditor(targetPath, initialContent, tempPath) {
|
|
116
|
+
if (shell.vfs.exists(targetPath)) {
|
|
117
|
+
await writeFile(tempPath, initialContent, "utf8");
|
|
118
|
+
}
|
|
119
|
+
const editor = spawnNanoEditorProcess(tempPath, terminalSize, stream);
|
|
120
|
+
editor.on("error", (error) => {
|
|
121
|
+
stream.write(`nano: ${error.message}\r\n`);
|
|
122
|
+
void finishNanoEditor();
|
|
123
|
+
});
|
|
124
|
+
editor.on("close", () => {
|
|
125
|
+
void finishNanoEditor();
|
|
126
|
+
});
|
|
127
|
+
nanoSession = {
|
|
128
|
+
kind: "nano",
|
|
129
|
+
targetPath,
|
|
130
|
+
tempPath,
|
|
131
|
+
process: editor,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function startHtop() {
|
|
135
|
+
const pidList = await getVisibleHtopPidList();
|
|
136
|
+
if (!pidList) {
|
|
137
|
+
stream.write("htop: no child_process processes to display\r\n");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const monitor = spawnHtopProcess(pidList, terminalSize, stream);
|
|
141
|
+
monitor.on("error", (error) => {
|
|
142
|
+
stream.write(`htop: ${error.message}\r\n`);
|
|
143
|
+
void finishNanoEditor();
|
|
144
|
+
});
|
|
145
|
+
monitor.on("close", () => {
|
|
146
|
+
void finishNanoEditor();
|
|
147
|
+
});
|
|
148
|
+
nanoSession = {
|
|
149
|
+
kind: "htop",
|
|
150
|
+
targetPath: "",
|
|
151
|
+
tempPath: "",
|
|
152
|
+
process: monitor,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function applyHistoryLine(nextLine) {
|
|
156
|
+
lineBuffer = nextLine;
|
|
157
|
+
cursorPos = lineBuffer.length;
|
|
158
|
+
renderLine();
|
|
159
|
+
}
|
|
160
|
+
function insertText(text) {
|
|
161
|
+
lineBuffer = `${lineBuffer.slice(0, cursorPos)}${text}${lineBuffer.slice(cursorPos)}`;
|
|
162
|
+
cursorPos += text.length;
|
|
163
|
+
renderLine();
|
|
164
|
+
}
|
|
165
|
+
function getTokenRange(line, cursor) {
|
|
166
|
+
let start = cursor;
|
|
167
|
+
while (start > 0 && !/\s/.test(line[start - 1])) {
|
|
168
|
+
start -= 1;
|
|
169
|
+
}
|
|
170
|
+
let end = cursor;
|
|
171
|
+
while (end < line.length && !/\s/.test(line[end])) {
|
|
172
|
+
end += 1;
|
|
173
|
+
}
|
|
174
|
+
return { start, end };
|
|
175
|
+
}
|
|
176
|
+
function listPathCompletions(prefix) {
|
|
177
|
+
const slashIndex = prefix.lastIndexOf("/");
|
|
178
|
+
const dirPart = slashIndex >= 0 ? prefix.slice(0, slashIndex + 1) : "";
|
|
179
|
+
const namePart = slashIndex >= 0 ? prefix.slice(slashIndex + 1) : prefix;
|
|
180
|
+
const basePath = resolvePath(cwd, dirPart || ".");
|
|
181
|
+
try {
|
|
182
|
+
return shell.vfs
|
|
183
|
+
.list(basePath)
|
|
184
|
+
.filter((entry) => !entry.startsWith("."))
|
|
185
|
+
.filter((entry) => entry.startsWith(namePart))
|
|
186
|
+
.map((entry) => {
|
|
187
|
+
const fullPath = path.posix.join(basePath, entry);
|
|
188
|
+
const st = shell.vfs.stat(fullPath);
|
|
189
|
+
const suffix = st.type === "directory" ? "/" : "";
|
|
190
|
+
return `${dirPart}${entry}${suffix}`;
|
|
191
|
+
})
|
|
192
|
+
.sort();
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function handleTabCompletion() {
|
|
199
|
+
const { start, end } = getTokenRange(lineBuffer, cursorPos);
|
|
200
|
+
const token = lineBuffer.slice(start, cursorPos);
|
|
201
|
+
if (token.length === 0) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const firstToken = lineBuffer.slice(0, start).trim().length === 0;
|
|
205
|
+
const commandCandidates = firstToken
|
|
206
|
+
? commandNames.filter((name) => name.startsWith(token))
|
|
207
|
+
: [];
|
|
208
|
+
const pathCandidates = listPathCompletions(token);
|
|
209
|
+
const candidates = Array.from(new Set([...commandCandidates, ...pathCandidates])).sort();
|
|
210
|
+
if (candidates.length === 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (candidates.length === 1) {
|
|
214
|
+
const completed = candidates[0];
|
|
215
|
+
const suffix = completed.endsWith("/") ? "" : " ";
|
|
216
|
+
lineBuffer = `${lineBuffer.slice(0, start)}${completed}${suffix}${lineBuffer.slice(end)}`;
|
|
217
|
+
cursorPos = start + completed.length + suffix.length;
|
|
218
|
+
renderLine();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
stream.write("\r\n");
|
|
222
|
+
stream.write(`${candidates.join(" ")}\r\n`);
|
|
223
|
+
renderLine();
|
|
224
|
+
}
|
|
225
|
+
function pushHistory(cmd) {
|
|
226
|
+
if (cmd.length === 0) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
history.push(cmd);
|
|
230
|
+
if (history.length > 500) {
|
|
231
|
+
history = history.slice(history.length - 500);
|
|
232
|
+
}
|
|
233
|
+
const data = history.length > 0 ? `${history.join("\n")}\n` : "";
|
|
234
|
+
shell.vfs.writeFile("/virtual-env-js/.bash_history", data);
|
|
235
|
+
}
|
|
236
|
+
function readLastLogin() {
|
|
237
|
+
const lastlogPath = `/virtual-env-js/.lastlog/${authUser}.json`;
|
|
238
|
+
if (!shell.vfs.exists(lastlogPath)) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(shell.vfs.readFile(lastlogPath));
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function writeLastLogin(nowIso) {
|
|
249
|
+
const dir = "/virtual-env-js/.lastlog";
|
|
250
|
+
if (!shell.vfs.exists(dir)) {
|
|
251
|
+
shell.vfs.mkdir(dir, 0o700);
|
|
252
|
+
}
|
|
253
|
+
const lastlogPath = `${dir}/${authUser}.json`;
|
|
254
|
+
shell.vfs.writeFile(lastlogPath, JSON.stringify({ at: nowIso, from: remoteAddress }));
|
|
255
|
+
}
|
|
256
|
+
function renderLoginBanner() {
|
|
257
|
+
const last = readLastLogin();
|
|
258
|
+
const nowIso = new Date().toISOString();
|
|
259
|
+
stream.write(`Linux ${hostname} ${properties.kernel} ${properties.arch}\r\n`);
|
|
260
|
+
stream.write("\r\n");
|
|
261
|
+
stream.write("The programs included with the Fortune GNU/Linux system are free software;\r\n");
|
|
262
|
+
stream.write("the exact distribution terms for each program are described in the\r\n");
|
|
263
|
+
stream.write("individual files in /usr/share/doc/*/copyright.\r\n");
|
|
264
|
+
stream.write("\r\n");
|
|
265
|
+
stream.write("Fortune GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent\r\n");
|
|
266
|
+
stream.write("permitted by applicable law.\r\n");
|
|
267
|
+
if (last) {
|
|
268
|
+
const when = new Date(last.at);
|
|
269
|
+
const displayed = Number.isNaN(when.getTime())
|
|
270
|
+
? last.at
|
|
271
|
+
: formatLoginDate(when);
|
|
272
|
+
stream.write(`Last login: ${displayed} from ${last.from || "unknown"}\r\n`);
|
|
273
|
+
}
|
|
274
|
+
stream.write("\r\n");
|
|
275
|
+
writeLastLogin(nowIso);
|
|
276
|
+
}
|
|
277
|
+
renderLoginBanner();
|
|
278
|
+
renderLine();
|
|
279
|
+
stream.on("data", async (chunk) => {
|
|
280
|
+
if (nanoSession) {
|
|
281
|
+
nanoSession.process.stdin.write(chunk);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (pendingSudo) {
|
|
285
|
+
const input = chunk.toString("utf8");
|
|
286
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
287
|
+
const ch = input[i];
|
|
288
|
+
if (ch === "\u0003") {
|
|
289
|
+
pendingSudo = null;
|
|
290
|
+
stream.write("^C\r\n");
|
|
291
|
+
renderLine();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
295
|
+
pendingSudo.buffer = pendingSudo.buffer.slice(0, -1);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (ch === "\r" || ch === "\n") {
|
|
299
|
+
const password = pendingSudo.buffer;
|
|
300
|
+
pendingSudo.buffer = "";
|
|
301
|
+
const valid = shell.users.verifyPassword(pendingSudo.username, password);
|
|
302
|
+
await finishSudoPrompt(valid);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (ch >= " ") {
|
|
306
|
+
pendingSudo.buffer += ch;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const input = chunk.toString("utf8");
|
|
312
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
313
|
+
const ch = input[i];
|
|
314
|
+
if (ch === "\u0004") {
|
|
315
|
+
stream.write("logout\r\n");
|
|
316
|
+
stream.exit(0);
|
|
317
|
+
stream.end();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (ch === "\t") {
|
|
321
|
+
handleTabCompletion();
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (ch === "\u001b") {
|
|
325
|
+
const next = input[i + 1];
|
|
326
|
+
const third = input[i + 2];
|
|
327
|
+
const fourth = input[i + 3];
|
|
328
|
+
if (next === "[" && third) {
|
|
329
|
+
if (third === "A") {
|
|
330
|
+
i += 2;
|
|
331
|
+
if (history.length > 0) {
|
|
332
|
+
if (historyIndex === null) {
|
|
333
|
+
historyDraft = lineBuffer;
|
|
334
|
+
historyIndex = history.length - 1;
|
|
335
|
+
}
|
|
336
|
+
else if (historyIndex > 0) {
|
|
337
|
+
historyIndex -= 1;
|
|
338
|
+
}
|
|
339
|
+
applyHistoryLine(history[historyIndex] ?? "");
|
|
340
|
+
}
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (third === "B") {
|
|
344
|
+
i += 2;
|
|
345
|
+
if (historyIndex !== null) {
|
|
346
|
+
if (historyIndex < history.length - 1) {
|
|
347
|
+
historyIndex += 1;
|
|
348
|
+
applyHistoryLine(history[historyIndex] ?? "");
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
historyIndex = null;
|
|
352
|
+
applyHistoryLine(historyDraft);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (third === "C") {
|
|
358
|
+
i += 2;
|
|
359
|
+
if (cursorPos < lineBuffer.length) {
|
|
360
|
+
cursorPos += 1;
|
|
361
|
+
stream.write("\u001b[C");
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (third === "D") {
|
|
366
|
+
i += 2;
|
|
367
|
+
if (cursorPos > 0) {
|
|
368
|
+
cursorPos -= 1;
|
|
369
|
+
stream.write("\u001b[D");
|
|
370
|
+
}
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (third === "3" && fourth === "~") {
|
|
374
|
+
i += 3;
|
|
375
|
+
if (cursorPos < lineBuffer.length) {
|
|
376
|
+
lineBuffer = `${lineBuffer.slice(0, cursorPos)}${lineBuffer.slice(cursorPos + 1)}`;
|
|
377
|
+
renderLine();
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (ch === "\u0003") {
|
|
384
|
+
lineBuffer = "";
|
|
385
|
+
cursorPos = 0;
|
|
386
|
+
historyIndex = null;
|
|
387
|
+
historyDraft = "";
|
|
388
|
+
stream.write("^C\r\n");
|
|
389
|
+
renderLine();
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (ch === "\r" || ch === "\n") {
|
|
393
|
+
const line = lineBuffer.trim();
|
|
394
|
+
lineBuffer = "";
|
|
395
|
+
cursorPos = 0;
|
|
396
|
+
historyIndex = null;
|
|
397
|
+
historyDraft = "";
|
|
398
|
+
stream.write("\r\n");
|
|
399
|
+
if (line.length > 0) {
|
|
400
|
+
const result = await Promise.resolve(runCommand(line, authUser, hostname, "shell", cwd, shell));
|
|
401
|
+
pushHistory(line);
|
|
402
|
+
if (result.openEditor) {
|
|
403
|
+
await startNanoEditor(result.openEditor.targetPath, result.openEditor.initialContent, result.openEditor.tempPath);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (result.openHtop) {
|
|
407
|
+
await startHtop();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (result.sudoChallenge) {
|
|
411
|
+
startSudoPrompt(result.sudoChallenge);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (result.clearScreen) {
|
|
415
|
+
stream.write("\u001b[2J\u001b[H");
|
|
416
|
+
}
|
|
417
|
+
if (result.stdout) {
|
|
418
|
+
stream.write(`${toTtyLines(result.stdout)}\r\n`);
|
|
419
|
+
}
|
|
420
|
+
if (result.stderr) {
|
|
421
|
+
stream.write(`${toTtyLines(result.stderr)}\r\n`);
|
|
422
|
+
}
|
|
423
|
+
if (result.closeSession) {
|
|
424
|
+
stream.write("logout\r\n");
|
|
425
|
+
stream.exit(result.exitCode ?? 0);
|
|
426
|
+
stream.end();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (result.nextCwd) {
|
|
430
|
+
cwd = result.nextCwd;
|
|
431
|
+
}
|
|
432
|
+
if (result.switchUser) {
|
|
433
|
+
authUser = result.switchUser;
|
|
434
|
+
cwd = result.nextCwd ?? `/home/${authUser}`;
|
|
435
|
+
shell.users.updateSession(sessionId, authUser, remoteAddress);
|
|
436
|
+
lineBuffer = "";
|
|
437
|
+
cursorPos = 0;
|
|
438
|
+
}
|
|
439
|
+
await shell.vfs.flushMirror();
|
|
440
|
+
}
|
|
441
|
+
renderLine();
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
445
|
+
if (cursorPos > 0) {
|
|
446
|
+
lineBuffer = `${lineBuffer.slice(0, cursorPos - 1)}${lineBuffer.slice(cursorPos)}`;
|
|
447
|
+
cursorPos -= 1;
|
|
448
|
+
renderLine();
|
|
449
|
+
}
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
insertText(ch);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
stream.on("close", () => {
|
|
456
|
+
if (nanoSession) {
|
|
457
|
+
nanoSession.process.kill("SIGTERM");
|
|
458
|
+
nanoSession = null;
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function loadHistory(vfs) {
|
|
463
|
+
const historyPath = "/virtual-env-js/.bash_history";
|
|
464
|
+
if (!vfs.exists(historyPath)) {
|
|
465
|
+
vfs.writeFile(historyPath, "");
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
const raw = vfs.readFile(historyPath);
|
|
469
|
+
return raw
|
|
470
|
+
.split("\n")
|
|
471
|
+
.map((line) => line.trim())
|
|
472
|
+
.filter((line) => line.length > 0);
|
|
473
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shellParser.d.ts","sourceRoot":"","sources":["../../src/VirtualShell/shellParser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAmB,MAAM,mBAAmB,CAAC;AAEnE,4DAA4D;AAC5D,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAkC7D"}
|