typescript-virtual-container 1.4.2 → 1.4.3

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 (48) hide show
  1. package/README.md +72 -35
  2. package/builds/self-standalone.js +160 -160
  3. package/builds/self-standalone.js.map +4 -4
  4. package/builds/standalone-wo-sftp.js +18 -18
  5. package/builds/standalone-wo-sftp.js.map +3 -3
  6. package/builds/standalone.js +46 -46
  7. package/builds/standalone.js.map +3 -3
  8. package/dist/VirtualFileSystem/index.d.ts +47 -0
  9. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  10. package/dist/VirtualFileSystem/index.js +159 -0
  11. package/dist/VirtualShell/index.d.ts +29 -0
  12. package/dist/VirtualShell/index.d.ts.map +1 -1
  13. package/dist/VirtualShell/index.js +29 -0
  14. package/dist/VirtualShell/shellParser.js +28 -1
  15. package/dist/commands/export.d.ts.map +1 -1
  16. package/dist/commands/export.js +5 -3
  17. package/dist/commands/registry.d.ts.map +1 -1
  18. package/dist/commands/registry.js +2 -0
  19. package/dist/commands/runtime.d.ts.map +1 -1
  20. package/dist/commands/runtime.js +28 -3
  21. package/dist/commands/seq.d.ts +4 -0
  22. package/dist/commands/seq.d.ts.map +1 -0
  23. package/dist/commands/seq.js +50 -0
  24. package/dist/commands/sh.d.ts +0 -6
  25. package/dist/commands/sh.d.ts.map +1 -1
  26. package/dist/commands/sh.js +153 -10
  27. package/dist/types/pipeline.d.ts +6 -0
  28. package/dist/types/pipeline.d.ts.map +1 -1
  29. package/dist/types/vfs.d.ts +15 -0
  30. package/dist/types/vfs.d.ts.map +1 -1
  31. package/dist/utils/expand.d.ts +9 -0
  32. package/dist/utils/expand.d.ts.map +1 -1
  33. package/dist/utils/expand.js +84 -2
  34. package/dist/utils/tokenize.d.ts.map +1 -1
  35. package/dist/utils/tokenize.js +40 -0
  36. package/package.json +1 -1
  37. package/src/VirtualFileSystem/index.ts +164 -1
  38. package/src/VirtualShell/index.ts +36 -0
  39. package/src/VirtualShell/shellParser.ts +26 -1
  40. package/src/commands/export.ts +5 -3
  41. package/src/commands/registry.ts +2 -0
  42. package/src/commands/runtime.ts +30 -3
  43. package/src/commands/seq.ts +43 -0
  44. package/src/commands/sh.ts +144 -19
  45. package/src/types/pipeline.ts +6 -0
  46. package/src/types/vfs.ts +17 -0
  47. package/src/utils/expand.ts +75 -2
  48. package/src/utils/tokenize.ts +20 -0
@@ -298,6 +298,42 @@ class VirtualShell extends EventEmitter {
298
298
  );
299
299
  }
300
300
 
