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
@@ -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,48 @@ The format is based on Keep a Changelog.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.0.5] - 2026-04-15
10
+
11
+ ### Changed
12
+
13
+ - Refactored commands to use shared argument/flag parsing helpers.
14
+ - Improved maintainability and consistency of argument parsing across commands.
15
+
16
+ ### Fixed
17
+
18
+ - Verified all refactored commands pass existing test cases without regressions.
19
+
20
+ ## [1.0.4] - 2026-04-15
21
+
22
+ ### Added
23
+
24
+ - Shell pipeline parser and executor with support for:
25
+ - Pipes (`|`)
26
+ - Input redirection (`<`)
27
+ - Output redirection (`>`)
28
+ - Append redirection (`>>`)
29
+ - New built-in commands:
30
+ - `echo`
31
+ - `grep`
32
+ - `set`
33
+ - `env`
34
+ - `export`
35
+ - `unset`
36
+ - `sh` (with `bash` alias)
37
+ - Command stdin support in runtime context so commands can consume piped input.
38
+
39
+ ### Changed
40
+
41
+ - Argument parsing now respects quoted strings, including for commands like `sh -c "echo hi"`.
42
+ - `echo` now expands environment variables (`$VAR`) and can read from stdin when no explicit text argument is provided.
43
+ - `grep` now supports stdin input (e.g. `ls | grep ".txt"`) in addition to file operands.
44
+
45
+ ### Fixed
46
+
47
+ - Relative file paths in redirections are now resolved from current working directory during pipeline execution.
48
+ - Example fixed behavior: `echo hi > cat.txt` writes to `./cat.txt` in current virtual directory.
49
+ - Pipeline chaining now correctly passes command stdout as stdin to next command.
50
+
9
51
  ## [1.0.2] - 2026-04-14
10
52
 
11
53
  ### 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
 
@@ -417,6 +417,56 @@ console.log(client.getUsername()); // Username from constructor
417
417
 
418
418
  ---
419
419
 
420
+ ### VirtualShell
421
+
422
+ Encapsulates shell execution primitives used by the SSH runtime for command dispatch and interactive sessions.
423
+
424
+ #### Constructor
425
+
426
+ ```typescript
427
+ new VirtualShell(
428
+ vfs: VirtualFileSystem,
429
+ users: VirtualUserManager,
430
+ hostname: string,
431
+ )
432
+ ```
433
+
434
+ - **vfs**: Virtual filesystem instance used by shell commands.
435
+ - **users**: User manager for authentication/session-aware command behavior.
436
+ - **hostname**: Hostname injected into command context and prompt behavior.
437
+
438
+ **Example:**
439
+
440
+ ```typescript
441
+ const shell = new VirtualShell(vfs, users, "typescript-vm");
442
+ ```
443
+
444
+ #### Methods
445
+
446
+ ##### `executeCommand(rawInput: string, authUser: string, cwd: string): void`
447
+
448
+ Runs one command input in shell mode for a given user and working directory.
449
+
450
+ ```typescript
451
+ shell.executeCommand("ls -la", "root", "/home/root");
452
+ ```
453
+
454
+ ##### `startInteractiveSession(stream: ShellStream, authUser: string, sessionId: string | null, remoteAddress: string, terminalSize: { cols: number; rows: number }): void`
455
+
456
+ Starts an interactive shell session over a shell stream.
457
+
458
+ ```typescript
459
+ shell.startInteractiveSession(
460
+ stream,
461
+ "root",
462
+ sessionId,
463
+ "127.0.0.1",
464
+ { cols: 120, rows: 30 },
465
+ );
466
+ ```
467
+
468
+ ---
469
+
420
470
  ### VirtualFileSystem
421
471
 
422
472
  In-memory filesystem with optional gzip compression and tar.gz persistence.
@@ -1006,7 +1056,7 @@ ssh.stop();
1006
1056
 
1007
1057
  ## Built-in Commands
1008
1058
 
1009
- The following commands are available in both SSH shell mode and via `SshClient.exec()`:
1059
+ 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
1060
 
1011
1061
  | Command | Purpose | Notes |
1012
1062
  |---------|---------|-------|
@@ -1125,7 +1175,7 @@ No. It emulates SSH sessions, users, and filesystem behavior in memory. It is id
1125
1175
 
1126
1176
  ### Can I use this in production?
1127
1177
 
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.
1178
+ 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
1179
 
1130
1180
  ### Does data persist between restarts?
1131
1181
 
@@ -1262,26 +1312,4 @@ MIT License. See LICENSE file for details.
1262
1312
  - [ ] Improved shell compatibility for complex piping and redirection
1263
1313
  - [ ] Snapshot diff tooling for test assertions
