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.
Files changed (67) hide show
  1. package/.vscode/settings.json +2 -0
  2. package/README.md +77 -36
  3. package/benchmark-virtualshell.ts +3 -11
  4. package/builds/self-standalone.js +224 -224
  5. package/builds/self-standalone.js.map +4 -4
  6. package/builds/standalone-wo-sftp.js +23 -23
  7. package/builds/standalone-wo-sftp.js.map +3 -3
  8. package/builds/standalone.js +23 -23
  9. package/builds/standalone.js.map +3 -3
  10. package/dist/VirtualFileSystem/index.d.ts +47 -0
  11. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  12. package/dist/VirtualFileSystem/index.js +159 -0
  13. package/dist/VirtualShell/index.d.ts +29 -0
  14. package/dist/VirtualShell/index.d.ts.map +1 -1
  15. package/dist/VirtualShell/index.js +29 -0
  16. package/dist/VirtualShell/shell.d.ts.map +1 -1
  17. package/dist/VirtualShell/shell.js +6 -10
  18. package/dist/VirtualShell/shellParser.js +28 -1
  19. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  20. package/dist/VirtualUserManager/index.js +4 -4
  21. package/dist/commands/export.d.ts.map +1 -1
  22. package/dist/commands/export.js +5 -3
  23. package/dist/commands/helpers.js +1 -1
  24. package/dist/commands/history.js +2 -2
  25. package/dist/commands/registry.d.ts.map +1 -1
  26. package/dist/commands/registry.js +2 -0
  27. package/dist/commands/runtime.d.ts.map +1 -1
  28. package/dist/commands/runtime.js +28 -3
  29. package/dist/commands/seq.d.ts +4 -0
  30. package/dist/commands/seq.d.ts.map +1 -0
  31. package/dist/commands/seq.js +50 -0
  32. package/dist/commands/sh.d.ts +0 -6
  33. package/dist/commands/sh.d.ts.map +1 -1
  34. package/dist/commands/sh.js +153 -10
  35. package/dist/modules/linuxRootfs.d.ts +1 -1
  36. package/dist/modules/linuxRootfs.d.ts.map +1 -1
  37. package/dist/modules/linuxRootfs.js +5 -5
  38. package/dist/self-standalone.js +149 -102
  39. package/dist/types/pipeline.d.ts +6 -0
  40. package/dist/types/pipeline.d.ts.map +1 -1
  41. package/dist/types/vfs.d.ts +15 -0
  42. package/dist/types/vfs.d.ts.map +1 -1
  43. package/dist/utils/expand.d.ts +9 -0
  44. package/dist/utils/expand.d.ts.map +1 -1
  45. package/dist/utils/expand.js +84 -2
  46. package/dist/utils/tokenize.d.ts.map +1 -1
  47. package/dist/utils/tokenize.js +40 -0
  48. package/package.json +1 -1
  49. package/src/VirtualFileSystem/index.ts +164 -1
  50. package/src/VirtualShell/index.ts +36 -0
  51. package/src/VirtualShell/shell.ts +6 -11
  52. package/src/VirtualShell/shellParser.ts +26 -1
  53. package/src/VirtualUserManager/index.ts +4 -4
  54. package/src/commands/export.ts +5 -3
  55. package/src/commands/helpers.ts +1 -1
  56. package/src/commands/history.ts +2 -2
  57. package/src/commands/registry.ts +2 -0
  58. package/src/commands/runtime.ts +30 -3
  59. package/src/commands/seq.ts +43 -0
  60. package/src/commands/sh.ts +144 -19
  61. package/src/modules/linuxRootfs.ts +6 -6
  62. package/src/self-standalone.ts +190 -141
  63. package/src/types/pipeline.ts +6 -0
  64. package/src/types/vfs.ts +17 -0
  65. package/src/utils/expand.ts +75 -2
  66. package/src/utils/tokenize.ts +20 -0
  67. 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
