typescript-virtual-container 1.0.7 → 1.1.0

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 (38) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +138 -87
  3. package/package.json +1 -1
  4. package/src/SSHMimic/client.ts +15 -18
  5. package/src/SSHMimic/exec.ts +5 -16
  6. package/src/SSHMimic/executor.ts +18 -29
  7. package/src/SSHMimic/index.ts +23 -85
  8. package/src/VirtualFileSystem/index.ts +3 -1
  9. package/src/VirtualShell/commands/adduser.ts +2 -2
  10. package/src/VirtualShell/commands/cat.ts +3 -3
  11. package/src/VirtualShell/commands/cd.ts +2 -2
  12. package/src/VirtualShell/commands/command-helpers.ts +64 -0
  13. package/src/VirtualShell/commands/curl.ts +14 -92
  14. package/src/VirtualShell/commands/deluser.ts +2 -2
  15. package/src/VirtualShell/commands/echo.ts +5 -12
  16. package/src/VirtualShell/commands/grep.ts +8 -16
  17. package/src/VirtualShell/commands/helpers.ts +74 -0
  18. package/src/VirtualShell/commands/index.ts +46 -112
  19. package/src/VirtualShell/commands/ls.ts +6 -4
  20. package/src/VirtualShell/commands/mkdir.ts +2 -2
  21. package/src/VirtualShell/commands/nano.ts +3 -3
  22. package/src/VirtualShell/commands/neofetch.ts +2 -2
  23. package/src/VirtualShell/commands/rm.ts +2 -2
  24. package/src/VirtualShell/commands/sh.ts +2 -13
  25. package/src/VirtualShell/commands/su.ts +2 -1
  26. package/src/VirtualShell/commands/sudo.ts +12 -25
  27. package/src/VirtualShell/commands/touch.ts +3 -3
  28. package/src/VirtualShell/commands/tree.ts +2 -2
  29. package/src/VirtualShell/commands/wget.ts +19 -29
  30. package/src/VirtualShell/commands/who.ts +2 -2
  31. package/src/VirtualShell/index.ts +114 -25
  32. package/src/VirtualShell/shell.ts +28 -35
  33. package/src/{SSHMimic/users.ts → VirtualUserManager/index.ts} +6 -3
  34. package/src/index.ts +4 -4
  35. package/src/standalone.ts +19 -14
  36. package/src/types/commands.ts +3 -11
  37. package/tests/parser-executor.test.ts +37 -0
  38. package/tests/users.test.ts +1 -1
@@ -1,10 +1,8 @@
1
1
  import type { CommandMode, CommandResult } from "../types/commands";
2
2
  import type { Pipeline, PipelineCommand } from "../types/pipeline";
3
- import type VirtualFileSystem from "../VirtualFileSystem";
4
- import { defaultShellProperties } from "../VirtualShell";
3
+ import type { VirtualShell } from "../VirtualShell";
5
4
  import { runCommand as runSingleCommand } from "../VirtualShell/commands";
6
5
  import { resolvePath } from "../VirtualShell/commands/helpers";
7
- import type { VirtualUserManager } from "./users";
8
6
 
