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
@@ -19,6 +19,38 @@ function parseBlocks(lines) {
19
19
  i++;
20
20
  continue;
21
21
  }
22
+ // Function definition: name() { or function name { or name() { body }
23
+ const funcMatchInline = line.match(/^(?:function\s+)?(\w+)\s*\(\s*\)\s*\{(.+)\}\s*$/);
24
+ const funcMatch = funcMatchInline ?? (line.match(/^(?:function\s+)?(\w+)\s*\(\s*\)\s*\{?\s*$/) ||
25
+ line.match(/^function\s+(\w+)\s*\{?\s*$/));
26
+ if (funcMatch) {
27
+ const funcName = funcMatch[1];
28
+ const body = [];
29
+ // Inline: name() { cmd; } — single-line form
30
+ if (funcMatchInline) {
31
+ body.push(...funcMatchInline[2].split(";").map((s) => s.trim()).filter(Boolean));
32
+ blocks.push({ type: "func", name: funcName, body });
33
+ i++;
34
+ continue;
35
+ }
36
+ i++;
37
+ while (i < lines.length && lines[i]?.trim() !== "}" && i < lines.length + 1) {
38
+ const l = lines[i].trim().replace(/^do\s+/, "");
39
+ if (l && l !== "{")
40
+ body.push(l);
41
+ i++;
42
+ }
43
+ i++; // skip closing }
44
+ blocks.push({ type: "func", name: funcName, body });
45
+ continue;
46
+ }
47
+ // (( expr )) arithmetic statement
48
+ const arithMatch = line.match(/^\(\(\s*(.+?)\s*\)\)$/);
49
+ if (arithMatch) {
50
+ blocks.push({ type: "arith", expr: arithMatch[1] });
51
+ i++;
52
+ continue;
53
+ }
22
54
  if (line.startsWith("if ") || line === "if") {
23
55
  const cond = line
24
56
  .replace(/^if\s+/, "")
@@ -174,7 +206,26 @@ async function runBlocks(blocks, ctx) {
174
206
  continue;
175
207
  }
176
208
  }
177
- const r = await runCommand(expanded, ctx.authUser, ctx.hostname, ctx.mode, ctx.cwd, ctx.shell, undefined, ctx.env);
209
+ const r = await (async () => {
210
+ // Check if expanded matches a registered function
211
+ const cmdName = expanded.trim().split(/\s+/)[0] ?? "";
212
+ const funcBody = ctx.env.vars[`__func_${cmdName}`];
213
+ if (funcBody) {
214
+ // Set positional params $1 $2 ... from remaining args
215
+ const funcArgs = expanded.trim().split(/\s+/).slice(1);
216
+ const savedVars = { ...ctx.env.vars };
217
+ funcArgs.forEach((a, i) => { ctx.env.vars[String(i + 1)] = a; });
218
+ ctx.env.vars["0"] = cmdName;
219
+ const funcLines = funcBody.split("\n");
220
+ const funcResult = await runBlocks(parseBlocks(funcLines), ctx);
221
+ // Restore positional params
222
+ for (let pi = 1; pi <= funcArgs.length; pi++)
223
+ delete ctx.env.vars[String(pi)];
224
+ Object.assign(ctx.env.vars, { ...savedVars, ...ctx.env.vars });
225
+ return funcResult;
226
+ }
227
+ return runCommand(expanded, ctx.authUser, ctx.hostname, ctx.mode, ctx.cwd, ctx.shell, undefined, ctx.env);
228
+ })();
178
229
  ctx.env.lastExitCode = r.exitCode ?? 0;
179
230
  if (r.stdout)
180
231
  output += `${r.stdout}\n`;
@@ -207,9 +258,35 @@ async function runBlocks(blocks, ctx) {
207
258
  }
208
259
  }
209
260
  }
