command-stream 0.6.0 → 0.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "command-stream",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Modern $ shell utility library with streaming, async iteration, and EventEmitter support, optimized for Bun runtime",
5
5
  "type": "module",
6
6
  "main": "src/$.mjs",
package/src/$.mjs CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  import cp from 'child_process';
9
9
  import path from 'path';
10
+ import fs from 'fs';
11
+ import { parseShellCommand, needsRealShell } from './shell-parser.mjs';
10
12
 
11
13
  const isBun = typeof globalThis.Bun !== 'undefined';
12
14
 
@@ -21,6 +23,73 @@ function trace(category, messageOrFunc) {
21
23
  console.error(`[TRACE ${timestamp}] [${category}] ${message}`);
22
24
  }
23
25
 
26
+ // Shell detection cache
27
+ let cachedShell = null;
28
+
29
+ // Save initial working directory for restoration
30
+ const initialWorkingDirectory = process.cwd();
31
+
32
+ /**
33
+ * Find an available shell by checking multiple options in order
34
+ * Returns the shell command and arguments to use
35
+ */
36
+ function findAvailableShell() {
37
+ if (cachedShell) {
38
+ trace('ShellDetection', () => `Using cached shell: ${cachedShell.cmd}`);
39
+ return cachedShell;
40
+ }
41
+
42
+ const shellsToTry = [
43
+ // Try absolute paths first (most reliable)
44
+ { cmd: '/bin/sh', args: ['-l', '-c'], checkPath: true },
45
+ { cmd: '/usr/bin/sh', args: ['-l', '-c'], checkPath: true },
46
+ { cmd: '/bin/bash', args: ['-l', '-c'], checkPath: true },
47
+ { cmd: '/usr/bin/bash', args: ['-l', '-c'], checkPath: true },
48
+ { cmd: '/bin/zsh', args: ['-l', '-c'], checkPath: true },
49
+ { cmd: '/usr/bin/zsh', args: ['-l', '-c'], checkPath: true },
50
+ // macOS specific paths
51
+ { cmd: '/usr/local/bin/bash', args: ['-l', '-c'], checkPath: true },
52
+ { cmd: '/usr/local/bin/zsh', args: ['-l', '-c'], checkPath: true },
53
+ // Linux brew paths
54
+ { cmd: '/home/linuxbrew/.linuxbrew/bin/bash', args: ['-l', '-c'], checkPath: true },
55
+ { cmd: '/home/linuxbrew/.linuxbrew/bin/zsh', args: ['-l', '-c'], checkPath: true },
56
+ // Try shells in PATH as fallback (which might not work in all environments)
57
+ // Using separate -l and -c flags for better compatibility
58
+ { cmd: 'sh', args: ['-l', '-c'], checkPath: false },
59
+ { cmd: 'bash', args: ['-l', '-c'], checkPath: false },
60
+ { cmd: 'zsh', args: ['-l', '-c'], checkPath: false }
61
+ ];
62
+
63
+ for (const shell of shellsToTry) {
64
+ try {
65
+ if (shell.checkPath) {
66
+ // Check if the absolute path exists
67
+ if (fs.existsSync(shell.cmd)) {
68
+ trace('ShellDetection', () => `Found shell at absolute path: ${shell.cmd}`);
69
+ cachedShell = { cmd: shell.cmd, args: shell.args };
70
+ return cachedShell;
71
+ }
72
+ } else {
73
+ // Try to execute 'which' to check if command is in PATH
74
+ const result = cp.spawnSync('which', [shell.cmd], { encoding: 'utf-8' });
75
+ if (result.status === 0 && result.stdout) {
76
+ const shellPath = result.stdout.trim();
77
+ trace('ShellDetection', () => `Found shell in PATH: ${shell.cmd} => ${shellPath}`);
78
+ cachedShell = { cmd: shell.cmd, args: shell.args };
79
+ return cachedShell;
80
+ }
81
+ }
82
+ } catch (e) {
83
+ // Continue to next shell option
84
+ }
85
+ }
86
+
87
+ // Final fallback - use absolute path to sh
88
+ trace('ShellDetection', () => 'WARNING: No shell found, using /bin/sh as fallback');
89
+ cachedShell = { cmd: '/bin/sh', args: ['-l', '-c'] };
90
+ return cachedShell;
91
+ }
92
+
24
93
 
25
94
 
26
95
  // Track parent stream state for graceful shutdown
@@ -211,13 +280,127 @@ function forceCleanupAll() {
211
280
  // Clear activeProcessRunners
212
281
  activeProcessRunners.clear();
213
282
 
214
- // Reset flags
283
+ // Reset signal handler flags
215
284
  sigintHandlerInstalled = false;
216
285
  sigintHandler = null;
217
286
 
218
287
  trace('SignalHandler', () => `Force cleanup completed - removed ${commandStreamListeners.length} handlers`);
219
288
  }
220
289
 
