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.
- package/README.md +72 -35
- package/builds/self-standalone.js +160 -160
- package/builds/self-standalone.js.map +4 -4
- package/builds/standalone-wo-sftp.js +18 -18
- package/builds/standalone-wo-sftp.js.map +3 -3
- package/builds/standalone.js +46 -46
- package/builds/standalone.js.map +3 -3
- package/dist/VirtualFileSystem/index.d.ts +47 -0
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +159 -0
- package/dist/VirtualShell/index.d.ts +29 -0
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +29 -0
- package/dist/VirtualShell/shellParser.js +28 -1
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +5 -3
- package/dist/commands/registry.d.ts.map +1 -1
- package/dist/commands/registry.js +2 -0
- package/dist/commands/runtime.d.ts.map +1 -1
- package/dist/commands/runtime.js +28 -3
- package/dist/commands/seq.d.ts +4 -0
- package/dist/commands/seq.d.ts.map +1 -0
- package/dist/commands/seq.js +50 -0
- package/dist/commands/sh.d.ts +0 -6
- package/dist/commands/sh.d.ts.map +1 -1
- package/dist/commands/sh.js +153 -10
- package/dist/types/pipeline.d.ts +6 -0
- package/dist/types/pipeline.d.ts.map +1 -1
- package/dist/types/vfs.d.ts +15 -0
- package/dist/types/vfs.d.ts.map +1 -1
- package/dist/utils/expand.d.ts +9 -0
- package/dist/utils/expand.d.ts.map +1 -1
- package/dist/utils/expand.js +84 -2
- package/dist/utils/tokenize.d.ts.map +1 -1
- package/dist/utils/tokenize.js +40 -0
- package/package.json +1 -1
- package/src/VirtualFileSystem/index.ts +164 -1
- package/src/VirtualShell/index.ts +36 -0
- package/src/VirtualShell/shellParser.ts +26 -1
- package/src/commands/export.ts +5 -3
- package/src/commands/registry.ts +2 -0
- package/src/commands/runtime.ts +30 -3
- package/src/commands/seq.ts +43 -0
- package/src/commands/sh.ts +144 -19
- package/src/types/pipeline.ts +6 -0
- package/src/types/vfs.ts +17 -0
- package/src/utils/expand.ts +75 -2
- package/src/utils/tokenize.ts +20 -0
package/dist/commands/sh.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/types/pipeline.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/types/vfs.d.ts
CHANGED
|
@@ -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
|
package/dist/types/vfs.d.ts.map
CHANGED
|
@@ -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"}
|
package/dist/utils/expand.d.ts
CHANGED
|
@@ -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"}
|
package/dist/utils/expand.js
CHANGED
|
@@ -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_]
|
|
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,
|
|
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"}
|
package/dist/utils/tokenize.js
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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);
|