typescript-virtual-container 1.0.8 → 1.1.1-b

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 (54) hide show
  1. package/.vscode/settings.json +18 -0
  2. package/README.md +183 -92
  3. package/modules/shellInteractive.ts +45 -0
  4. package/modules/shellRuntime.ts +76 -0
  5. package/package.json +1 -1
  6. package/src/{SSHMimic/client.ts → SSHClient/index.ts} +17 -20
  7. package/src/SSHMimic/exec.ts +6 -17
  8. package/src/SSHMimic/executor.ts +20 -31
  9. package/src/SSHMimic/index.ts +23 -85
  10. package/src/VirtualFileSystem/index.ts +26 -1
  11. package/src/VirtualShell/index.ts +131 -26
  12. package/src/VirtualShell/shell.ts +43 -141
  13. package/src/VirtualShell/shellParser.ts +32 -7
  14. package/src/{SSHMimic/users.ts → VirtualUserManager/index.ts} +155 -3
  15. package/src/{VirtualShell/commands → commands}/adduser.ts +3 -3
  16. package/src/{VirtualShell/commands → commands}/cat.ts +4 -4
  17. package/src/{VirtualShell/commands → commands}/cd.ts +3 -3
  18. package/src/{VirtualShell/commands → commands}/clear.ts +1 -1
  19. package/src/{VirtualShell/commands → commands}/curl.ts +3 -3
  20. package/src/{VirtualShell/commands → commands}/deluser.ts +3 -3
  21. package/src/{VirtualShell/commands → commands}/echo.ts +1 -1
  22. package/src/{VirtualShell/commands → commands}/env.ts +1 -1
  23. package/src/{VirtualShell/commands → commands}/exit.ts +1 -1
  24. package/src/{VirtualShell/commands → commands}/export.ts +1 -1
  25. package/src/{VirtualShell/commands → commands}/grep.ts +3 -3
  26. package/src/{VirtualShell/commands → commands}/help.ts +1 -1
  27. package/src/{VirtualShell/commands → commands}/helpers.ts +1 -1
  28. package/src/{VirtualShell/commands → commands}/hostname.ts +1 -1
  29. package/src/{VirtualShell/commands → commands}/htop.ts +1 -1
  30. package/src/{VirtualShell/commands → commands}/index.ts +19 -110
  31. package/src/{VirtualShell/commands → commands}/ls.ts +7 -5
  32. package/src/{VirtualShell/commands → commands}/mkdir.ts +3 -3
  33. package/src/{VirtualShell/commands → commands}/nano.ts +4 -4
  34. package/src/{VirtualShell/commands → commands}/neofetch.ts +4 -4
  35. package/src/{VirtualShell/commands → commands}/pwd.ts +1 -1
  36. package/src/{VirtualShell/commands → commands}/rm.ts +3 -3
  37. package/src/{VirtualShell/commands → commands}/set.ts +1 -1
  38. package/src/{VirtualShell/commands → commands}/sh.ts +3 -14
  39. package/src/{VirtualShell/commands → commands}/su.ts +3 -2
  40. package/src/{VirtualShell/commands → commands}/sudo.ts +4 -7
  41. package/src/{VirtualShell/commands → commands}/touch.ts +4 -4
  42. package/src/{VirtualShell/commands → commands}/tree.ts +3 -3
  43. package/src/{VirtualShell/commands → commands}/unset.ts +1 -1
  44. package/src/{VirtualShell/commands → commands}/wget.ts +3 -3
  45. package/src/{VirtualShell/commands → commands}/who.ts +4 -4
  46. package/src/{VirtualShell/commands → commands}/whoami.ts +1 -1
  47. package/src/index.ts +6 -6
  48. package/src/standalone.ts +19 -14
  49. package/src/types/commands.ts +3 -11
  50. package/tests/command-helpers.test.ts +1 -1
  51. package/tests/helpers.test.ts +1 -1
  52. package/tests/parser-executor.test.ts +3 -6
  53. package/tests/users.test.ts +61 -1
  54. /package/src/{VirtualShell/commands → commands}/command-helpers.ts +0 -0
