command-stream 0.6.0 → 0.7.1

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.1",
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;
@@ -569,6 +766,20 @@ function buildShellCommand(strings, values) {
569
766
  valuesLength: values.length
570
767
  }, null, 2)}`);
571
768
 
769
+ // Special case: if we have a single value with empty surrounding strings,
770
+ // and the value looks like a complete shell command, treat it as raw
771
+ if (values.length === 1 && strings.length === 2 &&
772
+ strings[0] === '' && strings[1] === '' &&
773
+ typeof values[0] === 'string') {
774
+ const commandStr = values[0];
775
+ // Check if this looks like a complete shell command (contains spaces and shell-safe characters)
776
+ const commandPattern = /^[a-zA-Z0-9_\-./=,+@:\s"'`$(){}<>|&;*?[\]~\\]+$/;
777
+ if (commandPattern.test(commandStr) && commandStr.trim().length > 0) {
778
+ trace('Utils', () => `BRANCH: buildShellCommand => COMPLETE_COMMAND | ${JSON.stringify({ command: commandStr }, null, 2)}`);
779
+ return commandStr;
780
+ }
781
+ }
782
+
572
783
  let out = '';
573
784
  for (let i = 0; i < strings.length; i++) {
574
785
  out += strings[i];
@@ -590,16 +801,28 @@ function buildShellCommand(strings, values) {
590
801
  }
591
802
 
592
803
  function asBuffer(chunk) {
593
- if (Buffer.isBuffer(chunk)) return chunk;
594
- if (typeof chunk === 'string') return Buffer.from(chunk);
804
+ if (Buffer.isBuffer(chunk)) {
805
+ trace('Utils', () => `asBuffer: Already a buffer, length: ${chunk.length}`);
806
+ return chunk;
807
+ }
808
+ if (typeof chunk === 'string') {
809
+ trace('Utils', () => `asBuffer: Converting string to buffer, length: ${chunk.length}`);
810
+ return Buffer.from(chunk);
811
+ }
812
+ trace('Utils', () => 'asBuffer: Converting unknown type to buffer');
595
813
  return Buffer.from(chunk);
596
814
  }
597
815
 
598
816
  async function pumpReadable(readable, onChunk) {
599
- if (!readable) return;
817
+ if (!readable) {
818
+ trace('Utils', () => 'pumpReadable: No readable stream provided');
819
+ return;
820
+ }
821
+ trace('Utils', () => 'pumpReadable: Starting to pump readable stream');
600
822
  for await (const chunk of readable) {
601
823
  await onChunk(asBuffer(chunk));
602
824
  }
825
+ trace('Utils', () => 'pumpReadable: Finished pumping readable stream');
603
826
  }
604
827
 
605
828
  // Enhanced process runner with streaming capabilities
@@ -620,6 +843,7 @@ class ProcessRunner extends StreamEmitter {
620
843
  cwd: undefined,
621
844
  env: undefined,
622
845
  interactive: false, // Explicitly request TTY forwarding for interactive commands
846
+ shellOperators: true, // Enable shell operator parsing by default
623
847
  ...options
624
848
  };
625
849
 
@@ -660,14 +884,26 @@ class ProcessRunner extends StreamEmitter {
660
884
 
661
885
  // Stream property getters for child process streams (null for virtual commands)
662
886
  get stdout() {
887
+ trace('ProcessRunner', () => `stdout getter accessed | ${JSON.stringify({
888
+ hasChild: !!this.child,
889
+ hasStdout: !!(this.child && this.child.stdout)
890
+ }, null, 2)}`);
663
891
  return this.child ? this.child.stdout : null;
664
892
  }
665
893
 
666
894
  get stderr() {
895
+ trace('ProcessRunner', () => `stderr getter accessed | ${JSON.stringify({
896
+ hasChild: !!this.child,
897
+ hasStderr: !!(this.child && this.child.stderr)
898
+ }, null, 2)}`);
667
899
  return this.child ? this.child.stderr : null;
668
900
  }
669
901
 
670
902
  get stdin() {
903
+ trace('ProcessRunner', () => `stdin getter accessed | ${JSON.stringify({
904
+ hasChild: !!this.child,
905
+ hasStdin: !!(this.child && this.child.stdin)
906
+ }, null, 2)}`);
671
907
  return this.child ? this.child.stdin : null;
672
908
  }
673
909
 
@@ -995,7 +1231,13 @@ class ProcessRunner extends StreamEmitter {
995
1231
  }
996
1232
 
997
1233
  async _forwardTTYStdin() {
1234
+ trace('ProcessRunner', () => `_forwardTTYStdin ENTER | ${JSON.stringify({
1235
+ isTTY: process.stdin.isTTY,
1236
+ hasChildStdin: !!this.child?.stdin
1237
+ }, null, 2)}`);
1238
+
998
1239
  if (!process.stdin.isTTY || !this.child.stdin) {
1240
+ trace('ProcessRunner', () => 'TTY forwarding skipped - no TTY or no child stdin');
999
1241
  return;
1000
1242
  }
1001
1243
 
@@ -1048,6 +1290,7 @@ class ProcessRunner extends StreamEmitter {
1048
1290
  };
1049
1291
 
1050
1292
  const cleanup = () => {
1293
+ trace('ProcessRunner', () => 'TTY stdin cleanup - restoring terminal mode');
1051
1294
  process.stdin.removeListener('data', onData);
1052
1295
  if (process.stdin.setRawMode) {
1053
1296
  process.stdin.setRawMode(false);
@@ -1401,6 +1644,49 @@ class ProcessRunner extends StreamEmitter {
1401
1644
  if (this.spec.mode === 'shell') {
1402
1645
  trace('ProcessRunner', () => `BRANCH: spec.mode => shell | ${JSON.stringify({}, null, 2)}`);
1403
1646
 
1647
+ // Check if shell operator parsing is enabled and command contains operators
1648
+ const hasShellOperators = this.spec.command.includes('&&') ||
1649
+ this.spec.command.includes('||') ||
1650
+ this.spec.command.includes('(') ||
1651
+ this.spec.command.includes(';') ||
1652
+ (this.spec.command.includes('cd ') && this.spec.command.includes('&&'));
1653
+
1654
+ // Intelligent detection: disable shell operators for streaming patterns
1655
+ const isStreamingPattern = this.spec.command.includes('sleep') && this.spec.command.includes(';') &&
1656
+ (this.spec.command.includes('echo') || this.spec.command.includes('printf'));
1657
+
1658
+ // Also check if we're in streaming mode (via .stream() method)
1659
+ const shouldUseShellOperators = this.options.shellOperators && hasShellOperators && !isStreamingPattern && !this._isStreaming;
1660
+
1661
+ trace('ProcessRunner', () => `Shell operator detection | ${JSON.stringify({
1662
+ hasShellOperators,
1663
+ shellOperatorsEnabled: this.options.shellOperators,
1664
+ isStreamingPattern,
1665
+ isStreaming: this._isStreaming,
1666
+ shouldUseShellOperators,
1667
+ command: this.spec.command.slice(0, 100)
1668
+ }, null, 2)}`);
1669
+
1670
+ // Only use enhanced parser when appropriate
1671
+ if (!this.options._bypassVirtual && shouldUseShellOperators && !needsRealShell(this.spec.command)) {
1672
+ const enhancedParsed = parseShellCommand(this.spec.command);
1673
+ if (enhancedParsed && enhancedParsed.type !== 'simple') {
1674
+ trace('ProcessRunner', () => `Using enhanced parser for shell operators | ${JSON.stringify({
1675
+ type: enhancedParsed.type,
1676
+ command: this.spec.command.slice(0, 50)
1677
+ }, null, 2)}`);
1678
+
1679
+ if (enhancedParsed.type === 'sequence') {
1680
+ return await this._runSequence(enhancedParsed);
1681
+ } else if (enhancedParsed.type === 'subshell') {
1682
+ return await this._runSubshell(enhancedParsed);
1683
+ } else if (enhancedParsed.type === 'pipeline') {
1684
+ return await this._runPipeline(enhancedParsed.commands);
1685
+ }
1686
+ }
1687
+ }
1688
+
1689
+ // Fallback to original simple parser
1404
1690
  const parsed = this._parseCommand(this.spec.command);
1405
1691
  trace('ProcessRunner', () => `Parsed command | ${JSON.stringify({
1406
1692
  type: parsed?.type,
@@ -1448,7 +1734,8 @@ class ProcessRunner extends StreamEmitter {
1448
1734
  }
1449
1735
  }
1450
1736
 
1451
- const argv = this.spec.mode === 'shell' ? ['sh', '-lc', this.spec.command] : [this.spec.file, ...this.spec.args];
1737
+ const shell = findAvailableShell();
1738
+ const argv = this.spec.mode === 'shell' ? [shell.cmd, ...shell.args, this.spec.command] : [this.spec.file, ...this.spec.args];
1452
1739
  trace('ProcessRunner', () => `Constructed argv | ${JSON.stringify({
1453
1740
  mode: this.spec.mode,
1454
1741
  argv: argv,
@@ -1506,6 +1793,14 @@ class ProcessRunner extends StreamEmitter {
1506
1793
  // For non-interactive commands, spawn with detached to create process group (for proper signal handling)
1507
1794
  // This allows us to send signals to the entire process group, killing shell and all its children
1508
1795
  trace('ProcessRunner', () => `spawnBun: Using non-interactive mode with pipes and detached=${process.platform !== 'win32'}`);
1796
+ trace('ProcessRunner', () => `spawnBun: About to spawn | ${JSON.stringify({
1797
+ argv,
1798
+ cwd,
1799
+ shellCmd: argv[0],
1800
+ shellArgs: argv.slice(1, -1),
1801
+ command: argv[argv.length - 1]?.slice(0, 50)
1802
+ }, null, 2)}`);
1803
+
1509
1804
  const child = Bun.spawn(argv, {
1510
1805
  cwd,
1511
1806
  env,
@@ -1856,7 +2151,16 @@ class ProcessRunner extends StreamEmitter {
1856
2151
  }
1857
2152
 
1858
2153
  async _pumpStdinTo(child, captureChunks) {
1859
- if (!child.stdin) return;
2154
+ trace('ProcessRunner', () => `_pumpStdinTo ENTER | ${JSON.stringify({
2155
+ hasChildStdin: !!child?.stdin,
2156
+ willCapture: !!captureChunks,
2157
+ isBun
2158
+ }, null, 2)}`);
2159
+
2160
+ if (!child.stdin) {
2161
+ trace('ProcessRunner', () => 'No child stdin to pump to');
2162
+ return;
2163
+ }
1860
2164
  const bunWriter = isBun && child.stdin && typeof child.stdin.getWriter === 'function' ? child.stdin.getWriter() : null;
1861
2165
  for await (const chunk of process.stdin) {
1862
2166
  const buf = asBuffer(chunk);
@@ -1874,6 +2178,11 @@ class ProcessRunner extends StreamEmitter {
1874
2178
  }
1875
2179
 
1876
2180
  async _writeToStdin(buf) {
2181
+ trace('ProcessRunner', () => `_writeToStdin ENTER | ${JSON.stringify({
2182
+ bufferLength: buf?.length || 0,
2183
+ hasChildStdin: !!this.child?.stdin
2184
+ }, null, 2)}`);
2185
+
1877
2186
  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer, buf.byteOffset ?? 0, buf.byteLength);
1878
2187
  if (await StreamUtils.writeToStream(this.child.stdin, bytes, 'stdin')) {
1879
2188
  // Successfully wrote to stream
@@ -1888,8 +2197,16 @@ class ProcessRunner extends StreamEmitter {
1888
2197
  }
1889
2198
 
1890
2199
  _parseCommand(command) {
2200
+ trace('ProcessRunner', () => `_parseCommand ENTER | ${JSON.stringify({
2201
+ commandLength: command?.length || 0,
2202
+ preview: command?.slice(0, 50)
2203
+ }, null, 2)}`);
2204
+
1891
2205
  const trimmed = command.trim();
1892
- if (!trimmed) return null;
2206
+ if (!trimmed) {
2207
+ trace('ProcessRunner', () => 'Empty command after trimming');
2208
+ return null;
2209
+ }
1893
2210
 
1894
2211
  if (trimmed.includes('|')) {
1895
2212
  return this._parsePipeline(trimmed);
@@ -1913,6 +2230,11 @@ class ProcessRunner extends StreamEmitter {
1913
2230
  }
1914
2231
 
1915
2232
  _parsePipeline(command) {
2233
+ trace('ProcessRunner', () => `_parsePipeline ENTER | ${JSON.stringify({
2234
+ commandLength: command?.length || 0,
2235
+ hasPipe: command?.includes('|')
2236
+ }, null, 2)}`);
2237
+
1916
2238
  // Split by pipe, respecting quotes
1917
2239
  const segments = [];
1918
2240
  let current = '';
@@ -2220,8 +2542,17 @@ class ProcessRunner extends StreamEmitter {
2220
2542
 
2221
2543
  return result;
2222
2544
  } catch (error) {
2545
+ // Check if this is a cancellation error
2546
+ let exitCode = error.code ?? 1;
2547
+ if (this._cancelled && this._cancellationSignal) {
2548
+ // Use appropriate exit code based on the signal
2549
+ exitCode = this._cancellationSignal === 'SIGINT' ? 130 :
2550
+ this._cancellationSignal === 'SIGTERM' ? 143 : 1;
2551
+ trace('ProcessRunner', () => `Virtual command error during cancellation, using signal-based exit code: ${exitCode}`);
2552
+ }
2553
+
2223
2554
  const result = {
2224
- code: error.code ?? 1,
2555
+ code: exitCode,
2225
2556
  stdout: error.stdout ?? '',
2226
2557
  stderr: error.stderr ?? error.message,
2227
2558
  stdin: ''
@@ -2342,8 +2673,9 @@ class ProcessRunner extends StreamEmitter {
2342
2673
  commandStr.includes('&&') || commandStr.includes('||') ||
2343
2674
  commandStr.includes(';') || commandStr.includes('`');
2344
2675
 
2676
+ const shell = findAvailableShell();
2345
2677
  const spawnArgs = needsShell
2346
- ? ['sh', '-c', commandStr]
2678
+ ? [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr]
2347
2679
  : [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
2348
2680
 
2349
2681
  const proc = Bun.spawn(spawnArgs, {
@@ -2505,8 +2837,9 @@ class ProcessRunner extends StreamEmitter {
2505
2837
  commandStr.includes('&&') || commandStr.includes('||') ||
2506
2838
  commandStr.includes(';') || commandStr.includes('`');
2507
2839
 
2840
+ const shell = findAvailableShell();
2508
2841
  const spawnArgs = needsShell
2509
- ? ['sh', '-c', commandStr]
2842
+ ? [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr]
2510
2843
  : [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
2511
2844
 
2512
2845
  const proc = Bun.spawn(spawnArgs, {
@@ -2743,7 +3076,8 @@ class ProcessRunner extends StreamEmitter {
2743
3076
  }
2744
3077
  const commandStr = commandParts.join(' ');
2745
3078
 
2746
- const proc = Bun.spawn(['sh', '-c', commandStr], {
3079
+ const shell = findAvailableShell();
3080
+ const proc = Bun.spawn([shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr], {
2747
3081
  cwd: this.options.cwd,
2748
3082
  env: this.options.env,
2749
3083
  stdin: currentInputStream ? 'pipe' : 'ignore',
@@ -3131,7 +3465,8 @@ class ProcessRunner extends StreamEmitter {
3131
3465
  };
3132
3466
 
3133
3467
  // Execute using shell to handle complex commands
3134
- const argv = ['sh', '-c', commandStr];
3468
+ const shell = findAvailableShell();
3469
+ const argv = [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr];
3135
3470
  const isLastCommand = (i === commands.length - 1);
3136
3471
  const proc = await spawnNodeAsync(argv, currentInput, isLastCommand);
3137
3472
 
@@ -3306,14 +3641,181 @@ class ProcessRunner extends StreamEmitter {
3306
3641
  }
3307
3642
  }
3308
3643
 
3644
+ async _runSequence(sequence) {
3645
+ trace('ProcessRunner', () => `_runSequence ENTER | ${JSON.stringify({
3646
+ commandCount: sequence.commands.length,
3647
+ operators: sequence.operators
3648
+ }, null, 2)}`);
3649
+
3650
+ let lastResult = { code: 0, stdout: '', stderr: '' };
3651
+ let combinedStdout = '';
3652
+ let combinedStderr = '';
3653
+
3654
+ for (let i = 0; i < sequence.commands.length; i++) {
3655
+ const command = sequence.commands[i];
3656
+ const operator = i > 0 ? sequence.operators[i - 1] : null;
3657
+
3658
+ trace('ProcessRunner', () => `Executing command ${i} | ${JSON.stringify({
3659
+ command: command.type,
3660
+ operator,
3661
+ lastCode: lastResult.code
3662
+ }, null, 2)}`);
3663
+
3664
+ // Check operator conditions
3665
+ if (operator === '&&' && lastResult.code !== 0) {
3666
+ trace('ProcessRunner', () => `Skipping due to && with exit code ${lastResult.code}`);
3667
+ continue;
3668
+ }
3669
+ if (operator === '||' && lastResult.code === 0) {
3670
+ trace('ProcessRunner', () => `Skipping due to || with exit code ${lastResult.code}`);
3671
+ continue;
3672
+ }
3673
+
3674
+ // Execute command based on type
3675
+ if (command.type === 'subshell') {
3676
+ lastResult = await this._runSubshell(command);
3677
+ } else if (command.type === 'pipeline') {
3678
+ lastResult = await this._runPipeline(command.commands);
3679
+ } else if (command.type === 'sequence') {
3680
+ lastResult = await this._runSequence(command);
3681
+ } else if (command.type === 'simple') {
3682
+ lastResult = await this._runSimpleCommand(command);
3683
+ }
3684
+
3685
+ // Accumulate output
3686
+ combinedStdout += lastResult.stdout;
3687
+ combinedStderr += lastResult.stderr;
3688
+ }
3689
+
3690
+ return {
3691
+ code: lastResult.code,
3692
+ stdout: combinedStdout,
3693
+ stderr: combinedStderr,
3694
+ async text() {
3695
+ return combinedStdout;
3696
+ }
3697
+ };
3698
+ }
3699
+
3700
+ async _runSubshell(subshell) {
3701
+ trace('ProcessRunner', () => `_runSubshell ENTER | ${JSON.stringify({
3702
+ commandType: subshell.command.type
3703
+ }, null, 2)}`);
3704
+
3705
+ // Save current directory
3706
+ const savedCwd = process.cwd();
3707
+
3708
+ try {
3709
+ // Execute subshell command
3710
+ let result;
3711
+ if (subshell.command.type === 'sequence') {
3712
+ result = await this._runSequence(subshell.command);
3713
+ } else if (subshell.command.type === 'pipeline') {
3714
+ result = await this._runPipeline(subshell.command.commands);
3715
+ } else if (subshell.command.type === 'simple') {
3716
+ result = await this._runSimpleCommand(subshell.command);
3717
+ } else {
3718
+ result = { code: 0, stdout: '', stderr: '' };
3719
+ }
3720
+
3721
+ return result;
3722
+ } finally {
3723
+ // Restore directory - check if it still exists first
3724
+ trace('ProcessRunner', () => `Restoring cwd from ${process.cwd()} to ${savedCwd}`);
3725
+ const fs = await import('fs');
3726
+ if (fs.existsSync(savedCwd)) {
3727
+ process.chdir(savedCwd);
3728
+ } else {
3729
+ // If the saved directory was deleted, try to go to a safe location
3730
+ const fallbackDir = process.env.HOME || process.env.USERPROFILE || '/';
3731
+ trace('ProcessRunner', () => `Saved directory ${savedCwd} no longer exists, falling back to ${fallbackDir}`);
3732
+ try {
3733
+ process.chdir(fallbackDir);
3734
+ } catch (e) {
3735
+ // If even fallback fails, just stay where we are
3736
+ trace('ProcessRunner', () => `Failed to restore directory: ${e.message}`);
3737
+ }
3738
+ }
3739
+ }
3740
+ }
3741
+
3742
+ async _runSimpleCommand(command) {
3743
+ trace('ProcessRunner', () => `_runSimpleCommand ENTER | ${JSON.stringify({
3744
+ cmd: command.cmd,
3745
+ argsCount: command.args?.length || 0,
3746
+ hasRedirects: !!command.redirects
3747
+ }, null, 2)}`);
3748
+
3749
+ const { cmd, args, redirects } = command;
3750
+
3751
+ // Check for virtual command
3752
+ if (virtualCommandsEnabled && virtualCommands.has(cmd)) {
3753
+ trace('ProcessRunner', () => `Using virtual command: ${cmd}`);
3754
+ const argValues = args.map(a => a.value || a);
3755
+ const result = await this._runVirtual(cmd, argValues);
3756
+
3757
+ // Handle output redirection for virtual commands
3758
+ if (redirects && redirects.length > 0) {
3759
+ for (const redirect of redirects) {
3760
+ if (redirect.type === '>' || redirect.type === '>>') {
3761
+ const fs = await import('fs');
3762
+ if (redirect.type === '>') {
3763
+ fs.writeFileSync(redirect.target, result.stdout);
3764
+ } else {
3765
+ fs.appendFileSync(redirect.target, result.stdout);
3766
+ }
3767
+ // Clear stdout since it was redirected
3768
+ result.stdout = '';
3769
+ }
3770
+ }
3771
+ }
3772
+
3773
+ return result;
3774
+ }
3775
+
3776
+ // Build command string for real execution
3777
+ let commandStr = cmd;
3778
+ for (const arg of args) {
3779
+ if (arg.quoted && arg.quoteChar) {
3780
+ commandStr += ` ${arg.quoteChar}${arg.value}${arg.quoteChar}`;
3781
+ } else if (arg.value !== undefined) {
3782
+ commandStr += ` ${arg.value}`;
3783
+ } else {
3784
+ commandStr += ` ${arg}`;
3785
+ }
3786
+ }
3787
+
3788
+ // Add redirections
3789
+ if (redirects) {
3790
+ for (const redirect of redirects) {
3791
+ commandStr += ` ${redirect.type} ${redirect.target}`;
3792
+ }
3793
+ }
3794
+
3795
+ trace('ProcessRunner', () => `Executing real command: ${commandStr}`);
3796
+
3797
+ // Create a new ProcessRunner for the real command
3798
+ // Use current working directory since cd virtual command may have changed it
3799
+ const runner = new ProcessRunner(
3800
+ { mode: 'shell', command: commandStr },
3801
+ { ...this.options, cwd: process.cwd(), _bypassVirtual: true }
3802
+ );
3803
+
3804
+ return await runner;
3805
+ }
3806
+
3309
3807
  async* stream() {
3310
3808
  trace('ProcessRunner', () => `stream ENTER | ${JSON.stringify({
3311
3809
  started: this.started,
3312
- finished: this.finished
3810
+ finished: this.finished,
3811
+ command: this.spec?.command?.slice(0, 100)
3313
3812
  }, null, 2)}`);
3314
3813
 
3814
+ // Mark that we're in streaming mode to bypass shell operator interception
3815
+ this._isStreaming = true;
3816
+
3315
3817
  if (!this.started) {
3316
- trace('ProcessRunner', () => 'Auto-starting async process from stream()');
3818
+ trace('ProcessRunner', () => 'Auto-starting async process from stream() with streaming mode');
3317
3819
  this._startAsync(); // Start but don't await
3318
3820
  }
3319
3821
 
@@ -3637,6 +4139,12 @@ class ProcessRunner extends StreamEmitter {
3637
4139
 
3638
4140
  // Promise interface (for await)
3639
4141
  then(onFulfilled, onRejected) {
4142
+ trace('ProcessRunner', () => `then() called | ${JSON.stringify({
4143
+ hasPromise: !!this.promise,
4144
+ started: this.started,
4145
+ finished: this.finished
4146
+ }, null, 2)}`);
4147
+
3640
4148
  if (!this.promise) {
3641
4149
  this.promise = this._startAsync();
3642
4150
  }
@@ -3644,6 +4152,12 @@ class ProcessRunner extends StreamEmitter {
3644
4152
  }
3645
4153
 
3646
4154
  catch(onRejected) {
4155
+ trace('ProcessRunner', () => `catch() called | ${JSON.stringify({
4156
+ hasPromise: !!this.promise,
4157
+ started: this.started,
4158
+ finished: this.finished
4159
+ }, null, 2)}`);
4160
+
3647
4161
  if (!this.promise) {
3648
4162
  this.promise = this._startAsync();
3649
4163
  }
@@ -3651,6 +4165,12 @@ class ProcessRunner extends StreamEmitter {
3651
4165
  }
3652
4166
 
3653
4167
  finally(onFinally) {
4168
+ trace('ProcessRunner', () => `finally() called | ${JSON.stringify({
4169
+ hasPromise: !!this.promise,
4170
+ started: this.started,
4171
+ finished: this.finished
4172
+ }, null, 2)}`);
4173
+
3654
4174
  if (!this.promise) {
3655
4175
  this.promise = this._startAsync();
3656
4176
  }
@@ -3687,7 +4207,8 @@ class ProcessRunner extends StreamEmitter {
3687
4207
  trace('ProcessRunner', () => `Starting sync execution | ${JSON.stringify({ mode: this._mode }, null, 2)}`);
3688
4208
 
3689
4209
  const { cwd, env, stdin } = this.options;
3690
- const argv = this.spec.mode === 'shell' ? ['sh', '-lc', this.spec.command] : [this.spec.file, ...this.spec.args];
4210
+ const shell = findAvailableShell();
4211
+ const argv = this.spec.mode === 'shell' ? [shell.cmd, ...shell.args, this.spec.command] : [this.spec.file, ...this.spec.args];
3691
4212
 
3692
4213
  if (globalShellSettings.xtrace) {
3693
4214
  const traceCmd = this.spec.mode === 'shell' ? this.spec.command : argv.join(' ');
@@ -3879,10 +4400,12 @@ function create(defaultOptions = {}) {
3879
4400
  }
3880
4401
 
3881
4402
  function raw(value) {
4403
+ trace('API', () => `raw() called with value: ${String(value).slice(0, 50)}`);
3882
4404
  return { raw: String(value) };
3883
4405
  }
3884
4406
 
3885
4407
  function set(option) {
4408
+ trace('API', () => `set() called with option: ${option}`);
3886
4409
  const mapping = {
3887
4410
  'e': 'errexit', // set -e: exit on error
3888
4411
  'errexit': 'errexit',
@@ -3906,6 +4429,7 @@ function set(option) {
3906
4429
  }
3907
4430
 
3908
4431
  function unset(option) {
4432
+ trace('API', () => `unset() called with option: ${option}`);
3909
4433
  const mapping = {
3910
4434
  'e': 'errexit',
3911
4435
  'errexit': 'errexit',
@@ -3958,15 +4482,19 @@ function unregister(name) {
3958
4482
  }
3959
4483
 
3960
4484
  function listCommands() {
3961
- return Array.from(virtualCommands.keys());
4485
+ const commands = Array.from(virtualCommands.keys());
4486
+ trace('VirtualCommands', () => `listCommands() returning ${commands.length} commands`);
4487
+ return commands;
3962
4488
  }
3963
4489
 
3964
4490
  function enableVirtualCommands() {
4491
+ trace('VirtualCommands', () => 'Enabling virtual commands');
3965
4492
  virtualCommandsEnabled = true;
3966
4493
  return virtualCommandsEnabled;
3967
4494
  }
3968
4495
 
3969
4496
  function disableVirtualCommands() {
4497
+ trace('VirtualCommands', () => 'Disabling virtual commands');
3970
4498
  virtualCommandsEnabled = false;
3971
4499
  return virtualCommandsEnabled;
3972
4500
  }
@@ -3996,6 +4524,7 @@ import testCommand from './commands/$.test.mjs';
3996
4524
 
3997
4525
  // Built-in commands that match Bun.$ functionality
3998
4526
  function registerBuiltins() {
4527
+ trace('VirtualCommands', () => 'registerBuiltins() called - registering all built-in commands');
3999
4528
  // Register all imported commands
4000
4529
  register('cd', cdCommand);
4001
4530
  register('pwd', pwdCommand);
@@ -4030,12 +4559,14 @@ const AnsiUtils = {
4030
4559
 
4031
4560
  stripControlChars(text) {
4032
4561
  if (typeof text !== 'string') return text;
4033
- return text.replace(/[\x00-\x1F\x7F]/g, '');
4562
+ // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09)
4563
+ return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
4034
4564
  },
4035
4565
 
4036
4566
  stripAll(text) {
4037
4567
  if (typeof text !== 'string') return text;
4038
- return text.replace(/[\x00-\x1F\x7F]|\x1b\[[0-9;]*[mGKHFJ]/g, '');
4568
+ // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09)
4569
+ return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\x1b\[[0-9;]*[mGKHFJ]/g, '');
4039
4570
  },
4040
4571
 
4041
4572
  cleanForProcessing(data) {
@@ -4052,15 +4583,23 @@ let globalAnsiConfig = {
4052
4583
  };
4053
4584
 
4054
4585
  function configureAnsi(options = {}) {
4586
+ trace('AnsiUtils', () => `configureAnsi() called | ${JSON.stringify({ options }, null, 2)}`);
4055
4587
  globalAnsiConfig = { ...globalAnsiConfig, ...options };
4588
+ trace('AnsiUtils', () => `New ANSI config | ${JSON.stringify({ globalAnsiConfig }, null, 2)}`);
4056
4589
  return globalAnsiConfig;
4057
4590
  }
4058
4591
 
4059
4592
  function getAnsiConfig() {
4593
+ trace('AnsiUtils', () => `getAnsiConfig() returning | ${JSON.stringify({ globalAnsiConfig }, null, 2)}`);
4060
4594
  return { ...globalAnsiConfig };
4061
4595
  }
4062
4596
 
4063
4597
  function processOutput(data, options = {}) {
4598
+ trace('AnsiUtils', () => `processOutput() called | ${JSON.stringify({
4599
+ dataType: typeof data,
4600
+ dataLength: Buffer.isBuffer(data) ? data.length : data?.length,
4601
+ options
4602
+ }, null, 2)}`);
4064
4603
  const config = { ...globalAnsiConfig, ...options };
4065
4604
  if (!config.preserveAnsi && !config.preserveControlChars) {
4066
4605
  return AnsiUtils.cleanForProcessing(data);
@@ -4077,7 +4616,9 @@ function processOutput(data, options = {}) {
4077
4616
  }
4078
4617
 
4079
4618
  // Initialize built-in commands
4619
+ trace('Initialization', () => 'Registering built-in virtual commands');
4080
4620
  registerBuiltins();
4621
+ trace('Initialization', () => `Built-in commands registered: ${listCommands().join(', ')}`);
4081
4622
 
4082
4623
  export {
4083
4624
  $tagged as $,
@@ -4090,6 +4631,7 @@ export {
4090
4631
  ProcessRunner,
4091
4632
  shell,
4092
4633
  set,
4634
+ resetGlobalState,
4093
4635
  unset,
4094
4636
  register,
4095
4637
  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 };