9
7
  /**
10
8
  * Execute a parsed pipeline, chaining commands and handling redirections.
@@ -14,10 +12,9 @@ export async function executePipeline(
14
12
  pipeline: Pipeline,
15
13
  authUser: string,
16
14
  hostname: string,
17
- users: VirtualUserManager,
18
15
  mode: CommandMode,
19
16
  cwd: string,
20
- vfs: VirtualFileSystem,
17
+ shell: VirtualShell,
21
18
  ): Promise<CommandResult> {
22
19
  if (pipeline.commands.length === 0) {
23
20
  return { exitCode: 0 };
@@ -29,10 +26,9 @@ export async function executePipeline(
29
26
  pipeline.commands[0] as PipelineCommand,
30
27
  authUser,
31
28
  hostname,
32
- users,
33
29
  mode,
34
30
  cwd,
35
- vfs,
31
+ shell,
36
32
  );
37
33
  }
38
34
 
@@ -41,10 +37,9 @@ export async function executePipeline(
41
37
  pipeline.commands as PipelineCommand[],
42
38
  authUser,
43
39
  hostname,
44
- users,
45
40
  mode,
46
41
  cwd,
47
- vfs,
42
+ shell,
48
43
  );
49
44
  }
50
45
 
@@ -55,17 +50,16 @@ async function executeSingleCommandWithRedirections(
55
50
  cmd: PipelineCommand,
56
51
  authUser: string,
57
52
  hostname: string,
58
- users: VirtualUserManager,
59
53
  mode: CommandMode,
60
54
  cwd: string,
61
- vfs: VirtualFileSystem,
55
+ shell: VirtualShell,
62
56
  ): Promise<CommandResult> {
63
57
  // Prepare input if input file specified
64
58
  let stdin: string | undefined;
65
59
  if (cmd.inputFile) {
66
60
  const inputPath = resolvePath(cwd, cmd.inputFile);
67
61
  try {
68
- stdin = vfs.readFile(inputPath);
62
+ stdin = shell.vfs.readFile(inputPath);
69
63
  } catch {
70
64
  return {
71
65
  stderr: `cat: ${cmd.inputFile}: No such file or directory`,
@@ -82,11 +76,9 @@ async function executeSingleCommandWithRedirections(
82
76
  rawInput,
83
77
  authUser,
84
78
  hostname,
85
- users,
86
79
  mode,
87
80
  cwd,
88
- defaultShellProperties,
89
- vfs,
81
+ shell,
90
82
  stdin,
91
83
  );
92
84
 
@@ -97,13 +89,13 @@ async function executeSingleCommandWithRedirections(
97
89
  try {
98
90
  if (cmd.appendOutput) {
99
91
  try {
100
- const existing = vfs.readFile(outputPath);
101
- vfs.writeFile(outputPath, existing + output);
92
+ const existing = shell.vfs.readFile(outputPath);
93
+ shell.vfs.writeFile(outputPath, existing + output);
102
94
  } catch {
103
- vfs.writeFile(outputPath, output);
95
+ shell.vfs.writeFile(outputPath, output);
104
96
  }
105
97
  } else {
106
- vfs.writeFile(outputPath, output);
98
+ shell.vfs.writeFile(outputPath, output);
107
99
  }
108
100
  return { ...result, stdout: "" };
109
101
  } catch {
@@ -125,10 +117,9 @@ async function executePipelineChain(
125
117
  commands: PipelineCommand[],
126
118
  authUser: string,
127
119
  hostname: string,
128
- users: VirtualUserManager,
129
120
  mode: CommandMode,
130
121
  cwd: string,
131
- vfs: VirtualFileSystem,
122
+ shell: VirtualShell,
132
123
  ): Promise<CommandResult> {
133
124
  let currentOutput = "";
134
125
  let exitCode = 0;
@@ -140,7 +131,7 @@ async function executePipelineChain(
140
131
  if (i === 0 && cmd.inputFile) {
141
132
  const inputPath = resolvePath(cwd, cmd.inputFile);
142
133
  try {
143
- currentOutput = vfs.readFile(inputPath);
134
+ currentOutput = shell.vfs.readFile(inputPath);
144
135
  } catch {
145
136
  return {
146
137
  stderr: `cat: ${cmd.inputFile}: No such file or directory`,
@@ -158,11 +149,9 @@ async function executePipelineChain(
158
149
  rawInput,
159
150
  authUser,
160
151
  hostname,
161
- users,
162
152
  mode,
163
153
  cwd,
164
- defaultShellProperties,
165
- vfs,
154
+ shell,
166
155
  currentOutput,
167
156
  );
168
157
 
@@ -175,13 +164,13 @@ async function executePipelineChain(
175
164
  try {
176
165
  if (cmd.appendOutput) {
177
166
  try {
178
- const existing = vfs.readFile(outputPath);
179
- vfs.writeFile(outputPath, existing + output);
167
+ const existing = shell.vfs.readFile(outputPath);
168
+ shell.vfs.writeFile(outputPath, existing + output);
180
169
  } catch {
181
- vfs.writeFile(outputPath, output);
170
+ shell.vfs.writeFile(outputPath, output);
182
171
  }
183
172
  } else {
184
- vfs.writeFile(outputPath, output);
173
+ shell.vfs.writeFile(outputPath, output);
185
174
  }
186
175
  currentOutput = "";
187
176
  } catch {
@@ -1,67 +1,40 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import { Server as SshServer } from "ssh2";
3
- import VirtualFileSystem from "../VirtualFileSystem";
4
2
  import { VirtualShell } from "../VirtualShell";
5
3
  import { loadOrCreateHostKey } from "./hostKey";
6
- import { VirtualUserManager } from "./users";
7
-
8
- function resolveRootPassword(): string {
9
- const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
10
- if (configured && configured.trim().length > 0) {
11
- return configured;
12
- }
13
-
14
- const generated = randomBytes(18).toString("base64url");
15
- console.warn(
16
- `[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`,
17
- );
18
- return generated;
19
- }
20
-
21
- function resolveAutoSudoForNewUsers(): boolean {
22
- const configured = process.env.SSH_MIMIC_AUTO_SUDO_NEW_USERS;
23
- if (!configured) {
24
- return true;
25
- }
26
-
27
- return !["0", "false", "no", "off"].includes(configured.toLowerCase());
28
- }
29
4
 
30
5
  /**
31
- * SSH server wrapper that exposes virtual shell and exec sessions.
6
+ * SSH server facade that wires the virtual shell runtime into ssh2 sessions.
32
7
  *
8
+ * This class is exported as `VirtualSshServer` for public API compatibility.
33
9
  * Create an instance, call {@link SshMimic.start}, and stop it with
34
10
  * {@link SshMimic.stop} when your process exits.
35
11
  */