301
+ /**
302
+ * Mount a host directory into the VFS at `vPath`.
303
+ *
304
+ * Delegates file operations inside `vPath` to the host filesystem via
305
+ * `node:fs`. Silently ignored in browser environments.
306
+ *
307
+ * @param vPath Absolute path inside the VM (e.g. `"/app"`).
308
+ * @param hostPath Path on the host — relative paths are resolved from `process.cwd()`.
309
+ * @param options `{ readOnly?: boolean }` — default `true`.
310
+ *
311
+ * @example
312
+ * ```ts
313
+ * const shell = new VirtualShell("dev-vm");
314
+ * await shell.ensureInitialized();
315
+ * shell.mount("/workspace", "./my-project");
316
+ * // shell commands can now read ./my-project files via /workspace
317
+ * ```
318
+ */
319
+ public mount(
320
+ vPath: string,
321
+ hostPath: string,
322
+ options: { readOnly?: boolean } = {},
323
+ ): void {
324
+ this.vfs.mount(vPath, hostPath, options);
325
+ }
326
+
327
+ /** Remove a previously mounted host directory. */
328
+ public unmount(vPath: string): void {
329
+ this.vfs.unmount(vPath);
330
+ }
331
+
332
+ /** List all active mounts. */
333
+ public getMounts(): Array<{ vPath: string; hostPath: string; readOnly: boolean }> {
334
+ return this.vfs.getMounts();
335
+ }
336
+
301
337
  /**
302
338
  * Updates only the session-dependent `/proc` entries (`/proc/<pid>`,
303
339
  * `/proc/self`). Cheaper than a full `refreshProcFs()` — call this
@@ -225,6 +225,10 @@ function parseCommandWithRedirections(token: string): PipelineCommand {
225
225
  let appendOutput = false;
226
226
  let i = 0;
227
227
 
228
+ let stderrFile: string | undefined;
229
+ let stderrAppend = false;
230
+ let stderrToStdout = false;
231
+
228
232
  while (i < parts.length) {
229
233
  const part = parts[i]!;
230
234
  if (part === "<") {
@@ -247,6 +251,23 @@ function parseCommandWithRedirections(token: string): PipelineCommand {
247
251
  outputFile = parts[i];
248
252
  appendOutput = false;
249
253
  i++;
254
+ } else if (part === "2>&1") {
255
+ stderrToStdout = true;
256
+ i++;
257
+ } else if (part === "2>>") {
258
+ i++;
259
+ if (i >= parts.length)
260
+ throw new Error("Syntax error: expected filename after 2>>");
261
+ stderrFile = parts[i];
262
+ stderrAppend = true;
263
+ i++;
264
+ } else if (part === "2>") {
265
+ i++;
266
+ if (i >= parts.length)
267
+ throw new Error("Syntax error: expected filename after 2>");
268
+ stderrFile = parts[i];
269
+ stderrAppend = false;
270
+ i++;
250
271
  } else {
251
272
  cmdParts.push(part);
252
273
  i++;
@@ -254,6 +275,10 @@ function parseCommandWithRedirections(token: string): PipelineCommand {
254
275
  }
255
276
 
256
277
  const name = (cmdParts[0] ?? "").toLowerCase();
257
- return { name, args: cmdParts.slice(1), inputFile, outputFile, appendOutput };
278
+ return {
279
+ name, args: cmdParts.slice(1),
280
+ inputFile, outputFile, appendOutput,
281
+ stderrFile, stderrAppend, stderrToStdout,
282
+ };
258
283
  }
259
284
 
@@ -11,13 +11,15 @@ export const exportCommand: ShellModule = {
11
11
  category: "shell",
12
12
  params: ["[VAR=value]"],
13
13
  run: ({ args, env }) => {
14
- if (args.length === 0) {
14
+ // export -p or export with no args list all exported vars
15
+ if (args.length === 0 || (args.length === 1 && args[0] === "-p")) {
15
16
  const out = Object.entries(env.vars)
17
+ .filter(([k]) => k && /^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
16
18
  .map(([k, v]) => `declare -x ${k}="${v}"`)
17
19
  .join("\n");
18
- return { stdout: out, exitCode: 0 };
20
+ return { stdout: out ? `${out}\n` : "", exitCode: 0 };
19
21
  }
20
- for (const arg of args) {
22
+ for (const arg of args.filter((a) => a !== "-p")) {
21
23
  if (arg.includes("=")) {
22
24
  const eq = arg.indexOf("=");
23
25
  const name = arg.slice(0, eq);
@@ -36,6 +36,7 @@ import { htopCommand } from "./htop";
36
36
  import { idCommand } from "./id";
37
37
  import { killCommand } from "./kill";
38
38
  import { lnCommand, readlinkCommand } from "./ln";
39
+ import { seqCommand } from "./seq";
39
40
  import { statCommand } from "./stat";
40
41
  import { lsCommand } from "./ls";
41
42
  import { lsbReleaseCommand } from "./lsb-release";
@@ -99,6 +100,7 @@ const BASE_COMMANDS: ShellModule[] = [
99
100
  lnCommand,
100
101
  readlinkCommand,
101
102
  chmodCommand,
103
+ seqCommand,
102
104
  statCommand,
103
105
  findCommand,
104
106
  // Text processing
@@ -7,7 +7,7 @@ import type {
7
7
  CommandResult,
8
8
  ShellEnv,
9
9
  } from "../types/commands";
10
- import { expandAsync } from "../utils/expand";
10
+ import { expandAsync, expandBraces } from "../utils/expand";
11
11
  import { tokenizeCommand } from "../utils/tokenize";
12
12
  import { resolveModule } from "./registry";
13
13
 
@@ -176,6 +176,15 @@ export async function runCommand(
176
176
  ? trimmed.replace(rawFirstWord, aliasVal)
177
177
  : trimmed;
178
178
 
179
+ // Detect sh-syntax constructs that must be handled by the sh interpreter
180
+ const isShScript =
181
+ /\bfor\s+\w+\s+in\b/.test(aliasExpanded) ||
182
+ /\bwhile\s+/.test(aliasExpanded) ||
183
+ /\bif\s+/.test(aliasExpanded) ||
184
+ /\w+\s*\(\s*\)\s*\{/.test(aliasExpanded) ||
185
+ /\bfunction\s+\w+/.test(aliasExpanded) ||
186
+ /\(\(\s*.+\s*\)\)/.test(aliasExpanded);
187
+
179
188
  const hasOperators =
180
189
  /(?<![|&])[|](?![|])/.test(aliasExpanded) ||
181
190
  aliasExpanded.includes(">") ||
@@ -184,7 +193,24 @@ export async function runCommand(
184
193
  aliasExpanded.includes("||") ||
185
194
  aliasExpanded.includes(";");
186
195
 
187
- if (hasOperators) {
196
+ if ((isShScript && rawFirstWord !== "sh" && rawFirstWord !== "bash") || hasOperators) {
197
+ // sh-syntax: route through sh interpreter to handle for/while/functions
198
+ if (isShScript && rawFirstWord !== "sh" && rawFirstWord !== "bash") {
199
+ const shMod = resolveModule("sh");
200
+ if (shMod) {
201
+ return await shMod.run({
202
+ authUser, hostname,
203
+ activeSessions: shell.users.listActiveSessions(),
204
+ rawInput: aliasExpanded,
205
+ mode,
206
+ args: ["-c", aliasExpanded],
207
+ stdin: undefined,
208
+ cwd,
209
+ shell,
210
+ env: shellEnv,
211
+ });
212
+ }
213
+ }
188
214
  const script = parseScript(aliasExpanded);
189
215
  if (!script.isValid)
190
216
  return { stderr: script.error || "Syntax error", exitCode: 1 };
@@ -225,7 +251,8 @@ export async function runCommand(
225
251
 
226
252
  const parts = tokenizeCommand(expanded.trim());
227
253
  const commandName = parts[0]?.toLowerCase() ?? "";
228
- const args = parts.slice(1);
254
+ // Apply brace expansion to each arg token
255
+ const args = parts.slice(1).flatMap(expandBraces);
229
256
  const mod = resolveModule(commandName);
230
257
 
231
258
  if (!mod) {
@@ -0,0 +1,43 @@
1
+ import type { ShellModule } from "../types/commands";
2
+
3
+ /** Generate sequences of numbers. seq LAST / seq FIRST LAST / seq FIRST INCREMENT LAST */
4
+ export const seqCommand: ShellModule = {
5
+ name: "seq",
6
+ description: "Print a sequence of numbers",
7
+ category: "text",
8
+ params: ["[FIRST [INCREMENT]] LAST"],
9
+ run: ({ args }) => {
10
+ const nums = args.filter((a) => !a.startsWith("-") || /^-[\d.]/.test(a)).map(Number);
11
+ const sep = (() => { const i = args.indexOf("-s"); return i !== -1 ? (args[i + 1] ?? "\n") : "\n"; })();
12
+ const fmt = (() => { const i = args.indexOf("-f"); return i !== -1 ? (args[i + 1] ?? "%g") : null; })();
13
+ const width = args.includes("-w");
14
+
15
+ let first = 1, inc = 1, last: number;
16
+ if (nums.length === 1) { last = nums[0]!; }
17
+ else if (nums.length === 2) { first = nums[0]!; last = nums[1]!; }
18
+ else { first = nums[0]!; inc = nums[1]!; last = nums[2]!; }
19
+
20
+ if (inc === 0) return { stderr: "seq: zero increment\n", exitCode: 1 };
21
+ if ((inc > 0 && first > last) || (inc < 0 && first < last)) return { stdout: "", exitCode: 0 };
22
+
23
+ const results: string[] = [];
24
+ const maxSteps = 100000;
25
+ let steps = 0;
26
+ for (let n = first; inc > 0 ? n <= last : n >= last; n = Math.round((n + inc) * 1e10) / 1e10) {
27
+ if (++steps > maxSteps) break;
28
+ let s: string;
29
+ if (fmt) {
30
+ s = fmt.replace("%g", String(n)).replace("%f", n.toFixed(6)).replace("%d", String(Math.trunc(n)));
31
+ } else {
32
+ s = Number.isInteger(n) ? String(n) : n.toPrecision(12).replace(/\.?0+$/, "");
33
+ }
34
+ if (width) {
35
+ const maxLen = String(Math.trunc(last)).length;
36
+ s = s.padStart(maxLen, "0");
37
+ }
38
+ results.push(s);
39
+ }
40
+
41
+ return { stdout: `${results.join(sep)}\n`, exitCode: 0 };
42
+ },
43
+ };
@@ -45,6 +45,8 @@ type Block =
45
45
  }