@@ -1,40 +1,96 @@
1
- import type { VirtualUserManager } from "../SSHMimic/users";
1
+ import { randomBytes } from "node:crypto";
2
+ import { createCustomCommand, registerCommand, runCommand } from "../commands";
2
3
  import type { CommandContext, CommandResult } from "../types/commands";
3
4
  import type { ShellStream } from "../types/streams";
4
- import type VirtualFileSystem from "../VirtualFileSystem";
5
- import { createCustomCommand, registerCommand, runCommand } from "./commands";
5
+ import VirtualFileSystem from "../VirtualFileSystem";
6
+ import { VirtualUserManager } from "../VirtualUserManager";
6
7
  import { startShell } from "./shell";
7
8
 
8
9
  export interface ShellProperties {
9
10
  kernel: string;
10
- os: "Fortune GNU/Linux x64";
11
- arch: "x86_64";
11
+ os: string;
12
+ arch: string;
12
13
  }
13
14
 
14
- export const defaultShellProperties: ShellProperties = {
15
+ const defaultShellProperties: ShellProperties = {
15
16
  kernel: "1.0.0+itsrealfortune+1-amd64",
16
17
  os: "Fortune GNU/Linux x64",
17
18
  arch: "x86_64",
18
19
  };
19
20
 
21
+ function resolveRootPassword(): string {
22
+ const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
23
+ if (configured && configured.trim().length > 0) {
24
+ return configured;
25
+ }
26
+
27
+ const generated = randomBytes(18).toString("base64url");
28
+ console.warn(
29
+ `[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`,
30
+ );
31
+ return generated;
32
+ }
33
+
34
+ function resolveAutoSudoForNewUsers(): boolean {
35
+ const configured = process.env.SSH_MIMIC_AUTO_SUDO_NEW_USERS;
36
+ if (!configured) {
37
+ return true;
38
+ }
39
+
40
+ return !["0", "false", "no", "off"].includes(configured.toLowerCase());
41
+ }
42
+
43
+ /**
44
+ * Coordinates the virtual filesystem, user manager, and command runtime.
45
+ *
46
+ * Instances are used both by the SSH server facade and by the programmatic
47
+ * client API.
48
+ */
20
49
  class VirtualShell {
21
- private vfs: VirtualFileSystem;
22
- private users: VirtualUserManager;
23
- private hostname: string;
24
- public properties: ShellProperties;
50
+ basePath: string = ".";
51
+ vfs: VirtualFileSystem;
52
+ users: VirtualUserManager;
53
+ hostname: string;
54
+ properties: ShellProperties;
25
55
 
56
+ /**
57
+ * Creates a new virtual shell instance.
58
+ *
59
+ * @param hostname Virtual hostname used for prompts and idents.
60
+ * @param properties Customizable properties shown in `uname -a` and similar commands.
61
+ * @param basePath Optional base path for the virtual filesystem (defaults to process.cwd()).
62
+ */
26
63
  constructor(
27
- vfs: VirtualFileSystem,
28
- users: VirtualUserManager,
29
64
  hostname: string,
30
65
  properties?: ShellProperties,
66
+ basePath?: string,
31
67
  ) {
32
- this.vfs = vfs;
33
- this.users = users;
34
68
  this.hostname = hostname;
35
69
  this.properties = properties || defaultShellProperties;
70
+ this.basePath = basePath || ".";
71
+ this.vfs = new VirtualFileSystem(this.basePath);
72
+ this.users = new VirtualUserManager(
73
+ this.vfs,
74
+ resolveRootPassword(),
75
+ resolveAutoSudoForNewUsers(),
76
+ );
77
+ this.vfs.restoreMirror().then(() => {
78
+ this.users = new VirtualUserManager(
79
+ this.vfs,
80
+ resolveRootPassword(),
81
+ resolveAutoSudoForNewUsers(),
82
+ );
83
+ this.users.initialize();
84
+ });
36
85
  }
37
86
 
87
+ /**
88
+ * Registers a new command in the shell runtime.
89
+ *
90
+ * @param name Case-insensitive command name (no spaces).
91
+ * @param params List of parameter names for help text (no validation).
92
+ * @param callback Function invoked with command context on execution.
93
+ */
38
94
  addCommand(
39
95
  name: string,
40
96
  params: string[],
@@ -48,19 +104,26 @@ class VirtualShell {
48
104
  registerCommand(createCustomCommand(normalized, params, callback));
49
105
  }
50
106
 
107
+ /**
108
+ * Executes a command line string in the context of this shell instance.
109
+ *
110
+ * @param rawInput
111
+ * @param authUser
112
+ * @param cwd
113
+ */
51
114
  executeCommand(rawInput: string, authUser: string, cwd: string): void {
52
- runCommand(
53
- rawInput,
54
- authUser,
55
- this.hostname,
56
- this.users,
57
- "shell",
58
- cwd,
59
- this.properties,
60
- this.vfs,
61
- );
115
+ runCommand(rawInput, authUser, this.hostname, "shell", cwd, this);
62
116
  }
63
117
 
118
+ /**
119
+ * Starts an interactive session with the shell.
120
+ *
121
+ * @param stream The stream for the interactive session.
122
+ * @param authUser The authenticated user for the session.
123
+ * @param sessionId The ID of the session.
124
+ * @param remoteAddress The address of the remote client.
125
+ */
126
+
64
127
  startInteractiveSession(
65
128
  stream: ShellStream,
66
129
  authUser: string,
@@ -73,14 +136,56 @@ class VirtualShell {
73
136
  this.properties,
74
137
  stream,
75
138
  authUser,
76
- this.vfs!,
77
139
  this.hostname,
78
- this.users!,
79
140
  sessionId,
80
141
  remoteAddress,
81
142
  terminalSize,
143
+ this,
82
144
  );
83
145
  }
146
+
147
+ /**
148
+ * Returns virtual filesystem instance after server started.
149
+ *
150
+ * @returns VirtualFileSystem or null when not started.
151
+ */
152
+ public getVfs(): VirtualFileSystem | null {
153
+ return this?.vfs ?? null;
154
+ }
155
+
156
+ /**
157
+ * Returns user manager instance after server started.
158
+ *
159
+ * @returns VirtualUserManager or null when not started.
160
+ */
161
+ public getUsers(): VirtualUserManager | null {
162
+ return this?.users ?? null;
163
+ }
164
+
165
+ /**
166
+ * Returns hostname shown in prompts and idents.
167
+ *
168
+ * @returns Configured hostname label.
169
+ */
170
+ public getHostname(): string {
171
+ return this?.hostname;
172
+ }
173
+
174
+ /**
175
+ * Writes a file on behalf of a user with quota enforcement.
176
+ *
177
+ * @param authUser User performing the write.
178
+ * @param targetPath Destination path.
179
+ * @param content File content.
180
+ */
181
+ public writeFileAsUser(
182
+ authUser: string,
183
+ targetPath: string,
184
+ content: string | Buffer,
185
+ ): void {
186
+ this.users.assertWriteWithinQuota(authUser, targetPath, content);
187
+ this.vfs.writeFile(targetPath, content);
188
+ }
84
189
  }
85
190
 
86
191
  export { VirtualShell };
@@ -1,13 +1,22 @@
1
- import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { readFile, unlink, writeFile } from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { defaultShellProperties, type ShellProperties } from ".";
4
+ import type { ShellProperties, VirtualShell } from ".";
5
+ import {
6
+ spawnHtopProcess,
7
+ spawnNanoEditorProcess,
8
+ } from "../../modules/shellInteractive";
9
+ import {
10
+ getVisibleHtopPidList,
11
+ resolvePath,
12
+ type TerminalSize,
13
+ toTtyLines,
14
+ } from "../../modules/shellRuntime";
15
+ import { getCommandNames, runCommand } from "../commands";
5
16
  import { formatLoginDate } from "../SSHMimic/loginFormat";
6
17
  import { buildPrompt } from "../SSHMimic/prompt";
7
- import type { VirtualUserManager } from "../SSHMimic/users";
8
18
  import type { ShellStream } from "../types/streams";
9
19
  import type VirtualFileSystem from "../VirtualFileSystem";
10
- import { getCommandNames, runCommand } from "./commands";
11
20
 
12
21
  interface NanoSession {
13
22
  kind: "nano" | "htop";
@@ -25,36 +34,19 @@ interface PendingSudo {
25
34
  buffer: string;
26
35
  }
27
36
 
28
- function shellQuote(value: string): string {
29
- return `'${value.replace(/'/g, `'\\''`)}'`;
30
- }
31
-
32
- interface TerminalSize {
33
- cols: number;
34
- rows: number;
35
- }
36
-
37
- function toTtyLines(text: string): string {
38
- return text
39
- .replace(/\r\n/g, "\n")
40
- .replace(/\r/g, "\n")
41
- .replace(/\n/g, "\r\n");
42
- }
43
-
44
37
  export function startShell(
45
38
  properties: ShellProperties,
46
39
  stream: ShellStream,
47
40
  authUser: string,
48
- vfs: VirtualFileSystem,
49
41
  hostname: string,
50
- users: VirtualUserManager,
51
42
  sessionId: string | null,
52
43
  remoteAddress = "unknown",
53
44
  terminalSize: TerminalSize = { cols: 80, rows: 24 },
45
+ shell: VirtualShell,
54
46
  ): void {
55
47
  let lineBuffer = "";
56
48
  let cursorPos = 0;
57
- let history = loadHistory(vfs);
49
+ let history = loadHistory(shell.vfs);
58
50
  let historyIndex: number | null = null;
59
51
  let historyDraft = "";
60
52
  let cwd = `/home/${authUser}`;
@@ -70,60 +62,6 @@ export function startShell(
70
62
  `[${sessionId}] Shell started for user '${authUser}' at ${remoteAddress}`,
71
63
  );
72
64
 
73
- async function collectChildPids(parentPid: number): Promise<number[]> {
74
- try {
75
- const childrenRaw = await readFile(
76
- `/proc/${parentPid}/task/${parentPid}/children`,
77
- "utf8",
78
- );
79
- const directChildren = childrenRaw
80
- .trim()
81
- .split(/\s+/)
82
- .filter(Boolean)
83
- .map((value) => Number.parseInt(value, 10))
84
- .filter((pid) => Number.isInteger(pid) && pid > 0);
85
-
86
- const nested = await Promise.all(
87
- directChildren.map((pid) => collectChildPids(pid)),
88
- );
89
- return [...directChildren, ...nested.flat()];
90
- } catch {
91
- return [];
92
- }
93
- }
94
-
95
- async function getVisibleHtopPidList(): Promise<string | null> {
96
- const rootPid = process.pid;
97
- const descendants = await collectChildPids(rootPid);
98
- const unique = Array.from(new Set(descendants)).sort((a, b) => a - b);
99
- if (unique.length === 0) {
100
- return null;
101
- }
102
-
103
- return unique.join(",");
104
- }
105
-
106
- function withTerminalSize(command: string): string {
107
- const cols =
108
- Number.isFinite(terminalSize.cols) && terminalSize.cols > 0
109
- ? Math.floor(terminalSize.cols)
110
- : 80;
111
- const rows =
112
- Number.isFinite(terminalSize.rows) && terminalSize.rows > 0
113
- ? Math.floor(terminalSize.rows)
114
- : 24;
115
- return `stty cols ${cols} rows ${rows} 2>/dev/null; ${command}`;
116
- }
117
-
118
- function resolvePath(base: string, inputPath: string): string {
119
- if (!inputPath || inputPath.trim() === "" || inputPath === ".") {
120
- return base;
121
- }
122
- return inputPath.startsWith("/")
123
- ? path.posix.normalize(inputPath)
124
- : path.posix.normalize(path.posix.join(base, inputPath));
125
- }
126
-
127
65
  function renderLine(): void {
128
66
  const prompt = buildCurrentPrompt();
129
67
  stream.write(`\r${prompt}${lineBuffer}\u001b[K`);
@@ -170,7 +108,7 @@ export function startShell(
170
108
  if (!challenge.commandLine) {
171
109
  authUser = challenge.targetUser;
172
110
  cwd = `/home/${authUser}`;
173
- users.updateSession(sessionId, authUser, remoteAddress);
111
+ shell.users.updateSession(sessionId, authUser, remoteAddress);
174
112
  stream.write("\r\n");
175
113
  renderLine();
176
114
  return;
@@ -182,11 +120,9 @@ export function startShell(
182
120
  challenge.commandLine,
183
121
  challenge.targetUser,
184
122
  hostname,
185
- users,
186
123
  "shell",
187
124
  runCwd,
188
- defaultShellProperties,
189
- vfs,
125
+ shell,
190
126
  ),
191
127
  );
192
128
 
@@ -221,12 +157,12 @@ export function startShell(
221
157
  if (result.switchUser) {
222
158
  authUser = result.switchUser;
223
159
  cwd = result.nextCwd ?? `/home/${authUser}`;
224
- users.updateSession(sessionId, authUser, remoteAddress);
160
+ shell.users.updateSession(sessionId, authUser, remoteAddress);
225
161
  } else if (result.nextCwd) {
226
162
  cwd = result.nextCwd;
227
163
  }
228
164
 
229
- await vfs.flushMirror();
165
+ await shell.vfs.flushMirror();
230
166
  renderLine();
231
167
  }
232
168
 
@@ -240,8 +176,12 @@ export function startShell(
240
176
  if (activeSession.kind === "nano") {
241
177
  try {
242
178
  const updatedContent = await readFile(activeSession.tempPath, "utf8");
243
- vfs.writeFile(activeSession.targetPath, updatedContent);
244
- await vfs.flushMirror();
179
+ shell.writeFileAsUser(
180
+ authUser,
181
+ activeSession.targetPath,
182
+ updatedContent,
183
+ );
184
+ await shell.vfs.flushMirror();
245
185
  } catch {
246
186
  // If temp file does not exist, nano exited without writing.
247
187
  }
@@ -261,27 +201,11 @@ export function startShell(
261
201
  initialContent: string,
262
202
  tempPath: string,
263
203
  ): Promise<void> {
264
- if (vfs.exists(targetPath)) {
204
+ if (shell.vfs.exists(targetPath)) {
265
205
  await writeFile(tempPath, initialContent, "utf8");
266
206
  }
267
207
 
268
- const command = withTerminalSize(`nano -- ${shellQuote(tempPath)}`);
269
- const editor = spawn("script", ["-qfec", command, "/dev/null"], {
270
- stdio: ["pipe", "pipe", "pipe"],
271
- env: {
272
- ...process.env,
273
- // biome-ignore lint/style/useNamingConvention: TERM is an environment variable conventionally in uppercase
274
- TERM: process.env.TERM ?? "xterm-256color",
275
- },
276
- });
277
-
278
- editor.stdout.on("data", (data: Buffer) => {
279
- stream.write(data.toString("utf8"));
280
- });
281
-
282
- editor.stderr.on("data", (data: Buffer) => {
283
- stream.write(data.toString("utf8"));
284
- });
208
+ const editor = spawnNanoEditorProcess(tempPath, terminalSize, stream);
285
209
 
286
210
  editor.on("error", (error: Error) => {
287
211
  stream.write(`nano: ${error.message}\r\n`);
@@ -307,23 +231,7 @@ export function startShell(
307
231
  return;
308
232
  }
309
233
 
310
- const command = withTerminalSize(`htop -p ${shellQuote(pidList)}`);
311
- const monitor = spawn("script", ["-qfec", command, "/dev/null"], {
312
- stdio: ["pipe", "pipe", "pipe"],
313
- env: {
314
- ...process.env,
315
- // biome-ignore lint/style/useNamingConvention: TERM is an environment variable conventionally in uppercase
316
- TERM: process.env.TERM ?? "xterm-256color",
317
- },
318
- });
319
-
320
- monitor.stdout.on("data", (data: Buffer) => {
321
- stream.write(data.toString("utf8"));
322
- });
323
-
324
- monitor.stderr.on("data", (data: Buffer) => {
325
- stream.write(data.toString("utf8"));
326
- });
234
+ const monitor = spawnHtopProcess(pidList, terminalSize, stream);
327
235
 
328
236
  monitor.on("error", (error: Error) => {
329
237
  stream.write(`htop: ${error.message}\r\n`);
@@ -378,13 +286,13 @@ export function startShell(
378
286
  const basePath = resolvePath(cwd, dirPart || ".");
379
287
 
380
288
  try {
381
- return vfs
289
+ return shell.vfs
382
290
  .list(basePath)
383
291
  .filter((entry) => !entry.startsWith("."))
384
292
  .filter((entry) => entry.startsWith(namePart))
385
293
  .map((entry) => {
386
294
  const fullPath = path.posix.join(basePath, entry);
387
- const st = vfs.stat(fullPath);
295
+ const st = shell.vfs.stat(fullPath);
388
296
  const suffix = st.type === "directory" ? "/" : "";
389
297
  return `${dirPart}${entry}${suffix}`;
390
298
  })
@@ -440,17 +348,17 @@ export function startShell(
440
348
  }
441
349
 
442
350
  const data = history.length > 0 ? `${history.join("\n")}\n` : "";
443
- vfs.writeFile("/virtual-env-js/.bash_history", data);
351
+ shell.vfs.writeFile("/virtual-env-js/.bash_history", data);
444
352
  }
445
353
 
446
354
  function readLastLogin(): { at: string; from: string } | null {
447
355
  const lastlogPath = `/virtual-env-js/.lastlog/${authUser}.json`;
448
- if (!vfs.exists(lastlogPath)) {
356
+ if (!shell.vfs.exists(lastlogPath)) {
449
357
  return null;
450
358
  }
451
359
 
452
360
  try {
453
- return JSON.parse(vfs.readFile(lastlogPath)) as {
361
+ return JSON.parse(shell.vfs.readFile(lastlogPath)) as {
454
362
  at: string;
455
363
  from: string;
456
364
  };
@@ -461,12 +369,12 @@ export function startShell(
461
369
 
462
370
  function writeLastLogin(nowIso: string): void {
463
371
  const dir = "/virtual-env-js/.lastlog";
464
- if (!vfs.exists(dir)) {
465
- vfs.mkdir(dir, 0o700);
372
+ if (!shell.vfs.exists(dir)) {
373
+ shell.vfs.mkdir(dir, 0o700);
466
374
  }
467
375
 
468
376
  const lastlogPath = `${dir}/${authUser}.json`;
469
- vfs.writeFile(
377
+ shell.vfs.writeFile(
470
378
  lastlogPath,
471
379
  JSON.stringify({ at: nowIso, from: remoteAddress }),
472
380
  );
@@ -537,7 +445,10 @@ export function startShell(
537
445
  if (ch === "\r" || ch === "\n") {
538
446
  const password = pendingSudo.buffer;
539
447
  pendingSudo.buffer = "";
540
- const valid = users.verifyPassword(pendingSudo.username, password);
448
+ const valid = shell.users.verifyPassword(
449
+ pendingSudo.username,
450
+ password,
451
+ );
541
452
  await finishSudoPrompt(valid);
542
453
  return;
543
454
  }
@@ -650,16 +561,7 @@ export function startShell(
650
561
 
651
562
  if (line.length > 0) {
652
563
  const result = await Promise.resolve(
653
- runCommand(
654
- line,
655
- authUser,
656
- hostname,
657
- users,
658
- "shell",
659
- cwd,
660
- defaultShellProperties,
661
- vfs,
662
- ),
564
+ runCommand(line, authUser, hostname, "shell", cwd, shell),
663
565
  );
664
566
 
665
567
  pushHistory(line);
@@ -709,12 +611,12 @@ export function startShell(
709
611
  if (result.switchUser) {
710
612
  authUser = result.switchUser;
711
613
  cwd = result.nextCwd ?? `/home/${authUser}`;
712
- users.updateSession(sessionId, authUser, remoteAddress);
614
+ shell.users.updateSession(sessionId, authUser, remoteAddress);
713
615
  lineBuffer = "";
714
616
  cursorPos = 0;
715
617
  }
716
618
 
717
- await vfs.flushMirror();
619
+ await shell.vfs.flushMirror();
718
620
  }
719
621
 
720
622
  renderLine();
@@ -9,7 +9,16 @@ export function parseShellPipeline(rawInput: string): Pipeline {
9
9
  }
10
10
 
11
11
  const commands: PipelineCommand[] = [];
12
- const pipeTokens = tokenizePipeline(trimmed);
12
+ const tokenized = tokenizePipeline(trimmed);
13
+ if (tokenized.error) {
14
+ return {
15
+ commands: [],
16
+ isValid: false,
17
+ error: tokenized.error,
18
+ };
19
+ }
20
+
21
+ const pipeTokens = tokenized.tokens;
13
22
 
14
23
  for (const token of pipeTokens) {
15
24
  const cmd = parseCommandWithRedirections(token);
@@ -29,7 +38,7 @@ export function parseShellPipeline(rawInput: string): Pipeline {
29
38
  }
30
39
 
31
40
  /** Tokenize input by pipes, respecting quoted strings */
32
- function tokenizePipeline(input: string): string[] {
41
+ function tokenizePipeline(input: string): { tokens: string[]; error?: string } {
33
42
  const tokens: string[] = [];
34
43
  let current = "";
35
44
  let inQuotes = false;
@@ -49,9 +58,13 @@ function tokenizePipeline(input: string): string[] {
49
58
  current += ch;
50
59
  i++;
51
60
  } else if (ch === "|" && !inQuotes) {
52
- if (current.trim()) {
53
- tokens.push(current.trim());
61
+ if (!current.trim()) {
62
+ return {
63
+ tokens: [],
64
+ error: "Syntax error near unexpected token '|'",
65
+ };
54
66
  }
67
+ tokens.push(current.trim());
55
68
  current = "";
56
69
  i++;
57
70
  } else {
@@ -60,11 +73,23 @@ function tokenizePipeline(input: string): string[] {
60
73
  }
61
74
  }
62
75
 
63
- if (current.trim()) {
64
- tokens.push(current.trim());
76
+ if (inQuotes) {
77
+ return {
78
+ tokens: [],
79
+ error: "Syntax error: unterminated quote",
80
+ };
65
81
  }
66
82
 
67
- return tokens;
83
+ if (!current.trim()) {
84
+ return {
85
+ tokens: [],
86
+ error: "Syntax error near unexpected token '|'",
87
+ };
88
+ }
89
+
90
+ tokens.push(current.trim());
91
+
92
+ return { tokens };
68
93
  }
69
94
 
70
95
  interface ParseResult {