261
+ else if (block.type === "func") {
262
+ // Register function in env vars as __func_<name>=<body>
263
+ ctx.env.vars[`__func_${block.name}`] = block.body.join("\n");
264
+ }
265
+ else if (block.type === "arith") {
266
+ // (( expr )) — evaluate arithmetic, update vars
267
+ const { expandSync } = await import("../utils/expand");
268
+ const expr = expandSync(block.expr, ctx.env.vars, ctx.env.lastExitCode);
269
+ // Handle i++ / i-- / i+=N / i-=N
270
+ const incMatch = expr.match(/^(\w+)\s*(\+\+|--)$/);
271
+ if (incMatch) {
272
+ const val = parseInt(ctx.env.vars[incMatch[1]] ?? "0", 10);
273
+ ctx.env.vars[incMatch[1]] = String(incMatch[2] === "++" ? val + 1 : val - 1);
274
+ }
275
+ else {
276
+ const assignMatch = expr.match(/^(\w+)\s*([+\-*/])=\s*(.+)$/);
277
+ if (assignMatch) {
278
+ const lhs = parseInt(ctx.env.vars[assignMatch[1]] ?? "0", 10);
279
+ const rhs = parseInt(assignMatch[3], 10);
280
+ const ops = { "+": lhs + rhs, "-": lhs - rhs, "*": lhs * rhs, "/": Math.floor(lhs / rhs) };
281
+ ctx.env.vars[assignMatch[1]] = String(ops[assignMatch[2]] ?? lhs);
282
+ }
283
+ }
284
+ }
210
285
  else if (block.type === "for") {
211
286
  const listExpanded = await expandVars(block.list, ctx.env.vars, ctx.env.lastExitCode, ctx);
212
- const items = listExpanded.trim().split(/\s+/);
287
+ // Apply brace expansion to each token in the list
288
+ const { expandBraces } = await import("../utils/expand");
289
+ const items = listExpanded.trim().split(/\s+/).flatMap(expandBraces);
213
290
  for (const item of items) {
214
291
  ctx.env.vars[block.var] = item;
215
292
  const sub = await runBlocks(parseBlocks(block.body), ctx);
@@ -239,6 +316,78 @@ async function runBlocks(blocks, ctx) {
239
316
  * @category shell
240
317
  * @params ["-c <script>", "[<file>]"]
241
318
  */
319
+ /**
320
+ * Split a sh script into logical lines, respecting:
321
+ * - `{...}` braces (function bodies)
322
+ * - Newlines and semicolons at depth 0 only
323
+ */
324
+ function splitShScript(script) {
325
+ const lines = [];
326
+ let current = "";
327
+ let depth = 0;
328
+ let inSingleQ = false;
329
+ let inDoubleQ = false;
330
+ let i = 0;
331
+ while (i < script.length) {
332
+ const ch = script[i];
333
+ if (!inSingleQ && !inDoubleQ) {
334
+ if (ch === "'") {
335
+ inSingleQ = true;
336
+ current += ch;
337
+ i++;
338
+ continue;
339
+ }
340
+ if (ch === '"') {
341
+ inDoubleQ = true;
342
+ current += ch;
343
+ i++;
344
+ continue;
345
+ }
346
+ if (ch === "{") {
347
+ depth++;
348
+ current += ch;
349
+ i++;
350
+ continue;
351
+ }
352
+ if (ch === "}") {
353
+ depth--;
354
+ current += ch;
355
+ i++;
356
+ // At depth 0, closing } ends the function body line
357
+ if (depth === 0) {
358
+ const t = current.trim();
359
+ if (t)
360
+ lines.push(t);
361
+ current = "";
362
+ // Skip trailing ; or whitespace
363
+ while (i < script.length && (script[i] === ";" || script[i] === " "))
364
+ i++;
365
+ }
366
+ continue;
367
+ }
368
+ if (depth === 0 && (ch === ";" || ch === "\n")) {
369
+ const t = current.trim();
370
+ if (t && !t.startsWith("#"))
371
+ lines.push(t);
372
+ current = "";
373
+ i++;
374
+ continue;
375
+ }
376
+ }
377
+ else if (inSingleQ && ch === "'") {
378
+ inSingleQ = false;
379
+ }
380
+ else if (inDoubleQ && ch === '"') {
381
+ inDoubleQ = false;
382
+ }
383
+ current += ch;
384
+ i++;
385
+ }
386
+ const t = current.trim();
387
+ if (t && !t.startsWith("#"))
388
+ lines.push(t);
389
+ return lines;
390
+ }
242
391
  export const shCommand = {
243
392
  name: "sh",
244
393
  aliases: ["bash"],
@@ -252,10 +401,7 @@ export const shCommand = {
252
401
  const script = args[args.indexOf("-c") + 1] ?? "";
253
402
  if (!script)
254
403
  return { stderr: "sh: -c requires a script", exitCode: 1 };
255
- const lines = script
256
- .split(/[;\n]/)
257
- .map((l) => l.trim())
258
- .filter((l) => l && !l.startsWith("#"));
404
+ const lines = splitShScript(script);
259
405
  const blocks = parseBlocks(lines);
260
406
  return runBlocks(blocks, ctx);
261
407
  }
@@ -269,10 +415,7 @@ export const shCommand = {
269
415
  exitCode: 1,
270
416
  };
271
417
  const content = shell.vfs.readFile(p);
272
- const lines = content
273
- .split("\n")
274
- .map((l) => l.trim())
275
- .filter((l) => l && !l.startsWith("#"));
418
+ const lines = splitShScript(content);
276
419
  const blocks = parseBlocks(lines);
277
420
  return runBlocks(blocks, ctx);
278
421
  }
@@ -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
  /** Logical operator connecting two statement groups. */
15
21
  export type LogicalOp = "&&" | "||" | ";";
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/types/pipeline.ts"],"names":[],"mappings":"AAAA,iDAAiD;AACjD,MAAM,WAAW,eAAe;IAC/B,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,YAAY,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,wDAAwD;AACxD,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,GAAG,GAAG,CAAC;AAE1C,yCAAyC;AACzC,MAAM,WAAW,QAAQ;IACxB,uCAAuC;IACvC,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,uCAAuC;IACvC,OAAO,EAAE,OAAO,CAAC;IACjB,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,0FAA0F;AAC1F,MAAM,WAAW,SAAS;IACzB,8CAA8C;IAC9C,QAAQ,EAAE,QAAQ,CAAC;IACnB,0DAA0D;IAC1D,EAAE,CAAC,EAAE,SAAS,CAAC;IACf,2CAA2C;IAC3C,IAAI,CAAC,EAAE,SAAS,CAAC;CACjB;AAED,2CAA2C;AAC3C,MAAM,WAAW,MAAM;IACtB,0CAA0C;IAC1C,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,kDAAkD;IAClD,OAAO,EAAE,OAAO,CAAC;IACjB,oCAAoC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;CACf"}
1
+ {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/types/pipeline.ts"],"names":[],"mappings":"AAAA,iDAAiD;AACjD,MAAM,WAAW,eAAe;IAC/B,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,uCAAuC;IACvC,cAAc,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,wDAAwD;AACxD,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,GAAG,GAAG,CAAC;AAE1C,yCAAyC;AACzC,MAAM,WAAW,QAAQ;IACxB,uCAAuC;IACvC,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,uCAAuC;IACvC,OAAO,EAAE,OAAO,CAAC;IACjB,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,0FAA0F;AAC1F,MAAM,WAAW,SAAS;IACzB,8CAA8C;IAC9C,QAAQ,EAAE,QAAQ,CAAC;IACnB,0DAA0D;IAC1D,EAAE,CAAC,EAAE,SAAS,CAAC;IACf,2CAA2C;IAC3C,IAAI,CAAC,EAAE,SAAS,CAAC;CACjB;AAED,2CAA2C;AAC3C,MAAM,WAAW,MAAM;IACtB,0CAA0C;IAC1C,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,kDAAkD;IAClD,OAAO,EAAE,OAAO,CAAC;IACjB,oCAAoC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;CACf"}
@@ -68,4 +68,19 @@ export type VfsSnapshotNode = VfsSnapshotFileNode | VfsSnapshotDirectoryNode;
68
68
  export interface VfsSnapshot {
69
69
  root: VfsSnapshotDirectoryNode;
70
70
  }
71
+ /** Options for mounting a host directory into the VFS. */
72
+ export interface MountOptions {
73
+ /** Absolute path inside the VM (e.g. `"/app"`). */
74
+ vPath: string;
75
+ /** Path on the host filesystem. Relative paths resolved from `process.cwd()`. */
76
+ hostPath: string;
77
+ /** When `true` (default), write operations inside the mount throw `EROFS`. */
78
+ readOnly?: boolean;
79
+ }
80
+ /** Describes an active mount point. */
81
+ export interface MountPoint {
82
+ vPath: string;
83
+ hostPath: string;
84
+ readOnly: boolean;
85
+ }
71
86
  //# sourceMappingURL=vfs.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vfs.d.ts","sourceRoot":"","sources":["../../src/types/vfs.ts"],"names":[],"mappings":"AAAA,oCAAoC;AACpC,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,WAAW,CAAC;AAE/C,oEAAoE;AACpE,MAAM,WAAW,WAAW;IAC3B,qCAAqC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,qCAAqC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,4BAA4B;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,SAAS,EAAE,IAAI,CAAC;IAChB,6BAA6B;IAC7B,SAAS,EAAE,IAAI,CAAC;CAChB;AAED,0CAA0C;AAC1C,MAAM,WAAW,WAAY,SAAQ,WAAW;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,mDAAmD;IACnD,UAAU,EAAE,OAAO,CAAC;IACpB,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC;CACb;AAED,+CAA+C;AAC/C,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACpD,IAAI,EAAE,WAAW,CAAC;IAClB,8CAA8C;IAC9C,aAAa,EAAE,MAAM,CAAC;CACtB;AAED,kDAAkD;AAClD,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,gBAAgB,CAAC;AAE1D,wDAAwD;AACxD,MAAM,WAAW,gBAAgB;IAChC,uDAAuD;IACvD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,qDAAqD;AACrD,MAAM,WAAW,aAAa;IAC7B,gDAAgD;IAChD,SAAS,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,gEAAgE;AAChE,MAAM,WAAW,mBAAmB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,iCAAiC;IACjC,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,gDAAgD;AAChD,MAAM,WAAW,mBAAoB,SAAQ,mBAAmB;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,OAAO,CAAC;IACpB,qCAAqC;IACrC,aAAa,EAAE,MAAM,CAAC;CACtB;AAED,qDAAqD;AACrD,MAAM,WAAW,wBAAyB,SAAQ,mBAAmB;IACpE,IAAI,EAAE,WAAW,CAAC;IAClB,QAAQ,EAAE,eAAe,EAAE,CAAC;CAC5B;AAED,kDAAkD;AAClD,MAAM,MAAM,eAAe,GAAG,mBAAmB,GAAG,wBAAwB,CAAC;AAE7E,gDAAgD;AAChD,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,wBAAwB,CAAC;CAC/B"}
1
+ {"version":3,"file":"vfs.d.ts","sourceRoot":"","sources":["../../src/types/vfs.ts"],"names":[],"mappings":"AAAA,oCAAoC;AACpC,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,WAAW,CAAC;AAE/C,oEAAoE;AACpE,MAAM,WAAW,WAAW;IAC3B,qCAAqC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,qCAAqC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,4BAA4B;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,SAAS,EAAE,IAAI,CAAC;IAChB,6BAA6B;IAC7B,SAAS,EAAE,IAAI,CAAC;CAChB;AAED,0CAA0C;AAC1C,MAAM,WAAW,WAAY,SAAQ,WAAW;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,mDAAmD;IACnD,UAAU,EAAE,OAAO,CAAC;IACpB,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC;CACb;AAED,+CAA+C;AAC/C,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACpD,IAAI,EAAE,WAAW,CAAC;IAClB,8CAA8C;IAC9C,aAAa,EAAE,MAAM,CAAC;CACtB;AAED,kDAAkD;AAClD,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,gBAAgB,CAAC;AAE1D,wDAAwD;AACxD,MAAM,WAAW,gBAAgB;IAChC,uDAAuD;IACvD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,qDAAqD;AACrD,MAAM,WAAW,aAAa;IAC7B,gDAAgD;IAChD,SAAS,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,gEAAgE;AAChE,MAAM,WAAW,mBAAmB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,iCAAiC;IACjC,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,gDAAgD;AAChD,MAAM,WAAW,mBAAoB,SAAQ,mBAAmB;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,OAAO,CAAC;IACpB,qCAAqC;IACrC,aAAa,EAAE,MAAM,CAAC;CACtB;AAED,qDAAqD;AACrD,MAAM,WAAW,wBAAyB,SAAQ,mBAAmB;IACpE,IAAI,EAAE,WAAW,CAAC;IAClB,QAAQ,EAAE,eAAe,EAAE,CAAC;CAC5B;AAED,kDAAkD;AAClD,MAAM,MAAM,eAAe,GAAG,mBAAmB,GAAG,wBAAwB,CAAC;AAE7E,gDAAgD;AAChD,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,wBAAwB,CAAC;CAC/B;AAED,0DAA0D;AAC1D,MAAM,WAAW,YAAY;IAC5B,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,uCAAuC;AACvC,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAK,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;CAClB"}
@@ -34,6 +34,15 @@ export declare function evalArith(expr: string, env: Record<string, string>): nu
34
34
  * @param lastExit Last command exit code (for `$?`).
35
35
  * @param home Home directory path (for `~`).
36
36
  */
37
+ /**
38
+ * Expand brace expressions in a single token.
39
+ * - `{a,b,c}` → `["a", "b", "c"]`
40
+ * - `{1..5}` → `["1", "2", "3", "4", "5"]`
41
+ * - `{a..e}` → `["a", "b", "c", "d", "e"]`
42
+ * - `prefix{a,b}suffix` → `["prefixasuffix", "prefixbsuffix"]`
43
+ * Returns a single-element array when no brace expansion applies.
44
+ */
45
+ export declare function expandBraces(token: string): string[];
37
46
  export declare function expandSync(input: string, env: Record<string, string>, lastExit?: number, home?: string): string;
38
47
  /**
39
48
  * Expand all shell forms including `$(cmd)` command substitution.
@@ -1 +1 @@
1
- {"version":3,"file":"expand.d.ts","sourceRoot":"","sources":["../../src/utils/expand.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAuB3E;AAoCD;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CACzB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC3B,QAAQ,SAAI,EACZ,IAAI,CAAC,EAAE,MAAM,GACX,MAAM,CA4DR;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAChC,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC3B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GACtC,OAAO,CAAC,MAAM,CAAC,CAuDjB"}
1
+ {"version":3,"file":"expand.d.ts","sourceRoot":"","sources":["../../src/utils/expand.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAuB3E;AAoCD;;;;;;;;;GASG;AAEH;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA8DpD;AAED,wBAAgB,UAAU,CACzB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC3B,QAAQ,SAAI,EACZ,IAAI,CAAC,EAAE,MAAM,GACX,MAAM,CA4DR;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAChC,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC3B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GACtC,OAAO,CAAC,MAAM,CAAC,CAuDjB"}
@@ -82,6 +82,88 @@ function outsideSingleQuotes(input, replacer) {
82
82
  * @param lastExit Last command exit code (for `$?`).
83
83
  * @param home Home directory path (for `~`).
84
84
  */
85
+ /**
86
+ * Expand brace expressions in a single token.
87
+ * - `{a,b,c}` → `["a", "b", "c"]`
88
+ * - `{1..5}` → `["1", "2", "3", "4", "5"]`
89
+ * - `{a..e}` → `["a", "b", "c", "d", "e"]`
90
+ * - `prefix{a,b}suffix` → `["prefixasuffix", "prefixbsuffix"]`
91
+ * Returns a single-element array when no brace expansion applies.
92
+ */
93
+ export function expandBraces(token) {
94
+ // Find the first { not preceded by $
95
+ let depth = 0;
96
+ let start = -1;
97
+ for (let i = 0; i < token.length; i++) {
98
+ const ch = token[i];
99
+ if (ch === "{" && token[i - 1] !== "$") {
100
+ if (depth === 0)
101
+ start = i;
102
+ depth++;
103
+ }
104
+ else if (ch === "}") {
105
+ depth--;
106
+ if (depth === 0 && start !== -1) {
107
+ const prefix = token.slice(0, start);
108
+ const inner = token.slice(start + 1, i);
109
+ const suffix = token.slice(i + 1);
110
+ // Range: {1..5} or {a..e}
111
+ const rangeMatch = inner.match(/^(-?\d+)\.\.(-?\d+)(?:\.\.-?(\d+))?$/) ||
112
+ inner.match(/^([a-z])\.\.([a-z])$/);
113
+ if (rangeMatch) {
114
+ const items = [];
115
+ if (/\d/.test(rangeMatch[1])) {
116
+ const from = parseInt(rangeMatch[1], 10);
117
+ const to = parseInt(rangeMatch[2], 10);
118
+ const step = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : 1;
119
+ const inc = from <= to ? step : -step;
120
+ for (let n = from; from <= to ? n <= to : n >= to; n += inc) {
121
+ items.push(String(n));
122
+ }
123
+ }
124
+ else {
125
+ const from = rangeMatch[1].charCodeAt(0);
126
+ const to = rangeMatch[2].charCodeAt(0);
127
+ const inc = from <= to ? 1 : -1;
128
+ for (let c = from; from <= to ? c <= to : c >= to; c += inc) {
129
+ items.push(String.fromCharCode(c));
130
+ }
131
+ }
132
+ const expanded = items.map((v) => `${prefix}${v}${suffix}`);
133
+ return expanded.flatMap(expandBraces);
134
+ }
135
+ // Comma list: {a,b,c} — split respecting nested braces
136
+ const parts = [];
137
+ let cur = "";
138
+ let d2 = 0;
139
+ for (const ch2 of inner) {
140
+ if (ch2 === "{") {
141
+ d2++;
142
+ cur += ch2;
143
+ }
144
+ else if (ch2 === "}") {
145
+ d2--;
146
+ cur += ch2;
147
+ }
148
+ else if (ch2 === "," && d2 === 0) {
149
+ parts.push(cur);
150
+ cur = "";
151
+ }
152
+ else {
153
+ cur += ch2;
154
+ }
155
+ }
156
+ parts.push(cur);
157
+ if (parts.length > 1) {
158
+ const expanded = parts.map((p) => `${prefix}${p}${suffix}`);
159
+ return expanded.flatMap(expandBraces);
160
+ }
161
+ break;
162
+ }
163
+ }
164
+ }
165
+ return [token];
166
+ }
85
167
  export function expandSync(input, env, lastExit = 0, home) {
86
168
  const homePath = home ?? env.HOME ?? "/home/user";
87
169
  return outsideSingleQuotes(input, (chunk) => {
@@ -111,8 +193,8 @@ export function expandSync(input, env, lastExit = 0, home) {
111
193
  s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*):\+([^}]*)\}/g, (_, name, alt) => env[name] !== undefined && env[name] !== "" ? alt : "");
112
194
  // ${VAR}
113
195
  s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => env[name] ?? "");
114
- // $VAR
115
- s = s.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => env[name] ?? "");
196
+ // $VAR and positional params $1 $2 ...
197
+ s = s.replace(/\$([A-Za-z_][A-Za-z0-9_]*|\d+)/g, (_, name) => env[name] ?? "");
116
198
  return s;
117
199
  });