36
12
  class SshMimic {
37
13
  port: number;
38
- hostname: string;
39
14
  server: SshServer | null;
40
- vfs: VirtualFileSystem | null = null;
41
- users: VirtualUserManager | null = null;
42
- shell: VirtualShell | null = null;
43
- basePath: string = ".";
15
+ private shell: VirtualShell;
16
+ private shellHostname: string;
44
17
 
45
18
  /**
46
19
  * Creates a new SSH mimic server instance.
47
20
  *
48
21
  * @param port TCP port to bind on localhost.
49
- * @param hostname SSH ident hostname suffix and virtual host label.
50
- * @param basePath Optional base path for virtual filesystem (default: current directory).
22
+ * @param hostname Virtual hostname used for the SSH ident and default shell label.
23
+ * @param shell Optional preconfigured virtual shell instance to reuse.
51
24
  */
52
25
  constructor({
53
26
  port,
54
27
  hostname = "typescript-vm",
55
- basePath = ".",
28
+ shell = new VirtualShell(hostname),
56
29
  }: {
57
30
  port: number;
58
31
  hostname?: string;
59
- basePath?: string;
32
+ shell?: VirtualShell;
60
33
  }) {
61
34
  this.port = port;
62
- this.hostname = hostname;
63
- this.basePath = basePath;
35
+ this.shellHostname = hostname;
64
36
  this.server = null;
37
+ this.shell = shell;
65
38
  }
66
39
 
67
40
  /**
@@ -70,22 +43,13 @@ class SshMimic {
70
43
  * @returns Promise resolved with bound listening port.
71
44
  */
72
45
  public async start(): Promise<number> {
46
+ const shell = this.shell;
73
47
  const privateKey = loadOrCreateHostKey();
74
- this.vfs = new VirtualFileSystem(this.basePath);
75
- await this.vfs.restoreMirror();
76
- this.users = new VirtualUserManager(
77
- this.vfs,
78
- resolveRootPassword(),
79
- resolveAutoSudoForNewUsers(),
80
- );
81
- await this.users.initialize();
82
-
83
- this.shell = new VirtualShell(this.vfs, this.users, this.hostname);
84
48
 
85
49
  this.server = new SshServer(
86
50
  {
87
51
  hostKeys: [privateKey],
88
- ident: `SSH-2.0-${this.hostname}`,
52
+ ident: `SSH-2.0-${shell.hostname}`,
89
53
  },
90
54
  (client) => {
91
55
  let authUser = "root";
@@ -93,28 +57,29 @@ class SshMimic {
93
57
  let sessionId: string | null = null;
94
58
 
95
59
  client.on("authentication", (ctx) => {
60
+ shell;
96
61
  if (ctx.method === "password") {
97
62
  const candidateUser = ctx.username || "root";
98
63
  remoteAddress = (ctx as { ip?: string }).ip ?? remoteAddress;
99
64
 
100
65
  if (
101
- !this.users!.verifyPassword(candidateUser, ctx.password ?? "")
66
+ !shell.users.verifyPassword(candidateUser, ctx.password ?? "")
102
67
  ) {
103
68
  ctx.reject();
104
69
  return;
105
70
  }
106
71
 
107
72
  authUser = candidateUser;
108
- sessionId = this.users!.registerSession(authUser, remoteAddress).id;
73
+ sessionId = shell.users.registerSession(authUser, remoteAddress).id;
109
74
 
110
75
  const homePath = `/home/${authUser}`;
111
- if (!this.vfs!.exists(homePath)) {
112
- this.vfs!.mkdir(homePath, 0o755);
113
- this.vfs!.writeFile(
76
+ if (!shell.vfs.exists(homePath)) {
77
+ shell.vfs.mkdir(homePath, 0o755);
78
+ shell.vfs.writeFile(
114
79
  `${homePath}/README.txt`,
115
- `Welcome to ${this.hostname}`,
80
+ `Welcome to ${shell?.hostname ?? this.shellHostname}`,
116
81
  );
117
- void this.vfs!.flushMirror();
82
+ void shell.vfs.flushMirror();
118
83
  }
119
84
 
120
85
  ctx.accept();
@@ -125,7 +90,7 @@ class SshMimic {
125
90
  });
126
91
 
127
92
  client.on("close", () => {
128
- this.users!.unregisterSession(sessionId);
93
+ shell.users.unregisterSession(sessionId);
129
94
  sessionId = null;
130
95
  });
131
96
 
@@ -150,7 +115,7 @@ class SshMimic {
150
115
 
151
116
  session.on("shell", (acceptShell) => {
152
117
  const stream = acceptShell();
153
- this.shell?.startInteractiveSession(
118
+ shell?.startInteractiveSession(
154
119
  stream,
155
120
  authUser,
156
121
  sessionId,
@@ -161,7 +126,7 @@ class SshMimic {
161
126
 
162
127
  session.on("exec", (acceptExec, _rejectExec, info) => {
163
128
  const _stream = acceptExec();
164
- this.shell?.executeCommand(
129
+ shell?.executeCommand(
165
130
  info.command.trim(),
166
131
  authUser,
167
132
  `/home/${authUser}`,
@@ -191,33 +156,6 @@ class SshMimic {
191
156
  });
192
157
  }
193
158
  }
194
-
195
- /**
196
- * Returns virtual filesystem instance after server started.
197
- *
198
- * @returns VirtualFileSystem or null when not started.
199
- */
200
- public getVfs(): VirtualFileSystem | null {
201
- return this.vfs;
202
- }
203
-
204
- /**
205
- * Returns user manager instance after server started.
206
- *
207
- * @returns VirtualUserManager or null when not started.
208
- */
209
- public getUsers(): VirtualUserManager | null {
210
- return this.users;
211
- }
212
-
213
- /**
214
- * Returns hostname shown in prompts and idents.
215
- *
216
- * @returns Configured hostname label.
217
- */
218
- public getHostname(): string {
219
- return this.hostname;
220
- }
221
159
  }
222
160
 
223
161
  export { SshMimic };
@@ -49,7 +49,6 @@ class VirtualFileSystem {
49
49
  */
50
50
  public async restoreMirror(): Promise<void> {
51
51
  await fs.mkdir(path.dirname(this.archivePath), { recursive: true });
52
-
53
52
  try {
54
53
  const compressed = await fs.readFile(this.archivePath);
55
54
  const tarBuffer = gunzipSync(compressed);
@@ -58,6 +57,9 @@ class VirtualFileSystem {
58
57
  this.dirty = false;
59
58
  return;
60
59
  } catch {
60
+ console.warn(
61
+ `No valid mirror archive found at '${this.archivePath}'. Starting with empty filesystem.`,
62
+ );
61
63
  await this.flushMirror();
62
64
  }
63
65
  }
@@ -3,7 +3,7 @@ import type { ShellModule } from "../../types/commands";
3
3
  export const adduserCommand: ShellModule = {
4
4
  name: "adduser",
5
5
  params: ["<username> <password>"],
6
- run: async ({ authUser, users, args }) => {
6
+ run: async ({ authUser, shell, args }) => {
7
7
  if (authUser !== "root") {
8
8
  return { stderr: "adduser: permission denied", exitCode: 1 };
9
9
  }
@@ -16,7 +16,7 @@ export const adduserCommand: ShellModule = {
16
16
  };
17
17
  }
18
18
 
19
- await users.addUser(username, password);
19
+ await shell.users.addUser(username, password);
20
20
  return { stdout: `adduser: user '${username}' created`, exitCode: 0 };
21
21
  },
22
22
  };
@@ -5,14 +5,14 @@ import { assertPathAccess, resolveReadablePath } from "./helpers";
5
5
  export const catCommand: ShellModule = {
6
6
  name: "cat",
7
7
  params: ["<file>"],
8
- run: ({ authUser, vfs, cwd, args }) => {
8
+ run: ({ authUser, shell, cwd, args }) => {
9
9
  const fileArg = getArg(args, 0);
10
10
  if (!fileArg) {
11
11
  return { stderr: "cat: missing file operand", exitCode: 1 };
12
12
  }
13
13
 
14
- const target = resolveReadablePath(vfs, cwd, fileArg);
14
+ const target = resolveReadablePath(shell.vfs, cwd, fileArg);
15
15
  assertPathAccess(authUser, target, "cat");
16
- return { stdout: vfs.readFile(target), exitCode: 0 };
16
+ return { stdout: shell.vfs.readFile(target), exitCode: 0 };
17
17
  },
18
18
  };
@@ -4,10 +4,10 @@ import { assertPathAccess, resolvePath } from "./helpers";
4
4
  export const cdCommand: ShellModule = {
5
5
  name: "cd",
6
6
  params: ["[path]"],
7
- run: ({ authUser, vfs, cwd, args, mode }) => {
7
+ run: ({ authUser, shell, cwd, args, mode }) => {
8
8
  const target = resolvePath(cwd, args[0] ?? "/virtual-env-js");
9
9
  assertPathAccess(authUser, target, "cd");
10
- const stats = vfs.stat(target);
10
+ const stats = shell.vfs.stat(target);
11
11
  if (stats.type !== "directory") {
12
12
  return { stderr: `cd: not a directory: ${target}`, exitCode: 1 };
13
13
  }
@@ -133,3 +133,67 @@ export function getArg(
133
133
  const positionals = collectPositionals(args, options);
134
134
  return positionals[index];
135
135
  }
136
+
137
+ /**
138
+ * Parse arguments into flags, flags with values, and positionals.
139
+ * @param args - Array of arguments to parse.
140
+ * @param options - Parsing options for flags and flags with values.
141
+ * @returns Parsed arguments as { flags, flagsWithValues, positionals }.
142
+ */
143
+ export function parseArgs(
144
+ args: string[],
145
+ options: { flags?: string[]; flagsWithValue?: string[] } = {},
146
+ ): {
147
+ flags: Set<string>;
148
+ flagsWithValues: Map<string, string>;
149
+ positionals: string[];
150
+ } {
151
+ const flags = new Set<string>();
152
+ const flagsWithValues = new Map<string, string>();
153
+ const positionals: string[] = [];
154
+ const boolFlags = new Set(options.flags ?? []);
155
+ const valueFlags = new Set(options.flagsWithValue ?? []);
156
+ let passthrough = false;
157
+
158
+ for (let index = 0; index < args.length; index += 1) {
159
+ const arg = args[index]!;
160
+
161
+ if (passthrough) {
162
+ positionals.push(arg);
163
+ continue;
164
+ }
165
+
166
+ if (arg === "--") {
167
+ passthrough = true;
168
+ continue;
169
+ }
170
+
171
+ if (boolFlags.has(arg)) {
172
+ flags.add(arg);
173
+ continue;
174
+ }
175
+
176
+ if (valueFlags.has(arg)) {
177
+ const next = args[index + 1];
178
+ if (next && !next.startsWith("-")) {
179
+ flagsWithValues.set(arg, next);
180
+ index += 1;
181
+ } else {
182
+ flagsWithValues.set(arg, "");
183
+ }
184
+ continue;
185
+ }
186
+
187
+ const inlineFlag = Array.from(valueFlags).find((flag) =>
188
+ arg.startsWith(`${flag}=`),
189
+ );
190
+ if (inlineFlag) {
191
+ flagsWithValues.set(inlineFlag, arg.slice(inlineFlag.length + 1));
192
+ continue;
193
+ }
194
+
195
+ positionals.push(arg);
196
+ }
197
+
198
+ return { flags, flagsWithValues, positionals };
199
+ }
@@ -1,107 +1,31 @@
1
- import { spawn } from "node:child_process";
2
1
  import type { ShellModule } from "../../types/commands";
3
- import { getArg, getFlag, ifFlag } from "./command-helpers";
2
+ import { parseArgs } from "./command-helpers";
4
3
  import {
5
4
  assertPathAccess,
6
5
  normalizeTerminalOutput,
7
6
  resolvePath,
7
+ runHostCommand,
8
8
  } from "./helpers";
9
9
 
10
- function runHostCurl(args: string[]): Promise<{
11
- stdout: string;
12
- stderr: string;
13
- exitCode: number;
14
- }> {
15
- return new Promise((resolve) => {
16
- let childProcess: ReturnType<typeof spawn>;
17
-
18
- try {
19
- childProcess = spawn("curl", args, {
20
- stdio: ["ignore", "pipe", "pipe"],
21
- });
22
- } catch (error) {
23
- resolve({
24
- stdout: "",
25
- stderr: `curl: ${error instanceof Error ? error.message : String(error)}`,
26
- exitCode: 1,
27
- });
28
- return;
29
- }
30
-
31
- let stdout = "";
32
- let stderr = "";
33
- const stdoutStream = childProcess.stdout;
34
- const stderrStream = childProcess.stderr;
35
-
36
- if (!stdoutStream || !stderrStream) {
37
- resolve({
38
- stdout: "",
39
- stderr: "curl: failed to capture process output",
40
- exitCode: 1,
41
- });
42
- return;
43
- }
44
-
45
- stdoutStream.setEncoding("utf8");
46
- stderrStream.setEncoding("utf8");
47
-
48
- stdoutStream.on("data", (chunk: string) => {
49
- stdout += chunk;
50
- });
51
-
52
- stderrStream.on("data", (chunk: string) => {
53
- stderr += chunk;
54
- });
55
-
56
- childProcess.on("error", (error) => {
57
- const errorCode =
58
- error instanceof Error && "code" in error
59
- ? String((error as NodeJS.ErrnoException).code ?? "")
60
- : "";
61
- resolve({
62
- stdout: "",
63
- stderr: `curl: ${error.message}`,
64
- exitCode: errorCode === "ENOENT" ? 127 : 1,
65
- });
66
- });
67
-
68
- childProcess.on("close", (code) => {
69
- resolve({
70
- stdout,
71
- stderr,
72
- exitCode: code ?? 1,
73
- });
74
- });
75
- });
76
- }
77
-
78
10
  export const curlCommand: ShellModule = {
79
11
  name: "curl",
80
12
  params: ["[-o file] <url>"],
81
- run: async ({ authUser, vfs, cwd, args }) => {
82
- const outputPathValue = getFlag(args, ["-o", "--output"]);
13
+ run: async ({ authUser, cwd, args, shell }) => {
14
+ const { flagsWithValues, positionals } = parseArgs(args, {
15
+ flagsWithValue: ["-o", "--output"],
16
+ });
83
17
  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
- }
96
- const url = inputArgs[0];
97
- const isHelpLike = ifFlag(args, ["-h", "--help", "-V", "--version"]);
18
+ flagsWithValues.get("-o") || flagsWithValues.get("--output") || null;
19
+ const url = positionals[0];
98
20
 
99
21
  if (!url) {
100
22
  return { stderr: "curl: missing URL", exitCode: 1 };
101
23
  }
102
24
 
103
- const passthroughArgs = outputPath ? [...inputArgs, "-o", "-"] : inputArgs;
104
- const result = await runHostCurl(passthroughArgs);
25
+ const passthroughArgs = outputPath
26
+ ? [...positionals, "-o", "-"]
27
+ : positionals;
28
+ const result = await runHostCommand("curl", passthroughArgs);
105
29
 
106
30
  if (result.exitCode !== 0) {
107
31
  return {
@@ -115,7 +39,7 @@ export const curlCommand: ShellModule = {
115
39
  if (outputPath) {
116
40
  const target = resolvePath(cwd, outputPath);
117
41
  assertPathAccess(authUser, target, "curl");
118
- vfs.writeFile(target, result.stdout);
42
+ shell.vfs.writeFile(target, result.stdout);
119
43
  return {
120
44
  stderr: result.stderr
121
45
  ? normalizeTerminalOutput(result.stderr)
@@ -125,9 +49,7 @@ export const curlCommand: ShellModule = {
125
49
  }
126
50
 
127
51
  return {
128
- stdout: isHelpLike
129
- ? normalizeTerminalOutput(result.stdout)
130
- : result.stdout,
52
+ stdout: result.stdout,
131
53
  stderr: result.stderr
132
54
  ? normalizeTerminalOutput(result.stderr)
133
55
  : undefined,
@@ -3,7 +3,7 @@ import type { ShellModule } from "../../types/commands";
3
3
  export const deluserCommand: ShellModule = {
4
4
  name: "deluser",
5
5
  params: ["<username>"],
6
- run: async ({ authUser, users, args }) => {
6
+ run: async ({ authUser, args, shell }) => {
7
7
  if (authUser !== "root") {
8
8
  return { stderr: "deluser: permission denied", exitCode: 1 };
9
9
  }
@@ -13,7 +13,7 @@ export const deluserCommand: ShellModule = {
13
13
  return { stderr: "deluser: usage: deluser <username>", exitCode: 1 };
14
14
  }
15
15
 
16
- await users.deleteUser(username);
16
+ await shell.users.deleteUser(username);
17
17
  return { stdout: `deluser: user '${username}' deleted`, exitCode: 0 };
18
18
  },
19
19
  };
@@ -1,5 +1,5 @@
1
1
  import type { ShellModule } from "../../types/commands";
2
- import { getArg, ifFlag } from "./command-helpers";
2
+ import { parseArgs } from "./command-helpers";
3
3
  import { getAllEnvVars } from "./set";
4
4
 
5
5
  function expandEnvVars(input: string, env: Record<string, string>): string {
@@ -12,18 +12,11 @@ export const echoCommand: ShellModule = {
12
12
  name: "echo",
13
13
  params: ["[options] [text...]"],
14
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);
15
+ const { flags, positionals } = parseArgs(args, { flags: ["-n"] });
16
+ const newline = !flags.has("-n");
25
17
  const rawText =
26
- filteredArgs.length > 0 ? filteredArgs.join(" ") : (stdin ?? "");
18
+ positionals.length > 0 ? positionals.join(" ") : (stdin ?? "");
19
+ const env = getAllEnvVars(authUser);
27
20
  const text = expandEnvVars(rawText, env);
28
21
 
29
22
  return {