typescript-virtual-container 1.0.3 → 1.0.4
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 +31 -0
- package/README.md +6 -28
- package/package.json +1 -1
- package/src/SSHMimic/commands/echo.ts +26 -0
- package/src/SSHMimic/commands/env.ts +22 -0
- package/src/SSHMimic/commands/export.ts +32 -0
- package/src/SSHMimic/commands/grep.ts +81 -0
- package/src/SSHMimic/commands/index.ts +153 -1
- package/src/SSHMimic/commands/set.ts +67 -0
- package/src/SSHMimic/commands/sh.ts +121 -0
- package/src/SSHMimic/commands/unset.ts +19 -0
- package/src/SSHMimic/executor.ts +201 -0
- package/src/SSHMimic/shellParser.ts +203 -0
- package/src/types/commands.ts +2 -0
- package/src/types/pipeline.ts +23 -0
|
@@ -70,10 +70,32 @@ jobs:
|
|
|
70
70
|
- name: Run lint
|
|
71
71
|
run: bun lint
|
|
72
72
|
|
|
73
|
+
tests:
|
|
74
|
+
needs: setup
|
|
75
|
+
runs-on: ubuntu-latest
|
|
76
|
+
steps:
|
|
77
|
+
- name: Checkout repository
|
|
78
|
+
uses: actions/checkout@v4
|
|
79
|
+
|
|
80
|
+
- name: Setup Bun
|
|
81
|
+
uses: oven-sh/setup-bun@v1
|
|
82
|
+
with:
|
|
83
|
+
bun-version: latest
|
|
84
|
+
|
|
85
|
+
- name: Restore dependencies
|
|
86
|
+
uses: actions/cache@v3
|
|
87
|
+
with:
|
|
88
|
+
path: node_modules
|
|
89
|
+
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
|
90
|
+
|
|
91
|
+
- name: Run tests
|
|
92
|
+
run: bun test
|
|
93
|
+
|
|
73
94
|
test-battery:
|
|
74
95
|
needs:
|
|
75
96
|
- typecheck
|
|
76
97
|
- lint
|
|
98
|
+
- tests
|
|
77
99
|
runs-on: ubuntu-latest
|
|
78
100
|
steps:
|
|
79
101
|
- name: Test battery passed
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,37 @@ The format is based on Keep a Changelog.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.0.4] - 2026-04-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Shell pipeline parser and executor with support for:
|
|
14
|
+
- Pipes (`|`)
|
|
15
|
+
- Input redirection (`<`)
|
|
16
|
+
- Output redirection (`>`)
|
|
17
|
+
- Append redirection (`>>`)
|
|
18
|
+
- New built-in commands:
|
|
19
|
+
- `echo`
|
|
20
|
+
- `grep`
|
|
21
|
+
- `set`
|
|
22
|
+
- `env`
|
|
23
|
+
- `export`
|
|
24
|
+
- `unset`
|
|
25
|
+
- `sh` (with `bash` alias)
|
|
26
|
+
- Command stdin support in runtime context so commands can consume piped input.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Argument parsing now respects quoted strings, including for commands like `sh -c "echo hi"`.
|
|
31
|
+
- `echo` now expands environment variables (`$VAR`) and can read from stdin when no explicit text argument is provided.
|
|
32
|
+
- `grep` now supports stdin input (e.g. `ls | grep ".txt"`) in addition to file operands.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Relative file paths in redirections are now resolved from current working directory during pipeline execution.
|
|
37
|
+
- Example fixed behavior: `echo hi > cat.txt` writes to `./cat.txt` in current virtual directory.
|
|
38
|
+
- Pipeline chaining now correctly passes command stdout as stdin to next command.
|
|
39
|
+
|
|
9
40
|
## [1.0.2] - 2026-04-14
|
|
10
41
|
|
|
11
42
|
### Added
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
- **Virtual Filesystem**: In-memory filesystem with optional compression, persistence to disk via tar.gz snapshots, and programmatic access.
|
|
40
40
|
- **User Management**: Create, authenticate, and manage virtual users with strict password hashing (scrypt) and sudo-like privilege elevation.
|
|
41
41
|
- **Programmatic Shell API**: Execute shell commands and query filesystem state directly from TypeScript without SSH overhead.
|
|
42
|
-
- **Built-in Commands**: `ls`, `cd`, `pwd`, `cat`, `mkdir`, `touch`, `rm`, `tree`, `whoami`, `hostname`, `who`, `sudo`, `su`, `adduser`, `deluser`, `nano` (text editor), `curl`, `wget`, and
|
|
42
|
+
- **Built-in Commands**: `ls`, `cd`, `pwd`, `cat`, `mkdir`, `touch`, `rm`, `tree`, `whoami`, `hostname`, `who`, `sudo`, `su`, `adduser`, `deluser`, `nano` (text editor), `curl`, `wget`, and a growing set of additional commands. Not everything is implemented yet, and shell compatibility is still being expanded.
|
|
43
43
|
- **Full TypeScript Support**: Complete JSDoc coverage, exported types, and first-class async/await for all operations.
|
|
44
44
|
|
|
45
45
|
## Why This Package
|
|
@@ -140,7 +140,7 @@ ssh.stop();
|
|
|
140
140
|
|
|
141
141
|
## Architecture Overview
|
|
142
142
|
|
|
143
|
-
### Core Components
|
|
143
|
+
<!-- ### Core Components
|
|
144
144
|
|
|
145
145
|
```
|
|
146
146
|
┌─────────────────────────────────────────────┐
|
|
@@ -158,7 +158,7 @@ ssh.stop();
|
|
|
158
158
|
│ Backed by disk
|
|
159
159
|
│ .vfs/mirror.tar.gz
|
|
160
160
|
└──────────────────────────────────┘
|
|
161
|
-
```
|
|
161
|
+
``` -->
|
|
162
162
|
|
|
163
163
|
### Execution Modes
|
|
164
164
|
|
|
@@ -1006,7 +1006,7 @@ ssh.stop();
|
|
|
1006
1006
|
|
|
1007
1007
|
## Built-in Commands
|
|
1008
1008
|
|
|
1009
|
-
The following commands are available in both SSH shell mode and via `SshClient.exec()
|
|
1009
|
+
The following commands are available in both SSH shell mode and via `SshClient.exec()`. This list is intentionally incomplete: some commands, flags, and edge cases are still missing or only partially compatible with real shells, and that will continue to be worked on.
|
|
1010
1010
|
|
|
1011
1011
|
| Command | Purpose | Notes |
|
|
1012
1012
|
|---------|---------|-------|
|
|
@@ -1125,7 +1125,7 @@ No. It emulates SSH sessions, users, and filesystem behavior in memory. It is id
|
|
|
1125
1125
|
|
|
1126
1126
|
### Can I use this in production?
|
|
1127
1127
|
|
|
1128
|
-
You can use it in production-like automation contexts (sandboxed command runners, test harnesses, training environments), but it is not a security boundary like a real container/VM.
|
|
1128
|
+
You can use it in production-like automation contexts (sandboxed command runners, test harnesses, training environments), but it is not a security boundary like a real container/VM. And at the moment, all commands are not implemented with full fidelity, so it may not be suitable for all production use cases.
|
|
1129
1129
|
|
|
1130
1130
|
### Does data persist between restarts?
|
|
1131
1131
|
|
|
@@ -1262,26 +1262,4 @@ MIT License. See LICENSE file for details.
|
|
|
1262
1262
|
- [ ] Improved shell compatibility for complex piping and redirection
|
|
1263
1263
|
- [ ] Snapshot diff tooling for test assertions
|
|
1264
1264
|
- [ ] Structured event hooks (session open/close, file write, sudo challenge)
|
|
1265
|
-
|
|
1266
|
-
---
|
|
1267
|
-
|
|
1268
|
-
## Changelog
|
|
1269
|
-
|
|
1270
|
-
### v1.0.0 (2026-04-14)
|
|
1271
|
-
|
|
1272
|
-
**Initial Release**
|
|
1273
|
-
|
|
1274
|
-
- ✨ SSH server with password auth
|
|
1275
|
-
- ✨ Virtual filesystem with persistence
|
|
1276
|
-
- ✨ User management & sudoers
|
|
1277
|
-
- ✨ Programmatic `SshClient` API
|
|
1278
|
-
- ✨ 20+ built-in shell commands
|
|
1279
|
-
- ✨ Full TypeScript support & JSDoc coverage
|
|
1280
|
-
- ✨ tar.gz snapshot persistence
|
|
1281
|
-
- ✨ Session & TTY management
|
|
1282
|
-
- ✨ Interactive `nano` editor
|
|
1283
|
-
- ✨ Sudo/su privilege escalation
|
|
1284
|
-
|
|
1285
|
-
---
|
|
1286
|
-
|
|
1287
|
-
**Made with ❤️ for testing, automation, and interactive TypeScript development.**
|
|
1265
|
+
- [ ] WebSocket-based remote shell client (experimental)
|
package/package.json
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ShellModule } from "../../types/commands";
|
|
2
|
+
import { getAllEnvVars } from "./set";
|
|
3
|
+
|
|
4
|
+
function expandEnvVars(input: string, env: Record<string, string>): string {
|
|
5
|
+
return input.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name: string) => {
|
|
6
|
+
return env[name] ?? "";
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const echoCommand: ShellModule = {
|
|
11
|
+
name: "echo",
|
|
12
|
+
params: ["[options] [text...]"],
|
|
13
|
+
run: ({ args, authUser, stdin }) => {
|
|
14
|
+
const newline = !args.includes("-n");
|
|
15
|
+
const filteredArgs = args.filter((arg) => arg !== "-n");
|
|
16
|
+
const env = getAllEnvVars(authUser);
|
|
17
|
+
const rawText =
|
|
18
|
+
filteredArgs.length > 0 ? filteredArgs.join(" ") : (stdin ?? "");
|
|
19
|
+
const text = expandEnvVars(rawText, env);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
stdout: newline ? text : text.trimEnd(),
|
|
23
|
+
exitCode: 0,
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ShellModule } from "../../types/commands";
|
|
2
|
+
import { getAllEnvVars } from "./set";
|
|
3
|
+
|
|
4
|
+
export const envCommand: ShellModule = {
|
|
5
|
+
name: "env",
|
|
6
|
+
params: ["[VAR=value...] [command]"],
|
|
7
|
+
run: ({ authUser }) => {
|
|
8
|
+
// For now, just display all environment variables
|
|
9
|
+
// In a full implementation, this would also handle running commands with modified env
|
|
10
|
+
|
|
11
|
+
const allVars = getAllEnvVars(authUser);
|
|
12
|
+
const envVarsOutput = Object.entries(allVars)
|
|
13
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
14
|
+
.sort()
|
|
15
|
+
.join("\n");
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
stdout: envVarsOutput,
|
|
19
|
+
exitCode: 0,
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ShellModule } from "../../types/commands";
|
|
2
|
+
import { getEnvVar, setEnvVar } from "./set";
|
|
3
|
+
|
|
4
|
+
export const exportCommand: ShellModule = {
|
|
5
|
+
name: "export",
|
|
6
|
+
params: ["[VAR=value]"],
|
|
7
|
+
run: ({ args }) => {
|
|
8
|
+
// export VAR=value or export VAR (to make it available to child processes)
|
|
9
|
+
if (args.length === 0) {
|
|
10
|
+
// List all exported variables
|
|
11
|
+
return {
|
|
12
|
+
stdout: "# export command - sets variables for child processes",
|
|
13
|
+
exitCode: 0,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Parse VAR=value format
|
|
18
|
+
for (const arg of args) {
|
|
19
|
+
if (arg.includes("=")) {
|
|
20
|
+
const [varName, varValue] = arg.split("=", 2);
|
|
21
|
+
if (varName && varValue !== undefined) {
|
|
22
|
+
setEnvVar(varName, varValue);
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
// export VAR_NAME makes it available but we just set it
|
|
26
|
+
setEnvVar(arg, getEnvVar(arg) || "");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { exitCode: 0 };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ShellModule } from "../../types/commands";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
|
+
|
|
4
|
+
export const grepCommand: ShellModule = {
|
|
5
|
+
name: "grep",
|
|
6
|
+
params: ["[-i] [-v] <pattern> [file...]"],
|
|
7
|
+
run: ({ authUser, vfs, cwd, args, stdin }) => {
|
|
8
|
+
const caseInsensitive = args.includes("-i");
|
|
9
|
+
const invertMatch = args.includes("-v");
|
|
10
|
+
const filteredArgs = args.filter((arg) => arg !== "-i" && arg !== "-v");
|
|
11
|
+
|
|
12
|
+
if (filteredArgs.length === 0) {
|
|
13
|
+
return { stderr: "grep: no pattern specified", exitCode: 1 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pattern = filteredArgs[0];
|
|
17
|
+
const files = filteredArgs.slice(1);
|
|
18
|
+
|
|
19
|
+
let regex: RegExp;
|
|
20
|
+
try {
|
|
21
|
+
const flags = caseInsensitive ? "gmi" : "gm";
|
|
22
|
+
regex = new RegExp(pattern as string, flags);
|
|
23
|
+
} catch {
|
|
24
|
+
return { stderr: `grep: invalid regex: ${pattern}`, exitCode: 1 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const results: string[] = [];
|
|
28
|
+
|
|
29
|
+
// If no files specified, read from stdin (pipe/input redirection).
|
|
30
|
+
if (files.length === 0) {
|
|
31
|
+
if (!stdin) {
|
|
32
|
+
return { stdout: "", exitCode: 1 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const lines = stdin.split("\n");
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
regex.lastIndex = 0;
|
|
38
|
+
const matches = regex.test(line);
|
|
39
|
+
const shouldInclude = invertMatch ? !matches : matches;
|
|
40
|
+
|
|
41
|
+
if (shouldInclude) {
|
|
42
|
+
results.push(line);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
stdout: results.length > 0 ? results.join("\n") : "",
|
|
48
|
+
exitCode: results.length > 0 ? 0 : 1,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const target = resolvePath(cwd, file);
|
|
54
|
+
try {
|
|
55
|
+
assertPathAccess(authUser, target, "grep");
|
|
56
|
+
const content = vfs.readFile(target);
|
|
57
|
+
const lines = content.split("\n");
|
|
58
|
+
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
regex.lastIndex = 0;
|
|
61
|
+
const matches = regex.test(line);
|
|
62
|
+
const shouldInclude = invertMatch ? !matches : matches;
|
|
63
|
+
|
|
64
|
+
if (shouldInclude) {
|
|
65
|
+
results.push(line);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
return {
|
|
70
|
+
stderr: `grep: ${file}: No such file or directory`,
|
|
71
|
+
exitCode: 1,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
stdout: results.length > 0 ? results.join("\n") : "",
|
|
78
|
+
exitCode: results.length > 0 ? 0 : 1,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CommandMode,
|
|
3
3
|
CommandOutcome,
|
|
4
|
+
CommandResult,
|
|
4
5
|
ShellModule,
|
|
5
6
|
} from "../../types/commands";
|
|
6
7
|
import type VirtualFileSystem from "../../VirtualFileSystem";
|
|
@@ -11,7 +12,11 @@ import { cdCommand } from "./cd";
|
|
|
11
12
|
import { clearCommand } from "./clear";
|
|
12
13
|
import { curlCommand } from "./curl";
|
|
13
14
|
import { deluserCommand } from "./deluser";
|
|
15
|
+
import { echoCommand } from "./echo";
|
|
16
|
+
import { envCommand } from "./env";
|
|
14
17
|
import { exitCommand } from "./exit";
|
|
18
|
+
import { exportCommand } from "./export";
|
|
19
|
+
import { grepCommand } from "./grep";
|
|
15
20
|
import { createHelpCommand } from "./help";
|
|
16
21
|
import { hostnameCommand } from "./hostname";
|
|
17
22
|
import { htopCommand } from "./htop";
|
|
@@ -20,10 +25,13 @@ import { mkdirCommand } from "./mkdir";
|
|
|
20
25
|
import { nanoCommand } from "./nano";
|
|
21
26
|
import { pwdCommand } from "./pwd";
|
|
22
27
|
import { rmCommand } from "./rm";
|
|
28
|
+
import { setCommand } from "./set";
|
|
29
|
+
import { shCommand } from "./sh";
|
|
23
30
|
import { suCommand } from "./su";
|
|
24
31
|
import { sudoCommand } from "./sudo";
|
|
25
32
|
import { touchCommand } from "./touch";
|
|
26
33
|
import { treeCommand } from "./tree";
|
|
34
|
+
import { unsetCommand } from "./unset";
|
|
27
35
|
import { wgetCommand } from "./wget";
|
|
28
36
|
import { whoCommand } from "./who";
|
|
29
37
|
import { whoamiCommand } from "./whoami";
|
|
@@ -36,6 +44,7 @@ const BASE_COMMANDS: ShellModule[] = [
|
|
|
36
44
|
lsCommand,
|
|
37
45
|
cdCommand,
|
|
38
46
|
catCommand,
|
|
47
|
+
echoCommand,
|
|
39
48
|
mkdirCommand,
|
|
40
49
|
touchCommand,
|
|
41
50
|
rmCommand,
|
|
@@ -47,7 +56,13 @@ const BASE_COMMANDS: ShellModule[] = [
|
|
|
47
56
|
sudoCommand,
|
|
48
57
|
suCommand,
|
|
49
58
|
curlCommand,
|
|
59
|
+
envCommand,
|
|
50
60
|
wgetCommand,
|
|
61
|
+
grepCommand,
|
|
62
|
+
exportCommand,
|
|
63
|
+
setCommand,
|
|
64
|
+
unsetCommand,
|
|
65
|
+
shCommand,
|
|
51
66
|
clearCommand,
|
|
52
67
|
exitCommand,
|
|
53
68
|
];
|
|
@@ -68,14 +83,134 @@ function resolveModule(name: string): ShellModule | undefined {
|
|
|
68
83
|
);
|
|
69
84
|
}
|
|
70
85
|
|
|
86
|
+
function splitArgsRespectingQuotes(input: string): string[] {
|
|
87
|
+
const tokens: string[] = [];
|
|
88
|
+
let current = "";
|
|
89
|
+
let inQuotes = false;
|
|
90
|
+
let quoteChar = "";
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
93
|
+
const ch = input[i] || "";
|
|
94
|
+
const prev = i > 0 ? input[i - 1] : "";
|
|
95
|
+
|
|
96
|
+
if ((ch === '"' || ch === "'") && prev !== "\\") {
|
|
97
|
+
if (!inQuotes) {
|
|
98
|
+
inQuotes = true;
|
|
99
|
+
quoteChar = ch;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (ch === quoteChar) {
|
|
104
|
+
inQuotes = false;
|
|
105
|
+
quoteChar = "";
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (/\s/.test(ch) && !inQuotes) {
|
|
111
|
+
if (current.length > 0) {
|
|
112
|
+
tokens.push(current);
|
|
113
|
+
current = "";
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
current += ch;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (current.length > 0) {
|
|
122
|
+
tokens.push(current);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return tokens;
|
|
126
|
+
}
|
|
127
|
+
|
|
71
128
|
function parseInput(rawInput: string): { commandName: string; args: string[] } {
|
|
72
|
-
const parts = rawInput.trim()
|
|
129
|
+
const parts = splitArgsRespectingQuotes(rawInput.trim());
|
|
73
130
|
return {
|
|
74
131
|
commandName: parts[0]?.toLowerCase() ?? "",
|
|
75
132
|
args: parts.slice(1),
|
|
76
133
|
};
|
|
77
134
|
}
|
|
78
135
|
|
|
136
|
+
// Internal async function for pipeline execution
|
|
137
|
+
async function runCommandInternal(
|
|
138
|
+
rawInput: string,
|
|
139
|
+
authUser: string,
|
|
140
|
+
hostname: string,
|
|
141
|
+
users: VirtualUserManager,
|
|
142
|
+
mode: CommandMode,
|
|
143
|
+
cwd: string,
|
|
144
|
+
vfs: VirtualFileSystem,
|
|
145
|
+
stdin?: string,
|
|
146
|
+
): Promise<CommandResult> {
|
|
147
|
+
// Check if input contains pipes or redirections
|
|
148
|
+
if (
|
|
149
|
+
rawInput.includes("|") ||
|
|
150
|
+
rawInput.includes(">") ||
|
|
151
|
+
rawInput.includes("<")
|
|
152
|
+
) {
|
|
153
|
+
// Use pipeline executor
|
|
154
|
+
const { parseShellPipeline } = await import("../shellParser");
|
|
155
|
+
const { executePipeline } = await import("../executor");
|
|
156
|
+
|
|
157
|
+
const pipeline = parseShellPipeline(rawInput);
|
|
158
|
+
if (!pipeline.isValid) {
|
|
159
|
+
return {
|
|
160
|
+
stderr: pipeline.error || "Syntax error",
|
|
161
|
+
exitCode: 1,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
return await executePipeline(
|
|
167
|
+
pipeline,
|
|
168
|
+
authUser,
|
|
169
|
+
hostname,
|
|
170
|
+
users,
|
|
171
|
+
mode,
|
|
172
|
+
cwd,
|
|
173
|
+
vfs,
|
|
174
|
+
);
|
|
175
|
+
} catch (error: unknown) {
|
|
176
|
+
const message =
|
|
177
|
+
error instanceof Error ? error.message : "Pipeline execution failed";
|
|
178
|
+
return { stderr: message, exitCode: 1 };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Regular command execution
|
|
183
|
+
const { commandName, args } = parseInput(rawInput);
|
|
184
|
+
const mod = resolveModule(commandName);
|
|
185
|
+
|
|
186
|
+
if (!mod) {
|
|
187
|
+
return {
|
|
188
|
+
stderr: `Command '${rawInput}' not found`,
|
|
189
|
+
exitCode: 127,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const result = mod.run({
|
|
195
|
+
authUser,
|
|
196
|
+
hostname,
|
|
197
|
+
users,
|
|
198
|
+
activeSessions: users.listActiveSessions(),
|
|
199
|
+
rawInput,
|
|
200
|
+
mode,
|
|
201
|
+
args,
|
|
202
|
+
stdin,
|
|
203
|
+
cwd,
|
|
204
|
+
vfs,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return await Promise.resolve(result);
|
|
208
|
+
} catch (error: unknown) {
|
|
209
|
+
const message = error instanceof Error ? error.message : "Command failed";
|
|
210
|
+
return { stderr: message, exitCode: 1 };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
79
214
|
export function runCommand(
|
|
80
215
|
rawInput: string,
|
|
81
216
|
authUser: string,
|
|
@@ -84,6 +219,7 @@ export function runCommand(
|
|
|
84
219
|
mode: CommandMode,
|
|
85
220
|
cwd: string,
|
|
86
221
|
vfs: VirtualFileSystem,
|
|
222
|
+
stdin?: string,
|
|
87
223
|
): CommandOutcome {
|
|
88
224
|
const trimmed = rawInput.trim();
|
|
89
225
|
|
|
@@ -91,6 +227,21 @@ export function runCommand(
|
|
|
91
227
|
return { exitCode: 0 };
|
|
92
228
|
}
|
|
93
229
|
|
|
230
|
+
// Check if input contains pipes or redirections - use async version
|
|
231
|
+
if (trimmed.includes("|") || trimmed.includes(">") || trimmed.includes("<")) {
|
|
232
|
+
return runCommandInternal(
|
|
233
|
+
trimmed,
|
|
234
|
+
authUser,
|
|
235
|
+
hostname,
|
|
236
|
+
users,
|
|
237
|
+
mode,
|
|
238
|
+
cwd,
|
|
239
|
+
vfs,
|
|
240
|
+
stdin,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Regular synchronous command execution
|
|
94
245
|
const { commandName, args } = parseInput(trimmed);
|
|
95
246
|
const mod = resolveModule(commandName);
|
|
96
247
|
|
|
@@ -110,6 +261,7 @@ export function runCommand(
|
|
|
110
261
|
rawInput: trimmed,
|
|
111
262
|
mode,
|
|
112
263
|
args,
|
|
264
|
+
stdin,
|
|
113
265
|
cwd,
|
|
114
266
|
vfs,
|
|
115
267
|
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** biome-ignore-all lint/style/useNamingConvention: env variables */
|
|
2
|
+
import type { ShellModule } from "../../types/commands";
|
|
3
|
+
|
|
4
|
+
// Simple in-memory environment variables store
|
|
5
|
+
// In a real implementation, this would be per-session/per-user
|
|
6
|
+
const envVars: Record<string, string> = {
|
|
7
|
+
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
8
|
+
HOME: "/home/user",
|
|
9
|
+
SHELL: "/bin/sh",
|
|
10
|
+
TERM: "xterm-256color",
|
|
11
|
+
USER: "user",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function getEnvVar(name: string): string | undefined {
|
|
15
|
+
return envVars[name];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function setEnvVar(name: string, value: string): void {
|
|
19
|
+
envVars[name] = value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getAllEnvVars(authUser: string): Record<string, string> {
|
|
23
|
+
envVars.USER = authUser;
|
|
24
|
+
envVars.HOME = `/home/${authUser}`;
|
|
25
|
+
return { ...envVars };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const setCommand: ShellModule = {
|
|
29
|
+
name: "set",
|
|
30
|
+
params: ["[VAR=value]"],
|
|
31
|
+
run: ({ args }) => {
|
|
32
|
+
// No arguments: display all environment variables
|
|
33
|
+
if (args.length === 0) {
|
|
34
|
+
const output = Object.entries(envVars)
|
|
35
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
36
|
+
.sort()
|
|
37
|
+
.join("\n");
|
|
38
|
+
|
|
39
|
+
return { stdout: output, exitCode: 0 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Parse VAR=value format
|
|
43
|
+
const assignments: string[] = [];
|
|
44
|
+
for (const arg of args) {
|
|
45
|
+
if (arg.includes("=")) {
|
|
46
|
+
const [varName, varValue] = arg.split("=", 2);
|
|
47
|
+
if (varName && varValue !== undefined) {
|
|
48
|
+
setEnvVar(varName, varValue);
|
|
49
|
+
assignments.push(arg);
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
// If no '=' present, display that specific variable
|
|
53
|
+
const value = getEnvVar(arg);
|
|
54
|
+
if (value !== undefined) {
|
|
55
|
+
assignments.push(`${arg}=${value}`);
|
|
56
|
+
} else {
|
|
57
|
+
assignments.push(`${arg}: not set`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
stdout: assignments.length > 0 ? assignments.join("\n") : "",
|
|
64
|
+
exitCode: 0,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { CommandContext, ShellModule } from "../../types/commands";
|
|
2
|
+
import { runCommand } from "./index";
|
|
3
|
+
|
|
4
|
+
/** Simple shell script executor with basic variable support */
|
|
5
|
+
export const shCommand: ShellModule = {
|
|
6
|
+
name: "sh",
|
|
7
|
+
params: ["-c <script>", "[<file>]"],
|
|
8
|
+
aliases: ["bash"],
|
|
9
|
+
run: async (ctx: CommandContext) => {
|
|
10
|
+
const { vfs, args, authUser, hostname, users, mode, cwd } = ctx;
|
|
11
|
+
|
|
12
|
+
// Handle -c option: sh -c "command"
|
|
13
|
+
if (args[0] === "-c" && args.length >= 2) {
|
|
14
|
+
const script = args[1] ?? "";
|
|
15
|
+
if (!script) {
|
|
16
|
+
return { stderr: "sh: -c requires a script", exitCode: 1 };
|
|
17
|
+
}
|
|
18
|
+
const scriptArgs = args.slice(2);
|
|
19
|
+
|
|
20
|
+
// Split by semicolon and newline
|
|
21
|
+
const lines = script
|
|
22
|
+
.split(/[;\n]/)
|
|
23
|
+
.map((line) => line.trim())
|
|
24
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
25
|
+
|
|
26
|
+
let output = "";
|
|
27
|
+
let exitCode = 0;
|
|
28
|
+
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
// Simple variable substitution
|
|
31
|
+
let command = line;
|
|
32
|
+
for (let i = 0; i < scriptArgs.length; i++) {
|
|
33
|
+
const arg = scriptArgs[i] ?? "";
|
|
34
|
+
command = command.replaceAll(`$${i}`, arg);
|
|
35
|
+
}
|
|
36
|
+
command = command.replaceAll("$@", scriptArgs.join(" "));
|
|
37
|
+
|
|
38
|
+
// Execute the command
|
|
39
|
+
const result = await Promise.resolve(
|
|
40
|
+
runCommand(command, authUser, hostname, users, mode, cwd, vfs),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (result.stdout) {
|
|
44
|
+
output += `${result.stdout}\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (result.stderr) {
|
|
48
|
+
return { stderr: result.stderr, exitCode: result.exitCode ?? 1 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
exitCode = result.exitCode ?? 0;
|
|
52
|
+
if (exitCode !== 0) {
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
stdout: output.trimEnd(),
|
|
59
|
+
exitCode,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Handle script file execution: sh <file>
|
|
64
|
+
if (args.length > 0 && args[0]) {
|
|
65
|
+
try {
|
|
66
|
+
const scriptFile = args[0];
|
|
67
|
+
const content = vfs.readFile(scriptFile);
|
|
68
|
+
const scriptArgs = args.slice(1);
|
|
69
|
+
|
|
70
|
+
// Split by newline
|
|
71
|
+
const lines = content
|
|
72
|
+
.split("\n")
|
|
73
|
+
.map((line) => line.trim())
|
|
74
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
75
|
+
|
|
76
|
+
let output = "";
|
|
77
|
+
let exitCode = 0;
|
|
78
|
+
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
// Simple variable substitution
|
|
81
|
+
let command = line;
|
|
82
|
+
for (let i = 0; i < scriptArgs.length; i++) {
|
|
83
|
+
const arg = scriptArgs[i] ?? "";
|
|
84
|
+
command = command.replaceAll(`$${i}`, arg);
|
|
85
|
+
}
|
|
86
|
+
command = command.replaceAll("$@", scriptArgs.join(" "));
|
|
87
|
+
|
|
88
|
+
// Execute the command
|
|
89
|
+
const result = await Promise.resolve(
|
|
90
|
+
runCommand(command, authUser, hostname, users, mode, cwd, vfs),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (result.stdout) {
|
|
94
|
+
output += `${result.stdout}\n`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (result.stderr) {
|
|
98
|
+
return { stderr: result.stderr, exitCode: result.exitCode ?? 1 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
exitCode = result.exitCode ?? 0;
|
|
102
|
+
if (exitCode !== 0) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
stdout: output.trimEnd(),
|
|
109
|
+
exitCode,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
return {
|
|
113
|
+
stderr: `sh: ${args[0]}: No such file or directory`,
|
|
114
|
+
exitCode: 1,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { stderr: "sh: missing operand or script", exitCode: 1 };
|
|
120
|
+
},
|
|
121
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { CommandMode, CommandResult } from "../types/commands";
|
|
2
|
+
import type { Pipeline, PipelineCommand } from "../types/pipeline";
|
|
3
|
+
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
4
|
+
import { runCommand as runSingleCommand } from "./commands";
|
|
5
|
+
import { resolvePath } from "./commands/helpers";
|
|
6
|
+
import type { VirtualUserManager } from "./users";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Execute a parsed pipeline, chaining commands and handling redirections.
|
|
10
|
+
* Manages stdout/stderr flow between commands and file I/O.
|
|
11
|
+
*/
|
|
12
|
+
export async function executePipeline(
|
|
13
|
+
pipeline: Pipeline,
|
|
14
|
+
authUser: string,
|
|
15
|
+
hostname: string,
|
|
16
|
+
users: VirtualUserManager,
|
|
17
|
+
mode: CommandMode,
|
|
18
|
+
cwd: string,
|
|
19
|
+
vfs: VirtualFileSystem,
|
|
20
|
+
): Promise<CommandResult> {
|
|
21
|
+
if (pipeline.commands.length === 0) {
|
|
22
|
+
return { exitCode: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (pipeline.commands.length === 1) {
|
|
26
|
+
// Single command with possible redirections
|
|
27
|
+
return executeSingleCommandWithRedirections(
|
|
28
|
+
pipeline.commands[0] as PipelineCommand,
|
|
29
|
+
authUser,
|
|
30
|
+
hostname,
|
|
31
|
+
users,
|
|
32
|
+
mode,
|
|
33
|
+
cwd,
|
|
34
|
+
vfs,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Multiple commands in a pipeline
|
|
39
|
+
return executePipelineChain(
|
|
40
|
+
pipeline.commands as PipelineCommand[],
|
|
41
|
+
authUser,
|
|
42
|
+
hostname,
|
|
43
|
+
users,
|
|
44
|
+
mode,
|
|
45
|
+
cwd,
|
|
46
|
+
vfs,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Execute a single command with input/output redirections
|
|
52
|
+
*/
|
|
53
|
+
async function executeSingleCommandWithRedirections(
|
|
54
|
+
cmd: PipelineCommand,
|
|
55
|
+
authUser: string,
|
|
56
|
+
hostname: string,
|
|
57
|
+
users: VirtualUserManager,
|
|
58
|
+
mode: CommandMode,
|
|
59
|
+
cwd: string,
|
|
60
|
+
vfs: VirtualFileSystem,
|
|
61
|
+
): Promise<CommandResult> {
|
|
62
|
+
// Prepare input if input file specified
|
|
63
|
+
let stdin: string | undefined;
|
|
64
|
+
if (cmd.inputFile) {
|
|
65
|
+
const inputPath = resolvePath(cwd, cmd.inputFile);
|
|
66
|
+
try {
|
|
67
|
+
stdin = vfs.readFile(inputPath);
|
|
68
|
+
} catch {
|
|
69
|
+
return {
|
|
70
|
+
stderr: `cat: ${cmd.inputFile}: No such file or directory`,
|
|
71
|
+
exitCode: 1,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build raw input for the command
|
|
77
|
+
const rawInput = [cmd.name, ...cmd.args].join(" ");
|
|
78
|
+
|
|
79
|
+
// Run the command with potential input
|
|
80
|
+
const result = await runSingleCommand(
|
|
81
|
+
rawInput,
|
|
82
|
+
authUser,
|
|
83
|
+
hostname,
|
|
84
|
+
users,
|
|
85
|
+
mode,
|
|
86
|
+
cwd,
|
|
87
|
+
vfs,
|
|
88
|
+
stdin,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Handle output redirection
|
|
92
|
+
if (cmd.outputFile) {
|
|
93
|
+
const outputPath = resolvePath(cwd, cmd.outputFile);
|
|
94
|
+
const output = result.stdout || "";
|
|
95
|
+
try {
|
|
96
|
+
if (cmd.appendOutput) {
|
|
97
|
+
try {
|
|
98
|
+
const existing = vfs.readFile(outputPath);
|
|
99
|
+
vfs.writeFile(outputPath, existing + output);
|
|
100
|
+
} catch {
|
|
101
|
+
vfs.writeFile(outputPath, output);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
vfs.writeFile(outputPath, output);
|
|
105
|
+
}
|
|
106
|
+
return { ...result, stdout: "" };
|
|
107
|
+
} catch {
|
|
108
|
+
return {
|
|
109
|
+
...result,
|
|
110
|
+
stderr: `Failed to write to ${cmd.outputFile}`,
|
|
111
|
+
exitCode: 1,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Execute a chain of commands connected by pipes
|
|
121
|
+
*/
|
|
122
|
+
async function executePipelineChain(
|
|
123
|
+
commands: PipelineCommand[],
|
|
124
|
+
authUser: string,
|
|
125
|
+
hostname: string,
|
|
126
|
+
users: VirtualUserManager,
|
|
127
|
+
mode: CommandMode,
|
|
128
|
+
cwd: string,
|
|
129
|
+
vfs: VirtualFileSystem,
|
|
130
|
+
): Promise<CommandResult> {
|
|
131
|
+
let currentOutput = "";
|
|
132
|
+
let exitCode = 0;
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < commands.length; i++) {
|
|
135
|
+
const cmd = commands[i] as PipelineCommand;
|
|
136
|
+
|
|
137
|
+
// Handle input file for first command
|
|
138
|
+
if (i === 0 && cmd.inputFile) {
|
|
139
|
+
const inputPath = resolvePath(cwd, cmd.inputFile);
|
|
140
|
+
try {
|
|
141
|
+
currentOutput = vfs.readFile(inputPath);
|
|
142
|
+
} catch {
|
|
143
|
+
return {
|
|
144
|
+
stderr: `cat: ${cmd.inputFile}: No such file or directory`,
|
|
145
|
+
exitCode: 1,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Build raw input
|
|
151
|
+
const rawInput = [cmd.name, ...cmd.args].join(" ");
|
|
152
|
+
|
|
153
|
+
// Create a modified context that might accept stdin
|
|
154
|
+
// For now, we'll append input as an additional arg for commands that support it
|
|
155
|
+
const result = await runSingleCommand(
|
|
156
|
+
rawInput,
|
|
157
|
+
authUser,
|
|
158
|
+
hostname,
|
|
159
|
+
users,
|
|
160
|
+
mode,
|
|
161
|
+
cwd,
|
|
162
|
+
vfs,
|
|
163
|
+
currentOutput,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
exitCode = result.exitCode ?? 0;
|
|
167
|
+
|
|
168
|
+
// Handle output redirection (only for last command)
|
|
169
|
+
if (i === commands.length - 1 && cmd.outputFile) {
|
|
170
|
+
const outputPath = resolvePath(cwd, cmd.outputFile);
|
|
171
|
+
const output = result.stdout || "";
|
|
172
|
+
try {
|
|
173
|
+
if (cmd.appendOutput) {
|
|
174
|
+
try {
|
|
175
|
+
const existing = vfs.readFile(outputPath);
|
|
176
|
+
vfs.writeFile(outputPath, existing + output);
|
|
177
|
+
} catch {
|
|
178
|
+
vfs.writeFile(outputPath, output);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
vfs.writeFile(outputPath, output);
|
|
182
|
+
}
|
|
183
|
+
currentOutput = "";
|
|
184
|
+
} catch {
|
|
185
|
+
return {
|
|
186
|
+
stderr: `Failed to write to ${cmd.outputFile}`,
|
|
187
|
+
exitCode: 1,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// Pass output to next command
|
|
192
|
+
currentOutput = result.stdout || "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (result.stderr && exitCode !== 0) {
|
|
196
|
+
return { stderr: result.stderr, exitCode };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { stdout: currentOutput, exitCode };
|
|
201
|
+
}
|
|
@@ -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/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
|
+
}
|