typescript-virtual-container 1.0.4 → 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 (53) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +50 -0
  3. package/package.json +1 -1
  4. package/src/SSHMimic/client.ts +1 -1
  5. package/src/SSHMimic/exec.ts +1 -1
  6. package/src/SSHMimic/executor.ts +2 -2
  7. package/src/SSHMimic/index.ts +14 -18
  8. package/src/{VirtualFileSystem.ts → VirtualFileSystem/index.ts} +6 -15
  9. package/src/{SSHMimic → VirtualShell}/commands/cat.ts +2 -1
  10. package/src/VirtualShell/commands/command-helpers.ts +135 -0
  11. package/src/{SSHMimic → VirtualShell}/commands/curl.ts +16 -37
  12. package/src/{SSHMimic → VirtualShell}/commands/echo.ts +10 -2
  13. package/src/{SSHMimic → VirtualShell}/commands/export.ts +7 -1
  14. package/src/{SSHMimic → VirtualShell}/commands/grep.ts +15 -8
  15. package/src/{SSHMimic → VirtualShell}/commands/helpers.ts +0 -37
  16. package/src/{SSHMimic → VirtualShell}/commands/index.ts +63 -8
  17. package/src/{SSHMimic → VirtualShell}/commands/ls.ts +3 -2
  18. package/src/{SSHMimic → VirtualShell}/commands/mkdir.ts +6 -1
  19. package/src/{SSHMimic → VirtualShell}/commands/rm.ts +10 -3
  20. package/src/{SSHMimic → VirtualShell}/commands/set.ts +7 -1
  21. package/src/VirtualShell/commands/sh.ts +58 -0
  22. package/src/{SSHMimic → VirtualShell}/commands/su.ts +3 -3
  23. package/src/{SSHMimic → VirtualShell}/commands/sudo.ts +16 -26
  24. package/src/{SSHMimic → VirtualShell}/commands/tree.ts +2 -1
  25. package/src/{SSHMimic → VirtualShell}/commands/wget.ts +23 -6
  26. package/src/{SSHMimic → VirtualShell}/commands/who.ts +1 -1
  27. package/src/VirtualShell/index.ts +69 -0
  28. package/src/{SSHMimic → VirtualShell}/shell.ts +3 -3
  29. package/src/index.ts +8 -0
  30. package/src/standalone.ts +10 -1
  31. package/tests/command-helpers.test.ts +40 -0
  32. package/tests/helpers.test.ts +1 -1
  33. package/src/SSHMimic/commands/sh.ts +0 -121
  34. /package/src/{vfs → VirtualFileSystem}/archive.ts +0 -0
  35. /package/src/{vfs → VirtualFileSystem}/internalTypes.ts +0 -0
  36. /package/src/{vfs → VirtualFileSystem}/path.ts +0 -0
  37. /package/src/{vfs → VirtualFileSystem}/snapshot.ts +0 -0
  38. /package/src/{vfs → VirtualFileSystem}/tree.ts +0 -0
  39. /package/src/{SSHMimic → VirtualShell}/commands/adduser.ts +0 -0
  40. /package/src/{SSHMimic → VirtualShell}/commands/cd.ts +0 -0
  41. /package/src/{SSHMimic → VirtualShell}/commands/clear.ts +0 -0
  42. /package/src/{SSHMimic → VirtualShell}/commands/deluser.ts +0 -0
  43. /package/src/{SSHMimic → VirtualShell}/commands/env.ts +0 -0
  44. /package/src/{SSHMimic → VirtualShell}/commands/exit.ts +0 -0
  45. /package/src/{SSHMimic → VirtualShell}/commands/help.ts +0 -0
  46. /package/src/{SSHMimic → VirtualShell}/commands/hostname.ts +0 -0
  47. /package/src/{SSHMimic → VirtualShell}/commands/htop.ts +0 -0
  48. /package/src/{SSHMimic → VirtualShell}/commands/nano.ts +0 -0
  49. /package/src/{SSHMimic → VirtualShell}/commands/pwd.ts +0 -0
  50. /package/src/{SSHMimic → VirtualShell}/commands/touch.ts +0 -0
  51. /package/src/{SSHMimic → VirtualShell}/commands/unset.ts +0 -0
  52. /package/src/{SSHMimic → VirtualShell}/commands/whoami.ts +0 -0
  53. /package/src/{SSHMimic → VirtualShell}/shellParser.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,17 @@ 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
+
9
20
  ## [1.0.4] - 2026-04-15
10
21
 
11
22
  ### Added