290
+ // Complete global state reset for testing - clears all library state
291
+ function resetGlobalState() {
292
+ // CRITICAL: Restore working directory first before anything else
293
+ // This MUST succeed or tests will fail with spawn errors
294
+ try {
295
+ // Try to get current directory - this might fail if we're in a deleted directory
296
+ let currentDir;
297
+ try {
298
+ currentDir = process.cwd();
299
+ } catch (e) {
300
+ // Can't even get cwd, we're in a deleted directory
301
+ currentDir = null;
302
+ }
303
+
304
+ // Always try to restore to initial directory
305
+ if (!currentDir || currentDir !== initialWorkingDirectory) {
306
+ // Check if initial directory still exists
307
+ if (fs.existsSync(initialWorkingDirectory)) {
308
+ process.chdir(initialWorkingDirectory);
309
+ trace('GlobalState', () => `Restored working directory from ${currentDir} to ${initialWorkingDirectory}`);
310
+ } else {
311
+ // Initial directory is gone, use fallback
312
+ const fallback = process.env.HOME || '/workspace/command-stream' || '/';
313
+ if (fs.existsSync(fallback)) {
314
+ process.chdir(fallback);
315
+ trace('GlobalState', () => `Initial directory gone, changed to fallback: ${fallback}`);
316
+ } else {
317
+ // Last resort - try root
318
+ process.chdir('/');
319
+ trace('GlobalState', () => `Emergency fallback to root directory`);
320
+ }
321
+ }
322
+ }
323
+ } catch (e) {
324
+ trace('GlobalState', () => `Critical error restoring working directory: ${e.message}`);
325
+ // This is critical - we MUST have a valid working directory
326
+ try {
327
+ // Try home directory
328
+ if (process.env.HOME && fs.existsSync(process.env.HOME)) {
329
+ process.chdir(process.env.HOME);
330
+ } else {
331
+ // Last resort - root
332
+ process.chdir('/');
333
+ }
334
+ } catch (e2) {
335
+ console.error('FATAL: Cannot set any working directory!', e2);
336
+ }
337
+ }
338
+
339
+ // First, properly clean up all active ProcessRunners
340
+ for (const runner of activeProcessRunners) {
341
+ if (runner) {
342
+ try {
343
+ // If the runner was never started, clean it up
344
+ if (!runner.started) {
345
+ trace('resetGlobalState', () => `Cleaning up unstarted ProcessRunner: ${runner.spec?.command?.slice(0, 50)}`);
346
+ // Call the cleanup method to properly release resources
347
+ if (runner._cleanup) {
348
+ runner._cleanup();
349
+ }
350
+ } else if (runner.kill) {
351
+ // For started runners, kill them
352
+ runner.kill();
353
+ }
354
+ } catch (e) {
355
+ // Ignore errors
356
+ trace('resetGlobalState', () => `Error during cleanup: ${e.message}`);
357
+ }
358
+ }
359
+ }
360
+
361
+ // Call existing cleanup
362
+ forceCleanupAll();
363
+
364
+ // Clear shell cache to force re-detection with our fixed logic
365
+ cachedShell = null;
366
+
367
+ // Reset parent stream monitoring
368
+ parentStreamsMonitored = false;
369
+
370
+ // Reset shell settings to defaults
371
+ globalShellSettings = {
372
+ xtrace: false,
373
+ errexit: false,
374
+ pipefail: false,
375
+ verbose: false,
376
+ noglob: false,
377
+ allexport: false
378
+ };
379
+
380
+ // Don't clear virtual commands - they should persist across tests
381
+ // Just make sure they're enabled
382
+ virtualCommandsEnabled = true;
383
+
384
+ // Reset ANSI config to defaults
385
+ globalAnsiConfig = {
386
+ forceColor: false,
387
+ noColor: false
388
+ };
389
+
390
+ // Make sure built-in virtual commands are registered
391
+ if (virtualCommands.size === 0) {
392
+ // Re-import to re-register commands (synchronously if possible)
393
+ trace('GlobalState', () => 'Re-registering virtual commands');
394
+ import('./commands/index.mjs').then(() => {
395
+ trace('GlobalState', () => `Virtual commands re-registered, count: ${virtualCommands.size}`);
396
+ }).catch((e) => {
397
+ trace('GlobalState', () => `Error re-registering virtual commands: ${e.message}`);
398
+ });
399
+ }
400
+
401
+ trace('GlobalState', () => 'Global state reset completed');
402
+ }
403
+
221
404
  function monitorParentStreams() {
222
405
  if (parentStreamsMonitored) {
223
406
  trace('StreamMonitor', () => 'Parent streams already monitored, skipping');
@@ -478,6 +661,12 @@ class StreamEmitter {
478
661
  }
479
662
 
480
663
  on(event, listener) {
664
+ trace('StreamEmitter', () => `on() called | ${JSON.stringify({
665
+ event,
666
+ hasExistingListeners: this.listeners.has(event),
667
+ listenerCount: this.listeners.get(event)?.length || 0
668
+ })}`);
669
+
481
670
  if (!this.listeners.has(event)) {
482
671
  this.listeners.set(event, []);
483
672
  }
@@ -489,6 +678,7 @@ class StreamEmitter {
489
678
  }
490
679
 
491
680
  once(event, listener) {
681
+ trace('StreamEmitter', () => `once() called for event: ${event}`);
492
682
  const onceWrapper = (...args) => {
493
683
  this.off(event, onceWrapper);
494
684
  listener(...args);
@@ -514,11 +704,18 @@ class StreamEmitter {
514
704
  }
515
705
 
516
706
  off(event, listener) {
707
+ trace('StreamEmitter', () => `off() called | ${JSON.stringify({
708
+ event,
709
+ hasListeners: !!this.listeners.get(event),
710
+ listenerCount: this.listeners.get(event)?.length || 0
711
+ })}`);
712
+
517
713
  const eventListeners = this.listeners.get(event);
518
714
  if (eventListeners) {
519
715
  const index = eventListeners.indexOf(listener);
520
716
  if (index !== -1) {
521
717
  eventListeners.splice(index, 1);
718
+ trace('StreamEmitter', () => `Removed listener at index ${index}`);
522
719
  }
523
720
  }
524
721
  return this;
@@ -590,16 +787,28 @@ function buildShellCommand(strings, values) {
590
787
  }
591
788
 
592
789
  function asBuffer(chunk) {
593
- if (Buffer.isBuffer(chunk)) return chunk;
594
- if (typeof chunk === 'string') return Buffer.from(chunk);
790
+ if (Buffer.isBuffer(chunk)) {
791
+ trace('Utils', () => `asBuffer: Already a buffer, length: ${chunk.length}`);
792
+ return chunk;
793
+ }
794
+ if (typeof chunk === 'string') {
795
+ trace('Utils', () => `asBuffer: Converting string to buffer, length: ${chunk.length}`);
796
+ return Buffer.from(chunk);
797
+ }
798
+ trace('Utils', () => 'asBuffer: Converting unknown type to buffer');
595
799
  return Buffer.from(chunk);
596
800
  }
597
801
 
598
802
  async function pumpReadable(readable, onChunk) {
599
- if (!readable) return;
803
+ if (!readable) {
804
+ trace('Utils', () => 'pumpReadable: No readable stream provided');
805
+ return;
806
+ }
807
+ trace('Utils', () => 'pumpReadable: Starting to pump readable stream');
600
808
  for await (const chunk of readable) {
601
809
  await onChunk(asBuffer(chunk));
602
810
  }
811
+ trace('Utils', () => 'pumpReadable: Finished pumping readable stream');
603
812
  }
604
813
 
605
814
  // Enhanced process runner with streaming capabilities
@@ -620,6 +829,7 @@ class ProcessRunner extends StreamEmitter {
620
829
  cwd: undefined,
621
830
  env: undefined,
622
831
  interactive: false, // Explicitly request TTY forwarding for interactive commands
832
+ shellOperators: true, // Enable shell operator parsing by default
623
833
  ...options
624
834
  };
625
835
 
@@ -660,14 +870,26 @@ class ProcessRunner extends StreamEmitter {
660
870
 
661
871
  // Stream property getters for child process streams (null for virtual commands)
662
872
  get stdout() {
873
+ trace('ProcessRunner', () => `stdout getter accessed | ${JSON.stringify({
874
+ hasChild: !!this.child,
875
+ hasStdout: !!(this.child && this.child.stdout)
876
+ }, null, 2)}`);
663
877
  return this.child ? this.child.stdout : null;
664
878
  }
665
879
 
666
880
  get stderr() {
881
+ trace('ProcessRunner', () => `stderr getter accessed | ${JSON.stringify({
882
+ hasChild: !!this.child,
883
+ hasStderr: !!(this.child && this.child.stderr)
884
+ }, null, 2)}`);
667
885
  return this.child ? this.child.stderr : null;
668
886
  }
669
887
 
670
888
  get stdin() {
889
+ trace('ProcessRunner', () => `stdin getter accessed | ${JSON.stringify({
890
+ hasChild: !!this.child,
891
+ hasStdin: !!(this.child && this.child.stdin)
892
+ }, null, 2)}`);
671
893
  return this.child ? this.child.stdin : null;
672
894
  }
673
895
 
@@ -995,7 +1217,13 @@ class ProcessRunner extends StreamEmitter {
995
1217
  }
996
1218
 
997
1219
  async _forwardTTYStdin() {
1220
+ trace('ProcessRunner', () => `_forwardTTYStdin ENTER | ${JSON.stringify({
1221
+ isTTY: process.stdin.isTTY,
1222
+ hasChildStdin: !!this.child?.stdin
1223
+ }, null, 2)}`);
1224
+
998
1225
  if (!process.stdin.isTTY || !this.child.stdin) {
1226
+ trace('ProcessRunner', () => 'TTY forwarding skipped - no TTY or no child stdin');
999
1227
  return;
1000
1228
  }
1001
1229
 
@@ -1048,6 +1276,7 @@ class ProcessRunner extends StreamEmitter {
1048
1276
  };
1049
1277
 
1050
1278
  const cleanup = () => {
1279
+ trace('ProcessRunner', () => 'TTY stdin cleanup - restoring terminal mode');
1051
1280
  process.stdin.removeListener('data', onData);
1052
1281
  if (process.stdin.setRawMode) {
1053
1282
  process.stdin.setRawMode(false);
@@ -1401,6 +1630,49 @@ class ProcessRunner extends StreamEmitter {
1401
1630
  if (this.spec.mode === 'shell') {
1402
1631
  trace('ProcessRunner', () => `BRANCH: spec.mode => shell | ${JSON.stringify({}, null, 2)}`);
1403
1632
 
1633
+ // Check if shell operator parsing is enabled and command contains operators
1634
+ const hasShellOperators = this.spec.command.includes('&&') ||
1635
+ this.spec.command.includes('||') ||
1636
+ this.spec.command.includes('(') ||
1637
+ this.spec.command.includes(';') ||
1638
+ (this.spec.command.includes('cd ') && this.spec.command.includes('&&'));
1639
+
1640
+ // Intelligent detection: disable shell operators for streaming patterns
1641
+ const isStreamingPattern = this.spec.command.includes('sleep') && this.spec.command.includes(';') &&
1642
+ (this.spec.command.includes('echo') || this.spec.command.includes('printf'));
1643
+
1644
+ // Also check if we're in streaming mode (via .stream() method)
1645
+ const shouldUseShellOperators = this.options.shellOperators && hasShellOperators && !isStreamingPattern && !this._isStreaming;
1646
+
1647
+ trace('ProcessRunner', () => `Shell operator detection | ${JSON.stringify({
1648
+ hasShellOperators,
1649
+ shellOperatorsEnabled: this.options.shellOperators,
1650
+ isStreamingPattern,
1651
+ isStreaming: this._isStreaming,
1652
+ shouldUseShellOperators,
1653
+ command: this.spec.command.slice(0, 100)
1654
+ }, null, 2)}`);
1655
+
1656
+ // Only use enhanced parser when appropriate
1657
+ if (!this.options._bypassVirtual && shouldUseShellOperators && !needsRealShell(this.spec.command)) {
1658
+ const enhancedParsed = parseShellCommand(this.spec.command);
1659
+ if (enhancedParsed && enhancedParsed.type !== 'simple') {
1660
+ trace('ProcessRunner', () => `Using enhanced parser for shell operators | ${JSON.stringify({
1661
+ type: enhancedParsed.type,
1662
+ command: this.spec.command.slice(0, 50)
1663
+ }, null, 2)}`);
1664
+
1665
+ if (enhancedParsed.type === 'sequence') {
1666
+ return await this._runSequence(enhancedParsed);
1667
+ } else if (enhancedParsed.type === 'subshell') {
1668
+ return await this._runSubshell(enhancedParsed);
1669
+ } else if (enhancedParsed.type === 'pipeline') {
1670
+ return await this._runPipeline(enhancedParsed.commands);
1671
+ }
1672
+ }
1673
+ }
1674
+
1675
+ // Fallback to original simple parser
1404
1676
  const parsed = this._parseCommand(this.spec.command);
1405
1677
  trace('ProcessRunner', () => `Parsed command | ${JSON.stringify({
1406
1678
  type: parsed?.type,
@@ -1448,7 +1720,8 @@ class ProcessRunner extends StreamEmitter {
1448
1720
  }
1449
1721
  }
1450
1722
 
1451
- const argv = this.spec.mode === 'shell' ? ['sh', '-lc', this.spec.command] : [this.spec.file, ...this.spec.args];
1723
+ const shell = findAvailableShell();
1724
+ const argv = this.spec.mode === 'shell' ? [shell.cmd, ...shell.args, this.spec.command] : [this.spec.file, ...this.spec.args];
1452
1725
  trace('ProcessRunner', () => `Constructed argv | ${JSON.stringify({
1453
1726
  mode: this.spec.mode,
1454
1727
  argv: argv,
@@ -1506,6 +1779,14 @@ class ProcessRunner extends StreamEmitter {
1506
1779
  // For non-interactive commands, spawn with detached to create process group (for proper signal handling)
1507
1780
  // This allows us to send signals to the entire process group, killing shell and all its children
1508
1781
  trace('ProcessRunner', () => `spawnBun: Using non-interactive mode with pipes and detached=${process.platform !== 'win32'}`);
1782
+ trace('ProcessRunner', () => `spawnBun: About to spawn | ${JSON.stringify({
1783
+ argv,
1784
+ cwd,
1785
+ shellCmd: argv[0],
1786
+ shellArgs: argv.slice(1, -1),
1787
+ command: argv[argv.length - 1]?.slice(0, 50)
1788
+ }, null, 2)}`);
1789
+
1509
1790
  const child = Bun.spawn(argv, {
1510
1791
  cwd,
1511
1792
  env,
@@ -1856,7 +2137,16 @@ class ProcessRunner extends StreamEmitter {
1856
2137
  }
1857
2138
 
1858
2139
  async _pumpStdinTo(child, captureChunks) {
1859
- if (!child.stdin) return;
2140
+ trace('ProcessRunner', () => `_pumpStdinTo ENTER | ${JSON.stringify({
2141
+ hasChildStdin: !!child?.stdin,
2142
+ willCapture: !!captureChunks,
2143
+ isBun
2144
+ }, null, 2)}`);
2145
+
2146
+ if (!child.stdin) {
2147
+ trace('ProcessRunner', () => 'No child stdin to pump to');
2148
+ return;
2149
+ }
1860
2150
  const bunWriter = isBun && child.stdin && typeof child.stdin.getWriter === 'function' ? child.stdin.getWriter() : null;
1861
2151
  for await (const chunk of process.stdin) {
1862
2152
  const buf = asBuffer(chunk);
@@ -1874,6 +2164,11 @@ class ProcessRunner extends StreamEmitter {
1874
2164
  }
1875
2165
 
1876
2166
  async _writeToStdin(buf) {
2167
+ trace('ProcessRunner', () => `_writeToStdin ENTER | ${JSON.stringify({
2168
+ bufferLength: buf?.length || 0,
2169
+ hasChildStdin: !!this.child?.stdin
2170
+ }, null, 2)}`);
2171
+
1877
2172
  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer, buf.byteOffset ?? 0, buf.byteLength);
1878
2173
  if (await StreamUtils.writeToStream(this.child.stdin, bytes, 'stdin')) {
1879
2174
  // Successfully wrote to stream
@@ -1888,8 +2183,16 @@ class ProcessRunner extends StreamEmitter {
1888
2183
  }
1889
2184
 
1890
2185
  _parseCommand(command) {
2186
+ trace('ProcessRunner', () => `_parseCommand ENTER | ${JSON.stringify({
2187
+ commandLength: command?.length || 0,
2188
+ preview: command?.slice(0, 50)
2189
+ }, null, 2)}`);
2190
+
1891
2191
  const trimmed = command.trim();
1892
- if (!trimmed) return null;
2192
+ if (!trimmed) {
2193
+ trace('ProcessRunner', () => 'Empty command after trimming');
2194
+ return null;
2195
+ }
1893
2196
 
1894
2197
  if (trimmed.includes('|')) {
1895
2198
  return this._parsePipeline(trimmed);
@@ -1913,6 +2216,11 @@ class ProcessRunner extends StreamEmitter {
1913
2216
  }
1914
2217
 
1915
2218
  _parsePipeline(command) {
2219
+ trace('ProcessRunner', () => `_parsePipeline ENTER | ${JSON.stringify({
2220
+ commandLength: command?.length || 0,
2221
+ hasPipe: command?.includes('|')
2222
+ }, null, 2)}`);
2223
+
1916
2224
  // Split by pipe, respecting quotes
1917
2225
  const segments = [];
1918
2226
  let current = '';
@@ -2220,8 +2528,17 @@ class ProcessRunner extends StreamEmitter {
2220
2528
 
2221
2529
  return result;
2222
2530
  } catch (error) {
2531
+ // Check if this is a cancellation error
2532
+ let exitCode = error.code ?? 1;
2533
+ if (this._cancelled && this._cancellationSignal) {
2534
+ // Use appropriate exit code based on the signal
2535
+ exitCode = this._cancellationSignal === 'SIGINT' ? 130 :
2536
+ this._cancellationSignal === 'SIGTERM' ? 143 : 1;
2537
+ trace('ProcessRunner', () => `Virtual command error during cancellation, using signal-based exit code: ${exitCode}`);
2538
+ }
2539
+
2223
2540
  const result = {
2224
- code: error.code ?? 1,
2541
+ code: exitCode,
2225
2542
  stdout: error.stdout ?? '',
2226
2543
  stderr: error.stderr ?? error.message,
2227
2544
  stdin: ''
@@ -2342,8 +2659,9 @@ class ProcessRunner extends StreamEmitter {
2342
2659
  commandStr.includes('&&') || commandStr.includes('||') ||
2343
2660
  commandStr.includes(';') || commandStr.includes('`');
2344
2661
 
2662
+ const shell = findAvailableShell();
2345
2663
  const spawnArgs = needsShell
2346
- ? ['sh', '-c', commandStr]
2664
+ ? [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr]
2347
2665
  : [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
2348
2666
 
2349
2667
  const proc = Bun.spawn(spawnArgs, {
@@ -2505,8 +2823,9 @@ class ProcessRunner extends StreamEmitter {
2505
2823
  commandStr.includes('&&') || commandStr.includes('||') ||
2506
2824
  commandStr.includes(';') || commandStr.includes('`');
2507
2825
 
2826
+ const shell = findAvailableShell();
2508
2827
  const spawnArgs = needsShell
2509
- ? ['sh', '-c', commandStr]
2828
+ ? [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr]
2510
2829
  : [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
2511
2830
 
2512
2831
  const proc = Bun.spawn(spawnArgs, {
@@ -2743,7 +3062,8 @@ class ProcessRunner extends StreamEmitter {
2743
3062
  }
2744
3063
  const commandStr = commandParts.join(' ');
2745
3064
 
2746
- const proc = Bun.spawn(['sh', '-c', commandStr], {
3065
+ const shell = findAvailableShell();
3066
+ const proc = Bun.spawn([shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr], {
2747
3067
  cwd: this.options.cwd,
2748
3068
  env: this.options.env,
2749
3069
  stdin: currentInputStream ? 'pipe' : 'ignore',
@@ -3131,7 +3451,8 @@ class ProcessRunner extends StreamEmitter {
3131
3451
  };
3132
3452
 
3133
3453
  // Execute using shell to handle complex commands
3134
- const argv = ['sh', '-c', commandStr];
3454
+ const shell = findAvailableShell();
3455
+ const argv = [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr];
3135
3456
  const isLastCommand = (i === commands.length - 1);
3136
3457
  const proc = await spawnNodeAsync(argv, currentInput, isLastCommand);
3137
3458
 
@@ -3306,14 +3627,181 @@ class ProcessRunner extends StreamEmitter {
3306
3627
  }
3307
3628
  }
3308
3629
 
3630
+ async _runSequence(sequence) {
3631
+ trace('ProcessRunner', () => `_runSequence ENTER | ${JSON.stringify({
3632
+ commandCount: sequence.commands.length,
3633
+ operators: sequence.operators
3634
+ }, null, 2)}`);
3635
+
3636
+ let lastResult = { code: 0, stdout: '', stderr: '' };
3637
+ let combinedStdout = '';
3638
+ let combinedStderr = '';
3639
+
3640
+ for (let i = 0; i < sequence.commands.length; i++) {
3641
+ const command = sequence.commands[i];
3642
+ const operator = i > 0 ? sequence.operators[i - 1] : null;
3643
+
3644
+ trace('ProcessRunner', () => `Executing command ${i} | ${JSON.stringify({
3645
+ command: command.type,
3646
+ operator,
3647
+ lastCode: lastResult.code
3648
+ }, null, 2)}`);
3649
+
3650
+ // Check operator conditions
3651
+ if (operator === '&&' && lastResult.code !== 0) {
3652
+ trace('ProcessRunner', () => `Skipping due to && with exit code ${lastResult.code}`);
3653
+ continue;
3654
+ }
3655
+ if (operator === '||' && lastResult.code === 0) {
3656
+ trace('ProcessRunner', () => `Skipping due to || with exit code ${lastResult.code}`);
3657
+ continue;
3658
+ }
3659
+
3660
+ // Execute command based on type
3661
+ if (command.type === 'subshell') {
3662
+ lastResult = await this._runSubshell(command);
3663
+ } else if (command.type === 'pipeline') {
3664
+ lastResult = await this._runPipeline(command.commands);
3665
+ } else if (command.type === 'sequence') {
3666
+ lastResult = await this._runSequence(command);
3667
+ } else if (command.type === 'simple') {
3668
+ lastResult = await this._runSimpleCommand(command);
3669
+ }
3670
+
3671
+ // Accumulate output
3672
+ combinedStdout += lastResult.stdout;
3673
+ combinedStderr += lastResult.stderr;
3674
+ }
3675
+
3676
+ return {
3677
+ code: lastResult.code,
3678
+ stdout: combinedStdout,
3679
+ stderr: combinedStderr,
3680
+ async text() {
3681
+ return combinedStdout;
3682
+ }
3683
+ };
3684
+ }
3685
+
3686
+ async _runSubshell(subshell) {
3687
+ trace('ProcessRunner', () => `_runSubshell ENTER | ${JSON.stringify({
3688
+ commandType: subshell.command.type
3689
+ }, null, 2)}`);
3690
+
3691
+ // Save current directory
3692
+ const savedCwd = process.cwd();
3693
+
3694
+ try {
3695
+ // Execute subshell command
3696
+ let result;
3697
+ if (subshell.command.type === 'sequence') {
3698
+ result = await this._runSequence(subshell.command);
3699
+ } else if (subshell.command.type === 'pipeline') {
3700
+ result = await this._runPipeline(subshell.command.commands);
3701
+ } else if (subshell.command.type === 'simple') {
3702
+ result = await this._runSimpleCommand(subshell.command);
3703
+ } else {
3704
+ result = { code: 0, stdout: '', stderr: '' };
3705
+ }
3706
+
3707
+ return result;
3708
+ } finally {
3709
+ // Restore directory - check if it still exists first
3710
+ trace('ProcessRunner', () => `Restoring cwd from ${process.cwd()} to ${savedCwd}`);
3711
+ const fs = await import('fs');
3712
+ if (fs.existsSync(savedCwd)) {
3713
+ process.chdir(savedCwd);
3714
+ } else {
3715
+ // If the saved directory was deleted, try to go to a safe location
3716
+ const fallbackDir = process.env.HOME || process.env.USERPROFILE || '/';
3717
+ trace('ProcessRunner', () => `Saved directory ${savedCwd} no longer exists, falling back to ${fallbackDir}`);
3718
+ try {
3719
+ process.chdir(fallbackDir);
3720
+ } catch (e) {
3721
+ // If even fallback fails, just stay where we are
3722
+ trace('ProcessRunner', () => `Failed to restore directory: ${e.message}`);
3723
+ }
3724
+ }
3725
+ }
3726
+ }
3727
+
3728
+ async _runSimpleCommand(command) {
3729
+ trace('ProcessRunner', () => `_runSimpleCommand ENTER | ${JSON.stringify({
3730
+ cmd: command.cmd,
3731
+ argsCount: command.args?.length || 0,
3732
+ hasRedirects: !!command.redirects
3733
+ }, null, 2)}`);
3734
+
3735
+ const { cmd, args, redirects } = command;
3736
+
3737
+ // Check for virtual command
3738
+ if (virtualCommandsEnabled && virtualCommands.has(cmd)) {
3739
+ trace('ProcessRunner', () => `Using virtual command: ${cmd}`);
3740
+ const argValues = args.map(a => a.value || a);
3741
+ const result = await this._runVirtual(cmd, argValues);
3742
+
3743
+ // Handle output redirection for virtual commands
3744
+ if (redirects && redirects.length > 0) {
3745
+ for (const redirect of redirects) {
3746
+ if (redirect.type === '>' || redirect.type === '>>') {
3747
+ const fs = await import('fs');
3748
+ if (redirect.type === '>') {
3749
+ fs.writeFileSync(redirect.target, result.stdout);
3750
+ } else {
3751
+ fs.appendFileSync(redirect.target, result.stdout);
3752
+ }
3753
+ // Clear stdout since it was redirected
3754
+ result.stdout = '';
3755
+ }
3756
+ }
3757
+ }
3758
+
3759
+ return result;
3760
+ }
3761
+
3762
+ // Build command string for real execution
3763
+ let commandStr = cmd;
3764
+ for (const arg of args) {
3765
+ if (arg.quoted && arg.quoteChar) {
3766
+ commandStr += ` ${arg.quoteChar}${arg.value}${arg.quoteChar}`;
3767
+ } else if (arg.value !== undefined) {
3768
+ commandStr += ` ${arg.value}`;
3769
+ } else {
3770
+ commandStr += ` ${arg}`;
3771
+ }
3772
+ }
3773
+
3774
+ // Add redirections
3775
+ if (redirects) {
3776
+ for (const redirect of redirects) {
3777
+ commandStr += ` ${redirect.type} ${redirect.target}`;
3778
+ }
3779
+ }
3780
+
3781
+ trace('ProcessRunner', () => `Executing real command: ${commandStr}`);
3782
+
3783
+ // Create a new ProcessRunner for the real command
3784
+ // Use current working directory since cd virtual command may have changed it
3785
+ const runner = new ProcessRunner(
3786
+ { mode: 'shell', command: commandStr },
3787
+ { ...this.options, cwd: process.cwd(), _bypassVirtual: true }
3788
+ );
3789
+
3790
+ return await runner;
3791
+ }
3792
+
3309
3793
  async* stream() {
3310
3794
  trace('ProcessRunner', () => `stream ENTER | ${JSON.stringify({
3311
3795
  started: this.started,
3312
- finished: this.finished
3796
+ finished: this.finished,
3797
+ command: this.spec?.command?.slice(0, 100)
3313
3798
  }, null, 2)}`);
3314
3799
 
3800
+ // Mark that we're in streaming mode to bypass shell operator interception
3801
+ this._isStreaming = true;
3802
+
3315
3803
  if (!this.started) {
3316
- trace('ProcessRunner', () => 'Auto-starting async process from stream()');
3804
+ trace('ProcessRunner', () => 'Auto-starting async process from stream() with streaming mode');
3317
3805
  this._startAsync(); // Start but don't await
3318
3806
  }
3319
3807
 
@@ -3637,6 +4125,12 @@ class ProcessRunner extends StreamEmitter {
3637
4125
 
3638
4126
  // Promise interface (for await)
3639
4127
  then(onFulfilled, onRejected) {
4128
+ trace('ProcessRunner', () => `then() called | ${JSON.stringify({
4129
+ hasPromise: !!this.promise,
4130
+ started: this.started,
4131
+ finished: this.finished
4132
+ }, null, 2)}`);
4133
+
3640
4134
  if (!this.promise) {
3641
4135
  this.promise = this._startAsync();
3642
4136
  }
@@ -3644,6 +4138,12 @@ class ProcessRunner extends StreamEmitter {
3644
4138
  }
3645
4139
 
3646
4140
  catch(onRejected) {
4141
+ trace('ProcessRunner', () => `catch() called | ${JSON.stringify({
4142
+ hasPromise: !!this.promise,
4143
+ started: this.started,
4144
+ finished: this.finished
4145
+ }, null, 2)}`);
4146
+
3647
4147
  if (!this.promise) {
3648
4148
  this.promise = this._startAsync();
3649
4149
  }
@@ -3651,6 +4151,12 @@ class ProcessRunner extends StreamEmitter {
3651
4151
  }
3652
4152
 
3653
4153
  finally(onFinally) {
4154
+ trace('ProcessRunner', () => `finally() called | ${JSON.stringify({
4155
+ hasPromise: !!this.promise,
4156
+ started: this.started,
4157
+ finished: this.finished
4158
+ }, null, 2)}`);
4159
+
3654
4160
  if (!this.promise) {
3655
4161
  this.promise = this._startAsync();
3656
4162
  }
@@ -3687,7 +4193,8 @@ class ProcessRunner extends StreamEmitter {
3687
4193
  trace('ProcessRunner', () => `Starting sync execution | ${JSON.stringify({ mode: this._mode }, null, 2)}`);
3688
4194
 
3689
4195
  const { cwd, env, stdin } = this.options;
3690
- const argv = this.spec.mode === 'shell' ? ['sh', '-lc', this.spec.command] : [this.spec.file, ...this.spec.args];
4196
+ const shell = findAvailableShell();
4197
+ const argv = this.spec.mode === 'shell' ? [shell.cmd, ...shell.args, this.spec.command] : [this.spec.file, ...this.spec.args];
3691
4198
 
3692
4199
  if (globalShellSettings.xtrace) {
3693
4200
  const traceCmd = this.spec.mode === 'shell' ? this.spec.command : argv.join(' ');
@@ -3879,10 +4386,12 @@ function create(defaultOptions = {}) {
3879
4386
  }
3880
4387
 
3881
4388
  function raw(value) {
4389
+ trace('API', () => `raw() called with value: ${String(value).slice(0, 50)}`);
3882
4390
  return { raw: String(value) };
3883
4391
  }
3884
4392
 
3885
4393
  function set(option) {
4394
+ trace('API', () => `set() called with option: ${option}`);
3886
4395
  const mapping = {
3887
4396
  'e': 'errexit', // set -e: exit on error
3888
4397
  'errexit': 'errexit',
@@ -3906,6 +4415,7 @@ function set(option) {
3906
4415
  }
3907
4416
 
3908
4417
  function unset(option) {
4418
+ trace('API', () => `unset() called with option: ${option}`);
3909
4419
  const mapping = {
3910
4420
  'e': 'errexit',
3911
4421
  'errexit': 'errexit',
@@ -3958,15 +4468,19 @@ function unregister(name) {
3958
4468
  }
3959
4469
 
3960
4470
  function listCommands() {
3961
- return Array.from(virtualCommands.keys());
4471
+ const commands = Array.from(virtualCommands.keys());
4472
+ trace('VirtualCommands', () => `listCommands() returning ${commands.length} commands`);
4473
+ return commands;
3962
4474
  }
3963
4475
 
3964
4476
  function enableVirtualCommands() {
4477
+ trace('VirtualCommands', () => 'Enabling virtual commands');
3965
4478
  virtualCommandsEnabled = true;
3966
4479
  return virtualCommandsEnabled;
3967
4480
  }
3968
4481
 
3969
4482
  function disableVirtualCommands() {
4483
+ trace('VirtualCommands', () => 'Disabling virtual commands');
3970
4484
  virtualCommandsEnabled = false;
3971
4485
  return virtualCommandsEnabled;
3972
4486
  }
@@ -3996,6 +4510,7 @@ import testCommand from './commands/$.test.mjs';
3996
4510
 
3997
4511
  // Built-in commands that match Bun.$ functionality
3998
4512
  function registerBuiltins() {
4513
+ trace('VirtualCommands', () => 'registerBuiltins() called - registering all built-in commands');
3999
4514
  // Register all imported commands
4000
4515
  register('cd', cdCommand);
4001
4516
  register('pwd', pwdCommand);
@@ -4030,12 +4545,14 @@ const AnsiUtils = {
4030
4545
 
4031
4546
  stripControlChars(text) {
4032
4547
  if (typeof text !== 'string') return text;
4033
- return text.replace(/[\x00-\x1F\x7F]/g, '');
4548
+ // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09)
4549
+ return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
4034
4550
  },
4035
4551
 
4036
4552
  stripAll(text) {
4037
4553
  if (typeof text !== 'string') return text;
4038
- return text.replace(/[\x00-\x1F\x7F]|\x1b\[[0-9;]*[mGKHFJ]/g, '');
4554
+ // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09)
4555
+ return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\x1b\[[0-9;]*[mGKHFJ]/g, '');
4039
4556
  },
4040
4557
 
4041
4558
  cleanForProcessing(data) {
@@ -4052,15 +4569,23 @@ let globalAnsiConfig = {
4052
4569
  };
4053
4570
 
4054
4571
  function configureAnsi(options = {}) {
4572
+ trace('AnsiUtils', () => `configureAnsi() called | ${JSON.stringify({ options }, null, 2)}`);
4055
4573
  globalAnsiConfig = { ...globalAnsiConfig, ...options };
4574
+ trace('AnsiUtils', () => `New ANSI config | ${JSON.stringify({ globalAnsiConfig }, null, 2)}`);
4056
4575
  return globalAnsiConfig;
4057
4576
  }
4058
4577
 
4059
4578
  function getAnsiConfig() {
4579
+ trace('AnsiUtils', () => `getAnsiConfig() returning | ${JSON.stringify({ globalAnsiConfig }, null, 2)}`);
4060
4580
  return { ...globalAnsiConfig };
4061
4581
  }
4062
4582
 
4063
4583
  function processOutput(data, options = {}) {
4584
+ trace('AnsiUtils', () => `processOutput() called | ${JSON.stringify({
4585
+ dataType: typeof data,
4586
+ dataLength: Buffer.isBuffer(data) ? data.length : data?.length,
4587
+ options
4588
+ }, null, 2)}`);
4064
4589
  const config = { ...globalAnsiConfig, ...options };
4065
4590
  if (!config.preserveAnsi && !config.preserveControlChars) {
4066
4591
  return AnsiUtils.cleanForProcessing(data);
@@ -4077,7 +4602,9 @@ function processOutput(data, options = {}) {
4077
4602
  }
4078
4603
 
4079
4604
  // Initialize built-in commands
4605
+ trace('Initialization', () => 'Registering built-in virtual commands');
4080
4606
  registerBuiltins();
4607
+ trace('Initialization', () => `Built-in commands registered: ${listCommands().join(', ')}`);
4081
4608
 
4082
4609
  export {
4083
4610
  $tagged as $,
@@ -4090,6 +4617,7 @@ export {
4090
4617
  ProcessRunner,
4091
4618
  shell,
4092
4619
  set,
4620
+ resetGlobalState,
4093
4621
  unset,
4094
4622
  register,
4095
4623
  unregister,
@@ -8,9 +8,10 @@ export default async function cd({ args }) {
8
8
  process.chdir(target);
9
9
  const newDir = process.cwd();
10
10
  trace('VirtualCommand', () => `cd: success | ${JSON.stringify({ newDir }, null, 2)}`);
11
- return VirtualUtils.success(newDir);
11
+ // cd command should not output anything on success, just like real cd
12
+ return VirtualUtils.success('');
12
13
  } catch (error) {
13
14
  trace('VirtualCommand', () => `cd: failed | ${JSON.stringify({ error: error.message }, null, 2)}`);
14
- return { stderr: `cd: ${error.message}`, code: 1 };
15
+ return { stderr: `cd: ${error.message}\n`, code: 1 };
15
16
  }
16
17
  }
@@ -4,5 +4,5 @@ export default async function pwd({ args, stdin, cwd }) {
4
4
  // If cwd option is provided, return that instead of process.cwd()
5
5
  const dir = cwd || process.cwd();
6
6
  trace('VirtualCommand', () => `pwd: getting directory | ${JSON.stringify({ dir }, null, 2)}`);
7
- return VirtualUtils.success(dir);
7
+ return VirtualUtils.success(dir + '\n');
8
8
  }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Enhanced shell command parser that handles &&, ||, ;, and () operators
3
+ * This allows virtual commands to work properly with shell operators
4
+ */
5
+
6
+ import { trace } from './$.utils.mjs';
7
+
8
+ /**
9
+ * Token types for the parser
10
+ */
11
+ const TokenType = {
12
+ WORD: 'word',
13
+ AND: '&&',
14
+ OR: '||',
15
+ SEMICOLON: ';',
16
+ PIPE: '|',
17
+ LPAREN: '(',
18
+ RPAREN: ')',
19
+ REDIRECT_OUT: '>',
20
+ REDIRECT_APPEND: '>>',
21
+ REDIRECT_IN: '<',
22
+ EOF: 'eof'
23
+ };
24
+
25
+ /**
26
+ * Tokenize a shell command string
27
+ */
28
+ function tokenize(command) {
29
+ const tokens = [];
30
+ let i = 0;
31
+
32
+ while (i < command.length) {
33
+ // Skip whitespace
34
+ while (i < command.length && /\s/.test(command[i])) {
35
+ i++;
36
+ }
37
+
38
+ if (i >= command.length) break;
39
+
40
+ // Check for operators
41
+ if (command[i] === '&' && command[i + 1] === '&') {
42
+ tokens.push({ type: TokenType.AND, value: '&&' });
43
+ i += 2;
44
+ } else if (command[i] === '|' && command[i + 1] === '|') {
45
+ tokens.push({ type: TokenType.OR, value: '||' });
46
+ i += 2;
47
+ } else if (command[i] === '|') {
48
+ tokens.push({ type: TokenType.PIPE, value: '|' });
49
+ i++;
50
+ } else if (command[i] === ';') {
51
+ tokens.push({ type: TokenType.SEMICOLON, value: ';' });
52
+ i++;
53
+ } else if (command[i] === '(') {
54
+ tokens.push({ type: TokenType.LPAREN, value: '(' });
55
+ i++;
56
+ } else if (command[i] === ')') {
57
+ tokens.push({ type: TokenType.RPAREN, value: ')' });
58
+ i++;
59
+ } else if (command[i] === '>' && command[i + 1] === '>') {
60
+ tokens.push({ type: TokenType.REDIRECT_APPEND, value: '>>' });
61
+ i += 2;
62
+ } else if (command[i] === '>') {
63
+ tokens.push({ type: TokenType.REDIRECT_OUT, value: '>' });
64
+ i++;
65
+ } else if (command[i] === '<') {
66
+ tokens.push({ type: TokenType.REDIRECT_IN, value: '<' });
67
+ i++;
68
+ } else {
69
+ // Parse word (respecting quotes)
70
+ let word = '';
71
+ let inQuote = false;
72
+ let quoteChar = '';
73
+
74
+ while (i < command.length) {
75
+ const char = command[i];
76
+
77
+ if (!inQuote) {
78
+ if (char === '"' || char === "'") {
79
+ inQuote = true;
80
+ quoteChar = char;
81
+ word += char;
82
+ i++;
83
+ } else if (/\s/.test(char) ||
84
+ '&|;()<>'.includes(char)) {
85
+ break;
86
+ } else if (char === '\\' && i + 1 < command.length) {
87
+ // Handle escape sequences
88
+ word += char;
89
+ i++;
90
+ if (i < command.length) {
91
+ word += command[i];
92
+ i++;
93
+ }
94
+ } else {
95
+ word += char;
96
+ i++;
97
+ }
98
+ } else {
99
+ if (char === quoteChar && command[i - 1] !== '\\') {
100
+ inQuote = false;
101
+ quoteChar = '';
102
+ word += char;
103
+ i++;
104
+ } else if (char === '\\' && i + 1 < command.length &&
105
+ (command[i + 1] === quoteChar || command[i + 1] === '\\')) {
106
+ // Handle escaped quotes and backslashes inside quotes
107
+ word += char;
108
+ i++;
109
+ if (i < command.length) {
110
+ word += command[i];
111
+ i++;
112
+ }
113
+ } else {
114
+ word += char;
115
+ i++;
116
+ }
117
+ }
118
+ }
119
+
120
+ if (word) {
121
+ tokens.push({ type: TokenType.WORD, value: word });
122
+ }
123
+ }
124
+ }
125
+
126
+ tokens.push({ type: TokenType.EOF, value: '' });
127
+ return tokens;
128
+ }
129
+
130
+ /**
131
+ * Parse a sequence of commands with operators
132
+ */
133
+ class ShellParser {
134
+ constructor(command) {
135
+ this.tokens = tokenize(command);
136
+ this.pos = 0;
137
+ }
138
+
139
+ current() {
140
+ return this.tokens[this.pos] || { type: TokenType.EOF, value: '' };
141
+ }
142
+
143
+ peek() {
144
+ return this.tokens[this.pos + 1] || { type: TokenType.EOF, value: '' };
145
+ }
146
+
147
+ consume() {
148
+ const token = this.current();
149
+ this.pos++;
150
+ return token;
151
+ }
152
+
153
+ /**
154
+ * Parse the main command sequence
155
+ */
156
+ parse() {
157
+ return this.parseSequence();
158
+ }
159
+
160
+ /**
161
+ * Parse a sequence of commands connected by &&, ||, ;
162
+ */
163
+ parseSequence() {
164
+ const commands = [];
165
+ const operators = [];
166
+
167
+ // Parse first command
168
+ let cmd = this.parsePipeline();
169
+ if (cmd) {
170
+ commands.push(cmd);
171
+ }
172
+
173
+ // Parse additional commands with operators
174
+ while (this.current().type !== TokenType.EOF &&
175
+ this.current().type !== TokenType.RPAREN) {
176
+ const op = this.current();
177
+
178
+ if (op.type === TokenType.AND ||
179
+ op.type === TokenType.OR ||
180
+ op.type === TokenType.SEMICOLON) {
181
+ operators.push(op.type);
182
+ this.consume();
183
+
184
+ cmd = this.parsePipeline();
185
+ if (cmd) {
186
+ commands.push(cmd);
187
+ }
188
+ } else {
189
+ break;
190
+ }
191
+ }
192
+
193
+ if (commands.length === 1 && operators.length === 0) {
194
+ return commands[0];
195
+ }
196
+
197
+ return {
198
+ type: 'sequence',
199
+ commands,
200
+ operators
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Parse a pipeline (commands connected by |)
206
+ */
207
+ parsePipeline() {
208
+ const commands = [];
209
+
210
+ let cmd = this.parseCommand();
211
+ if (cmd) {
212
+ commands.push(cmd);
213
+ }
214
+
215
+ while (this.current().type === TokenType.PIPE) {
216
+ this.consume();
217
+ cmd = this.parseCommand();
218
+ if (cmd) {
219
+ commands.push(cmd);
220
+ }
221
+ }
222
+
223
+ if (commands.length === 1) {
224
+ return commands[0];
225
+ }
226
+
227
+ return {
228
+ type: 'pipeline',
229
+ commands
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Parse a single command or subshell
235
+ */
236
+ parseCommand() {
237
+ // Check for subshell
238
+ if (this.current().type === TokenType.LPAREN) {
239
+ this.consume(); // consume (
240
+ const subshell = this.parseSequence();
241
+
242
+ if (this.current().type === TokenType.RPAREN) {
243
+ this.consume(); // consume )
244
+ }
245
+
246
+ return {
247
+ type: 'subshell',
248
+ command: subshell
249
+ };
250
+ }
251
+
252
+ // Parse simple command
253
+ return this.parseSimpleCommand();
254
+ }
255
+
256
+ /**
257
+ * Parse a simple command (command + args + redirections)
258
+ */
259
+ parseSimpleCommand() {
260
+ const words = [];
261
+ const redirects = [];
262
+
263
+ while (this.current().type !== TokenType.EOF) {
264
+ const token = this.current();
265
+
266
+ if (token.type === TokenType.WORD) {
267
+ words.push(token.value);
268
+ this.consume();
269
+ } else if (token.type === TokenType.REDIRECT_OUT ||
270
+ token.type === TokenType.REDIRECT_APPEND ||
271
+ token.type === TokenType.REDIRECT_IN) {
272
+ this.consume();
273
+ const target = this.current();
274
+ if (target.type === TokenType.WORD) {
275
+ redirects.push({
276
+ type: token.type,
277
+ target: target.value
278
+ });
279
+ this.consume();
280
+ }
281
+ } else {
282
+ break;
283
+ }
284
+ }
285
+
286
+ if (words.length === 0) {
287
+ return null;
288
+ }
289
+
290
+ const cmd = words[0];
291
+ const args = words.slice(1).map(word => {
292
+ // Remove quotes if present
293
+ if ((word.startsWith('"') && word.endsWith('"')) ||
294
+ (word.startsWith("'") && word.endsWith("'"))) {
295
+ return {
296
+ value: word.slice(1, -1),
297
+ quoted: true,
298
+ quoteChar: word[0]
299
+ };
300
+ }
301
+ return {
302
+ value: word,
303
+ quoted: false
304
+ };
305
+ });
306
+
307
+ const result = {
308
+ type: 'simple',
309
+ cmd,
310
+ args
311
+ };
312
+
313
+ if (redirects.length > 0) {
314
+ result.redirects = redirects;
315
+ }
316
+
317
+ return result;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Parse a shell command with support for &&, ||, ;, and ()
323
+ */
324
+ export function parseShellCommand(command) {
325
+ try {
326
+ const parser = new ShellParser(command);
327
+ const result = parser.parse();
328
+
329
+ trace('ShellParser', () => `Parsed command | ${JSON.stringify({
330
+ input: command.slice(0, 100),
331
+ result
332
+ }, null, 2)}`);
333
+
334
+ return result;
335
+ } catch (error) {
336
+ trace('ShellParser', () => `Parse error | ${JSON.stringify({
337
+ command: command.slice(0, 100),
338
+ error: error.message
339
+ }, null, 2)}`);
340
+
341
+ // Return null to fallback to sh -c
342
+ return null;
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Check if a command needs shell features we don't handle
348
+ */
349
+ export function needsRealShell(command) {
350
+ // Check for features we don't handle yet
351
+ const unsupported = [
352
+ '`', // Command substitution
353
+ '$(', // Command substitution
354
+ '${', // Variable expansion
355
+ '~', // Home expansion (at start of word)
356
+ '*', // Glob patterns
357
+ '?', // Glob patterns
358
+ '[', // Glob patterns
359
+ '2>', // stderr redirection
360
+ '&>', // Combined redirection
361
+ '>&', // File descriptor duplication
362
+ '<<', // Here documents
363
+ '<<<', // Here strings
364
+ ];
365
+
366
+ for (const feature of unsupported) {
367
+ if (command.includes(feature)) {
368
+ return true;
369
+ }
370
+ }
371
+
372
+ return false;
373
+ }
374
+
375
+ export default { parseShellCommand, needsRealShell };