46
46
  | { type: "for"; var: string; list: string; body: string[] }
47
47
  | { type: "while"; cond: string; body: string[] }
48
+ | { type: "func"; name: string; body: string[] }
49
+ | { type: "arith"; expr: string }
48
50
  | { type: "cmd"; line: string };
49
51
 
50
52
  /** Very small shell interpreter: supports if/elif/else/fi, for/do/done, while/do/done */
@@ -58,6 +60,41 @@ function parseBlocks(lines: string[]): Block[] {
58
60
  continue;
59
61
  }
60
62
 
63
+ // Function definition: name() { or function name { or name() { body }
64
+ const funcMatchInline = line.match(/^(?:function\s+)?(\w+)\s*\(\s*\)\s*\{(.+)\}\s*$/);
65
+ const funcMatch = funcMatchInline ?? (
66
+ line.match(/^(?:function\s+)?(\w+)\s*\(\s*\)\s*\{?\s*$/) ||
67
+ line.match(/^function\s+(\w+)\s*\{?\s*$/)
68
+ );
69
+ if (funcMatch) {
70
+ const funcName = funcMatch[1]!;
71
+ const body: string[] = [];
72
+ // Inline: name() { cmd; } — single-line form
73
+ if (funcMatchInline) {
74
+ body.push(...funcMatchInline[2]!.split(";").map((s: string) => s.trim()).filter(Boolean));
75
+ blocks.push({ type: "func", name: funcName, body });
76
+ i++;
77
+ continue;
78
+ }
79
+ i++;
80
+ while (i < lines.length && lines[i]?.trim() !== "}" && i < lines.length + 1) {
81
+ const l = lines[i]!.trim().replace(/^do\s+/, "");
82
+ if (l && l !== "{") body.push(l);
83
+ i++;
84
+ }
85
+ i++; // skip closing }
86
+ blocks.push({ type: "func", name: funcName, body });
87
+ continue;
88
+ }
89
+
90
+ // (( expr )) arithmetic statement
91
+ const arithMatch = line.match(/^\(\(\s*(.+?)\s*\)\)$/);
92
+ if (arithMatch) {
93
+ blocks.push({ type: "arith", expr: arithMatch[1]! });
94
+ i++;
95
+ continue;
96
+ }
97
+
61
98
  if (line.startsWith("if ") || line === "if") {
62
99
  const cond = line
63
100
  .replace(/^if\s+/, "")
@@ -225,16 +262,34 @@ async function runBlocks(
225
262
  }
226
263
  }
227
264
 
228
- const r = await runCommand(
229
- expanded,
230
- ctx.authUser,
231
- ctx.hostname,
232
- ctx.mode,
233
- ctx.cwd,
234
- ctx.shell,
235
- undefined,
236
- ctx.env,
237
- );
265
+ const r = await (async () => {
266
+ // Check if expanded matches a registered function
267
+ const cmdName = expanded.trim().split(/\s+/)[0] ?? "";
268
+ const funcBody = ctx.env.vars[`__func_${cmdName}`];
269
+ if (funcBody) {
270
+ // Set positional params $1 $2 ... from remaining args
271
+ const funcArgs = expanded.trim().split(/\s+/).slice(1);
272
+ const savedVars = { ...ctx.env.vars };
273
+ funcArgs.forEach((a, i) => { ctx.env.vars[String(i + 1)] = a; });
274
+ ctx.env.vars["0"] = cmdName;
275
+ const funcLines = funcBody.split("\n");
276
+ const funcResult = await runBlocks(parseBlocks(funcLines), ctx);
277
+ // Restore positional params
278
+ for (let pi = 1; pi <= funcArgs.length; pi++) delete ctx.env.vars[String(pi)];
279
+ Object.assign(ctx.env.vars, { ...savedVars, ...ctx.env.vars });
280
+ return funcResult;
281
+ }
282
+ return runCommand(
283
+ expanded,
284
+ ctx.authUser,
285
+ ctx.hostname,
286
+ ctx.mode,
287
+ ctx.cwd,
288
+ ctx.shell,
289
+ undefined,
290
+ ctx.env,
291
+ );
292
+ })();
238
293
  ctx.env.lastExitCode = r.exitCode ?? 0;
239
294
  if (r.stdout) output += `${r.stdout}\n`;
240
295
  if (r.stderr) return { ...r, stdout: output.trim() };
@@ -259,6 +314,27 @@ async function runBlocks(
259
314
  if (sub.stdout) output += `${sub.stdout}\n`;
260
315
  }
261
316
  }
317
+ } else if (block.type === "func") {
318
+ // Register function in env vars as __func_<name>=<body>
319
+ ctx.env.vars[`__func_${block.name}`] = block.body.join("\n");
320
+ } else if (block.type === "arith") {
321
+ // (( expr )) — evaluate arithmetic, update vars
322
+ const { expandSync } = await import("../utils/expand");
323
+ const expr = expandSync(block.expr, ctx.env.vars, ctx.env.lastExitCode);
324
+ // Handle i++ / i-- / i+=N / i-=N
325
+ const incMatch = expr.match(/^(\w+)\s*(\+\+|--)$/);
326
+ if (incMatch) {
327
+ const val = parseInt(ctx.env.vars[incMatch[1]!] ?? "0", 10);
328
+ ctx.env.vars[incMatch[1]!] = String(incMatch[2] === "++" ? val + 1 : val - 1);
329
+ } else {
330
+ const assignMatch = expr.match(/^(\w+)\s*([+\-*/])=\s*(.+)$/);
331
+ if (assignMatch) {
332
+ const lhs = parseInt(ctx.env.vars[assignMatch[1]!] ?? "0", 10);
333
+ const rhs = parseInt(assignMatch[3]!, 10);
334
+ const ops: Record<string, number> = { "+": lhs + rhs, "-": lhs - rhs, "*": lhs * rhs, "/": Math.floor(lhs / rhs) };
335
+ ctx.env.vars[assignMatch[1]!] = String(ops[assignMatch[2]!] ?? lhs);
336
+ }
337
+ }
262
338
  } else if (block.type === "for") {
263
339
  const listExpanded = await expandVars(
264
340
  block.list,
@@ -266,7 +342,9 @@ async function runBlocks(
266
342
  ctx.env.lastExitCode,
267
343
  ctx,
268
344
  );
269
- const items = listExpanded.trim().split(/\s+/);
345
+ // Apply brace expansion to each token in the list
346
+ const { expandBraces } = await import("../utils/expand");
347
+ const items = listExpanded.trim().split(/\s+/).flatMap(expandBraces);
270
348
  for (const item of items) {
271
349
  ctx.env.vars[block.var] = item;
272
350
  const sub = await runBlocks(parseBlocks(block.body), ctx);
@@ -292,6 +370,59 @@ async function runBlocks(
292
370
  * @category shell
293
371
  * @params ["-c <script>", "[<file>]"]
294
372
  */
373
+
374
+ /**
375
+ * Split a sh script into logical lines, respecting:
376
+ * - `{...}` braces (function bodies)
377
+ * - Newlines and semicolons at depth 0 only
378
+ */
379
+ function splitShScript(script: string): string[] {
380
+ const lines: string[] = [];
381
+ let current = "";
382
+ let depth = 0;
383
+ let inSingleQ = false;
384
+ let inDoubleQ = false;
385
+ let i = 0;
386
+ while (i < script.length) {
387
+ const ch = script[i]!;
388
+ if (!inSingleQ && !inDoubleQ) {
389
+ if (ch === "'") { inSingleQ = true; current += ch; i++; continue; }
390
+ if (ch === '"') { inDoubleQ = true; current += ch; i++; continue; }
391
+ if (ch === "{") { depth++; current += ch; i++; continue; }
392
+ if (ch === "}") {
393
+ depth--;
394
+ current += ch;
395
+ i++;
396
+ // At depth 0, closing } ends the function body line
397
+ if (depth === 0) {
398
+ const t = current.trim();
399
+ if (t) lines.push(t);
400
+ current = "";
401
+ // Skip trailing ; or whitespace
402
+ while (i < script.length && (script[i] === ";" || script[i] === " ")) i++;
403
+ }
404
+ continue;
405
+ }
406
+ if (depth === 0 && (ch === ";" || ch === "\n")) {
407
+ const t = current.trim();
408
+ if (t && !t.startsWith("#")) lines.push(t);
409
+ current = "";
410
+ i++;
411
+ continue;
412
+ }
413
+ } else if (inSingleQ && ch === "'") {
414
+ inSingleQ = false;
415
+ } else if (inDoubleQ && ch === '"') {
416
+ inDoubleQ = false;
417
+ }
418
+ current += ch;
419
+ i++;
420
+ }
421
+ const t = current.trim();
422
+ if (t && !t.startsWith("#")) lines.push(t);
423
+ return lines;
424
+ }
425
+
295
426
  export const shCommand: ShellModule = {
296
427
  name: "sh",
297
428
  aliases: ["bash"],
@@ -305,10 +436,7 @@ export const shCommand: ShellModule = {
305
436
  if (ifFlag(args, "-c")) {
306
437
  const script = args[args.indexOf("-c") + 1] ?? "";
307
438
  if (!script) return { stderr: "sh: -c requires a script", exitCode: 1 };
308
- const lines = script
309
- .split(/[;\n]/)
310
- .map((l) => l.trim())
311
- .filter((l) => l && !l.startsWith("#"));
439
+ const lines = splitShScript(script);
312
440
  const blocks = parseBlocks(lines);
313
441
  return runBlocks(blocks, ctx);
314
442
  }
@@ -323,10 +451,7 @@ export const shCommand: ShellModule = {
323
451
  exitCode: 1,
324
452
  };
325
453
  const content = shell.vfs.readFile(p);
326
- const lines = content
327
- .split("\n")
328
- .map((l) => l.trim())
329
- .filter((l) => l && !l.startsWith("#"));
454
+ const lines = splitShScript(content);
330
455
  const blocks = parseBlocks(lines);
331
456
  return runBlocks(blocks, ctx);
332
457
  }
@@ -10,6 +10,12 @@ export interface PipelineCommand {
10
10
  outputFile?: string;
11
11
  /** Append to output file (>> file) */
12
12
  appendOutput?: boolean;
13
+ /** Stderr redirection file path (2> file) */
14
+ stderrFile?: string;
15
+ /** Append stderr to file (2>> file) */
16
+ stderrAppend?: boolean;
17
+ /** Redirect stderr to stdout (2>&1) */
18
+ stderrToStdout?: boolean;
13
19
  }
14
20
 
15
21
  /** Logical operator connecting two statement groups. */
package/src/types/vfs.ts CHANGED
@@ -79,3 +79,20 @@ export type VfsSnapshotNode = VfsSnapshotFileNode | VfsSnapshotDirectoryNode;
79
79
  export interface VfsSnapshot {
80
80
  root: VfsSnapshotDirectoryNode;
81
81
  }
82
+
83
+ /** Options for mounting a host directory into the VFS. */
84
+ export interface MountOptions {
85
+ /** Absolute path inside the VM (e.g. `"/app"`). */
86
+ vPath: string;
87
+ /** Path on the host filesystem. Relative paths resolved from `process.cwd()`. */
88
+ hostPath: string;
89
+ /** When `true` (default), write operations inside the mount throw `EROFS`. */
90
+ readOnly?: boolean;
91
+ }
92
+
93
+ /** Describes an active mount point. */
94
+ export interface MountPoint {
95
+ vPath: string;
96
+ hostPath: string;
97
+ readOnly: boolean;
98
+ }
@@ -95,6 +95,79 @@ function outsideSingleQuotes(
95
95
  * @param lastExit Last command exit code (for `$?`).
96
96
  * @param home Home directory path (for `~`).
97
97
  */
98
+
99
+ /**
100
+ * Expand brace expressions in a single token.
101
+ * - `{a,b,c}` → `["a", "b", "c"]`
102
+ * - `{1..5}` → `["1", "2", "3", "4", "5"]`
103
+ * - `{a..e}` → `["a", "b", "c", "d", "e"]`
104
+ * - `prefix{a,b}suffix` → `["prefixasuffix", "prefixbsuffix"]`
105
+ * Returns a single-element array when no brace expansion applies.
106
+ */
107
+ export function expandBraces(token: string): string[] {
108
+ // Find the first { not preceded by $
109
+ let depth = 0;
110
+ let start = -1;
111
+ for (let i = 0; i < token.length; i++) {
112
+ const ch = token[i]!;
113
+ if (ch === "{" && token[i - 1] !== "$") {
114
+ if (depth === 0) start = i;
115
+ depth++;
116
+ } else if (ch === "}") {
117
+ depth--;
118
+ if (depth === 0 && start !== -1) {
119
+ const prefix = token.slice(0, start);
120
+ const inner = token.slice(start + 1, i);
121
+ const suffix = token.slice(i + 1);
122
+
123
+ // Range: {1..5} or {a..e}
124
+ const rangeMatch = inner.match(/^(-?\d+)\.\.(-?\d+)(?:\.\.-?(\d+))?$/) ||
125
+ inner.match(/^([a-z])\.\.([a-z])$/);
126
+ if (rangeMatch) {
127
+ const items: string[] = [];
128
+ if (/\d/.test(rangeMatch[1]!)) {
129
+ const from = parseInt(rangeMatch[1]!, 10);
130
+ const to = parseInt(rangeMatch[2]!, 10);
131
+ const step = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : 1;
132
+ const inc = from <= to ? step : -step;
133
+ for (let n = from; from <= to ? n <= to : n >= to; n += inc) {
134
+ items.push(String(n));
135
+ }
136
+ } else {
137
+ const from = rangeMatch[1]!.charCodeAt(0);
138
+ const to = rangeMatch[2]!.charCodeAt(0);
139
+ const inc = from <= to ? 1 : -1;
140
+ for (let c = from; from <= to ? c <= to : c >= to; c += inc) {
141
+ items.push(String.fromCharCode(c));
142
+ }
143
+ }
144
+ const expanded = items.map((v) => `${prefix}${v}${suffix}`);
145
+ return expanded.flatMap(expandBraces);
146
+ }
147
+
148
+ // Comma list: {a,b,c} — split respecting nested braces
149
+ const parts: string[] = [];
150
+ let cur = "";
151
+ let d2 = 0;
152
+ for (const ch2 of inner) {
153
+ if (ch2 === "{") { d2++; cur += ch2; }
154
+ else if (ch2 === "}") { d2--; cur += ch2; }
155
+ else if (ch2 === "," && d2 === 0) { parts.push(cur); cur = ""; }
156
+ else { cur += ch2; }
157
+ }
158
+ parts.push(cur);
159
+
160
+ if (parts.length > 1) {
161
+ const expanded = parts.map((p) => `${prefix}${p}${suffix}`);
162
+ return expanded.flatMap(expandBraces);
163
+ }
164
+ break;
165
+ }
166
+ }
167
+ }
168
+ return [token];
169
+ }
170
+
98
171
  export function expandSync(
99
172
  input: string,
100
173
  env: Record<string, string>,
@@ -155,8 +228,8 @@ export function expandSync(
155
228
  (_, name) => env[name] ?? "",
156
229
  );
157
230
 
158
- // $VAR
159
- s = s.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => env[name] ?? "");
231
+ // $VAR and positional params $1 $2 ...
232
+ s = s.replace(/\$([A-Za-z_][A-Za-z0-9_]*|\d+)/g, (_, name) => env[name] ?? "");
160
233
 
161
234
  return s;
162
235
  });
@@ -55,6 +55,26 @@ export function tokenizeCommand(input: string): string[] {
55
55
  continue;
56
56
  }
57
57
 
58
+ // Handle 2>&1, 2>>, 2>, >&, >>
59
+ if (!inQ && ch === "2" && (next === ">" )) {
60
+ const rest = input.slice(i + 1);
61
+ if (rest.startsWith(">>&1") || rest.startsWith(">> &1")) {
62
+ if (current) { tokens.push(current); current = ""; }
63
+ tokens.push("2>>&1"); i += 5; continue;
64
+ }
65
+ if (rest.startsWith(">&1")) {
66
+ if (current) { tokens.push(current); current = ""; }
67
+ tokens.push("2>&1"); i += 4; continue;
68
+ }
69
+ if (rest.startsWith(">>")) {
70
+ if (current) { tokens.push(current); current = ""; }
71
+ tokens.push("2>>"); i += 3; continue;
72
+ }
73
+ if (rest.startsWith(">")) {
74
+ if (current) { tokens.push(current); current = ""; }
75
+ tokens.push("2>"); i += 2; continue;
76
+ }
77
+ }
58
78
  if ((ch === ">" || ch === "<") && !inQ) {
59
79
  if (current) {
60
80
  tokens.push(current);