package/README.md CHANGED
@@ -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.
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.4",
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 {
@@ -1,8 +1,8 @@
1
1
  import type { CommandMode, CommandResult } from "../types/commands";
2
2
  import type { Pipeline, PipelineCommand } from "../types/pipeline";
3
3
  import type VirtualFileSystem from "../VirtualFileSystem";
4
- import { runCommand as runSingleCommand } from "./commands";
5
- import { resolvePath } from "./commands/helpers";
4
+ import { runCommand as runSingleCommand } from "../VirtualShell/commands";
5
+ import { resolvePath } from "../VirtualShell/commands/helpers";
6
6
  import type { VirtualUserManager } from "./users";
7
7
 
8
8
  /**
@@ -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
+ }
@@ -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 };
@@ -1,4 +1,5 @@
1
1
  import type { ShellModule } from "../../types/commands";
2
+ import { getArg, ifFlag } from "./command-helpers";
2
3
  import { getAllEnvVars } from "./set";
3
4
 
4
5
  function expandEnvVars(input: string, env: Record<string, string>): string {
@@ -11,8 +12,15 @@ export const echoCommand: ShellModule = {
11
12
  name: "echo",
12
13
  params: ["[options] [text...]"],
13
14
  run: ({ args, authUser, stdin }) => {
14
- const newline = !args.includes("-n");
15
- const filteredArgs = args.filter((arg) => arg !== "-n");
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
+ }
16
24
  const env = getAllEnvVars(authUser);
17
25
  const rawText =
18
26
  filteredArgs.length > 0 ? filteredArgs.join(" ") : (stdin ?? "");
@@ -1,4 +1,5 @@
1
1
  import type { ShellModule } from "../../types/commands";
2
+ import { getArg } from "./command-helpers";
2
3
  import { getEnvVar, setEnvVar } from "./set";
3
4
 
4
5
  export const exportCommand: ShellModule = {
@@ -15,7 +16,12 @@ export const exportCommand: ShellModule = {
15
16
  }
16
17
 
17
18
  // Parse VAR=value format
18
- for (const arg of args) {
19
+ for (let index = 0; ; index += 1) {
20
+ const arg = getArg(args, index);
21
+ if (!arg) {
22
+ break;
23
+ }
24
+
19
25
  if (arg.includes("=")) {
20
26
  const [varName, varValue] = arg.split("=", 2);
21
27
  if (varName && varValue !== undefined) {
@@ -1,25 +1,32 @@
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 grepCommand: ShellModule = {
5
6
  name: "grep",
6
7
  params: ["[-i] [-v] <pattern> [file...]"],
7
8
  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");
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
+ }
11
21
 
12
- if (filteredArgs.length === 0) {
22
+ if (!pattern) {
13
23
  return { stderr: "grep: no pattern specified", exitCode: 1 };
14
24
  }
15
25
 
16
- const pattern = filteredArgs[0];
17
- const files = filteredArgs.slice(1);
18
-
19
26
  let regex: RegExp;
20
27
  try {
21
28
  const flags = caseInsensitive ? "gmi" : "gm";
22
- regex = new RegExp(pattern as string, flags);
29
+ regex = new RegExp(pattern, flags);
23
30
  } catch {
24
31
  return { stderr: `grep: invalid regex: ${pattern}`, exitCode: 1 };
25
32
  }
@@ -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();
@@ -1,11 +1,12 @@
1
+ import type { VirtualUserManager } from "../../SSHMimic/users";
1
2
  import type {
3
+ CommandContext,
2
4
  CommandMode,
3
5
  CommandOutcome,
4
6
  CommandResult,
5
7
  ShellModule,
6
8
  } from "../../types/commands";
7
9
  import type VirtualFileSystem from "../../VirtualFileSystem";
8
- import type { VirtualUserManager } from "../users";
9
10
  import { adduserCommand } from "./adduser";
10
11
  import { catCommand } from "./cat";
11
12
  import { cdCommand } from "./cd";
@@ -67,18 +68,72 @@ const BASE_COMMANDS: ShellModule[] = [
67
68
  exitCommand,
68
69
  ];
69
70
 
70
- const COMMANDS: ShellModule[] = [
71
- ...BASE_COMMANDS,
72
- createHelpCommand(() => COMMANDS.map((cmd) => cmd.name)),
73
- ];
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
+ }
74
126
 
75
127
  export function getCommandNames(): string[] {
76
- return COMMANDS.flatMap((cmd) => [cmd.name, ...(cmd.aliases ?? [])]);
128
+ return getCommandModules().flatMap((cmd) => [
129
+ cmd.name,
130
+ ...(cmd.aliases ?? []),
131
+ ]);
77
132
  }
78
133
 
79
134
  function resolveModule(name: string): ShellModule | undefined {
80
135
  const lowered = name.toLowerCase();
81
- return COMMANDS.find(
136
+ return getCommandModules().find(
82
137
  (cmd) => cmd.name === lowered || cmd.aliases?.includes(lowered),
83
138
  );
84
139
  }
@@ -152,7 +207,7 @@ async function runCommandInternal(
152
207
  ) {
153
208
  // Use pipeline executor
154
209
  const { parseShellPipeline } = await import("../shellParser");
155
- const { executePipeline } = await import("../executor");
210
+ const { executePipeline } = await import("../../SSHMimic/executor");
156
211
 
157
212
  const pipeline = parseShellPipeline(rawInput);
158
213
  if (!pipeline.isValid) {
@@ -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("."));