typescript-virtual-container 1.2.4 → 1.2.6

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 (267) hide show
  1. package/README.md +1056 -1239
  2. package/benchmark-results.txt +20 -20
  3. package/dist/SSHMimic/exec.js +2 -2
  4. package/dist/SSHMimic/executor.d.ts +6 -7
  5. package/dist/SSHMimic/executor.d.ts.map +1 -1
  6. package/dist/SSHMimic/executor.js +77 -60
  7. package/dist/SSHMimic/index.d.ts +19 -2
  8. package/dist/SSHMimic/index.d.ts.map +1 -1
  9. package/dist/SSHMimic/index.js +106 -24
  10. package/dist/SSHMimic/sftp.d.ts.map +1 -1
  11. package/dist/SSHMimic/sftp.js +14 -0
  12. package/dist/VirtualFileSystem/index.d.ts +115 -88
  13. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  14. package/dist/VirtualFileSystem/index.js +389 -264
  15. package/dist/VirtualShell/index.d.ts +3 -4
  16. package/dist/VirtualShell/index.d.ts.map +1 -1
  17. package/dist/VirtualShell/index.js +4 -6
  18. package/dist/VirtualShell/shell.d.ts.map +1 -1
  19. package/dist/VirtualShell/shell.js +19 -2
  20. package/dist/VirtualShell/shellParser.d.ts +20 -2
  21. package/dist/VirtualShell/shellParser.d.ts.map +1 -1
  22. package/dist/VirtualShell/shellParser.js +229 -120
  23. package/dist/VirtualUserManager/index.d.ts +25 -0
  24. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  25. package/dist/VirtualUserManager/index.js +33 -0
  26. package/dist/commands/adduser.d.ts.map +1 -1
  27. package/dist/commands/adduser.js +2 -0
  28. package/dist/commands/awk.d.ts +3 -0
  29. package/dist/commands/awk.d.ts.map +1 -0
  30. package/dist/commands/awk.js +29 -0
  31. package/dist/commands/base64.d.ts +3 -0
  32. package/dist/commands/base64.d.ts.map +1 -0
  33. package/dist/commands/base64.js +20 -0
  34. package/dist/commands/cat.d.ts.map +1 -1
  35. package/dist/commands/cat.js +2 -0
  36. package/dist/commands/cd.d.ts.map +1 -1
  37. package/dist/commands/cd.js +2 -0
  38. package/dist/commands/chmod.d.ts +3 -0
  39. package/dist/commands/chmod.d.ts.map +1 -0
  40. package/dist/commands/chmod.js +33 -0
  41. package/dist/commands/clear.d.ts.map +1 -1
  42. package/dist/commands/clear.js +4 -1
  43. package/dist/commands/cp.d.ts +3 -0
  44. package/dist/commands/cp.d.ts.map +1 -0
  45. package/dist/commands/cp.js +70 -0
  46. package/dist/commands/curl.d.ts.map +1 -1
  47. package/dist/commands/curl.js +2 -0
  48. package/dist/commands/cut.d.ts +3 -0
  49. package/dist/commands/cut.d.ts.map +1 -0
  50. package/dist/commands/cut.js +27 -0
  51. package/dist/commands/date.d.ts +3 -0
  52. package/dist/commands/date.d.ts.map +1 -0
  53. package/dist/commands/date.js +22 -0
  54. package/dist/commands/deluser.d.ts.map +1 -1
  55. package/dist/commands/deluser.js +2 -0
  56. package/dist/commands/df.d.ts +3 -0
  57. package/dist/commands/df.d.ts.map +1 -0
  58. package/dist/commands/df.js +16 -0
  59. package/dist/commands/diff.d.ts +3 -0
  60. package/dist/commands/diff.d.ts.map +1 -0
  61. package/dist/commands/diff.js +40 -0
  62. package/dist/commands/du.d.ts +3 -0
  63. package/dist/commands/du.d.ts.map +1 -0
  64. package/dist/commands/du.js +39 -0
  65. package/dist/commands/echo.d.ts.map +1 -1
  66. package/dist/commands/echo.js +2 -0
  67. package/dist/commands/env.d.ts.map +1 -1
  68. package/dist/commands/env.js +6 -14
  69. package/dist/commands/export.d.ts.map +1 -1
  70. package/dist/commands/export.js +11 -21
  71. package/dist/commands/find.d.ts +3 -0
  72. package/dist/commands/find.d.ts.map +1 -0
  73. package/dist/commands/find.js +50 -0
  74. package/dist/commands/grep.d.ts.map +1 -1
  75. package/dist/commands/grep.js +58 -35
  76. package/dist/commands/groups.d.ts +3 -0
  77. package/dist/commands/groups.d.ts.map +1 -0
  78. package/dist/commands/groups.js +12 -0
  79. package/dist/commands/gzip.d.ts +4 -0
  80. package/dist/commands/gzip.d.ts.map +1 -0
  81. package/dist/commands/gzip.js +40 -0
  82. package/dist/commands/head.d.ts +3 -0
  83. package/dist/commands/head.d.ts.map +1 -0
  84. package/dist/commands/head.js +32 -0
  85. package/dist/commands/help.d.ts +1 -1
  86. package/dist/commands/help.d.ts.map +1 -1
  87. package/dist/commands/help.js +75 -3
  88. package/dist/commands/hostname.d.ts.map +1 -1
  89. package/dist/commands/hostname.js +2 -0
  90. package/dist/commands/htop.d.ts.map +1 -1
  91. package/dist/commands/htop.js +2 -0
  92. package/dist/commands/id.d.ts +3 -0
  93. package/dist/commands/id.d.ts.map +1 -0
  94. package/dist/commands/id.js +14 -0
  95. package/dist/commands/index.d.ts +5 -2
  96. package/dist/commands/index.d.ts.map +1 -1
  97. package/dist/commands/index.js +104 -87
  98. package/dist/commands/kill.d.ts +3 -0
  99. package/dist/commands/kill.d.ts.map +1 -0
  100. package/dist/commands/kill.js +13 -0
  101. package/dist/commands/ln.d.ts +3 -0
  102. package/dist/commands/ln.d.ts.map +1 -0
  103. package/dist/commands/ln.js +44 -0
  104. package/dist/commands/ls.d.ts.map +1 -1
  105. package/dist/commands/ls.js +2 -0
  106. package/dist/commands/mkdir.d.ts.map +1 -1
  107. package/dist/commands/mkdir.js +2 -0
  108. package/dist/commands/mv.d.ts +3 -0
  109. package/dist/commands/mv.d.ts.map +1 -0
  110. package/dist/commands/mv.js +37 -0
  111. package/dist/commands/nano.d.ts.map +1 -1
  112. package/dist/commands/nano.js +2 -0
  113. package/dist/commands/neofetch.d.ts.map +1 -1
  114. package/dist/commands/neofetch.js +2 -0
  115. package/dist/commands/passwd.d.ts.map +1 -1
  116. package/dist/commands/passwd.js +2 -0
  117. package/dist/commands/ping.d.ts +3 -0
  118. package/dist/commands/ping.d.ts.map +1 -0
  119. package/dist/commands/ping.js +18 -0
  120. package/dist/commands/ps.d.ts +3 -0
  121. package/dist/commands/ps.d.ts.map +1 -0
  122. package/dist/commands/ps.js +17 -0
  123. package/dist/commands/pwd.d.ts.map +1 -1
  124. package/dist/commands/pwd.js +2 -0
  125. package/dist/commands/rm.d.ts.map +1 -1
  126. package/dist/commands/rm.js +2 -0
  127. package/dist/commands/sed.d.ts +3 -0
  128. package/dist/commands/sed.d.ts.map +1 -0
  129. package/dist/commands/sed.js +47 -0
  130. package/dist/commands/set.d.ts +3 -0
  131. package/dist/commands/set.d.ts.map +1 -1
  132. package/dist/commands/set.js +19 -46
  133. package/dist/commands/sh.d.ts +0 -1
  134. package/dist/commands/sh.d.ts.map +1 -1
  135. package/dist/commands/sh.js +228 -35
  136. package/dist/commands/sleep.d.ts +3 -0
  137. package/dist/commands/sleep.d.ts.map +1 -0
  138. package/dist/commands/sleep.js +13 -0
  139. package/dist/commands/sort.d.ts +3 -0
  140. package/dist/commands/sort.d.ts.map +1 -0
  141. package/dist/commands/sort.js +37 -0
  142. package/dist/commands/su.d.ts.map +1 -1
  143. package/dist/commands/su.js +2 -0
  144. package/dist/commands/sudo.d.ts.map +1 -1
  145. package/dist/commands/sudo.js +2 -0
  146. package/dist/commands/tail.d.ts +3 -0
  147. package/dist/commands/tail.d.ts.map +1 -0
  148. package/dist/commands/tail.js +35 -0
  149. package/dist/commands/tar.d.ts +3 -0
  150. package/dist/commands/tar.d.ts.map +1 -0
  151. package/dist/commands/tar.js +64 -0
  152. package/dist/commands/tee.d.ts +3 -0
  153. package/dist/commands/tee.d.ts.map +1 -0
  154. package/dist/commands/tee.js +29 -0
  155. package/dist/commands/touch.d.ts.map +1 -1
  156. package/dist/commands/touch.js +2 -0
  157. package/dist/commands/tr.d.ts +3 -0
  158. package/dist/commands/tr.d.ts.map +1 -0
  159. package/dist/commands/tr.js +24 -0
  160. package/dist/commands/tree.d.ts.map +1 -1
  161. package/dist/commands/tree.js +2 -0
  162. package/dist/commands/uname.d.ts +3 -0
  163. package/dist/commands/uname.d.ts.map +1 -0
  164. package/dist/commands/uname.js +21 -0
  165. package/dist/commands/uniq.d.ts +3 -0
  166. package/dist/commands/uniq.d.ts.map +1 -0
  167. package/dist/commands/uniq.js +33 -0
  168. package/dist/commands/unset.d.ts.map +1 -1
  169. package/dist/commands/unset.js +6 -10
  170. package/dist/commands/wc.d.ts +3 -0
  171. package/dist/commands/wc.d.ts.map +1 -0
  172. package/dist/commands/wc.js +50 -0
  173. package/dist/commands/wget.d.ts.map +1 -1
  174. package/dist/commands/wget.js +2 -0
  175. package/dist/commands/who.d.ts.map +1 -1
  176. package/dist/commands/who.js +2 -0
  177. package/dist/commands/whoami.d.ts.map +1 -1
  178. package/dist/commands/whoami.js +2 -0
  179. package/dist/commands/xargs.d.ts +3 -0
  180. package/dist/commands/xargs.d.ts.map +1 -0
  181. package/dist/commands/xargs.js +16 -0
  182. package/dist/index.d.ts +1 -0
  183. package/dist/index.d.ts.map +1 -1
  184. package/dist/types/commands.d.ts +13 -0
  185. package/dist/types/commands.d.ts.map +1 -1
  186. package/dist/types/pipeline.d.ts +20 -0
  187. package/dist/types/pipeline.d.ts.map +1 -1
  188. package/package.json +5 -2
  189. package/scripts/publish-package.sh +70 -0
  190. package/src/SSHMimic/exec.ts +2 -2
  191. package/src/SSHMimic/executor.ts +95 -98
  192. package/src/SSHMimic/index.ts +138 -57
  193. package/src/SSHMimic/sftp.ts +15 -0
  194. package/src/VirtualFileSystem/index.ts +464 -292
  195. package/src/VirtualShell/index.ts +4 -6
  196. package/src/VirtualShell/shell.ts +19 -2
  197. package/src/VirtualShell/shellParser.ts +202 -168
  198. package/src/VirtualUserManager/index.ts +36 -0
  199. package/src/commands/adduser.ts +2 -0
  200. package/src/commands/awk.ts +30 -0
  201. package/src/commands/base64.ts +18 -0
  202. package/src/commands/cat.ts +2 -0
  203. package/src/commands/cd.ts +2 -0
  204. package/src/commands/chmod.ts +35 -0
  205. package/src/commands/clear.ts +4 -1
  206. package/src/commands/cp.ts +78 -0
  207. package/src/commands/curl.ts +2 -0
  208. package/src/commands/cut.ts +29 -0
  209. package/src/commands/date.ts +24 -0
  210. package/src/commands/deluser.ts +2 -0
  211. package/src/commands/df.ts +18 -0
  212. package/src/commands/diff.ts +29 -0
  213. package/src/commands/du.ts +39 -0
  214. package/src/commands/echo.ts +2 -0
  215. package/src/commands/env.ts +6 -16
  216. package/src/commands/export.ts +11 -24
  217. package/src/commands/find.ts +63 -0
  218. package/src/commands/grep.ts +51 -38
  219. package/src/commands/groups.ts +14 -0
  220. package/src/commands/gzip.ts +31 -0
  221. package/src/commands/head.ts +37 -0
  222. package/src/commands/help.ts +81 -3
  223. package/src/commands/hostname.ts +2 -0
  224. package/src/commands/htop.ts +2 -0
  225. package/src/commands/id.ts +16 -0
  226. package/src/commands/index.ts +114 -133
  227. package/src/commands/kill.ts +14 -0
  228. package/src/commands/ln.ts +49 -0
  229. package/src/commands/ls.ts +2 -0
  230. package/src/commands/mkdir.ts +2 -0
  231. package/src/commands/mv.ts +45 -0
  232. package/src/commands/nano.ts +2 -0
  233. package/src/commands/neofetch.ts +2 -0
  234. package/src/commands/passwd.ts +2 -0
  235. package/src/commands/ping.ts +20 -0
  236. package/src/commands/ps.ts +19 -0
  237. package/src/commands/pwd.ts +2 -0
  238. package/src/commands/rm.ts +2 -0
  239. package/src/commands/sed.ts +45 -0
  240. package/src/commands/set.ts +19 -50
  241. package/src/commands/sh.ts +192 -43
  242. package/src/commands/sleep.ts +14 -0
  243. package/src/commands/sort.ts +37 -0
  244. package/src/commands/su.ts +2 -0
  245. package/src/commands/sudo.ts +2 -0
  246. package/src/commands/tail.ts +39 -0
  247. package/src/commands/tar.ts +58 -0
  248. package/src/commands/tee.ts +25 -0
  249. package/src/commands/touch.ts +2 -0
  250. package/src/commands/tr.ts +24 -0
  251. package/src/commands/tree.ts +2 -0
  252. package/src/commands/uname.ts +20 -0
  253. package/src/commands/uniq.ts +28 -0
  254. package/src/commands/unset.ts +5 -12
  255. package/src/commands/wc.ts +50 -0
  256. package/src/commands/wget.ts +2 -0
  257. package/src/commands/who.ts +2 -0
  258. package/src/commands/whoami.ts +2 -0
  259. package/src/commands/xargs.ts +17 -0
  260. package/src/index.ts +1 -0
  261. package/src/types/commands.ts +14 -0
  262. package/src/types/pipeline.ts +23 -0
  263. package/standalone.js +93 -55
  264. package/standalone.js.map +4 -4
  265. package/tests/bun-test-shim.ts +1 -0
  266. package/tests/sftp.test.ts +115 -191
  267. package/tests/users.test.ts +42 -88
