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.
Files changed (56) hide show
  1. package/.github/workflows/test-battery.yml +22 -0
  2. package/CHANGELOG.md +42 -0
  3. package/README.md +56 -28
  4. package/package.json +1 -1
  5. package/src/SSHMimic/client.ts +1 -1
  6. package/src/SSHMimic/exec.ts +1 -1
  7. package/src/SSHMimic/executor.ts +201 -0
  8. package/src/SSHMimic/index.ts +14 -18
  9. package/src/{VirtualFileSystem.ts → VirtualFileSystem/index.ts} +6 -15
  10. package/src/{SSHMimic → VirtualShell}/commands/cat.ts +2 -1
  11. package/src/VirtualShell/commands/command-helpers.ts +135 -0
  12. package/src/{SSHMimic → VirtualShell}/commands/curl.ts +16 -37
  13. package/src/VirtualShell/commands/echo.ts +34 -0
  14. package/src/VirtualShell/commands/env.ts +22 -0
  15. package/src/VirtualShell/commands/export.ts +38 -0
  16. package/src/VirtualShell/commands/grep.ts +88 -0
  17. package/src/{SSHMimic → VirtualShell}/commands/helpers.ts +0 -37
  18. package/src/VirtualShell/commands/index.ts +327 -0
  19. package/src/{SSHMimic → VirtualShell}/commands/ls.ts +3 -2
  20. package/src/{SSHMimic → VirtualShell}/commands/mkdir.ts +6 -1
  21. package/src/{SSHMimic → VirtualShell}/commands/rm.ts +10 -3
  22. package/src/VirtualShell/commands/set.ts +73 -0
  23. package/src/VirtualShell/commands/sh.ts +58 -0
  24. package/src/{SSHMimic → VirtualShell}/commands/su.ts +3 -3
  25. package/src/{SSHMimic → VirtualShell}/commands/sudo.ts +16 -26
  26. package/src/{SSHMimic → VirtualShell}/commands/tree.ts +2 -1
  27. package/src/VirtualShell/commands/unset.ts +19 -0
  28. package/src/{SSHMimic → VirtualShell}/commands/wget.ts +23 -6
  29. package/src/{SSHMimic → VirtualShell}/commands/who.ts +1 -1
  30. package/src/VirtualShell/index.ts +69 -0
  31. package/src/{SSHMimic → VirtualShell}/shell.ts +3 -3
  32. package/src/VirtualShell/shellParser.ts +203 -0
  33. package/src/index.ts +8 -0
  34. package/src/standalone.ts +10 -1
  35. package/src/types/commands.ts +2 -0
  36. package/src/types/pipeline.ts +23 -0
  37. package/tests/command-helpers.test.ts +40 -0
  38. package/tests/helpers.test.ts +1 -1
  39. package/src/SSHMimic/commands/index.ts +0 -120
  40. /package/src/{vfs → VirtualFileSystem}/archive.ts +0 -0
  41. /package/src/{vfs → VirtualFileSystem}/internalTypes.ts +0 -0
  42. /package/src/{vfs → VirtualFileSystem}/path.ts +0 -0
  43. /package/src/{vfs → VirtualFileSystem}/snapshot.ts +0 -0
  44. /package/src/{vfs → VirtualFileSystem}/tree.ts +0 -0
  45. /package/src/{SSHMimic → VirtualShell}/commands/adduser.ts +0 -0
  46. /package/src/{SSHMimic → VirtualShell}/commands/cd.ts +0 -0
  47. /package/src/{SSHMimic → VirtualShell}/commands/clear.ts +0 -0
  48. /package/src/{SSHMimic → VirtualShell}/commands/deluser.ts +0 -0
  49. /package/src/{SSHMimic → VirtualShell}/commands/exit.ts +0 -0
  50. /package/src/{SSHMimic → VirtualShell}/commands/help.ts +0 -0
  51. /package/src/{SSHMimic → VirtualShell}/commands/hostname.ts +0 -0
  52. /package/src/{SSHMimic → VirtualShell}/commands/htop.ts +0 -0
  53. /package/src/{SSHMimic → VirtualShell}/commands/nano.ts +0 -0
  54. /package/src/{SSHMimic → VirtualShell}/commands/pwd.ts +0 -0
  55. /package/src/{SSHMimic → VirtualShell}/commands/touch.ts +0 -0
  56. /package/src/{SSHMimic → VirtualShell}/commands/whoami.ts +0 -0