118
200
  }
@@ -1 +1 @@
1
- {"version":3,"file":"tokenize.d.ts","sourceRoot":"","sources":["../../src/utils/tokenize.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA0DvD"}
1
+ {"version":3,"file":"tokenize.d.ts","sourceRoot":"","sources":["../../src/utils/tokenize.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA8EvD"}
@@ -50,6 +50,46 @@ export function tokenizeCommand(input) {
50
50
  i++;
51
51
  continue;
52
52
  }
53
+ // Handle 2>&1, 2>>, 2>, >&, >>
54
+ if (!inQ && ch === "2" && (next === ">")) {
55
+ const rest = input.slice(i + 1);
56
+ if (rest.startsWith(">>&1") || rest.startsWith(">> &1")) {
57
+ if (current) {
58
+ tokens.push(current);
59
+ current = "";
60
+ }
61
+ tokens.push("2>>&1");
62
+ i += 5;
63
+ continue;
64
+ }
65
+ if (rest.startsWith(">&1")) {
66
+ if (current) {
67
+ tokens.push(current);
68
+ current = "";
69
+ }
70
+ tokens.push("2>&1");
71
+ i += 4;
72
+ continue;
73
+ }
74
+ if (rest.startsWith(">>")) {
75
+ if (current) {
76
+ tokens.push(current);
77
+ current = "";
78
+ }
79
+ tokens.push("2>>");
80
+ i += 3;
81
+ continue;
82
+ }
83
+ if (rest.startsWith(">")) {
84
+ if (current) {
85
+ tokens.push(current);
86
+ current = "";
87
+ }
88
+ tokens.push("2>");
89
+ i += 2;
90
+ continue;
91
+ }
92
+ }
53
93
  if ((ch === ">" || ch === "<") && !inQ) {
54
94
  if (current) {
55
95
  tokens.push(current);
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
7
- "version": "1.4.2",
7
+ "version": "1.4.3",
8
8
  "license": "MIT",
9
9
  "repository": {
10
10
  "type": "git",
@@ -75,6 +75,11 @@ class VirtualFileSystem extends EventEmitter {
75
75
  private root: InternalDirectoryNode;
76
76
  private readonly mode: VfsPersistenceMode;
77
77
  private readonly snapshotFile: string | null;
78
+ /** Active host-directory mounts: vPath → { hostPath, readOnly } */
79
+ private readonly mounts = new Map<string, { hostPath: string; readOnly: boolean }>();
80
+ /** True when running in a browser environment (no host FS access). */
81
+ private static readonly isBrowser =
82
+ typeof process === "undefined" || typeof (process as NodeJS.Process).versions?.node === "undefined";
78
83
 
79
84
  constructor(options: VfsOptions = {}) {
80
85
  super();
@@ -219,7 +224,97 @@ class VirtualFileSystem extends EventEmitter {
219
224
  // ── Public filesystem API ─────────────────────────────────────────────────
220
225
 
221
226
  /** Creates a directory (and any missing parents). */
222
- public mkdir(targetPath: string, mode: number = 0o755): void {
227
+
228
+ // ── Mount API ─────────────────────────────────────────────────────────────
229
+
230
+ /**
231
+ * Mount a host directory into the VFS at `vPath`.
232
+ *
233
+ * Files inside `vPath` are read directly from the host filesystem via
234
+ * `node:fs`. All standard VFS operations (`readFile`, `writeFile`,
235
+ * `exists`, `stat`, `list`) are transparently delegated.
236
+ *
237
+ * In browser environments the mount is silently ignored — `vPath` remains
238
+ * an empty in-memory directory.
239
+ *
240
+ * @param vPath Absolute path inside the VM (e.g. `"/app"`).
241
+ * @param hostPath Path on the host filesystem — relative paths are
242
+ * resolved from `process.cwd()`.
243
+ * @param readOnly When `true` (default), write operations inside the
244
+ * mount throw `EROFS: read-only file system`.
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * shell.vfs.mount("/app", "./src", { readOnly: true });
249
+ * // cat /app/index.ts — reads ./src/index.ts from host
250
+ * ```
251
+ */
252
+ public mount(
253
+ vPath: string,
254
+ hostPath: string,
255
+ { readOnly = true }: { readOnly?: boolean } = {},
256
+ ): void {
257
+ if (VirtualFileSystem.isBrowser) return; // silently degrade in browser
258
+ const normalized = normalizePath(vPath);
259
+ const resolved = path.resolve(hostPath);
260
+ if (!fsSync.existsSync(resolved)) {
261
+ throw new Error(`VirtualFileSystem.mount: host path does not exist: "${resolved}"`);
262
+ }
263
+ if (!fsSync.statSync(resolved).isDirectory()) {
264
+ throw new Error(`VirtualFileSystem.mount: host path is not a directory: "${resolved}"`);
265
+ }
266
+ // Ensure the mount point exists in the VFS tree
267
+ this.mkdir(normalized);
268
+ this.mounts.set(normalized, { hostPath: resolved, readOnly });
269
+ this.emit("mount", { vPath: normalized, hostPath: resolved, readOnly });
270
+ }
271
+
272
+ /**
273
+ * Unmount a previously mounted host directory.
274
+ * The in-memory VFS directory at `vPath` is preserved but the host
275
+ * delegation is removed.
276
+ */
277
+ public unmount(vPath: string): void {
278
+ const normalized = normalizePath(vPath);
279
+ if (this.mounts.delete(normalized)) {
280
+ this.emit("unmount", { vPath: normalized });
281
+ }
282
+ }
283
+
284
+ /** List all active mounts. */
285
+ public getMounts(): Array<{ vPath: string; hostPath: string; readOnly: boolean }> {
286
+ return [...this.mounts.entries()].map(([vPath, opts]) => ({
287
+ vPath, ...opts,
288
+ }));
289
+ }
290
+
291
+ /**
292
+ * If `targetPath` is inside a mount, return `{ hostPath, readOnly, relPath }`.
293
+ * `relPath` is the path relative to the mount's host directory.
294
+ * Returns `null` if the path is not under any mount.
295
+ */
296
+ private resolveMount(targetPath: string): {
297
+ hostPath: string;
298
+ readOnly: boolean;
299
+ relPath: string;
300
+ fullHostPath: string;
301
+ } | null {
302
+ const normalized = normalizePath(targetPath);
303
+ // Iterate mounts from most specific to least specific
304
+ const sorted = [...this.mounts.entries()].sort(
305
+ ([a], [b]) => b.length - a.length,
306
+ );
307
+ for (const [vBase, opts] of sorted) {
308
+ if (normalized === vBase || normalized.startsWith(`${vBase}/`)) {
309
+ const relPath = normalized.slice(vBase.length).replace(/^\//, "");
310
+ const fullHostPath = relPath ? path.join(opts.hostPath, relPath) : opts.hostPath;
311
+ return { hostPath: opts.hostPath, readOnly: opts.readOnly, relPath, fullHostPath };
312
+ }
313
+ }
314
+ return null;
315
+ }
316
+
317
+ public mkdir(targetPath: string, mode: number = 0o755): void {
223
318
  const normalized = normalizePath(targetPath);
224
319
  const existing = (() => {
225
320
  try {
@@ -245,6 +340,15 @@ class VirtualFileSystem extends EventEmitter {
245
340
  content: string | Buffer,
246
341
  options: WriteFileOptions = {},
247
342
  ): void {
343
+ // Delegate to host FS if inside a mount
344
+ const m = this.resolveMount(targetPath);
345
+ if (m) {
346
+ if (m.readOnly) throw new Error(`EROFS: read-only file system, open '${m.fullHostPath}'`);
347
+ const dir = path.dirname(m.fullHostPath);
348
+ if (!fsSync.existsSync(dir)) fsSync.mkdirSync(dir, { recursive: true });
349
+ fsSync.writeFileSync(m.fullHostPath, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"));
350
+ return;
351
+ }
248
352
  const normalized = normalizePath(targetPath);
249
353
  const { parent, name } = getParentDirectory(
250
354
  this.root,
@@ -288,6 +392,11 @@ class VirtualFileSystem extends EventEmitter {
288
392
  * Gzip-compressed files are transparently decompressed.
289
393
  */
290
394
  public readFile(targetPath: string): string {
395
+ const m = this.resolveMount(targetPath);
396
+ if (m) {
397
+ if (!fsSync.existsSync(m.fullHostPath)) throw new Error(`ENOENT: no such file or directory, open '${m.fullHostPath}'`);
398
+ return fsSync.readFileSync(m.fullHostPath, "utf8");
399
+ }
291
400
  const normalized = normalizePath(targetPath);
292
401
  const node = getNode(this.root, normalized);
293
402
  if (node.type !== "file") {
@@ -301,6 +410,11 @@ class VirtualFileSystem extends EventEmitter {
301
410
 
302
411
  /** Reads file content as a Buffer (decompresses if needed). */
303
412
  public readFileRaw(targetPath: string): Buffer {
413
+ const m = this.resolveMount(targetPath);
414
+ if (m) {
415
+ if (!fsSync.existsSync(m.fullHostPath)) throw new Error(`ENOENT: no such file or directory, open '${m.fullHostPath}'`);
416
+ return fsSync.readFileSync(m.fullHostPath);
417
+ }
304
418
  const normalized = normalizePath(targetPath);
305
419
  const node = getNode(this.root, normalized);
306
420
  if (node.type !== "file") {
@@ -314,6 +428,8 @@ class VirtualFileSystem extends EventEmitter {
314
428
 
315
429
  /** Returns true when a file or directory exists at path. */
316
430
  public exists(targetPath: string): boolean {
431
+ const m = this.resolveMount(targetPath);
432
+ if (m) return fsSync.existsSync(m.fullHostPath);
317
433
  try {
318
434
  getNode(this.root, normalizePath(targetPath));
319
435
  return true;
@@ -329,6 +445,34 @@ class VirtualFileSystem extends EventEmitter {
329
445
 
330
446
  /** Returns metadata for a file or directory. */
331
447
  public stat(targetPath: string): VfsNodeStats {
448
+ const m = this.resolveMount(targetPath);
449
+ if (m) {
450
+ if (!fsSync.existsSync(m.fullHostPath)) throw new Error(`ENOENT: stat '${m.fullHostPath}'`);
451
+ const hst = fsSync.statSync(m.fullHostPath);
452
+ const name = m.relPath.split("/").pop() ?? m.fullHostPath.split("/").pop() ?? "";
453
+ const now = hst.mtime;
454
+ if (hst.isDirectory()) {
455
+ return {
456
+ type: "directory",
457
+ name,
458
+ path: normalizePath(targetPath),
459
+ mode: 0o755,
460
+ createdAt: hst.birthtime,
461
+ updatedAt: now,
462
+ childrenCount: fsSync.readdirSync(m.fullHostPath).length,
463
+ } satisfies import("../types/vfs").VfsDirectoryNode;
464
+ }
465
+ return {
466
+ type: "file",
467
+ name,
468
+ path: normalizePath(targetPath),
469
+ mode: m.readOnly ? 0o444 : 0o644,
470
+ createdAt: hst.birthtime,
471
+ updatedAt: now,
472
+ compressed: false,
473
+ size: hst.size,
474
+ } satisfies import("../types/vfs").VfsFileNode;
475
+ }
332
476
  const normalized = normalizePath(targetPath);
333
477
  const node = getNode(this.root, normalized);
334
478
  const name = normalized === "/" ? "" : path.posix.basename(normalized);
@@ -359,6 +503,13 @@ class VirtualFileSystem extends EventEmitter {
359
503
 
360
504
  /** Lists direct children names of a directory (sorted). */
361
505
  public list(dirPath: string = "/"): string[] {
506
+ const m = this.resolveMount(dirPath);
507
+ if (m) {
508
+ if (!fsSync.existsSync(m.fullHostPath)) return [];
509
+ try {
510
+ return fsSync.readdirSync(m.fullHostPath).sort();
511
+ } catch { return []; }
512
+ }
362
513
  const normalized = normalizePath(dirPath);
363
514
  const node = getNode(this.root, normalized);
364
515
  if (node.type !== "directory") {
@@ -508,6 +659,18 @@ class VirtualFileSystem extends EventEmitter {
508
659
 
509
660
  /** Removes a file or directory node. */
510
661
  public remove(targetPath: string, options: RemoveOptions = {}): void {
662
+ const m = this.resolveMount(targetPath);
663
+ if (m) {
664
+ if (m.readOnly) throw new Error(`EROFS: read-only file system, unlink '${m.fullHostPath}'`);
665
+ if (!fsSync.existsSync(m.fullHostPath)) throw new Error(`ENOENT: no such file or directory, unlink '${m.fullHostPath}'`);
666
+ const hst = fsSync.statSync(m.fullHostPath);
667
+ if (hst.isDirectory()) {
668
+ fsSync.rmSync(m.fullHostPath, { recursive: options.recursive ?? false });
669
+ } else {
670
+ fsSync.unlinkSync(m.fullHostPath);
671
+ }
672
+ return;
673
+ }
511
674
  const normalized = normalizePath(targetPath);
512
675
  if (normalized === "/") throw new Error("Cannot remove root directory.");
513
676
  const node = getNode(this.root, normalized);