command-stream 0.5.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/src/$.mjs CHANGED
@@ -7,18 +7,13 @@
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
 
13
15
  const VERBOSE = process.env.COMMAND_STREAM_VERBOSE === 'true' || process.env.CI === 'true';
14
16
 
15
- // Interactive commands that need TTY forwarding by default
16
- const INTERACTIVE_COMMANDS = new Set([
17
- 'top', 'htop', 'btop', 'less', 'more', 'vi', 'vim', 'nano', 'emacs',
18
- 'man', 'pager', 'watch', 'tmux', 'screen', 'ssh', 'ftp', 'sftp',
19
- 'mysql', 'psql', 'redis-cli', 'mongo', 'sqlite3', 'irb', 'python',
20
- 'node', 'repl', 'gdb', 'lldb', 'bc', 'dc', 'ed'
21
- ]);
22
17
 
23
18
  // Trace function for verbose logging
24
19
  function trace(category, messageOrFunc) {
@@ -28,26 +23,75 @@ function trace(category, messageOrFunc) {
28
23
  console.error(`[TRACE ${timestamp}] [${category}] ${message}`);
29
24
  }
30
25
 
31
- // Check if a command is interactive and needs TTY forwarding
32
- function isInteractiveCommand(command) {
33
- if (!command || typeof command !== 'string') return false;
34
-
35
- // Extract command and arguments from shell command string
36
- const parts = command.trim().split(/\s+/);
37
- const commandName = parts[0];
38
- const baseName = path.basename(commandName);
39
-
40
- // Special handling for commands that are only interactive when run without arguments/scripts
41
- if (baseName === 'node' || baseName === 'python' || baseName === 'python3') {
42
- // These are only interactive when run without a script file
43
- // If there are additional arguments (like a script file), they're not interactive
44
- return parts.length === 1;
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;
45
40
  }
46
-
47
- return INTERACTIVE_COMMANDS.has(baseName);
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;
48
91
  }
49
92
 
50
93
 
94
+
51
95
  // Track parent stream state for graceful shutdown
52
96
  let parentStreamsMonitored = false;
53
97
  const activeProcessRunners = new Set();
@@ -236,13 +280,127 @@ function forceCleanupAll() {
236
280
  // Clear activeProcessRunners
237
281
  activeProcessRunners.clear();
238
282
 
239
- // Reset flags
283
+ // Reset signal handler flags
240
284
  sigintHandlerInstalled = false;
241
285
  sigintHandler = null;
242
286
 
243
287
  trace('SignalHandler', () => `Force cleanup completed - removed ${commandStreamListeners.length} handlers`);
244
288
  }
245
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
+
246
404
  function monitorParentStreams() {
247
405
  if (parentStreamsMonitored) {
248
406
  trace('StreamMonitor', () => 'Parent streams already monitored, skipping');
@@ -503,6 +661,12 @@ class StreamEmitter {
503
661
  }
504
662
 
505
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
+
506
670
  if (!this.listeners.has(event)) {
507
671
  this.listeners.set(event, []);
508
672
  }
@@ -514,6 +678,7 @@ class StreamEmitter {
514
678
  }
515
679
 
516
680
  once(event, listener) {
681
+ trace('StreamEmitter', () => `once() called for event: ${event}`);
517
682
  const onceWrapper = (...args) => {
518
683
  this.off(event, onceWrapper);
519
684
  listener(...args);
@@ -539,11 +704,18 @@ class StreamEmitter {
539
704
  }
540
705
 
541
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
+
542
713
  const eventListeners = this.listeners.get(event);
543
714
  if (eventListeners) {
544
715
  const index = eventListeners.indexOf(listener);
545
716
  if (index !== -1) {
546
717
  eventListeners.splice(index, 1);
718
+ trace('StreamEmitter', () => `Removed listener at index ${index}`);
547
719
  }
548
720
  }
549
721
  return this;
@@ -555,6 +727,36 @@ function quote(value) {
555
727
  if (Array.isArray(value)) return value.map(quote).join(' ');
556
728
  if (typeof value !== 'string') value = String(value);
557
729
  if (value === '') return "''";
730
+
731
+ // If the value is already properly quoted and doesn't need further escaping,
732
+ // check if we can use it as-is or with simpler quoting
733
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
734
+ // If it's already single-quoted and doesn't contain unescaped single quotes in the middle,
735
+ // we can potentially use it as-is
736
+ const inner = value.slice(1, -1);
737
+ if (!inner.includes("'")) {
738
+ // The inner content has no single quotes, so the original quoting is fine
739
+ return value;
740
+ }
741
+ }
742
+
743
+ if (value.startsWith('"') && value.endsWith('"') && value.length > 2) {
744
+ // If it's already double-quoted, wrap it in single quotes to preserve it
745
+ return `'${value}'`;
746
+ }
747
+
748
+ // Check if the string needs quoting at all
749
+ // Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus
750
+ // This regex matches strings that DON'T need quoting
751
+ const safePattern = /^[a-zA-Z0-9_\-./=,+@:]+$/;
752
+
753
+ if (safePattern.test(value)) {
754
+ // The string is safe and doesn't need quoting
755
+ return value;
756
+ }
757
+
758
+ // Default behavior: wrap in single quotes and escape any internal single quotes
759
+ // This handles spaces, special shell characters, etc.
558
760
  return `'${value.replace(/'/g, "'\\''")}'`;
559
761
  }
560
762
 
@@ -585,16 +787,28 @@ function buildShellCommand(strings, values) {
585
787
  }
586
788
 
587
789
  function asBuffer(chunk) {
588
- if (Buffer.isBuffer(chunk)) return chunk;
589
- 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');
590
799
  return Buffer.from(chunk);
591
800
  }
592
801
 
593
802
  async function pumpReadable(readable, onChunk) {
594
- 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');
595
808
  for await (const chunk of readable) {
596
809
  await onChunk(asBuffer(chunk));
597
810
  }
811
+ trace('Utils', () => 'pumpReadable: Finished pumping readable stream');
598
812
  }
599
813
 
600
814
  // Enhanced process runner with streaming capabilities
@@ -614,6 +828,8 @@ class ProcessRunner extends StreamEmitter {
614
828
  stdin: 'inherit',
615
829
  cwd: undefined,
616
830
  env: undefined,
831
+ interactive: false, // Explicitly request TTY forwarding for interactive commands
832
+ shellOperators: true, // Enable shell operator parsing by default
617
833
  ...options
618
834
  };
619
835
 
@@ -654,14 +870,26 @@ class ProcessRunner extends StreamEmitter {
654
870
 
655
871
  // Stream property getters for child process streams (null for virtual commands)
656
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)}`);
657
877
  return this.child ? this.child.stdout : null;
658
878
  }
659
879
 
660
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)}`);
661
885
  return this.child ? this.child.stderr : null;
662
886
  }
663
887
 
664
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)}`);
665
893
  return this.child ? this.child.stdin : null;
666
894
  }
667
895
 
@@ -989,7 +1217,13 @@ class ProcessRunner extends StreamEmitter {
989
1217
  }
990
1218
 
991
1219
  async _forwardTTYStdin() {
1220
+ trace('ProcessRunner', () => `_forwardTTYStdin ENTER | ${JSON.stringify({
1221
+ isTTY: process.stdin.isTTY,
1222
+ hasChildStdin: !!this.child?.stdin
1223
+ }, null, 2)}`);
1224
+
992
1225
  if (!process.stdin.isTTY || !this.child.stdin) {
1226
+ trace('ProcessRunner', () => 'TTY forwarding skipped - no TTY or no child stdin');
993
1227
  return;
994
1228
  }
995
1229
 
@@ -1042,6 +1276,7 @@ class ProcessRunner extends StreamEmitter {
1042
1276
  };
1043
1277
 
1044
1278
  const cleanup = () => {
1279
+ trace('ProcessRunner', () => 'TTY stdin cleanup - restoring terminal mode');
1045
1280
  process.stdin.removeListener('data', onData);
1046
1281
  if (process.stdin.setRawMode) {
1047
1282
  process.stdin.setRawMode(false);
@@ -1395,6 +1630,49 @@ class ProcessRunner extends StreamEmitter {
1395
1630
  if (this.spec.mode === 'shell') {
1396
1631
  trace('ProcessRunner', () => `BRANCH: spec.mode => shell | ${JSON.stringify({}, null, 2)}`);
1397
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
1398
1676
  const parsed = this._parseCommand(this.spec.command);
1399
1677
  trace('ProcessRunner', () => `Parsed command | ${JSON.stringify({
1400
1678
  type: parsed?.type,
@@ -1442,7 +1720,8 @@ class ProcessRunner extends StreamEmitter {
1442
1720
  }
1443
1721
  }
1444
1722
 
1445
- 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];
1446
1725
  trace('ProcessRunner', () => `Constructed argv | ${JSON.stringify({
1447
1726
  mode: this.spec.mode,
1448
1727
  argv: argv,
@@ -1462,12 +1741,12 @@ class ProcessRunner extends StreamEmitter {
1462
1741
  }
1463
1742
 
1464
1743
  // Detect if this is an interactive command that needs direct TTY access
1465
- // Only activate for interactive commands when we have a real TTY and the command is likely to need it
1744
+ // Only activate for interactive commands when we have a real TTY and interactive mode is explicitly requested
1466
1745
  const isInteractive = stdin === 'inherit' &&
1467
1746
  process.stdin.isTTY === true &&
1468
1747
  process.stdout.isTTY === true &&
1469
1748
  process.stderr.isTTY === true &&
1470
- (this.spec.mode === 'shell' ? isInteractiveCommand(this.spec.command) : isInteractiveCommand(this.spec.file));
1749
+ this.options.interactive === true;
1471
1750
 
1472
1751
  trace('ProcessRunner', () => `Interactive command detection | ${JSON.stringify({
1473
1752
  isInteractive,
@@ -1475,7 +1754,7 @@ class ProcessRunner extends StreamEmitter {
1475
1754
  stdinTTY: process.stdin.isTTY,
1476
1755
  stdoutTTY: process.stdout.isTTY,
1477
1756
  stderrTTY: process.stderr.isTTY,
1478
- commandCheck: this.spec.mode === 'shell' ? isInteractiveCommand(this.spec.command) : isInteractiveCommand(this.spec.file)
1757
+ interactiveOption: this.options.interactive
1479
1758
  }, null, 2)}`);
1480
1759
 
1481
1760
  const spawnBun = (argv) => {
@@ -1500,6 +1779,14 @@ class ProcessRunner extends StreamEmitter {
1500
1779
  // For non-interactive commands, spawn with detached to create process group (for proper signal handling)
1501
1780
  // This allows us to send signals to the entire process group, killing shell and all its children
1502
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
+
1503
1790
  const child = Bun.spawn(argv, {
1504
1791
  cwd,
1505
1792
  env,
@@ -1850,7 +2137,16 @@ class ProcessRunner extends StreamEmitter {
1850
2137
  }
1851
2138
 
1852
2139
  async _pumpStdinTo(child, captureChunks) {
1853
- 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
+ }
1854
2150
  const bunWriter = isBun && child.stdin && typeof child.stdin.getWriter === 'function' ? child.stdin.getWriter() : null;
1855
2151
  for await (const chunk of process.stdin) {
1856
2152
  const buf = asBuffer(chunk);
@@ -1868,6 +2164,11 @@ class ProcessRunner extends StreamEmitter {
1868
2164
  }
1869
2165
 
1870
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
+
1871
2172
  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer, buf.byteOffset ?? 0, buf.byteLength);
1872
2173
  if (await StreamUtils.writeToStream(this.child.stdin, bytes, 'stdin')) {
1873
2174
  // Successfully wrote to stream
@@ -1882,8 +2183,16 @@ class ProcessRunner extends StreamEmitter {
1882
2183
  }
1883
2184
 
1884
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
+
1885
2191
  const trimmed = command.trim();
1886
- if (!trimmed) return null;
2192
+ if (!trimmed) {
2193
+ trace('ProcessRunner', () => 'Empty command after trimming');
2194
+ return null;
2195
+ }
1887
2196
 
1888
2197
  if (trimmed.includes('|')) {
1889
2198
  return this._parsePipeline(trimmed);
@@ -1907,6 +2216,11 @@ class ProcessRunner extends StreamEmitter {
1907
2216
  }
1908
2217
 
1909
2218
  _parsePipeline(command) {
2219
+ trace('ProcessRunner', () => `_parsePipeline ENTER | ${JSON.stringify({
2220
+ commandLength: command?.length || 0,
2221
+ hasPipe: command?.includes('|')
2222
+ }, null, 2)}`);
2223
+
1910
2224
  // Split by pipe, respecting quotes
1911
2225
  const segments = [];
1912
2226
  let current = '';
@@ -2214,8 +2528,17 @@ class ProcessRunner extends StreamEmitter {
2214
2528
 
2215
2529
  return result;
2216
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
+
2217
2540
  const result = {
2218
- code: error.code ?? 1,
2541
+ code: exitCode,
2219
2542
  stdout: error.stdout ?? '',
2220
2543
  stderr: error.stderr ?? error.message,
2221
2544
  stdin: ''
@@ -2336,8 +2659,9 @@ class ProcessRunner extends StreamEmitter {
2336
2659
  commandStr.includes('&&') || commandStr.includes('||') ||
2337
2660
  commandStr.includes(';') || commandStr.includes('`');
2338
2661
 
2662
+ const shell = findAvailableShell();
2339
2663
  const spawnArgs = needsShell
2340
- ? ['sh', '-c', commandStr]
2664
+ ? [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr]
2341
2665
  : [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
2342
2666
 
2343
2667
  const proc = Bun.spawn(spawnArgs, {
@@ -2499,8 +2823,9 @@ class ProcessRunner extends StreamEmitter {
2499
2823
  commandStr.includes('&&') || commandStr.includes('||') ||
2500
2824
  commandStr.includes(';') || commandStr.includes('`');
2501
2825
 
2826
+ const shell = findAvailableShell();
2502
2827
  const spawnArgs = needsShell
2503
- ? ['sh', '-c', commandStr]
2828
+ ? [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr]
2504
2829
  : [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
2505
2830
 
2506
2831
  const proc = Bun.spawn(spawnArgs, {
@@ -2737,7 +3062,8 @@ class ProcessRunner extends StreamEmitter {
2737
3062
  }
2738
3063
  const commandStr = commandParts.join(' ');
2739
3064
 
2740
- 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], {
2741
3067
  cwd: this.options.cwd,
2742
3068
  env: this.options.env,
2743
3069
  stdin: currentInputStream ? 'pipe' : 'ignore',
@@ -3125,7 +3451,8 @@ class ProcessRunner extends StreamEmitter {
3125
3451
  };
3126
3452
 
3127
3453
  // Execute using shell to handle complex commands
3128
- const argv = ['sh', '-c', commandStr];
3454
+ const shell = findAvailableShell();
3455
+ const argv = [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr];
3129
3456
  const isLastCommand = (i === commands.length - 1);
3130
3457
  const proc = await spawnNodeAsync(argv, currentInput, isLastCommand);
3131
3458
 
@@ -3300,14 +3627,181 @@ class ProcessRunner extends StreamEmitter {
3300
3627
  }
3301
3628
  }
3302
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
+
3303
3793
  async* stream() {
3304
3794
  trace('ProcessRunner', () => `stream ENTER | ${JSON.stringify({
3305
3795
  started: this.started,
3306
- finished: this.finished
3796
+ finished: this.finished,
3797
+ command: this.spec?.command?.slice(0, 100)
3307
3798
  }, null, 2)}`);
3308
3799
 
3800
+ // Mark that we're in streaming mode to bypass shell operator interception
3801
+ this._isStreaming = true;
3802
+
3309
3803
  if (!this.started) {
3310
- trace('ProcessRunner', () => 'Auto-starting async process from stream()');
3804
+ trace('ProcessRunner', () => 'Auto-starting async process from stream() with streaming mode');
3311
3805
  this._startAsync(); // Start but don't await
3312
3806
  }
3313
3807
 
@@ -3631,6 +4125,12 @@ class ProcessRunner extends StreamEmitter {
3631
4125
 
3632
4126
  // Promise interface (for await)
3633
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
+
3634
4134
  if (!this.promise) {
3635
4135
  this.promise = this._startAsync();
3636
4136
  }
@@ -3638,6 +4138,12 @@ class ProcessRunner extends StreamEmitter {
3638
4138
  }
3639
4139
 
3640
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
+
3641
4147
  if (!this.promise) {
3642
4148
  this.promise = this._startAsync();
3643
4149
  }
@@ -3645,6 +4151,12 @@ class ProcessRunner extends StreamEmitter {
3645
4151
  }
3646
4152
 
3647
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
+
3648
4160
  if (!this.promise) {
3649
4161
  this.promise = this._startAsync();
3650
4162
  }
@@ -3681,7 +4193,8 @@ class ProcessRunner extends StreamEmitter {
3681
4193
  trace('ProcessRunner', () => `Starting sync execution | ${JSON.stringify({ mode: this._mode }, null, 2)}`);
3682
4194
 
3683
4195
  const { cwd, env, stdin } = this.options;
3684
- 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];
3685
4198
 
3686
4199
  if (globalShellSettings.xtrace) {
3687
4200
  const traceCmd = this.spec.mode === 'shell' ? this.spec.command : argv.join(' ');
@@ -3873,10 +4386,12 @@ function create(defaultOptions = {}) {
3873
4386
  }
3874
4387
 
3875
4388
  function raw(value) {
4389
+ trace('API', () => `raw() called with value: ${String(value).slice(0, 50)}`);
3876
4390
  return { raw: String(value) };
3877
4391
  }
3878
4392
 
3879
4393
  function set(option) {
4394
+ trace('API', () => `set() called with option: ${option}`);
3880
4395
  const mapping = {
3881
4396
  'e': 'errexit', // set -e: exit on error
3882
4397
  'errexit': 'errexit',
@@ -3900,6 +4415,7 @@ function set(option) {
3900
4415
  }
3901
4416
 
3902
4417
  function unset(option) {
4418
+ trace('API', () => `unset() called with option: ${option}`);
3903
4419
  const mapping = {
3904
4420
  'e': 'errexit',
3905
4421
  'errexit': 'errexit',
@@ -3952,15 +4468,19 @@ function unregister(name) {
3952
4468
  }
3953
4469
 
3954
4470
  function listCommands() {
3955
- return Array.from(virtualCommands.keys());
4471
+ const commands = Array.from(virtualCommands.keys());
4472
+ trace('VirtualCommands', () => `listCommands() returning ${commands.length} commands`);
4473
+ return commands;
3956
4474
  }
3957
4475
 
3958
4476
  function enableVirtualCommands() {
4477
+ trace('VirtualCommands', () => 'Enabling virtual commands');
3959
4478
  virtualCommandsEnabled = true;
3960
4479
  return virtualCommandsEnabled;
3961
4480
  }
3962
4481
 
3963
4482
  function disableVirtualCommands() {
4483
+ trace('VirtualCommands', () => 'Disabling virtual commands');
3964
4484
  virtualCommandsEnabled = false;
3965
4485
  return virtualCommandsEnabled;
3966
4486
  }
@@ -3990,6 +4510,7 @@ import testCommand from './commands/$.test.mjs';
3990
4510
 
3991
4511
  // Built-in commands that match Bun.$ functionality
3992
4512
  function registerBuiltins() {
4513
+ trace('VirtualCommands', () => 'registerBuiltins() called - registering all built-in commands');
3993
4514
  // Register all imported commands
3994
4515
  register('cd', cdCommand);
3995
4516
  register('pwd', pwdCommand);
@@ -4024,12 +4545,14 @@ const AnsiUtils = {
4024
4545
 
4025
4546
  stripControlChars(text) {
4026
4547
  if (typeof text !== 'string') return text;
4027
- 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, '');
4028
4550
  },
4029
4551
 
4030
4552
  stripAll(text) {
4031
4553
  if (typeof text !== 'string') return text;
4032
- 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, '');
4033
4556
  },
4034
4557
 
4035
4558
  cleanForProcessing(data) {
@@ -4046,15 +4569,23 @@ let globalAnsiConfig = {
4046
4569
  };
4047
4570
 
4048
4571
  function configureAnsi(options = {}) {
4572
+ trace('AnsiUtils', () => `configureAnsi() called | ${JSON.stringify({ options }, null, 2)}`);
4049
4573
  globalAnsiConfig = { ...globalAnsiConfig, ...options };
4574
+ trace('AnsiUtils', () => `New ANSI config | ${JSON.stringify({ globalAnsiConfig }, null, 2)}`);
4050
4575
  return globalAnsiConfig;
4051
4576
  }
4052
4577
 
4053
4578
  function getAnsiConfig() {
4579
+ trace('AnsiUtils', () => `getAnsiConfig() returning | ${JSON.stringify({ globalAnsiConfig }, null, 2)}`);
4054
4580
  return { ...globalAnsiConfig };
4055
4581
  }
4056
4582
 
4057
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)}`);
4058
4589
  const config = { ...globalAnsiConfig, ...options };
4059
4590
  if (!config.preserveAnsi && !config.preserveControlChars) {
4060
4591
  return AnsiUtils.cleanForProcessing(data);
@@ -4071,7 +4602,9 @@ function processOutput(data, options = {}) {
4071
4602
  }
4072
4603
 
4073
4604
  // Initialize built-in commands
4605
+ trace('Initialization', () => 'Registering built-in virtual commands');
4074
4606
  registerBuiltins();
4607
+ trace('Initialization', () => `Built-in commands registered: ${listCommands().join(', ')}`);
4075
4608
 
4076
4609
  export {
4077
4610
  $tagged as $,
@@ -4084,6 +4617,7 @@ export {
4084
4617
  ProcessRunner,
4085
4618
  shell,
4086
4619
  set,
4620
+ resetGlobalState,
4087
4621
  unset,
4088
4622
  register,
4089
4623
  unregister,