@@ -1,43 +1,12 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import type { ShellModule } from "../../types/commands";
3
+ import { getArg, getFlag, ifFlag } from "./command-helpers";
3
4
  import {
4
5
  assertPathAccess,
5
6
  normalizeTerminalOutput,
6
7
  resolvePath,
7
8
  } from "./helpers";
8
9
 
9
- function parseCurlOutputPath(args: string[]): {
10
- outputPath: string | null;
11
- inputArgs: string[];
12
- } {
13
- const filtered: string[] = [];
14
- let outputPath: string | null = null;
15
-
16
- for (let index = 0; index < args.length; index += 1) {
17
- const arg = args[index]!;
18
-
19
- if (arg === "-o" || arg === "--output") {
20
- outputPath = args[index + 1] ?? null;
21
- index += 1;
22
- continue;
23
- }
24
-
25
- if (arg.startsWith("-o=")) {
26
- outputPath = arg.slice(3);
27
- continue;
28
- }
29
-
30
- if (arg.startsWith("--output=")) {
31
- outputPath = arg.slice("--output=".length);
32
- continue;
33
- }
34
-
35
- filtered.push(arg);
36
- }
37
-
38
- return { outputPath, inputArgs: filtered };
39
- }
40
-
41
10
  function runHostCurl(args: string[]): Promise<{
42
11
  stdout: string;
43
12
  stderr: string;
@@ -110,12 +79,22 @@ export const curlCommand: ShellModule = {
110
79
  name: "curl",
111
80
  params: ["[-o file] <url>"],
112
81
  run: async ({ authUser, vfs, cwd, args }) => {
113
- const { outputPath, inputArgs } = parseCurlOutputPath(args);
82
+ const outputPathValue = getFlag(args, ["-o", "--output"]);
83
+ const outputPath =
84
+ typeof outputPathValue === "string" && outputPathValue.length > 0
85
+ ? outputPathValue
86
+ : null;
87
+ const parserOptions = { flagsWithValue: ["-o", "--output"] };
88
+ const inputArgs: string[] = [];
89
+ for (let index = 0; ; index += 1) {
90
+ const arg = getArg(args, index, parserOptions);
91
+ if (!arg) {
92
+ break;
93
+ }
94
+ inputArgs.push(arg);
95
+ }
114
96
  const url = inputArgs[0];
115
- const isHelpLike = inputArgs.some(
116
- (arg) =>
117
- arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version",
118
- );
97
+ const isHelpLike = ifFlag(args, ["-h", "--help", "-V", "--version"]);
119
98
 
120
99
  if (!url) {
121
100
  return { stderr: "curl: missing URL", exitCode: 1 };
@@ -0,0 +1,34 @@
1
+ import type { ShellModule } from "../../types/commands";
2
+ import { getArg, ifFlag } from "./command-helpers";
3
+ import { getAllEnvVars } from "./set";
4
+
5
+ function expandEnvVars(input: string, env: Record<string, string>): string {
6
+ return input.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name: string) => {
7
+ return env[name] ?? "";
8
+ });
9
+ }
10
+
11
+ export const echoCommand: ShellModule = {
12
+ name: "echo",
13
+ params: ["[options] [text...]"],
14
+ run: ({ args, authUser, stdin }) => {
15
+ const newline = !ifFlag(args, "-n");
16
+ const filteredArgs: string[] = [];
17
+ for (let index = 0; ; index += 1) {
18
+ const value = getArg(args, index, { flags: ["-n"] });
19
+ if (value === undefined) {
20
+ break;
21
+ }
22
+ filteredArgs.push(value);
23
+ }
24
+ const env = getAllEnvVars(authUser);
25
+ const rawText =
26
+ filteredArgs.length > 0 ? filteredArgs.join(" ") : (stdin ?? "");
27
+ const text = expandEnvVars(rawText, env);
28
+
29
+ return {
30
+ stdout: newline ? text : text.trimEnd(),
31
+ exitCode: 0,
32
+ };
33
+ },
34
+ };
@@ -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,38 @@
1
+ import type { ShellModule } from "../../types/commands";
2
+ import { getArg } from "./command-helpers";
3
+ import { getEnvVar, setEnvVar } from "./set";
4
+
5
+ export const exportCommand: ShellModule = {
6
+ name: "export",
7
+ params: ["[VAR=value]"],
8
+ run: ({ args }) => {
9
+ // export VAR=value or export VAR (to make it available to child processes)
10
+ if (args.length === 0) {
11
+ // List all exported variables
12
+ return {
13
+ stdout: "# export command - sets variables for child processes",
14
+ exitCode: 0,
15
+ };
16
+ }
17
+
18
+ // Parse VAR=value format
19
+ for (let index = 0; ; index += 1) {
20
+ const arg = getArg(args, index);
21
+ if (!arg) {
22
+ break;
23
+ }
24
+
25
+ if (arg.includes("=")) {
26
+ const [varName, varValue] = arg.split("=", 2);
27
+ if (varName && varValue !== undefined) {
28
+ setEnvVar(varName, varValue);
29
+ }
30
+ } else {
31
+ // export VAR_NAME makes it available but we just set it
32
+ setEnvVar(arg, getEnvVar(arg) || "");
33
+ }
34
+ }
35
+
36
+ return { exitCode: 0 };
37
+ },
38
+ };
@@ -0,0 +1,88 @@
1
+ import type { ShellModule } from "../../types/commands";
2
+ import { getArg, ifFlag } from "./command-helpers";
3
+ import { assertPathAccess, resolvePath } from "./helpers";
4
+
5
+ export const grepCommand: ShellModule = {
6
+ name: "grep",
7
+ params: ["[-i] [-v] <pattern> [file...]"],
8
+ run: ({ authUser, vfs, cwd, args, stdin }) => {
9
+ const caseInsensitive = ifFlag(args, "-i");
10
+ const invertMatch = ifFlag(args, "-v");
11
+ const parserOptions = { flags: ["-i", "-v"] };
12
+ const pattern = getArg(args, 0, parserOptions);
13
+ const files: string[] = [];
14
+ for (let index = 1; ; index += 1) {
15
+ const file = getArg(args, index, parserOptions);
16
+ if (!file) {
17
+ break;
18
+ }
19
+ files.push(file);
20
+ }
21
+
22
+ if (!pattern) {
23
+ return { stderr: "grep: no pattern specified", exitCode: 1 };
24
+ }
25
+
26
+ let regex: RegExp;
27
+ try {
28
+ const flags = caseInsensitive ? "gmi" : "gm";
29
+ regex = new RegExp(pattern, flags);
30
+ } catch {
31
+ return { stderr: `grep: invalid regex: ${pattern}`, exitCode: 1 };
32
+ }
33
+
34
+ const results: string[] = [];
35
+
36
+ // If no files specified, read from stdin (pipe/input redirection).
37
+ if (files.length === 0) {
38
+ if (!stdin) {
39
+ return { stdout: "", exitCode: 1 };
40
+ }
41
+
42
+ const lines = stdin.split("\n");
43
+ for (const line of lines) {
44
+ regex.lastIndex = 0;
45
+ const matches = regex.test(line);
46
+ const shouldInclude = invertMatch ? !matches : matches;
47
+
48
+ if (shouldInclude) {
49
+ results.push(line);
50
+ }
51
+ }
52
+
53
+ return {
54
+ stdout: results.length > 0 ? results.join("\n") : "",
55
+ exitCode: results.length > 0 ? 0 : 1,
56
+ };
57
+ }
58
+
59
+ for (const file of files) {
60
+ const target = resolvePath(cwd, file);
61
+ try {
62
+ assertPathAccess(authUser, target, "grep");
63
+ const content = vfs.readFile(target);
64
+ const lines = content.split("\n");
65
+
66
+ for (const line of lines) {
67
+ regex.lastIndex = 0;
68
+ const matches = regex.test(line);
69
+ const shouldInclude = invertMatch ? !matches : matches;
70
+
71
+ if (shouldInclude) {
72
+ results.push(line);
73
+ }
74
+ }
75
+ } catch {
76
+ return {
77
+ stderr: `grep: ${file}: No such file or directory`,
78
+ exitCode: 1,
79
+ };
80
+ }
81
+ }
82
+
83
+ return {
84
+ stdout: results.length > 0 ? results.join("\n") : "",
85
+ exitCode: results.length > 0 ? 0 : 1,
86
+ };
87
+ },
88
+ };
@@ -58,43 +58,6 @@ export function assertPathAccess(
58
58
  }
59
59
  }
60
60
 
61
- export function parseOutputPath(args: string[]): {
62
- outputPath: string | null;
63
- inputArgs: string[];
64
- } {
65
- const filtered: string[] = [];
66
- let outputPath: string | null = null;
67
-
68
- for (let index = 0; index < args.length; index += 1) {
69
- const arg = args[index]!;
70
-
71
- if (
72
- arg === "-o" ||
73
- arg === "-O" ||
74
- arg === "--output" ||
75
- arg === "--output-document"
76
- ) {
77
- outputPath = args[index + 1] ?? null;
78
- index += 1;
79
- continue;
80
- }
81
-
82
- if (arg.startsWith("-o=")) {
83
- outputPath = arg.slice(3);
84
- continue;
85
- }
86
-
87
- if (arg.startsWith("-O=")) {
88
- outputPath = arg.slice(3);
89
- continue;
90
- }
91
-
92
- filtered.push(arg);
93
- }
94
-
95
- return { outputPath, inputArgs: filtered };
96
- }
97
-
98
61
  export function stripUrlFilename(url: string): string {
99
62
  const cleaned = url.split("?")[0]?.split("#")[0] ?? url;
100
63
  const lastPart = cleaned.split("/").filter(Boolean).pop();
@@ -0,0 +1,327 @@
1
+ import type { VirtualUserManager } from "../../SSHMimic/users";
2
+ import type {
3
+ CommandContext,
4
+ CommandMode,
5
+ CommandOutcome,
6
+ CommandResult,
7
+ ShellModule,
8
+ } from "../../types/commands";
9
+ import type VirtualFileSystem from "../../VirtualFileSystem";
10
+ import { adduserCommand } from "./adduser";
11
+ import { catCommand } from "./cat";
12
+ import { cdCommand } from "./cd";
13
+ import { clearCommand } from "./clear";
14
+ import { curlCommand } from "./curl";
15
+ import { deluserCommand } from "./deluser";
16
+ import { echoCommand } from "./echo";
17
+ import { envCommand } from "./env";
18
+ import { exitCommand } from "./exit";
19
+ import { exportCommand } from "./export";
20
+ import { grepCommand } from "./grep";
21
+ import { createHelpCommand } from "./help";
22
+ import { hostnameCommand } from "./hostname";
23
+ import { htopCommand } from "./htop";
24
+ import { lsCommand } from "./ls";
25
+ import { mkdirCommand } from "./mkdir";
26
+ import { nanoCommand } from "./nano";
27
+ import { pwdCommand } from "./pwd";
28
+ import { rmCommand } from "./rm";
29
+ import { setCommand } from "./set";
30
+ import { shCommand } from "./sh";
31
+ import { suCommand } from "./su";
32
+ import { sudoCommand } from "./sudo";
33
+ import { touchCommand } from "./touch";
34
+ import { treeCommand } from "./tree";
35
+ import { unsetCommand } from "./unset";
36
+ import { wgetCommand } from "./wget";
37
+ import { whoCommand } from "./who";
38
+ import { whoamiCommand } from "./whoami";
39
+
40
+ const BASE_COMMANDS: ShellModule[] = [
41
+ pwdCommand,
42
+ whoamiCommand,
43
+ whoCommand,
44
+ hostnameCommand,
45
+ lsCommand,
46
+ cdCommand,
47
+ catCommand,
48
+ echoCommand,
49
+ mkdirCommand,
50
+ touchCommand,
51
+ rmCommand,
52
+ treeCommand,
53
+ nanoCommand,
54
+ htopCommand,
55
+ adduserCommand,
56
+ deluserCommand,
57
+ sudoCommand,
58
+ suCommand,
59
+ curlCommand,
60
+ envCommand,
61
+ wgetCommand,
62
+ grepCommand,
63
+ exportCommand,
64
+ setCommand,
65
+ unsetCommand,
66
+ shCommand,
67
+ clearCommand,
68
+ exitCommand,
69
+ ];
70
+
71
+ const customCommands: ShellModule[] = [];
72
+
73
+ const helpCommand = createHelpCommand(() =>
74
+ getCommandModules().map((cmd) => cmd.name),
75
+ );
76
+
77
+ function getCommandModules(): ShellModule[] {
78
+ return [...BASE_COMMANDS, ...customCommands, helpCommand];
79
+ }
80
+
81
+ function getTakenCommandNames(modules: ShellModule[]): Set<string> {
82
+ const taken = new Set<string>();
83
+ for (const mod of modules) {
84
+ taken.add(mod.name);
85
+ for (const alias of mod.aliases ?? []) {
86
+ taken.add(alias);
87
+ }
88
+ }
89
+ return taken;
90
+ }
91
+
92
+ export function registerCommand(module: ShellModule): void {
93
+ const normalized: ShellModule = {
94
+ ...module,
95
+ name: module.name.trim().toLowerCase(),
96
+ aliases: module.aliases?.map((alias) => alias.trim().toLowerCase()),
97
+ };
98
+
99
+ const names = [normalized.name, ...(normalized.aliases ?? [])];
100
+ if (names.some((name) => name.length === 0 || /\s/.test(name))) {
101
+ throw new Error(
102
+ "Command names and aliases must be non-empty and contain no spaces",
103
+ );
104
+ }
105
+
106
+ const takenNames = getTakenCommandNames(getCommandModules());
107
+ const conflict = names.find((name) => takenNames.has(name));
108
+ if (conflict) {
109
+ throw new Error(`Command '${conflict}' already exists`);
110
+ }
111
+
112
+ customCommands.push(normalized);
113
+ }
114
+
115
+ export function createCustomCommand(
116
+ name: string,
117
+ params: string[],
118
+ run: (ctx: CommandContext) => CommandResult | Promise<CommandResult>,
119
+ ): ShellModule {
120
+ return {
121
+ name,
122
+ params,
123
+ run,
124
+ };
125
+ }
126
+
127
+ export function getCommandNames(): string[] {
128
+ return getCommandModules().flatMap((cmd) => [
129
+ cmd.name,
130
+ ...(cmd.aliases ?? []),
131
+ ]);
132
+ }
133
+
134
+ function resolveModule(name: string): ShellModule | undefined {
135
+ const lowered = name.toLowerCase();
136
+ return getCommandModules().find(
137
+ (cmd) => cmd.name === lowered || cmd.aliases?.includes(lowered),
138
+ );
139
+ }
140
+
141
+ function splitArgsRespectingQuotes(input: string): string[] {
142
+ const tokens: string[] = [];
143
+ let current = "";
144
+ let inQuotes = false;
145
+ let quoteChar = "";
146
+
147
+ for (let i = 0; i < input.length; i += 1) {
148
+ const ch = input[i] || "";
149
+ const prev = i > 0 ? input[i - 1] : "";
150
+
151
+ if ((ch === '"' || ch === "'") && prev !== "\\") {
152
+ if (!inQuotes) {
153
+ inQuotes = true;
154
+ quoteChar = ch;
155
+ continue;
156
+ }
157
+
158
+ if (ch === quoteChar) {
159
+ inQuotes = false;
160
+ quoteChar = "";
161
+ continue;
162
+ }
163
+ }
164
+
165
+ if (/\s/.test(ch) && !inQuotes) {
166
+ if (current.length > 0) {
167
+ tokens.push(current);
168
+ current = "";
169
+ }
170
+ continue;
171
+ }
172
+
173
+ current += ch;
174
+ }
175
+
176
+ if (current.length > 0) {
177
+ tokens.push(current);
178
+ }
179
+
180
+ return tokens;
181
+ }
182
+
183
+ function parseInput(rawInput: string): { commandName: string; args: string[] } {
184
+ const parts = splitArgsRespectingQuotes(rawInput.trim());
185
+ return {
186
+ commandName: parts[0]?.toLowerCase() ?? "",
187
+ args: parts.slice(1),
188
+ };
189
+ }
190
+
191
+ // Internal async function for pipeline execution
192
+ async function runCommandInternal(
193
+ rawInput: string,
194
+ authUser: string,
195
+ hostname: string,
196
+ users: VirtualUserManager,
197
+ mode: CommandMode,
198
+ cwd: string,
199
+ vfs: VirtualFileSystem,
200
+ stdin?: string,
201
+ ): Promise<CommandResult> {
202
+ // Check if input contains pipes or redirections
203
+ if (
204
+ rawInput.includes("|") ||
205
+ rawInput.includes(">") ||
206
+ rawInput.includes("<")
207
+ ) {
208
+ // Use pipeline executor
209
+ const { parseShellPipeline } = await import("../shellParser");
210
+ const { executePipeline } = await import("../../SSHMimic/executor");
211
+
212
+ const pipeline = parseShellPipeline(rawInput);
213
+ if (!pipeline.isValid) {
214
+ return {
215
+ stderr: pipeline.error || "Syntax error",
216
+ exitCode: 1,
217
+ };
218
+ }
219
+
220
+ try {
221
+ return await executePipeline(
222
+ pipeline,
223
+ authUser,
224
+ hostname,
225
+ users,
226
+ mode,
227
+ cwd,
228
+ vfs,
229
+ );
230
+ } catch (error: unknown) {
231
+ const message =
232
+ error instanceof Error ? error.message : "Pipeline execution failed";
233
+ return { stderr: message, exitCode: 1 };
234
+ }
235
+ }
236
+
237
+ // Regular command execution
238
+ const { commandName, args } = parseInput(rawInput);
239
+ const mod = resolveModule(commandName);
240
+
241
+ if (!mod) {
242
+ return {
243
+ stderr: `Command '${rawInput}' not found`,
244
+ exitCode: 127,
245
+ };
246
+ }
247
+
248
+ try {
249
+ const result = mod.run({
250
+ authUser,
251
+ hostname,
252
+ users,
253
+ activeSessions: users.listActiveSessions(),
254
+ rawInput,
255
+ mode,
256
+ args,
257
+ stdin,
258
+ cwd,
259
+ vfs,
260
+ });
261
+
262
+ return await Promise.resolve(result);
263
+ } catch (error: unknown) {
264
+ const message = error instanceof Error ? error.message : "Command failed";
265
+ return { stderr: message, exitCode: 1 };
266
+ }
267
+ }
268
+
269
+ export function runCommand(
270
+ rawInput: string,
271
+ authUser: string,
272
+ hostname: string,
273
+ users: VirtualUserManager,
274
+ mode: CommandMode,
275
+ cwd: string,
276
+ vfs: VirtualFileSystem,
277
+ stdin?: string,
278
+ ): CommandOutcome {
279
+ const trimmed = rawInput.trim();
280
+
281
+ if (trimmed.length === 0) {
282
+ return { exitCode: 0 };
283
+ }
284
+
285
+ // Check if input contains pipes or redirections - use async version
286
+ if (trimmed.includes("|") || trimmed.includes(">") || trimmed.includes("<")) {
287
+ return runCommandInternal(
288
+ trimmed,
289
+ authUser,
290
+ hostname,
291
+ users,
292
+ mode,
293
+ cwd,
294
+ vfs,
295
+ stdin,
296
+ );
297
+ }
298
+
299
+ // Regular synchronous command execution
300
+ const { commandName, args } = parseInput(trimmed);
301
+ const mod = resolveModule(commandName);
302
+
303
+ if (!mod) {
304
+ return {
305
+ stderr: `Command '${trimmed}' not found`,
306
+ exitCode: 127,
307
+ };
308
+ }
309
+
310
+ try {
311
+ return mod.run({
312
+ authUser,
313
+ hostname,
314
+ users,
315
+ activeSessions: users.listActiveSessions(),
316
+ rawInput: trimmed,
317
+ mode,
318
+ args,
319
+ stdin,
320
+ cwd,
321
+ vfs,
322
+ });
323
+ } catch (error: unknown) {
324
+ const message = error instanceof Error ? error.message : "Command failed";
325
+ return { stderr: message, exitCode: 1 };
326
+ }
327
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ShellModule } from "../../types/commands";
2
+ import { getArg, ifFlag } from "./command-helpers";
2
3
  import { assertPathAccess, joinListWithType, resolvePath } from "./helpers";
3
4
 
4
5
  function formatPermissions(mode: number, isDirectory: boolean): string {
@@ -29,8 +30,8 @@ export const lsCommand: ShellModule = {
29
30
  name: "ls",
30
31
  params: ["[path]"],
31
32
  run: ({ authUser, vfs, cwd, args }) => {
32
- const longFormat = args.includes("-l") || args.includes("--long");
33
- const targetArg = args.find((arg) => !arg.startsWith("-"));
33
+ const longFormat = ifFlag(args, ["-l", "--long"]);
34
+ const targetArg = getArg(args, 0, { flags: ["-l", "--long"] });
34
35
  const target = resolvePath(cwd, targetArg ?? cwd);
35
36
  assertPathAccess(authUser, target, "ls");
36
37
  const items = vfs.list(target).filter((name) => !name.startsWith("."));
@@ -1,4 +1,5 @@
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 mkdirCommand: ShellModule = {
@@ -9,7 +10,11 @@ export const mkdirCommand: ShellModule = {
9
10
  return { stderr: "mkdir: missing operand", exitCode: 1 };
10
11
  }
11
12
 
12
- for (const dir of args) {
13
+ for (let index = 0; index < args.length; index++) {
14
+ const dir = getArg(args, index);
15
+ if (!dir) {
16
+ return { stderr: "mkdir: missing operand", exitCode: 1 };
17
+ }
13
18
  const target = resolvePath(cwd, dir);
14
19
  assertPathAccess(authUser, target, "mkdir");
15
20
  vfs.mkdir(target);
@@ -1,4 +1,5 @@
1
1
  import type { ShellModule } from "../../types/commands";
2
+ import { getArg, ifFlag } from "./command-helpers";
2
3
  import { assertPathAccess, resolvePath } from "./helpers";
3
4
 
4
5
  export const rmCommand: ShellModule = {
@@ -9,9 +10,15 @@ export const rmCommand: ShellModule = {
9
10
  return { stderr: "rm: missing operand", exitCode: 1 };
10
11
  }
11
12
 
12
- const recursive =
13
- args.includes("-r") || args.includes("-rf") || args.includes("-fr");
14
- const targets = args.filter((arg) => !arg.startsWith("-"));
13
+ const recursive = ifFlag(args, ["-r", "-rf", "-fr"]);
14
+ const targets: string[] = [];
15
+ for (let index = 0; ; index += 1) {
16
+ const target = getArg(args, index, { flags: ["-r", "-rf", "-fr"] });
17
+ if (!target) {
18
+ break;
19
+ }
20
+ targets.push(target);
21
+ }
15
22
 
16
23
  if (targets.length === 0) {
17
24
  return { stderr: "rm: missing operand", exitCode: 1 };