typescript-virtual-container 1.0.3 → 1.0.5
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/.github/workflows/test-battery.yml +22 -0
- package/CHANGELOG.md +42 -0
- package/README.md +56 -28
- package/package.json +1 -1
- package/src/SSHMimic/client.ts +1 -1
- package/src/SSHMimic/exec.ts +1 -1
- package/src/SSHMimic/executor.ts +201 -0
- package/src/SSHMimic/index.ts +14 -18
- package/src/{VirtualFileSystem.ts → VirtualFileSystem/index.ts} +6 -15
- package/src/{SSHMimic → VirtualShell}/commands/cat.ts +2 -1
- package/src/VirtualShell/commands/command-helpers.ts +135 -0
- package/src/{SSHMimic → VirtualShell}/commands/curl.ts +16 -37
- package/src/VirtualShell/commands/echo.ts +34 -0
- package/src/VirtualShell/commands/env.ts +22 -0
- package/src/VirtualShell/commands/export.ts +38 -0
- package/src/VirtualShell/commands/grep.ts +88 -0
- package/src/{SSHMimic → VirtualShell}/commands/helpers.ts +0 -37
- package/src/VirtualShell/commands/index.ts +327 -0
- package/src/{SSHMimic → VirtualShell}/commands/ls.ts +3 -2
- package/src/{SSHMimic → VirtualShell}/commands/mkdir.ts +6 -1
- package/src/{SSHMimic → VirtualShell}/commands/rm.ts +10 -3
- package/src/VirtualShell/commands/set.ts +73 -0
- package/src/VirtualShell/commands/sh.ts +58 -0
- package/src/{SSHMimic → VirtualShell}/commands/su.ts +3 -3
- package/src/{SSHMimic → VirtualShell}/commands/sudo.ts +16 -26
- package/src/{SSHMimic → VirtualShell}/commands/tree.ts +2 -1
- package/src/VirtualShell/commands/unset.ts +19 -0
- package/src/{SSHMimic → VirtualShell}/commands/wget.ts +23 -6
- package/src/{SSHMimic → VirtualShell}/commands/who.ts +1 -1
- package/src/VirtualShell/index.ts +69 -0
- package/src/{SSHMimic → VirtualShell}/shell.ts +3 -3
- package/src/VirtualShell/shellParser.ts +203 -0
- package/src/index.ts +8 -0
- package/src/standalone.ts +10 -1
- package/src/types/commands.ts +2 -0
- package/src/types/pipeline.ts +23 -0
- package/tests/command-helpers.test.ts +40 -0
- package/tests/helpers.test.ts +1 -1
- package/src/SSHMimic/commands/index.ts +0 -120
- /package/src/{vfs → VirtualFileSystem}/archive.ts +0 -0
- /package/src/{vfs → VirtualFileSystem}/internalTypes.ts +0 -0
- /package/src/{vfs → VirtualFileSystem}/path.ts +0 -0
- /package/src/{vfs → VirtualFileSystem}/snapshot.ts +0 -0
- /package/src/{vfs → VirtualFileSystem}/tree.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/adduser.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/cd.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/clear.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/deluser.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/exit.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/help.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/hostname.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/htop.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/nano.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/pwd.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/touch.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/whoami.ts +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** biome-ignore-all lint/style/useNamingConvention: env variables */
|
|
2
|
+
import type { ShellModule } from "../../types/commands";
|
|
3
|
+
import { getArg } from "./command-helpers";
|
|
4
|
+
|
|
5
|
+
// Simple in-memory environment variables store
|
|
6
|
+
// In a real implementation, this would be per-session/per-user
|
|
7
|
+
const envVars: Record<string, string> = {
|
|
8
|
+
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
9
|
+
HOME: "/home/user",
|
|
10
|
+
SHELL: "/bin/sh",
|
|
11
|
+
TERM: "xterm-256color",
|
|
12
|
+
USER: "user",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function getEnvVar(name: string): string | undefined {
|
|
16
|
+
return envVars[name];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setEnvVar(name: string, value: string): void {
|
|
20
|
+
envVars[name] = value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getAllEnvVars(authUser: string): Record<string, string> {
|
|
24
|
+
envVars.USER = authUser;
|
|
25
|
+
envVars.HOME = `/home/${authUser}`;
|
|
26
|
+
return { ...envVars };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const setCommand: ShellModule = {
|
|
30
|
+
name: "set",
|
|
31
|
+
params: ["[VAR=value]"],
|
|
32
|
+
run: ({ args }) => {
|
|
33
|
+
// No arguments: display all environment variables
|
|
34
|
+
if (args.length === 0) {
|
|
35
|
+
const output = Object.entries(envVars)
|
|
36
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
37
|
+
.sort()
|
|
38
|
+
.join("\n");
|
|
39
|
+
|
|
40
|
+
return { stdout: output, exitCode: 0 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parse VAR=value format
|
|
44
|
+
const assignments: string[] = [];
|
|
45
|
+
for (let index = 0; ; index += 1) {
|
|
46
|
+
const arg = getArg(args, index);
|
|
47
|
+
if (!arg) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (arg.includes("=")) {
|
|
52
|
+
const [varName, varValue] = arg.split("=", 2);
|
|
53
|
+
if (varName && varValue !== undefined) {
|
|
54
|
+
setEnvVar(varName, varValue);
|
|
55
|
+
assignments.push(arg);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
// If no '=' present, display that specific variable
|
|
59
|
+
const value = getEnvVar(arg);
|
|
60
|
+
if (value !== undefined) {
|
|
61
|
+
assignments.push(`${arg}=${value}`);
|
|
62
|
+
} else {
|
|
63
|
+
assignments.push(`${arg}: not set`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
stdout: assignments.length > 0 ? assignments.join("\n") : "",
|
|
70
|
+
exitCode: 0,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { CommandContext, ShellModule } from "../../types/commands";
|
|
2
|
+
import { getArg, getFlag } from "./command-helpers";
|
|
3
|
+
import { runCommand } from "./index";
|
|
4
|
+
|
|
5
|
+
/** Simple shell script executor with basic variable support */
|
|
6
|
+
export const shCommand: ShellModule = {
|
|
7
|
+
name: "sh",
|
|
8
|
+
params: ["-c <script>", "[<file>]"],
|
|
9
|
+
aliases: ["bash"],
|
|
10
|
+
run: async (ctx: CommandContext) => {
|
|
11
|
+
const { vfs, args, authUser, hostname, users, mode, cwd } = ctx;
|
|
12
|
+
|
|
13
|
+
// Handle -c option: sh -c "command"
|
|
14
|
+
if (getFlag(args, "-c") && args.length >= 2) {
|
|
15
|
+
const script = getArg(args, 1) ?? "";
|
|
16
|
+
if (!script) {
|
|
17
|
+
return { stderr: "sh: -c requires a script", exitCode: 1 };
|
|
18
|
+
}
|
|
19
|
+
const scriptArgs = args.slice(2);
|
|
20
|
+
|
|
21
|
+
// Split by semicolon and newline
|
|
22
|
+
const lines = script
|
|
23
|
+
.split(/[;\n]/)
|
|
24
|
+
.map((line) => line.trim())
|
|
25
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
26
|
+
|
|
27
|
+
let output = "";
|
|
28
|
+
const exitCode = 0;
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
// Simple variable substitution
|
|
32
|
+
let command = line;
|
|
33
|
+
for (let i = 0; i < scriptArgs.length; i++) {
|
|
34
|
+
const arg = scriptArgs[i] ?? "";
|
|
35
|
+
command = command.replaceAll(`$${i}`, arg);
|
|
36
|
+
}
|
|
37
|
+
command = command.replaceAll("$@", scriptArgs.join(" "));
|
|
38
|
+
|
|
39
|
+
// Execute the command
|
|
40
|
+
const result = await Promise.resolve(
|
|
41
|
+
runCommand(command, authUser, hostname, users, mode, cwd, vfs),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (result.stdout) {
|
|
45
|
+
output += `${result.stdout}\n`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (result.stderr) {
|
|
49
|
+
return { stderr: result.stderr, exitCode: result.exitCode ?? 1 };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { stdout: output.trim(), exitCode };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { stderr: "sh: invalid usage", exitCode: 1 };
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
+
import { getArg } from "./command-helpers";
|
|
2
3
|
|
|
3
4
|
export const suCommand: ShellModule = {
|
|
4
5
|
name: "su",
|
|
5
6
|
params: ["- <username>"],
|
|
6
7
|
run: ({ authUser, users, args }) => {
|
|
7
|
-
const
|
|
8
|
-
const targetUser = filtered[0];
|
|
8
|
+
const targetUser = getArg(args, 0, { flags: ["-"] });
|
|
9
9
|
|
|
10
10
|
if (!targetUser) {
|
|
11
11
|
return { stderr: "su: missing username", exitCode: 1 };
|
|
@@ -16,7 +16,7 @@ export const suCommand: ShellModule = {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
if (
|
|
19
|
-
!users.verifyPassword(targetUser,
|
|
19
|
+
!users.verifyPassword(targetUser, getArg(args, 1) ?? "") &&
|
|
20
20
|
authUser !== "root"
|
|
21
21
|
) {
|
|
22
22
|
return { stderr: "su: authentication failure", exitCode: 1 };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
+
import { getArg, getFlag, ifFlag } from "./command-helpers";
|
|
2
3
|
import { runCommand } from "./index";
|
|
3
4
|
|
|
4
5
|
function parseSudoArgs(args: string[]): {
|
|
@@ -6,34 +7,23 @@ function parseSudoArgs(args: string[]): {
|
|
|
6
7
|
loginShell: boolean;
|
|
7
8
|
commandLine: string | null;
|
|
8
9
|
} {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (arg === "-i") {
|
|
17
|
-
loginShell = true;
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
if (arg === "-S") {
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (arg === "-u") {
|
|
26
|
-
targetUser = args[index + 1] ?? "root";
|
|
27
|
-
index += 1;
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
10
|
+
const loginShell = ifFlag(args, "-i");
|
|
11
|
+
const targetUserValue = getFlag(args, ["-u", "--user"]);
|
|
12
|
+
const targetUser =
|
|
13
|
+
typeof targetUserValue === "string" && targetUserValue.length > 0
|
|
14
|
+
? targetUserValue
|
|
15
|
+
: "root";
|
|
30
16
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
17
|
+
const commandParts: string[] = [];
|
|
18
|
+
for (let index = 0; ; index += 1) {
|
|
19
|
+
const part = getArg(args, index, {
|
|
20
|
+
flags: ["-i", "-S"],
|
|
21
|
+
flagsWithValue: ["-u", "--user"],
|
|
22
|
+
});
|
|
23
|
+
if (!part) {
|
|
24
|
+
break;
|
|
34
25
|
}
|
|
35
|
-
|
|
36
|
-
commandParts.push(arg);
|
|
26
|
+
commandParts.push(part);
|
|
37
27
|
}
|
|
38
28
|
|
|
39
29
|
const commandLine = commandParts.length > 0 ? commandParts.join(" ") : null;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
+
import { getArg } from "./command-helpers";
|
|
2
3
|
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
4
|
|
|
4
5
|
export const treeCommand: ShellModule = {
|
|
5
6
|
name: "tree",
|
|
6
7
|
params: ["[path]"],
|
|
7
8
|
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
|
-
const target = resolvePath(cwd, args
|
|
9
|
+
const target = resolvePath(cwd, getArg(args, 0) ?? cwd);
|
|
9
10
|
assertPathAccess(authUser, target, "tree");
|
|
10
11
|
return { stdout: vfs.tree(target), exitCode: 0 };
|
|
11
12
|
},
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ShellModule } from "../../types/commands";
|
|
2
|
+
import { setEnvVar } from "./set";
|
|
3
|
+
|
|
4
|
+
export const unsetCommand: ShellModule = {
|
|
5
|
+
name: "unset",
|
|
6
|
+
params: ["<VAR...>"],
|
|
7
|
+
run: ({ args }) => {
|
|
8
|
+
if (args.length === 0) {
|
|
9
|
+
return { stderr: "unset: missing variable name", exitCode: 1 };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Unset (remove) all specified variables
|
|
13
|
+
for (const varName of args) {
|
|
14
|
+
setEnvVar(varName, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return { exitCode: 0 };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -3,10 +3,10 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { ShellModule } from "../../types/commands";
|
|
6
|
+
import { getArg, getFlag, ifFlag } from "./command-helpers";
|
|
6
7
|
import {
|
|
7
8
|
assertPathAccess,
|
|
8
9
|
normalizeTerminalOutput,
|
|
9
|
-
parseOutputPath,
|
|
10
10
|
resolvePath,
|
|
11
11
|
stripUrlFilename,
|
|
12
12
|
} from "./helpers";
|
|
@@ -83,12 +83,29 @@ export const wgetCommand: ShellModule = {
|
|
|
83
83
|
name: "wget",
|
|
84
84
|
params: ["[url]"],
|
|
85
85
|
run: async ({ authUser, vfs, cwd, args }) => {
|
|
86
|
-
const
|
|
86
|
+
const outputPathValue = getFlag(args, [
|
|
87
|
+
"-o",
|
|
88
|
+
"-O",
|
|
89
|
+
"--output",
|
|
90
|
+
"--output-document",
|
|
91
|
+
]);
|
|
92
|
+
const outputPath =
|
|
93
|
+
typeof outputPathValue === "string" && outputPathValue.length > 0
|
|
94
|
+
? outputPathValue
|
|
95
|
+
: null;
|
|
96
|
+
const parserOptions = {
|
|
97
|
+
flagsWithValue: ["-o", "-O", "--output", "--output-document"],
|
|
98
|
+
};
|
|
99
|
+
const inputArgs: string[] = [];
|
|
100
|
+
for (let index = 0; ; index += 1) {
|
|
101
|
+
const arg = getArg(args, index, parserOptions);
|
|
102
|
+
if (!arg) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
inputArgs.push(arg);
|
|
106
|
+
}
|
|
87
107
|
const url = inputArgs[0];
|
|
88
|
-
const isHelpLike =
|
|
89
|
-
(arg) =>
|
|
90
|
-
arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version",
|
|
91
|
-
);
|
|
108
|
+
const isHelpLike = ifFlag(args, ["-h", "--help", "-V", "--version"]);
|
|
92
109
|
|
|
93
110
|
if (!url) {
|
|
94
111
|
return { stderr: "wget: missing URL", exitCode: 1 };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { VirtualUserManager } from "../SSHMimic/users";
|
|
2
|
+
import type { CommandContext, CommandResult } from "../types/commands";
|
|
3
|
+
import type { ShellStream } from "../types/streams";
|
|
4
|
+
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
5
|
+
import { createCustomCommand, registerCommand, runCommand } from "./commands";
|
|
6
|
+
import { startShell } from "./shell";
|
|
7
|
+
|
|
8
|
+
class VirtualShell {
|
|
9
|
+
private vfs: VirtualFileSystem;
|
|
10
|
+
private users: VirtualUserManager;
|
|
11
|
+
private hostname: string;
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
vfs: VirtualFileSystem,
|
|
15
|
+
users: VirtualUserManager,
|
|
16
|
+
hostname: string,
|
|
17
|
+
) {
|
|
18
|
+
this.vfs = vfs;
|
|
19
|
+
this.users = users;
|
|
20
|
+
this.hostname = hostname;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
addCommand(
|
|
24
|
+
name: string,
|
|
25
|
+
params: string[],
|
|
26
|
+
callback: (ctx: CommandContext) => CommandResult | Promise<CommandResult>,
|
|
27
|
+
): void {
|
|
28
|
+
const normalized = name.trim().toLowerCase();
|
|
29
|
+
if (normalized.length === 0 || /\s/.test(normalized)) {
|
|
30
|
+
throw new Error("Command name must be non-empty and contain no spaces");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
registerCommand(createCustomCommand(normalized, params, callback));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
executeCommand(rawInput: string, authUser: string, cwd: string): void {
|
|
37
|
+
runCommand(
|
|
38
|
+
rawInput,
|
|
39
|
+
authUser,
|
|
40
|
+
this.hostname,
|
|
41
|
+
this.users,
|
|
42
|
+
"shell",
|
|
43
|
+
cwd,
|
|
44
|
+
this.vfs,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
startInteractiveSession(
|
|
49
|
+
stream: ShellStream,
|
|
50
|
+
authUser: string,
|
|
51
|
+
sessionId: string | null,
|
|
52
|
+
remoteAddress: string,
|
|
53
|
+
terminalSize: { cols: number; rows: number },
|
|
54
|
+
): void {
|
|
55
|
+
// Interactive shell logic
|
|
56
|
+
startShell(
|
|
57
|
+
stream,
|
|
58
|
+
authUser,
|
|
59
|
+
this.vfs!,
|
|
60
|
+
this.hostname,
|
|
61
|
+
this.users!,
|
|
62
|
+
sessionId,
|
|
63
|
+
remoteAddress,
|
|
64
|
+
terminalSize,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { VirtualShell };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
2
|
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { formatLoginDate } from "../SSHMimic/loginFormat";
|
|
5
|
+
import { buildPrompt } from "../SSHMimic/prompt";
|
|
6
|
+
import type { VirtualUserManager } from "../SSHMimic/users";
|
|
4
7
|
import type { ShellStream } from "../types/streams";
|
|
5
8
|
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
6
9
|
import { getCommandNames, runCommand } from "./commands";
|
|
7
|
-
import { formatLoginDate } from "./loginFormat";
|
|
8
|
-
import { buildPrompt } from "./prompt";
|
|
9
|
-
import type { VirtualUserManager } from "./users";
|
|
10
10
|
|
|
11
11
|
interface NanoSession {
|
|
12
12
|
kind: "nano" | "htop";
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { Pipeline, PipelineCommand } from "../types/pipeline";
|
|
2
|
+
|
|
3
|
+
/** Parse a shell command line into a structured pipeline */
|
|
4
|
+
export function parseShellPipeline(rawInput: string): Pipeline {
|
|
5
|
+
const trimmed = rawInput.trim();
|
|
6
|
+
|
|
7
|
+
if (!trimmed) {
|
|
8
|
+
return { commands: [], isValid: true };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const commands: PipelineCommand[] = [];
|
|
12
|
+
const pipeTokens = tokenizePipeline(trimmed);
|
|
13
|
+
|
|
14
|
+
for (const token of pipeTokens) {
|
|
15
|
+
const cmd = parseCommandWithRedirections(token);
|
|
16
|
+
if (!cmd.isValid) {
|
|
17
|
+
return {
|
|
18
|
+
commands: [],
|
|
19
|
+
isValid: false,
|
|
20
|
+
error: cmd.error,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (cmd.command) {
|
|
24
|
+
commands.push(cmd.command);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { commands, isValid: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Tokenize input by pipes, respecting quoted strings */
|
|
32
|
+
function tokenizePipeline(input: string): string[] {
|
|
33
|
+
const tokens: string[] = [];
|
|
34
|
+
let current = "";
|
|
35
|
+
let inQuotes = false;
|
|
36
|
+
let quoteChar = "";
|
|
37
|
+
let i = 0;
|
|
38
|
+
|
|
39
|
+
while (i < input.length) {
|
|
40
|
+
const ch = input[i];
|
|
41
|
+
|
|
42
|
+
if ((ch === '"' || ch === "'") && (i === 0 || input[i - 1] !== "\\")) {
|
|
43
|
+
if (!inQuotes) {
|
|
44
|
+
inQuotes = true;
|
|
45
|
+
quoteChar = ch;
|
|
46
|
+
} else if (ch === quoteChar) {
|
|
47
|
+
inQuotes = false;
|
|
48
|
+
}
|
|
49
|
+
current += ch;
|
|
50
|
+
i++;
|
|
51
|
+
} else if (ch === "|" && !inQuotes) {
|
|
52
|
+
if (current.trim()) {
|
|
53
|
+
tokens.push(current.trim());
|
|
54
|
+
}
|
|
55
|
+
current = "";
|
|
56
|
+
i++;
|
|
57
|
+
} else {
|
|
58
|
+
current += ch;
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (current.trim()) {
|
|
64
|
+
tokens.push(current.trim());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return tokens;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ParseResult {
|
|
71
|
+
command?: PipelineCommand;
|
|
72
|
+
isValid: boolean;
|
|
73
|
+
error?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Parse a single command with its redirections (>, >>, <) */
|
|
77
|
+
function parseCommandWithRedirections(token: string): ParseResult {
|
|
78
|
+
const parts = tokenizeCommand(token);
|
|
79
|
+
|
|
80
|
+
if (parts.length === 0) {
|
|
81
|
+
return { isValid: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cmdParts: string[] = [];
|
|
85
|
+
let inputFile: string | undefined;
|
|
86
|
+
let outputFile: string | undefined;
|
|
87
|
+
let appendOutput = false;
|
|
88
|
+
|
|
89
|
+
let i = 0;
|
|
90
|
+
while (i < parts.length) {
|
|
91
|
+
const part = parts[i] as string;
|
|
92
|
+
|
|
93
|
+
if (part === "<") {
|
|
94
|
+
i++;
|
|
95
|
+
if (i >= parts.length) {
|
|
96
|
+
return {
|
|
97
|
+
isValid: false,
|
|
98
|
+
error: "Syntax error: expected filename after <",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
inputFile = parts[i];
|
|
102
|
+
i++;
|
|
103
|
+
} else if (part === ">>") {
|
|
104
|
+
i++;
|
|
105
|
+
if (i >= parts.length) {
|
|
106
|
+
return {
|
|
107
|
+
isValid: false,
|
|
108
|
+
error: "Syntax error: expected filename after >>",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
outputFile = parts[i];
|
|
112
|
+
appendOutput = true;
|
|
113
|
+
i++;
|
|
114
|
+
} else if (part === ">") {
|
|
115
|
+
i++;
|
|
116
|
+
if (i >= parts.length) {
|
|
117
|
+
return {
|
|
118
|
+
isValid: false,
|
|
119
|
+
error: "Syntax error: expected filename after >",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
outputFile = parts[i];
|
|
123
|
+
appendOutput = false;
|
|
124
|
+
i++;
|
|
125
|
+
} else {
|
|
126
|
+
cmdParts.push(part);
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (cmdParts.length === 0) {
|
|
132
|
+
return { isValid: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const name = (cmdParts[0] as string).toLowerCase();
|
|
136
|
+
const args = cmdParts.slice(1);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
command: {
|
|
140
|
+
name,
|
|
141
|
+
args,
|
|
142
|
+
inputFile,
|
|
143
|
+
outputFile,
|
|
144
|
+
appendOutput,
|
|
145
|
+
},
|
|
146
|
+
isValid: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Tokenize a command, respecting quotes and handling >> vs > */
|
|
151
|
+
function tokenizeCommand(input: string): string[] {
|
|
152
|
+
const tokens: string[] = [];
|
|
153
|
+
let current = "";
|
|
154
|
+
let inQuotes = false;
|
|
155
|
+
let quoteChar = "";
|
|
156
|
+
let i = 0;
|
|
157
|
+
|
|
158
|
+
while (i < input.length) {
|
|
159
|
+
const ch = input[i];
|
|
160
|
+
const next = input[i + 1];
|
|
161
|
+
|
|
162
|
+
// Handle quotes
|
|
163
|
+
if ((ch === '"' || ch === "'") && (i === 0 || input[i - 1] !== "\\")) {
|
|
164
|
+
if (!inQuotes) {
|
|
165
|
+
inQuotes = true;
|
|
166
|
+
quoteChar = ch;
|
|
167
|
+
} else if (ch === quoteChar) {
|
|
168
|
+
inQuotes = false;
|
|
169
|
+
quoteChar = "";
|
|
170
|
+
} else {
|
|
171
|
+
current += ch;
|
|
172
|
+
}
|
|
173
|
+
i++;
|
|
174
|
+
} else if (ch === " " && !inQuotes) {
|
|
175
|
+
if (current) {
|
|
176
|
+
tokens.push(current);
|
|
177
|
+
current = "";
|
|
178
|
+
}
|
|
179
|
+
i++;
|
|
180
|
+
} else if ((ch === ">" || ch === "<") && !inQuotes) {
|
|
181
|
+
if (current) {
|
|
182
|
+
tokens.push(current);
|
|
183
|
+
current = "";
|
|
184
|
+
}
|
|
185
|
+
if (ch === ">" && next === ">") {
|
|
186
|
+
tokens.push(">>");
|
|
187
|
+
i += 2;
|
|
188
|
+
} else {
|
|
189
|
+
tokens.push(ch);
|
|
190
|
+
i++;
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
current += ch;
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (current) {
|
|
199
|
+
tokens.push(current);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return tokens;
|
|
203
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { SshClient } from "./SSHMimic/client";
|
|
|
2
2
|
import { SshMimic } from "./SSHMimic/index";
|
|
3
3
|
import { VirtualUserManager } from "./SSHMimic/users";
|
|
4
4
|
import VirtualFileSystem from "./VirtualFileSystem";
|
|
5
|
+
import type { VirtualShell } from "./VirtualShell";
|
|
5
6
|
|
|
6
7
|
export type {
|
|
7
8
|
CommandContext,
|
|
@@ -33,4 +34,11 @@ export {
|
|
|
33
34
|
VirtualFileSystem,
|
|
34
35
|
SshMimic as VirtualMachine,
|
|
35
36
|
VirtualUserManager,
|
|
37
|
+
type VirtualShell,
|
|
36
38
|
};
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
getArg,
|
|
42
|
+
getFlag,
|
|
43
|
+
ifFlag,
|
|
44
|
+
} from "./VirtualShell/commands/command-helpers";
|
package/src/standalone.ts
CHANGED
|
@@ -6,7 +6,16 @@ const sshMimic = new VirtualMachine({ port: 2222, hostname: sshHostname });
|
|
|
6
6
|
sshMimic
|
|
7
7
|
.start()
|
|
8
8
|
.then((port: number) => {
|
|
9
|
-
console.
|
|
9
|
+
if (!sshMimic.shell) console.error("Failed to initialize SSH Mimic shell.");
|
|
10
|
+
else {
|
|
11
|
+
console.log(`SSH Mimic initialized. Listening on port ${port}.`);
|
|
12
|
+
sshMimic.shell.addCommand("demo", [], () => {
|
|
13
|
+
return {
|
|
14
|
+
stdout: "This is a demo command. It does nothing useful.",
|
|
15
|
+
exitCode: 0,
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
}
|
|
10
19
|
})
|
|
11
20
|
.catch((error: unknown) => {
|
|
12
21
|
console.error("Failed to start SSH Mimic:", error);
|
package/src/types/commands.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface CommandContext {
|
|
|
76
76
|
mode: CommandMode;
|
|
77
77
|
/** Tokenized arguments excluding command name. */
|
|
78
78
|
args: string[];
|
|
79
|
+
/** Optional stdin payload (used by pipes/redirections). */
|
|
80
|
+
stdin?: string;
|
|
79
81
|
/** Current working directory for command execution. */
|
|
80
82
|
cwd: string;
|
|
81
83
|
/** Virtual filesystem instance for IO operations. */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Represents a single command in a pipeline. */
|
|
2
|
+
export interface PipelineCommand {
|
|
3
|
+
/** Command name */
|
|
4
|
+
name: string;
|
|
5
|
+
/** Command arguments */
|
|
6
|
+
args: string[];
|
|
7
|
+
/** Input redirection file path (< file) */
|
|
8
|
+
inputFile?: string;
|
|
9
|
+
/** Output redirection file path (> file) */
|
|
10
|
+
outputFile?: string;
|
|
11
|
+
/** Append to output file (>> file) */
|
|
12
|
+
appendOutput?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Represents a parsed shell pipeline */
|
|
16
|
+
export interface Pipeline {
|
|
17
|
+
/** List of commands in the pipeline */
|
|
18
|
+
commands: PipelineCommand[];
|
|
19
|
+
/** Whether this is a valid pipeline */
|
|
20
|
+
isValid: boolean;
|
|
21
|
+
/** Error message if parsing failed */
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|