typescript-virtual-container 1.4.1 → 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/.vscode/settings.json +2 -0
- package/README.md +77 -36
- package/benchmark-virtualshell.ts +3 -11
- package/builds/self-standalone.js +224 -224
- package/builds/self-standalone.js.map +4 -4
- package/builds/standalone-wo-sftp.js +23 -23
- package/builds/standalone-wo-sftp.js.map +3 -3
- package/builds/standalone.js +23 -23
- 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/shell.d.ts.map +1 -1
- package/dist/VirtualShell/shell.js +6 -10
- package/dist/VirtualShell/shellParser.js +28 -1
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +4 -4
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +5 -3
- package/dist/commands/helpers.js +1 -1
- package/dist/commands/history.js +2 -2
- 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/modules/linuxRootfs.d.ts +1 -1
- package/dist/modules/linuxRootfs.d.ts.map +1 -1
- package/dist/modules/linuxRootfs.js +5 -5
- package/dist/self-standalone.js +149 -102
- 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/shell.ts +6 -11
- package/src/VirtualShell/shellParser.ts +26 -1
- package/src/VirtualUserManager/index.ts +4 -4
- package/src/commands/export.ts +5 -3
- package/src/commands/helpers.ts +1 -1
- package/src/commands/history.ts +2 -2
- 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/modules/linuxRootfs.ts +6 -6
- package/src/self-standalone.ts +190 -141
- 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/tests/helpers.test.ts +3 -3
|
@@ -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);
|
|
@@ -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
|
|
@@ -52,7 +52,7 @@ export function startShell(
|
|
|
52
52
|
): void {
|
|
53
53
|
let lineBuffer = "";
|
|
54
54
|
let cursorPos = 0;
|
|
55
|
-
let history = loadHistory(shell.vfs);
|
|
55
|
+
let history = loadHistory(shell.vfs, authUser);
|
|
56
56
|
let historyIndex: number | null = null;
|
|
57
57
|
let historyDraft = "";
|
|
58
58
|
let cwd = `/home/${authUser}`;
|
|
@@ -388,11 +388,11 @@ export function startShell(
|
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
const data = history.length > 0 ? `${history.join("\n")}\n` : "";
|
|
391
|
-
shell.vfs.writeFile(
|
|
391
|
+
shell.vfs.writeFile(`/home/${authUser}/.bash_history`, data);
|
|
392
392
|
}
|
|
393
393
|
|
|
394
394
|
function readLastLogin(): { at: string; from: string } | null {
|
|
395
|
-
const lastlogPath = `/
|
|
395
|
+
const lastlogPath = `/home/${authUser}/.lastlog.json`;
|
|
396
396
|
if (!shell.vfs.exists(lastlogPath)) {
|
|
397
397
|
return null;
|
|
398
398
|
}
|
|
@@ -408,12 +408,7 @@ export function startShell(
|
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
function writeLastLogin(nowIso: string): void {
|
|
411
|
-
const
|
|
412
|
-
if (!shell.vfs.exists(dir)) {
|
|
413
|
-
shell.vfs.mkdir(dir, 0o700);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const lastlogPath = `${dir}/${authUser}.json`;
|
|
411
|
+
const lastlogPath = `/home/${authUser}/.lastlog`;
|
|
417
412
|
shell.vfs.writeFile(
|
|
418
413
|
lastlogPath,
|
|
419
414
|
JSON.stringify({ at: nowIso, from: remoteAddress }),
|
|
@@ -690,8 +685,8 @@ export function startShell(
|
|
|
690
685
|
});
|
|
691
686
|
}
|
|
692
687
|
|
|
693
|
-
function loadHistory(vfs: VirtualFileSystem): string[] {
|
|
694
|
-
const historyPath =
|
|
688
|
+
function loadHistory(vfs: VirtualFileSystem, authUser: string): string[] {
|
|
689
|
+
const historyPath = `/home/${authUser}/.bash_history`;
|
|
695
690
|
if (!vfs.exists(historyPath)) {
|
|
696
691
|
vfs.writeFile(historyPath, "");
|
|
697
692
|
return [];
|
|
@@ -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
|
|
|
@@ -47,10 +47,10 @@ const perf: PerfLogger = createPerfLogger("VirtualUserManager");
|
|
|
47
47
|
export class VirtualUserManager extends EventEmitter {
|
|
48
48
|
private static readonly recordCache = new Map<string, VirtualUserRecord>();
|
|
49
49
|
private static readonly fastPasswordHash = resolveFastPasswordHash();
|
|
50
|
-
private readonly usersPath = "/
|
|
51
|
-
private readonly sudoersPath = "/
|
|
52
|
-
private readonly quotasPath = "/
|
|
53
|
-
private readonly authDirPath = "
|
|
50
|
+
private readonly usersPath = "/etc/htpasswd";
|
|
51
|
+
private readonly sudoersPath = "/etc/sudoers";
|
|
52
|
+
private readonly quotasPath = "/etc/quotas";
|
|
53
|
+
private readonly authDirPath = "/.virtual-env-js/.auth";
|
|
54
54
|
private readonly users = new Map<string, VirtualUserRecord>();
|
|
55
55
|
private readonly sudoers = new Set<string>();
|
|
56
56
|
private readonly quotas = new Map<string, number>();
|
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/helpers.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type VirtualFileSystem from "../VirtualFileSystem";
|
|
|
4
4
|
import type { VirtualPackageManager } from "../VirtualPackageManager";
|
|
5
5
|
import type { VirtualShell } from "../VirtualShell";
|
|
6
6
|
|
|
7
|
-
const PROTECTED_PREFIXES = ["
|
|
7
|
+
const PROTECTED_PREFIXES = ["/.virtual-env-js/.auth", "/etc/htpasswd"] as const;
|
|
8
8
|
|
|
9
9
|
function normalizeFetchUrl(input: string): string {
|
|
10
10
|
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input)) {
|
package/src/commands/history.ts
CHANGED
|
@@ -10,9 +10,9 @@ export const historyCommand: ShellModule = {
|
|
|
10
10
|
description: "Display command history",
|
|
11
11
|
category: "shell",
|
|
12
12
|
params: ["[n]"],
|
|
13
|
-
run: ({ args, shell }) => {
|
|
13
|
+
run: ({ args, shell, authUser }) => {
|
|
14
14
|
// History is persisted in the VFS by the interactive shell
|
|
15
|
-
const histPath =
|
|
15
|
+
const histPath = `/home/${authUser}/.bash_history`;
|
|
16
16
|
if (!shell.vfs.exists(histPath)) {
|
|
17
17
|
return { stdout: "", exitCode: 0 };
|
|
18
18
|
}
|
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
|
+
};
|