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.
@@ -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 more.
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
@@ -3,7 +3,7 @@
3
3
  "description": "In-memory SSH server with virtual filesystem and typed programmatic API",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
- "version": "1.0.3",
6
+ "version": "1.0.4",
7
7
  "license": "MIT",
8
8
  "keywords": [
9
9
  "ssh",
@@ -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().split(/\s+/).filter(Boolean);
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
+ }
@@ -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
+ }