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
|
@@ -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 {
|
|
278
|
+
return {
|
|
279
|
+
name, args: cmdParts.slice(1),
|
|
280
|
+
inputFile, outputFile, appendOutput,
|
|
281
|
+
stderrFile, stderrAppend, stderrToStdout,
|
|
282
|
+
};
|
|
258
283
|
}
|
|
259
284
|
|
package/src/commands/export.ts
CHANGED
|
@@ -11,13 +11,15 @@ export const exportCommand: ShellModule = {
|
|
|
11
11
|
category: "shell",
|
|
12
12
|
params: ["[VAR=value]"],
|
|
13
13
|
run: ({ args, env }) => {
|
|
14
|
-
|
|
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);
|
package/src/commands/registry.ts
CHANGED
|
@@ -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
|
package/src/commands/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/src/commands/sh.ts
CHANGED
|
@@ -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
|
|
229
|
-
expanded
|
|
230
|
-
|
|
231
|
-
ctx.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/types/pipeline.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
|
|
|
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
|
+
}
|
package/src/utils/expand.ts
CHANGED
|
@@ -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_]
|
|
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
|
});
|
package/src/utils/tokenize.ts
CHANGED
|
@@ -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);
|