@@ -4,7 +4,7 @@ import type { CommandContext, CommandResult } from "../types/commands";
4
4
  import type { ShellStream } from "../types/streams";
5
5
  import type { PerfLogger } from "../utils/perfLogger";
6
6
  import { createPerfLogger } from "../utils/perfLogger";
7
- import VirtualFileSystem from "../VirtualFileSystem";
7
+ import VirtualFileSystem, { type VfsOptions } from "../VirtualFileSystem";
8
8
  import { VirtualUserManager } from "../VirtualUserManager";
9
9
  import { startShell } from "./shell";
10
10
 
@@ -38,7 +38,6 @@ function resolveAutoSudoForNewUsers(): boolean {
38
38
  * client API.
39
39
  */
40
40
  class VirtualShell extends EventEmitter {
41
- basePath: string = ".";
42
41
  vfs: VirtualFileSystem;
43
42
  users: VirtualUserManager;
44
43
  hostname: string;
@@ -50,19 +49,18 @@ class VirtualShell extends EventEmitter {
50
49
  *
51
50
  * @param hostname Virtual hostname used for prompts and idents.
52
51
  * @param properties Customizable properties shown in `uname -a` and similar commands.
53
- * @param basePath Optional base path for the virtual filesystem (defaults to process.cwd()).
52
+ * @param vfsOptions Optional VFS persistence options (mode, snapshotPath).
54
53
  */
55
54
  constructor(
56
55
  hostname: string,
57
56
  properties?: ShellProperties,
58
- basePath?: string,
57
+ vfsOptions?: VfsOptions,
59
58
  ) {
60
59
  super();
61
60
  perf.mark("constructor");
62
61
  this.hostname = hostname;
63
62
  this.properties = properties || defaultShellProperties;
64
- this.basePath = basePath || ".";
65
- this.vfs = new VirtualFileSystem(this.basePath);
63
+ this.vfs = new VirtualFileSystem(vfsOptions ?? {});
66
64
  this.users = new VirtualUserManager(this.vfs, resolveAutoSudoForNewUsers());
67
65
 
68
66
  // Store references to avoid TypeScript "used before assigned" errors
@@ -2,7 +2,8 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { readFile, unlink, writeFile } from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import type { ShellProperties, VirtualShell } from ".";
5
- import { getCommandNames, runCommand } from "../commands";
5
+ import { getCommandNames, makeDefaultEnv, runCommand } from "../commands";
6
+ import type { ShellEnv } from "../types/commands";
6
7
  import {
7
8
  spawnHtopProcess,
8
9
  spawnNanoEditorProcess,
@@ -50,6 +51,7 @@ export function startShell(
50
51
  let historyIndex: number | null = null;
51
52
  let historyDraft = "";
52
53
  let cwd = `/home/${authUser}`;
54
+ const shellEnv: ShellEnv = makeDefaultEnv(authUser, hostname);
53
55
  let nanoSession: NanoSession | null = null;
54
56
  let pendingSudo: PendingSudo | null = null;
55
57
  const buildCurrentPrompt = (): string => {
@@ -62,6 +64,21 @@ export function startShell(
62
64
  `[${sessionId}] Shell started for user '${authUser}' at ${remoteAddress}`,
63
65
  );
64
66
 
67
+ // Load .bashrc if it exists
68
+ void (async () => {
69
+ const bashrcPath = `/home/${authUser}/.bashrc`;
70
+ if (shell.vfs.exists(bashrcPath)) {
71
+ try {
72
+ const bashrc = shell.vfs.readFile(bashrcPath);
73
+ for (const line of bashrc.split("\n")) {
74
+ const l = line.trim();
75
+ if (!l || l.startsWith("#")) continue;
76
+ await runCommand(l, authUser, hostname, "shell", cwd, shell, undefined, shellEnv);
77
+ }
78
+ } catch { /* ignore bashrc errors */ }
79
+ }
80
+ })();
81
+
65
82
  function renderLine(): void {
66
83
  const prompt = buildCurrentPrompt();
67
84
  stream.write(`\r${prompt}${lineBuffer}\u001b[K`);
@@ -568,7 +585,7 @@ export function startShell(
568
585
 
569
586
  if (line.length > 0) {
570
587
  const result = await Promise.resolve(
571
- runCommand(line, authUser, hostname, "shell", cwd, shell),
588
+ runCommand(line, authUser, hostname, "shell", cwd, shell, undefined, shellEnv),
572
589
  );
573
590
 
574
591
  pushHistory(line);
@@ -1,228 +1,262 @@
1
- import type { Pipeline, PipelineCommand } from "../types/pipeline";
1
+ import type { Pipeline, PipelineCommand, Script, Statement, LogicalOp } from "../types/pipeline";
2
2
 
3
- /** Parse a shell command line into a structured pipeline */
4
- export function parseShellPipeline(rawInput: string): Pipeline {
3
+ // ── Public API ───────────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * Parse a shell input line into a Script (sequence of statements connected
7
+ * by && / || / ;). Each statement contains one Pipeline (commands connected
8
+ * by |).
9
+ */
10
+ export function parseScript(rawInput: string): Script {
5
11
  const trimmed = rawInput.trim();
12
+ if (!trimmed) return { statements: [], isValid: true };
6
13
 
7
- if (!trimmed) {
8
- return { commands: [], isValid: true };
14
+ try {
15
+ const statements = parseStatements(trimmed);
16
+ return { statements, isValid: true };
17
+ } catch (e) {
18
+ return { statements: [], isValid: false, error: (e as Error).message };
9
19
  }
20
+ }
10
21
 
11
- const commands: PipelineCommand[] = [];
12
- const tokenized = tokenizePipeline(trimmed);
13
- if (tokenized.error) {
14
- return {
15
- commands: [],
16
- isValid: false,
17
- error: tokenized.error,
18
- };
22
+ /** Legacy compat: parse a single pipeline (no &&/||/;) */
23
+ export function parseShellPipeline(rawInput: string): Pipeline {
24
+ const trimmed = rawInput.trim();
25
+ if (!trimmed) return { commands: [], isValid: true };
26
+ try {
27
+ const commands = parsePipeline(trimmed);
28
+ return { commands, isValid: true };
29
+ } catch (e) {
30
+ return { commands: [], isValid: false, error: (e as Error).message };
19
31
  }
32
+ }
20
33
 
21
- const pipeTokens = tokenized.tokens;
34
+ // ── Variable & tilde expansion ────────────────────────────────────────────────
22
35
 
23
- for (const token of pipeTokens) {
24
- const cmd = parseCommandWithRedirections(token);
25
- if (!cmd.isValid) {
26
- return {
27
- commands: [],
28
- isValid: false,
29
- error: cmd.error,
30
- };
31
- }
32
- if (cmd.command) {
33
- commands.push(cmd.command);
34
- }
36
+ /**
37
+ * Expand ~ and $VAR / ${VAR} / ${VAR:-default} / $(cmd placeholder) in a
38
+ * token, given the current env vars and home path.
39
+ * Command substitution $(…) is NOT executed here — it's left as a marker so
40
+ * the executor can handle it.
41
+ */
42
+ export function expandToken(
43
+ token: string,
44
+ env: Record<string, string>,
45
+ authUser: string,
46
+ lastExitCode = 0,
47
+ ): string {
48
+ // tilde expansion
49
+ token = token.replace(/^~(\/|$)/, `/home/${authUser}$1`);
50
+
51
+ // $? special var
52
+ token = token.replace(/\$\?/g, String(lastExitCode));
53
+ // $$ PID (mock)
54
+ token = token.replace(/\$\$/g, "1");
55
+ // $# argc (0 for interactive)
56
+ token = token.replace(/\$#/g, "0");
57
+
58
+ // ${VAR:-default} and ${VAR:+value}
59
+ token = token.replace(/\$\{([^}:]+):-([^}]*)\}/g, (_, name, def) =>
60
+ env[name] ?? def,
61
+ );
62
+ token = token.replace(/\$\{([^}:]+):\+([^}]*)\}/g, (_, name, val) =>
63
+ env[name] ? val : "",
64
+ );
65
+
66
+ // ${VAR}
67
+ token = token.replace(/\$\{([^}]+)\}/g, (_, name) => env[name] ?? "");
68
+
69
+ // $VAR (greedy: match longest valid identifier)
70
+ token = token.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) =>
71
+ env[name] ?? "",
72
+ );
73
+
74
+ return token;
75
+ }
76
+
77
+ /**
78
+ * Expand glob patterns (*, ?, [abc]) against a list of entries.
79
+ * Returns the original pattern if no match.
80
+ */
81
+ export function expandGlob(pattern: string, entries: string[]): string[] {
82
+ if (!/[*?[]/.test(pattern)) return [pattern];
83
+ const regex = globToRegex(pattern);
84
+ const matches = entries.filter((e) => regex.test(e));
85
+ return matches.length > 0 ? matches.sort() : [pattern];
86
+ }
87
+
88
+ function globToRegex(pattern: string): RegExp {
89
+ let re = "^";
90
+ for (let i = 0; i < pattern.length; i++) {
91
+ const c = pattern[i]!;
92
+ if (c === "*") re += ".*";
93
+ else if (c === "?") re += ".";
94
+ else if (c === "[") {
95
+ const close = pattern.indexOf("]", i + 1);
96
+ if (close === -1) re += "\\[";
97
+ else {
98
+ re += `[${pattern.slice(i + 1, close)}]`;
99
+ i = close;
100
+ }
101
+ } else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
102
+ }
103
+ return new RegExp(`${re}$`);
104
+ }
105
+
106
+ // ── Internal parser ───────────────────────────────────────────────────────────
107
+
108
+ function parseStatements(input: string): Statement[] {
109
+ // Split by ;, &&, || — respecting quotes and parens
110
+ const segments = splitByLogicalOps(input);
111
+ const statements: Statement[] = [];
112
+
113
+ for (const seg of segments) {
114
+ const commands = parsePipeline(seg.text.trim());
115
+ const stmt: Statement = { pipeline: { commands, isValid: true } };
116
+ if (seg.op) stmt.op = seg.op;
117
+ statements.push(stmt);
35
118
  }
36
119
 
37
- return { commands, isValid: true };
120
+ return statements;
38
121
  }
39
122
 
40
- /** Tokenize input by pipes, respecting quoted strings */
41
- function tokenizePipeline(input: string): { tokens: string[]; error?: string } {
42
- const tokens: string[] = [];
123
+ interface Segment { text: string; op?: LogicalOp }
124
+
125
+ function splitByLogicalOps(input: string): Segment[] {
126
+ const segments: Segment[] = [];
43
127
  let current = "";
44
- let inQuotes = false;
45
- let quoteChar = "";
128
+ let depth = 0; // parens/subshell depth
129
+ let inQ = false;
130
+ let qChar = "";
46
131
  let i = 0;
47
132
 
133
+ const flush = (op?: LogicalOp) => {
134
+ if (current.trim()) segments.push({ text: current, op });
135
+ current = "";
136
+ };
137
+
48
138
  while (i < input.length) {
49
- const ch = input[i];
50
-
51
- if ((ch === '"' || ch === "'") && (i === 0 || input[i - 1] !== "\\")) {
52
- if (!inQuotes) {
53
- inQuotes = true;
54
- quoteChar = ch;
55
- } else if (ch === quoteChar) {
56
- inQuotes = false;
57
- }
58
- current += ch;
59
- i++;
60
- } else if (ch === "|" && !inQuotes) {
61
- if (!current.trim()) {
62
- return {
63
- tokens: [],
64
- error: "Syntax error near unexpected token '|'",
65
- };
66
- }
67
- tokens.push(current.trim());
68
- current = "";
69
- i++;
70
- } else {
71
- current += ch;
72
- i++;
73
- }
74
- }
139
+ const ch = input[i]!;
140
+ const ch2 = input.slice(i, i + 2);
75
141
 
76
- if (inQuotes) {
77
- return {
78
- tokens: [],
79
- error: "Syntax error: unterminated quote",
80
- };
81
- }
142
+ if ((ch === '"' || ch === "'") && !inQ) { inQ = true; qChar = ch; current += ch; i++; continue; }
143
+ if (inQ && ch === qChar) { inQ = false; current += ch; i++; continue; }
144
+ if (inQ) { current += ch; i++; continue; }
82
145
 
83
- if (!current.trim()) {
84
- return {
85
- tokens: [],
86
- error: "Syntax error near unexpected token '|'",
87
- };
88
- }
146
+ if (ch === "(") { depth++; current += ch; i++; continue; }
147
+ if (ch === ")") { depth--; current += ch; i++; continue; }
148
+ if (depth > 0) { current += ch; i++; continue; }
89
149
 
90
- tokens.push(current.trim());
150
+ if (ch2 === "&&") { flush("&&"); i += 2; continue; }
151
+ if (ch2 === "||") { flush("||"); i += 2; continue; }
152
+ if (ch === ";") { flush(";"); i++; continue; }
91
153
 
92
- return { tokens };
154
+ current += ch; i++;
155
+ }
156
+ flush();
157
+ return segments;
93
158
  }
94
159
 
95
- interface ParseResult {
96
- command?: PipelineCommand;
97
- isValid: boolean;
98
- error?: string;
160
+ function parsePipeline(input: string): PipelineCommand[] {
161
+ const pipeTokens = splitByPipe(input);
162
+ return pipeTokens.map(parseCommandWithRedirections);
99
163
  }
100
164
 
101
- /** Parse a single command with its redirections (>, >>, <) */
102
- function parseCommandWithRedirections(token: string): ParseResult {
103
- const parts = tokenizeCommand(token);
165
+ function splitByPipe(input: string): string[] {
166
+ const tokens: string[] = [];
167
+ let current = "";
168
+ let inQ = false;
169
+ let qChar = "";
104
170
 
105
- if (parts.length === 0) {
106
- return { isValid: true };
171
+ for (let i = 0; i < input.length; i++) {
172
+ const ch = input[i]!;
173
+ if ((ch === '"' || ch === "'") && !inQ) { inQ = true; qChar = ch; current += ch; continue; }
174
+ if (inQ && ch === qChar) { inQ = false; current += ch; continue; }
175
+ if (inQ) { current += ch; continue; }
176
+
177
+ // || was already consumed at statement level, bare | is pipe
178
+ if (ch === "|" && input[i + 1] !== "|") {
179
+ if (!current.trim()) throw new Error("Syntax error near unexpected token '|'");
180
+ tokens.push(current.trim());
181
+ current = "";
182
+ } else {
183
+ current += ch;
184
+ }
107
185
  }
108
186
 
187
+ const tail = current.trim();
188
+ if (!tail && tokens.length > 0) throw new Error("Syntax error near unexpected token '|'");
189
+ if (tail) tokens.push(tail);
190
+ return tokens;
191
+ }
192
+
193
+ function parseCommandWithRedirections(token: string): PipelineCommand {
194
+ const parts = tokenizeCommand(token);
195
+ if (parts.length === 0) return { name: "", args: [] };
196
+
109
197
  const cmdParts: string[] = [];
110
198
  let inputFile: string | undefined;
111
199
  let outputFile: string | undefined;
112
200
  let appendOutput = false;
113
-
114
201
  let i = 0;
115
- while (i < parts.length) {
116
- const part = parts[i] as string;
117
202
 
203
+ while (i < parts.length) {
204
+ const part = parts[i]!;
118
205
  if (part === "<") {
119
206
  i++;
120
- if (i >= parts.length) {
121
- return {
122
- isValid: false,
123
- error: "Syntax error: expected filename after <",
124
- };
125
- }
207
+ if (i >= parts.length) throw new Error("Syntax error: expected filename after <");
126
208
  inputFile = parts[i];
127
209
  i++;
128
210
  } else if (part === ">>") {
129
211
  i++;
130
- if (i >= parts.length) {
131
- return {
132
- isValid: false,
133
- error: "Syntax error: expected filename after >>",
134
- };
135
- }
136
- outputFile = parts[i];
137
- appendOutput = true;
138
- i++;
212
+ if (i >= parts.length) throw new Error("Syntax error: expected filename after >>");
213
+ outputFile = parts[i]; appendOutput = true; i++;
139
214
  } else if (part === ">") {
140
215
  i++;
141
- if (i >= parts.length) {
142
- return {
143
- isValid: false,
144
- error: "Syntax error: expected filename after >",
145
- };
146
- }
147
- outputFile = parts[i];
148
- appendOutput = false;
149
- i++;
216
+ if (i >= parts.length) throw new Error("Syntax error: expected filename after >");
217
+ outputFile = parts[i]; appendOutput = false; i++;
150
218
  } else {
151
- cmdParts.push(part);
152
- i++;
219
+ cmdParts.push(part); i++;
153
220
  }
154
221
  }
155
222
 
156
- if (cmdParts.length === 0) {
157
- return { isValid: true };
158
- }
159
-
160
- const name = (cmdParts[0] as string).toLowerCase();
161
- const args = cmdParts.slice(1);
162
-
163
- return {
164
- command: {
165
- name,
166
- args,
167
- inputFile,
168
- outputFile,
169
- appendOutput,
170
- },
171
- isValid: true,
172
- };
223
+ const name = (cmdParts[0] ?? "").toLowerCase();
224
+ return { name, args: cmdParts.slice(1), inputFile, outputFile, appendOutput };
173
225
  }
174
226
 
175
- /** Tokenize a command, respecting quotes and handling >> vs > */
176
227
  function tokenizeCommand(input: string): string[] {
177
228
  const tokens: string[] = [];
178
229
  let current = "";
179
- let inQuotes = false;
180
- let quoteChar = "";
230
+ let inQ = false;
231
+ let qChar = "";
181
232
  let i = 0;
182
233
 
183
234
  while (i < input.length) {
184
- const ch = input[i];
235
+ const ch = input[i]!;
185
236
  const next = input[i + 1];
186
237
 
187
- // Handle quotes
188
- if ((ch === '"' || ch === "'") && (i === 0 || input[i - 1] !== "\\")) {
189
- if (!inQuotes) {
190
- inQuotes = true;
191
- quoteChar = ch;
192
- } else if (ch === quoteChar) {
193
- inQuotes = false;
194
- quoteChar = "";
195
- } else {
196
- current += ch;
197
- }
198
- i++;
199
- } else if (ch === " " && !inQuotes) {
200
- if (current) {
201
- tokens.push(current);
202
- current = "";
203
- }
204
- i++;
205
- } else if ((ch === ">" || ch === "<") && !inQuotes) {
206
- if (current) {
207
- tokens.push(current);
208
- current = "";
209
- }
210
- if (ch === ">" && next === ">") {
211
- tokens.push(">>");
212
- i += 2;
213
- } else {
214
- tokens.push(ch);
215
- i++;
216
- }
217
- } else {
218
- current += ch;
219
- i++;
238
+ if ((ch === '"' || ch === "'") && !inQ) {
239
+ inQ = true; qChar = ch; i++; continue;
220
240
  }
221
- }
241
+ if (inQ && ch === qChar) {
242
+ inQ = false; qChar = ""; i++; continue;
243
+ }
244
+ if (inQ) { current += ch; i++; continue; }
222
245
 
223
- if (current) {
224
- tokens.push(current);
225
- }
246
+ if (ch === " ") {
247
+ if (current) { tokens.push(current); current = ""; }
248
+ i++; continue;
249
+ }
226
250
 
251
+ if ((ch === ">" || ch === "<") && !inQ) {
252
+ if (current) { tokens.push(current); current = ""; }
253
+ if (ch === ">" && next === ">") { tokens.push(">>"); i += 2; }
254
+ else { tokens.push(ch); i++; }
255
+ continue;
256
+ }
257
+
258
+ current += ch; i++;
259
+ }
260
+ if (current) tokens.push(current);
227
261
  return tokens;
228
262
  }
@@ -648,6 +648,42 @@ export class VirtualUserManager extends EventEmitter {
648
648
  throw new Error("invalid password");
649
649
  }
650
650
  }
651
+ private readonly authorizedKeys = new Map<string, Array<{ algo: string; data: Buffer }>>();
652
+
653
+ /**
654
+ * Adds an SSH public key for a user, enabling public-key authentication.
655
+ *
656
+ * @param username Target user.
657
+ * @param algo Key algorithm (e.g. "ssh-rsa", "ssh-ed25519").
658
+ * @param data Raw key data as a Buffer (the base64-decoded key bytes).
659
+ */
660
+ public addAuthorizedKey(username: string, algo: string, data: Buffer): void {
661
+ perf.mark("addAuthorizedKey");
662
+ const keys = this.authorizedKeys.get(username) ?? [];
663
+ keys.push({ algo, data });
664
+ this.authorizedKeys.set(username, keys);
665
+ this.emit("key:add", { username, algo });
666
+ }
667
+
668
+ /**
669
+ * Removes all authorized keys for a user.
670
+ *
671
+ * @param username Target user.
672
+ */
673
+ public removeAuthorizedKeys(username: string): void {
674
+ this.authorizedKeys.delete(username);
675
+ this.emit("key:remove", { username });
676
+ }
677
+
678
+ /**
679
+ * Returns the list of authorized keys for a user.
680
+ * Returns an empty array when no keys are registered.
681
+ *
682
+ * @param username Target user.
683
+ */
684
+ public getAuthorizedKeys(username: string): Array<{ algo: string; data: Buffer }> {
685
+ return this.authorizedKeys.get(username) ?? [];
686
+ }
651
687
  }
652
688
 
653
689
  function normalizeVfsPath(targetPath: string): string {
@@ -2,6 +2,8 @@ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export const adduserCommand: ShellModule = {
4
4
  name: "adduser",
5
+ description: "Add a new user",
6
+ category: "users",
5
7
  params: ["<username> <password>"],
6
8
  run: async ({ authUser, shell, args }) => {
7
9
  if (authUser !== "root") {
@@ -0,0 +1,30 @@
1
+ import type { ShellModule } from "../types/commands";
2
+ import { getFlag } from "./command-helpers";
3
+
4
+ export const awkCommand: ShellModule = {
5
+ name: "awk",
6
+ description: "Pattern scanning and processing language (minimal)",
7
+ category: "text",
8
+ params: ["[-F <sep>] '<program>' [file]"],
9
+ run: ({ args, stdin }) => {
10
+ const sep = (getFlag(args, ["-F"]) as string | undefined) ?? " ";
11
+ const prog = args.find((a) => !a.startsWith("-") && a !== sep);
12
+ if (!prog) return { stderr: "awk: no program", exitCode: 1 };
13
+
14
+ // Only support print $N and {print $N} patterns
15
+ const printMatch = prog.match(/^\{?\s*print\s+([^}]+)\s*\}?$/);
16
+ if (!printMatch) return { stderr: `awk: unsupported program: ${prog}`, exitCode: 1 };
17
+
18
+ const fields = printMatch[1]!.split(/\s*,\s*/).map((f) => f.trim());
19
+ const lines = (stdin ?? "").split("\n").filter(Boolean);
20
+ const out = lines.map((line) => {
21
+ const parts = line.split(sep === " " ? /\s+/ : sep);
22
+ return fields.map((f) => {
23
+ if (f === "$0") return line;
24
+ const n = parseInt(f.replace("$", ""), 10);
25
+ return Number.isNaN(n) ? f.replace(/"/g, "") : (parts[n - 1] ?? "");
26
+ }).join(sep === " " ? "\t" : sep);
27
+ });
28
+ return { stdout: out.join("\n"), exitCode: 0 };
29
+ },
30
+ };
@@ -0,0 +1,18 @@
1
+ import type { ShellModule } from "../types/commands";
2
+ import { ifFlag } from "./command-helpers";
3
+
4
+ export const base64Command: ShellModule = {
5
+ name: "base64",
6
+ description: "Encode/decode base64",
7
+ category: "text",
8
+ params: ["[-d] [file]"],
9
+ run: ({ args, stdin }) => {
10
+ const decode = ifFlag(args, ["-d", "--decode"]);
11
+ const input = stdin ?? "";
12
+ if (decode) {
13
+ try { return { stdout: Buffer.from(input.trim(), "base64").toString("utf8"), exitCode: 0 }; }
14
+ catch { return { stderr: "base64: invalid input", exitCode: 1 }; }
15
+ }
16
+ return { stdout: Buffer.from(input).toString("base64"), exitCode: 0 };
17
+ },
18
+ };
@@ -4,6 +4,8 @@ import { assertPathAccess, resolveReadablePath } from "./helpers";
4
4
 
5
5
  export const catCommand: ShellModule = {
6
6
  name: "cat",
7
+ description: "Concatenate and print files",
8
+ category: "files",
7
9
  params: ["<file>"],
8
10
  run: ({ authUser, shell, cwd, args }) => {
9
11
  const fileArg = getArg(args, 0);
@@ -3,6 +3,8 @@ import { assertPathAccess, resolvePath } from "./helpers";
3
3
 
4
4
  export const cdCommand: ShellModule = {
5
5
  name: "cd",
6
+ description: "Change directory",
7
+ category: "navigation",
6
8
  params: ["[path]"],
7
9
  run: ({ authUser, shell, cwd, args, mode }) => {
8
10
  const target = resolvePath(cwd, args[0] ?? "/virtual-env-js");
@@ -0,0 +1,35 @@
1
+ import type { ShellModule } from "../types/commands";
2
+ import { assertPathAccess, resolvePath } from "./helpers";
3
+
4
+ export const chmodCommand: ShellModule = {
5
+ name: "chmod",
6
+ description: "Change file permissions",
7
+ category: "files",
8
+ params: ["<mode> <file>"],
9
+ run: ({ authUser, shell, cwd, args }) => {
10
+ const [modeArg, fileArg] = args;
11
+ if (!modeArg || !fileArg) {
12
+ return { stderr: "chmod: missing operand", exitCode: 1 };
13
+ }
14
+
15
+ const filePath = resolvePath(cwd, fileArg);
16
+ try {
17
+ assertPathAccess(authUser, filePath, "chmod");
18
+ if (!shell.vfs.exists(filePath)) {
19
+ return {
20
+ stderr: `chmod: ${fileArg}: No such file or directory`,
21
+ exitCode: 1,
22
+ };
23
+ }
24
+ const mode = parseInt(modeArg, 8);
25
+ if (Number.isNaN(mode)) {
26
+ return { stderr: `chmod: invalid mode: ${modeArg}`, exitCode: 1 };
27
+ }
28
+ shell.vfs.chmod(filePath, mode);
29
+ return { exitCode: 0 };
30
+ } catch (err) {
31
+ const msg = err instanceof Error ? err.message : String(err);
32
+ return { stderr: `chmod: ${msg}`, exitCode: 1 };
33
+ }
34
+ },
35
+ };