1264
1314
  - [ ] 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.**
1315
+ - [ ] 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.5",
7
7
  "license": "MIT",
8
8
  "keywords": [
9
9
  "ssh",
@@ -1,5 +1,5 @@
1
1
  import type { CommandResult } from "../types/commands";
2
- import { runCommand } from "./commands";
2
+ import { runCommand } from "../VirtualShell/commands";
3
3
  import type { SshMimic } from "./index";
4
4
 
5
5
  /**
@@ -1,6 +1,6 @@
1
1
  import type { ExecStream } from "../types/streams";
2
2
  import type VirtualFileSystem from "../VirtualFileSystem";
3
- import { runCommand } from "./commands";
3
+ import { runCommand } from "../VirtualShell/commands";
4
4
  import type { VirtualUserManager } from "./users";
5
5
 
6
6
  function toTtyLines(text: string): string {
@@ -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 "../VirtualShell/commands";
5
+ import { resolvePath } from "../VirtualShell/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
+ }
@@ -1,9 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { Server as SshServer } from "ssh2";
3
3
  import VirtualFileSystem from "../VirtualFileSystem";
4
- import { runExec } from "./exec";
4
+ import { VirtualShell } from "../VirtualShell";
5
5
  import { loadOrCreateHostKey } from "./hostKey";
6
- import { startShell } from "./shell";
7
6
  import { VirtualUserManager } from "./users";
8
7
 
9
8
  function resolveRootPassword(): string {
@@ -35,12 +34,13 @@ function resolveAutoSudoForNewUsers(): boolean {
35
34
  * {@link SshMimic.stop} when your process exits.
36
35
  */
37
36
  class SshMimic {
38
- private port: number;
39
- private hostname: string;
40
- private server: SshServer | null;
41
- private vfs: VirtualFileSystem | null = null;
42
- private users: VirtualUserManager | null = null;
43
- private basePath: string = ".";
37
+ port: number;
38
+ hostname: string;
39
+ server: SshServer | null;
40
+ vfs: VirtualFileSystem | null = null;
41
+ users: VirtualUserManager | null = null;
42
+ shell: VirtualShell | null = null;
43
+ basePath: string = ".";
44
44
 
45
45
  /**
46
46
  * Creates a new SSH mimic server instance.
@@ -80,6 +80,8 @@ class SshMimic {
80
80
  );
81
81
  await this.users.initialize();
82
82
 
83
+ this.shell = new VirtualShell(this.vfs, this.users, this.hostname);
84
+
83
85
  this.server = new SshServer(
84
86
  {
85
87
  hostKeys: [privateKey],
@@ -148,12 +150,9 @@ class SshMimic {
148
150
 
149
151
  session.on("shell", (acceptShell) => {
150
152
  const stream = acceptShell();
151
- startShell(
153
+ this.shell?.startInteractiveSession(
152
154
  stream,
153
155
  authUser,
154
- this.vfs!,
155
- this.hostname,
156
- this.users!,
157
156
  sessionId,
158
157
  remoteAddress,
159
158
  terminalSize,
@@ -161,14 +160,11 @@ class SshMimic {
161
160
  });
162
161
 
163
162
  session.on("exec", (acceptExec, _rejectExec, info) => {
164
- const stream = acceptExec();
165
- runExec(
166
- stream,
163
+ const _stream = acceptExec();
164
+ this.shell?.executeCommand(
167
165
  info.command.trim(),
168
166
  authUser,
169
- this.hostname,
170
- this.users!,
171
- this.vfs!,
167
+ `/home/${authUser}`,
172
168
  );
173
169
  });
174
170
  });
@@ -5,21 +5,12 @@ import type {
5
5
  RemoveOptions,
6
6
  VfsNodeStats,
7
7
  WriteFileOptions,
8
- } from "./types/vfs";
9
- import {
10
- archiveExists,
11
- createTarBuffer,
12
- readSnapshotFromTar,
13
- } from "./vfs/archive";
14
- import type { InternalDirectoryNode, InternalNode } from "./vfs/internalTypes";
15
- import {
16
- getNode,
17
- getParentDirectory,
18
- normalizePath,
19
- splitPath,
20
- } from "./vfs/path";
21
- import { applySnapshot, createSnapshot } from "./vfs/snapshot";
22
- import { renderTree } from "./vfs/tree";
8
+ } from "../types/vfs";
9
+ import { archiveExists, createTarBuffer, readSnapshotFromTar } from "./archive";
10
+ import type { InternalDirectoryNode, InternalNode } from "./internalTypes";
11
+ import { getNode, getParentDirectory, normalizePath, splitPath } from "./path";
12
+ import { applySnapshot, createSnapshot } from "./snapshot";
13
+ import { renderTree } from "./tree";
23
14
 
24
15
  /**
25
16
  * In-memory virtual filesystem with tar.gz mirror persistence.
@@ -1,11 +1,12 @@
1
1
  import type { ShellModule } from "../../types/commands";
2
+ import { getArg } from "./command-helpers";
2
3
  import { assertPathAccess, resolveReadablePath } from "./helpers";
3
4
 
4
5
  export const catCommand: ShellModule = {
5
6
  name: "cat",
6
7
  params: ["<file>"],
7
8
  run: ({ authUser, vfs, cwd, args }) => {
8
- const fileArg = args[0];
9
+ const fileArg = getArg(args, 0);
9
10
  if (!fileArg) {
10
11
  return { stderr: "cat: missing file operand", exitCode: 1 };
11
12
  }
@@ -0,0 +1,135 @@
1
+ type ArgParseOptions = {
2
+ flags?: string[];
3
+ flagsWithValue?: string[];
4
+ };
5
+
6
+ function toFlagList(flags: string | string[]): string[] {
7
+ return Array.isArray(flags) ? flags : [flags];
8
+ }
9
+
10
+ function matchFlagToken(
11
+ token: string,
12
+ flag: string,
13
+ ): { matched: boolean; inlineValue: string | null } {
14
+ if (token === flag) {
15
+ return { matched: true, inlineValue: null };
16
+ }
17
+
18
+ const prefix = `${flag}=`;
19
+ if (token.startsWith(prefix)) {
20
+ return { matched: true, inlineValue: token.slice(prefix.length) };
21
+ }
22
+
23
+ return { matched: false, inlineValue: null };
24
+ }
25
+
26
+ function collectPositionals(
27
+ args: string[],
28
+ options: ArgParseOptions = {},
29
+ ): string[] {
30
+ const boolFlags = new Set(options.flags ?? []);
31
+ const valueFlags = new Set(options.flagsWithValue ?? []);
32
+ const positionals: string[] = [];
33
+ let passthrough = false;
34
+
35
+ for (let index = 0; index < args.length; index += 1) {
36
+ const arg = args[index]!;
37
+
38
+ if (passthrough) {
39
+ positionals.push(arg);
40
+ continue;
41
+ }
42
+
43
+ if (arg === "--") {
44
+ passthrough = true;
45
+ continue;
46
+ }
47
+
48
+ let consumed = false;
49
+
50
+ for (const flag of boolFlags) {
51
+ const { matched } = matchFlagToken(arg, flag);
52
+ if (matched) {
53
+ consumed = true;
54
+ break;
55
+ }
56
+ }
57
+
58
+ if (consumed) {
59
+ continue;
60
+ }
61
+
62
+ for (const flag of valueFlags) {
63
+ const match = matchFlagToken(arg, flag);
64
+ if (!match.matched) {
65
+ continue;
66
+ }
67
+
68
+ consumed = true;
69
+ if (match.inlineValue === null && index + 1 < args.length) {
70
+ index += 1;
71
+ }
72
+ break;
73
+ }
74
+
75
+ if (!consumed) {
76
+ positionals.push(arg);
77
+ }
78
+ }
79
+
80
+ return positionals;
81
+ }
82
+
83
+ export function ifFlag(args: string[], flags: string | string[]): boolean {
84
+ const allFlags = toFlagList(flags);
85
+
86
+ for (const arg of args) {
87
+ for (const flag of allFlags) {
88
+ if (matchFlagToken(arg, flag).matched) {
89
+ return true;
90
+ }
91
+ }
92
+ }
93
+
94
+ return false;
95
+ }
96
+
97
+ export function getFlag(
98
+ args: string[],
99
+ flags: string | string[],
100
+ ): string | true | undefined {
101
+ const allFlags = toFlagList(flags);
102
+
103
+ for (let index = 0; index < args.length; index += 1) {
104
+ const arg = args[index]!;
105
+
106
+ for (const flag of allFlags) {
107
+ const match = matchFlagToken(arg, flag);
108
+ if (!match.matched) {
109
+ continue;
110
+ }
111
+
112
+ if (match.inlineValue !== null) {
113
+ return match.inlineValue;
114
+ }
115
+
116
+ const next = args[index + 1];
117
+ if (next !== undefined && next !== "--") {
118
+ return next;
119
+ }
120
+
121
+ return true;
122
+ }
123
+ }
124
+
125
+ return undefined;
126
+ }
127
+
128
+ export function getArg(
129
+ args: string[],
130
+ index: number,
131
+ options: ArgParseOptions = {},
132
+ ): string | undefined {
133
+ const positionals = collectPositionals(args, options);
134
+ return positionals[index];
135
+ }