- 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);
@@ -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("/virtual-env-js/.bash_history", data);
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 = `/virtual-env-js/.lastlog/${authUser}.json`;
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 dir = "/virtual-env-js/.lastlog";
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 = "/virtual-env-js/.bash_history";
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 { name, args: cmdParts.slice(1), inputFile, outputFile, appendOutput };
278
+ return {
279
+ name, args: cmdParts.slice(1),
280
+ inputFile, outputFile, appendOutput,
281
+ stderrFile, stderrAppend, stderrToStdout,
282
+ };
258
283
  }
259
284
 
@@ -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 = "/virtual-env-js/.auth/htpasswd";
51
- private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
52
- private readonly quotasPath = "/virtual-env-js/.auth/quotas";
53
- private readonly authDirPath = "/virtual-env-js/.auth";
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>();
@@ -11,13 +11,15 @@ export const exportCommand: ShellModule = {
11
11
  category: "shell",
12
12
  params: ["[VAR=value]"],
13
13
  run: ({ args, env }) => {
14
- if (args.length === 0) {
14
+ // export -p or export with no args list all exported vars
15
+ if (args.length === 0 || (args.length === 1 && args[0] === "-p")) {
15
16
  const out = Object.entries(env.vars)
17
+ .filter(([k]) => k && /^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
16
18
  .map(([k, v]) => `declare -x ${k}="${v}"`)
17
19
  .join("\n");
18
- return { stdout: out, exitCode: 0 };
20
+ return { stdout: out ? `${out}\n` : "", exitCode: 0 };
19
21
  }
20
- for (const arg of args) {
22
+ for (const arg of args.filter((a) => a !== "-p")) {
21
23
  if (arg.includes("=")) {
22
24
  const eq = arg.indexOf("=");
23
25
  const name = arg.slice(0, eq);
@@ -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 = ["/virtual-env-js/.auth"] as const;
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)) {
@@ -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 = "/virtual-env-js/.bash_history";
15
+ const histPath = `/home/${authUser}/.bash_history`;
16
16
  if (!shell.vfs.exists(histPath)) {
17
17
  return { stdout: "", exitCode: 0 };
18
18
  }
@@ -36,6 +36,7 @@ import { htopCommand } from "./htop";
36
36
  import { idCommand } from "./id";
37
37
  import { killCommand } from "./kill";
38
38
  import { lnCommand, readlinkCommand } from "./ln";
39
+ import { seqCommand } from "./seq";
39
40
  import { statCommand } from "./stat";
40
41
  import { lsCommand } from "./ls";
41
42
  import { lsbReleaseCommand } from "./lsb-release";
@@ -99,6 +100,7 @@ const BASE_COMMANDS: ShellModule[] = [
99
100
  lnCommand,
100
101
  readlinkCommand,
101
102
  chmodCommand,
103
+ seqCommand,
102
104
  statCommand,
103
105
  findCommand,
104
106
  // Text processing
@@ -7,7 +7,7 @@ import type {
7
7
  CommandResult,
8
8
  ShellEnv,
9
9
  } from "../types/commands";
10
- import { expandAsync } from "../utils/expand";
10
+ import { expandAsync, expandBraces } from "../utils/expand";
11
11
  import { tokenizeCommand } from "../utils/tokenize";
12
12
  import { resolveModule } from "./registry";
13
13
 
@@ -176,6 +176,15 @@ export async function runCommand(
176
176
  ? trimmed.replace(rawFirstWord, aliasVal)
177
177
  : trimmed;
178
178
 
179
+ // Detect sh-syntax constructs that must be handled by the sh interpreter
180
+ const isShScript =
181
+ /\bfor\s+\w+\s+in\b/.test(aliasExpanded) ||
182
+ /\bwhile\s+/.test(aliasExpanded) ||
183
+ /\bif\s+/.test(aliasExpanded) ||
184
+ /\w+\s*\(\s*\)\s*\{/.test(aliasExpanded) ||
185
+ /\bfunction\s+\w+/.test(aliasExpanded) ||
186
+ /\(\(\s*.+\s*\)\)/.test(aliasExpanded);
187
+
179
188
  const hasOperators =
180
189
  /(?<![|&])[|](?![|])/.test(aliasExpanded) ||
181
190
  aliasExpanded.includes(">") ||
@@ -184,7 +193,24 @@ export async function runCommand(
184
193
  aliasExpanded.includes("||") ||
185
194
  aliasExpanded.includes(";");
186
195
 
187
- if (hasOperators) {
196
+ if ((isShScript && rawFirstWord !== "sh" && rawFirstWord !== "bash") || hasOperators) {
197
+ // sh-syntax: route through sh interpreter to handle for/while/functions
198
+ if (isShScript && rawFirstWord !== "sh" && rawFirstWord !== "bash") {
199
+ const shMod = resolveModule("sh");
200
+ if (shMod) {
201
+ return await shMod.run({
202
+ authUser, hostname,
203
+ activeSessions: shell.users.listActiveSessions(),
204
+ rawInput: aliasExpanded,
205
+ mode,
206
+ args: ["-c", aliasExpanded],
207
+ stdin: undefined,
208
+ cwd,
209
+ shell,
210
+ env: shellEnv,
211
+ });
212
+ }
213
+ }
188
214
  const script = parseScript(aliasExpanded);
189
215
  if (!script.isValid)
190
216
  return { stderr: script.error || "Syntax error", exitCode: 1 };
@@ -225,7 +251,8 @@ export async function runCommand(
225
251
 
226
252
  const parts = tokenizeCommand(expanded.trim());
227
253
  const commandName = parts[0]?.toLowerCase() ?? "";
228
- const args = parts.slice(1);
254
+ // Apply brace expansion to each arg token
255
+ const args = parts.slice(1).flatMap(expandBraces);
229
256
  const mod = resolveModule(commandName);
230
257
 
231
258
  if (!mod) {
@@ -0,0 +1,43 @@
1
+ import type { ShellModule } from "../types/commands";
2
+
3
+ /** Generate sequences of numbers. seq LAST / seq FIRST LAST / seq FIRST INCREMENT LAST */
4
+ export const seqCommand: ShellModule = {
5
+ name: "seq",
6
+ description: "Print a sequence of numbers",
7
+ category: "text",
8
+ params: ["[FIRST [INCREMENT]] LAST"],
9
+ run: ({ args }) => {
10
+ const nums = args.filter((a) => !a.startsWith("-") || /^-[\d.]/.test(a)).map(Number);
11
+ const sep = (() => { const i = args.indexOf("-s"); return i !== -1 ? (args[i + 1] ?? "\n") : "\n"; })();
12
+ const fmt = (() => { const i = args.indexOf("-f"); return i !== -1 ? (args[i + 1] ?? "%g") : null; })();
13
+ const width = args.includes("-w");
14
+
15
+ let first = 1, inc = 1, last: number;
16
+ if (nums.length === 1) { last = nums[0]!; }
17
+ else if (nums.length === 2) { first = nums[0]!; last = nums[1]!; }
18
+ else { first = nums[0]!; inc = nums[1]!; last = nums[2]!; }
19
+
20
+ if (inc === 0) return { stderr: "seq: zero increment\n", exitCode: 1 };
21
+ if ((inc > 0 && first > last) || (inc < 0 && first < last)) return { stdout: "", exitCode: 0 };
22
+
23
+ const results: string[] = [];
24
+ const maxSteps = 100000;
25
+ let steps = 0;
26
+ for (let n = first; inc > 0 ? n <= last : n >= last; n = Math.round((n + inc) * 1e10) / 1e10) {
27
+ if (++steps > maxSteps) break;
28
+ let s: string;
29
+ if (fmt) {
30
+ s = fmt.replace("%g", String(n)).replace("%f", n.toFixed(6)).replace("%d", String(Math.trunc(n)));
31
+ } else {
32
+ s = Number.isInteger(n) ? String(n) : n.toPrecision(12).replace(/\.?0+$/, "");
33
+ }
34
+ if (width) {
35
+ const maxLen = String(Math.trunc(last)).length;
36
+ s = s.padStart(maxLen, "0");
37
+ }
38
+ results.push(s);
39
+ }
40
+
41
+ return { stdout: `${results.join(sep)}\n`, exitCode: 0 };
42
+ },
43
+ };