command-stream 0.0.4 → 0.1.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.
Files changed (3) hide show
  1. package/$.mjs +1300 -85
  2. package/README.md +44 -3
  3. package/package.json +1 -1
package/$.mjs CHANGED
@@ -10,6 +10,32 @@ import { fileURLToPath } from 'url';
10
10
 
11
11
  const isBun = typeof globalThis.Bun !== 'undefined';
12
12
 
13
+ // Verbose tracing for debugging (enabled in CI or when COMMAND_STREAM_VERBOSE is set)
14
+ const VERBOSE = process.env.COMMAND_STREAM_VERBOSE === 'true' || process.env.CI === 'true';
15
+
16
+ // Trace function for verbose logging
17
+ function trace(category, message, data = {}) {
18
+ if (!VERBOSE) return;
19
+
20
+ const timestamp = new Date().toISOString();
21
+ const dataStr = Object.keys(data).length > 0 ? ' | ' + JSON.stringify(data) : '';
22
+ console.error(`[TRACE ${timestamp}] [${category}] ${message}${dataStr}`);
23
+ }
24
+
25
+ // Trace decision branches
26
+ function traceBranch(category, condition, branch, data = {}) {
27
+ if (!VERBOSE) return;
28
+
29
+ trace(category, `BRANCH: ${condition} => ${branch}`, data);
30
+ }
31
+
32
+ // Trace function entry/exit
33
+ function traceFunc(category, funcName, phase, data = {}) {
34
+ if (!VERBOSE) return;
35
+
36
+ trace(category, `${funcName} ${phase}`, data);
37
+ }
38
+
13
39
  // Global shell settings (like bash set -e / set +e)
14
40
  let globalShellSettings = {
15
41
  errexit: false, // set -e equivalent: exit on error
@@ -50,6 +76,9 @@ class StreamEmitter {
50
76
  this.listeners.set(event, []);
51
77
  }
52
78
  this.listeners.get(event).push(listener);
79
+
80
+ // No auto-start - explicit start() or await will start the process
81
+
53
82
  return this;
54
83
  }
55
84
 
@@ -84,18 +113,28 @@ function quote(value) {
84
113
  }
85
114
 
86
115
  function buildShellCommand(strings, values) {
116
+ traceFunc('Utils', 'buildShellCommand', 'ENTER', {
117
+ stringsLength: strings.length,
118
+ valuesLength: values.length
119
+ });
120
+
87
121
  let out = '';
88
122
  for (let i = 0; i < strings.length; i++) {
89
123
  out += strings[i];
90
124
  if (i < values.length) {
91
125
  const v = values[i];
92
126
  if (v && typeof v === 'object' && Object.prototype.hasOwnProperty.call(v, 'raw')) {
127
+ traceBranch('Utils', 'buildShellCommand', 'RAW_VALUE', { value: String(v.raw) });
93
128
  out += String(v.raw);
94
129
  } else {
95
- out += quote(v);
130
+ const quoted = quote(v);
131
+ traceBranch('Utils', 'buildShellCommand', 'QUOTED_VALUE', { original: v, quoted });
132
+ out += quoted;
96
133
  }
97
134
  }
98
135
  }
136
+
137
+ traceFunc('Utils', 'buildShellCommand', 'EXIT', { command: out });
99
138
  return out;
100
139
  }
101
140
 
@@ -116,6 +155,12 @@ async function pumpReadable(readable, onChunk) {
116
155
  class ProcessRunner extends StreamEmitter {
117
156
  constructor(spec, options = {}) {
118
157
  super();
158
+
159
+ traceFunc('ProcessRunner', 'constructor', 'ENTER', {
160
+ spec: typeof spec === 'object' ? { ...spec, command: spec.command?.slice(0, 100) } : spec,
161
+ options
162
+ });
163
+
119
164
  this.spec = spec;
120
165
  this.options = {
121
166
  mirror: true,
@@ -139,27 +184,92 @@ class ProcessRunner extends StreamEmitter {
139
184
 
140
185
  // Promise for awaiting final result
141
186
  this.promise = null;
187
+
188
+ // Track the execution mode
189
+ this._mode = null; // 'async' or 'sync'
190
+
191
+ // Cancellation support for virtual commands
192
+ this._cancelled = false;
193
+ this._virtualGenerator = null;
194
+ this._abortController = new AbortController();
142
195
  }
143
196
 
144
- async _start() {
145
- if (this.started) return;
197
+ // Unified start method that can work in both async and sync modes
198
+ start(options = {}) {
199
+ const mode = options.mode || 'async';
200
+
201
+ traceFunc('ProcessRunner', 'start', 'ENTER', { mode, options });
202
+
203
+ if (mode === 'sync') {
204
+ traceBranch('ProcessRunner', 'mode', 'sync', {});
205
+ return this._startSync();
206
+ } else {
207
+ traceBranch('ProcessRunner', 'mode', 'async', {});
208
+ return this._startAsync();
209
+ }
210
+ }
211
+
212
+ // Shortcut for sync mode
213
+ sync() {
214
+ return this.start({ mode: 'sync' });
215
+ }
216
+
217
+ // Shortcut for async mode
218
+ async() {
219
+ return this.start({ mode: 'async' });
220
+ }
221
+
222
+ async _startAsync() {
223
+ if (this.started) return this.promise;
224
+ if (this.promise) return this.promise;
225
+
226
+ this.promise = this._doStartAsync();
227
+ return this.promise;
228
+ }
229
+
230
+ async _doStartAsync() {
231
+ traceFunc('ProcessRunner', '_doStartAsync', 'ENTER', {
232
+ mode: this.spec.mode,
233
+ command: this.spec.command?.slice(0, 100)
234
+ });
235
+
146
236
  this.started = true;
237
+ this._mode = 'async';
147
238
 
148
239
  const { cwd, env, stdin } = this.options;
149
240
 
150
241
  // Handle programmatic pipeline mode
151
242
  if (this.spec.mode === 'pipeline') {
243
+ traceBranch('ProcessRunner', 'spec.mode', 'pipeline', {
244
+ hasSource: !!this.spec.source,
245
+ hasDestination: !!this.spec.destination
246
+ });
152
247
  return await this._runProgrammaticPipeline(this.spec.source, this.spec.destination);
153
248
  }
154
249
 
155
250
  // Check if this is a virtual command first
156
251
  if (this.spec.mode === 'shell') {
252
+ traceBranch('ProcessRunner', 'spec.mode', 'shell', {});
253
+
157
254
  // Parse the command to check for virtual commands or pipelines
158
255
  const parsed = this._parseCommand(this.spec.command);
256
+ trace('ProcessRunner', 'Parsed command', {
257
+ type: parsed?.type,
258
+ cmd: parsed?.cmd,
259
+ argsCount: parsed?.args?.length
260
+ });
261
+
159
262
  if (parsed) {
160
263
  if (parsed.type === 'pipeline') {
264
+ traceBranch('ProcessRunner', 'parsed.type', 'pipeline', {
265
+ commandCount: parsed.commands?.length
266
+ });
161
267
  return await this._runPipeline(parsed.commands);
162
268
  } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd)) {
269
+ traceBranch('ProcessRunner', 'virtualCommand', parsed.cmd, {
270
+ isVirtual: true,
271
+ args: parsed.args
272
+ });
163
273
  return await this._runVirtual(parsed.cmd, parsed.args);
164
274
  }
165
275
  }
@@ -236,8 +346,16 @@ class ProcessRunner extends StreamEmitter {
236
346
  const code = await exited;
237
347
  await Promise.all([outPump, errPump, stdinPumpPromise]);
238
348
 
239
- const resultData = {
349
+ // Debug: Check the raw exit code
350
+ trace('ProcessRunner', 'Raw exit code from child', {
240
351
  code,
352
+ codeType: typeof code,
353
+ childExitCode: this.child?.exitCode,
354
+ isBun
355
+ });
356
+
357
+ const resultData = {
358
+ code: code ?? 0, // Default to 0 if exit code is null/undefined
241
359
  stdout: this.options.capture ? Buffer.concat(this.outChunks).toString('utf8') : undefined,
242
360
  stderr: this.options.capture ? Buffer.concat(this.errChunks).toString('utf8') : undefined,
243
361
  stdin: this.options.capture && this.inChunks ? Buffer.concat(this.inChunks).toString('utf8') : undefined,
@@ -311,12 +429,12 @@ class ProcessRunner extends StreamEmitter {
311
429
 
312
430
  const cmd = parts[0];
313
431
  const args = parts.slice(1).map(arg => {
314
- // Remove quotes if present
432
+ // Keep track of whether the arg was quoted
315
433
  if ((arg.startsWith('"') && arg.endsWith('"')) ||
316
434
  (arg.startsWith("'") && arg.endsWith("'"))) {
317
- return arg.slice(1, -1);
435
+ return { value: arg.slice(1, -1), quoted: true, quoteChar: arg[0] };
318
436
  }
319
- return arg;
437
+ return { value: arg, quoted: false };
320
438
  });
321
439
 
322
440
  return { cmd, args, type: 'simple' };
@@ -359,11 +477,13 @@ class ProcessRunner extends StreamEmitter {
359
477
 
360
478
  const cmd = parts[0];
361
479
  const args = parts.slice(1).map(arg => {
480
+ // Keep track of whether the arg was quoted
362
481
  if ((arg.startsWith('"') && arg.endsWith('"')) ||
363
482
  (arg.startsWith("'") && arg.endsWith("'"))) {
364
- return arg.slice(1, -1);
483
+ // Store the original with quotes for system commands
484
+ return { value: arg.slice(1, -1), quoted: true, quoteChar: arg[0] };
365
485
  }
366
- return arg;
486
+ return { value: arg, quoted: false };
367
487
  });
368
488
 
369
489
  return { cmd, args };
@@ -373,11 +493,19 @@ class ProcessRunner extends StreamEmitter {
373
493
  }
374
494
 
375
495
  async _runVirtual(cmd, args) {
496
+ traceFunc('ProcessRunner', '_runVirtual', 'ENTER', { cmd, args });
497
+
376
498
  const handler = virtualCommands.get(cmd);
377
499
  if (!handler) {
500
+ trace('ProcessRunner', 'Virtual command not found', { cmd });
378
501
  throw new Error(`Virtual command not found: ${cmd}`);
379
502
  }
380
503
 
504
+ trace('ProcessRunner', 'Found virtual command handler', {
505
+ cmd,
506
+ isGenerator: handler.constructor.name === 'AsyncGeneratorFunction'
507
+ });
508
+
381
509
  try {
382
510
  // Prepare stdin
383
511
  let stdinData = '';
@@ -387,12 +515,15 @@ class ProcessRunner extends StreamEmitter {
387
515
  stdinData = this.options.stdin.toString('utf8');
388
516
  }
389
517
 
518
+ // Extract actual values for virtual command
519
+ const argValues = args.map(arg => arg.value !== undefined ? arg.value : arg);
520
+
390
521
  // Shell tracing for virtual commands
391
522
  if (globalShellSettings.xtrace) {
392
- console.log(`+ ${cmd} ${args.join(' ')}`);
523
+ console.log(`+ ${cmd} ${argValues.join(' ')}`);
393
524
  }
394
525
  if (globalShellSettings.verbose) {
395
- console.log(`${cmd} ${args.join(' ')}`);
526
+ console.log(`${cmd} ${argValues.join(' ')}`);
396
527
  }
397
528
 
398
529
  // Execute the virtual command
@@ -400,18 +531,65 @@ class ProcessRunner extends StreamEmitter {
400
531
 
401
532
  // Check if handler is async generator (streaming)
402
533
  if (handler.constructor.name === 'AsyncGeneratorFunction') {
403
- // Handle streaming virtual command
534
+ // Handle streaming virtual command with cancellation support
404
535
  const chunks = [];
405
- for await (const chunk of handler(args, stdinData, this.options)) {
406
- const buf = Buffer.from(chunk);
407
- chunks.push(buf);
536
+
537
+ // Create options with cancellation check and abort signal
538
+ const commandOptions = {
539
+ ...this.options,
540
+ isCancelled: () => this._cancelled,
541
+ signal: this._abortController.signal
542
+ };
543
+
544
+ const generator = handler(argValues, stdinData, commandOptions);
545
+ this._virtualGenerator = generator;
546
+
547
+ // Create a promise that resolves when cancelled
548
+ const cancelPromise = new Promise(resolve => {
549
+ this._cancelResolve = resolve;
550
+ });
551
+
552
+ try {
553
+ const iterator = generator[Symbol.asyncIterator]();
554
+ let done = false;
408
555
 
409
- if (this.options.mirror) {
410
- process.stdout.write(buf);
556
+ while (!done && !this._cancelled) {
557
+ // Race between getting next value and cancellation
558
+ const result = await Promise.race([
559
+ iterator.next(),
560
+ cancelPromise.then(() => ({ done: true, cancelled: true }))
561
+ ]);
562
+
563
+ if (result.cancelled || this._cancelled) {
564
+ // Cancelled - close the generator
565
+ if (iterator.return) {
566
+ await iterator.return();
567
+ }
568
+ break;
569
+ }
570
+
571
+ done = result.done;
572
+
573
+ if (!done) {
574
+ const chunk = result.value;
575
+ const buf = Buffer.from(chunk);
576
+ chunks.push(buf);
577
+
578
+ // Only output if not cancelled
579
+ if (!this._cancelled) {
580
+ if (this.options.mirror) {
581
+ process.stdout.write(buf);
582
+ }
583
+
584
+ this.emit('stdout', buf);
585
+ this.emit('data', { type: 'stdout', data: buf });
586
+ }
587
+ }
411
588
  }
412
-
413
- this.emit('stdout', buf);
414
- this.emit('data', { type: 'stdout', data: buf });
589
+ } finally {
590
+ // Clean up
591
+ this._virtualGenerator = null;
592
+ this._cancelResolve = null;
415
593
  }
416
594
 
417
595
  result = {
@@ -422,7 +600,7 @@ class ProcessRunner extends StreamEmitter {
422
600
  };
423
601
  } else {
424
602
  // Regular async function
425
- result = await handler(args, stdinData, this.options);
603
+ result = await handler(argValues, stdinData, this.options);
426
604
 
427
605
  // Ensure result has required fields, respecting capture option
428
606
  result = {
@@ -489,26 +667,642 @@ class ProcessRunner extends StreamEmitter {
489
667
  if (this.options.mirror) {
490
668
  process.stderr.write(buf);
491
669
  }
492
- this.emit('stderr', buf);
493
- this.emit('data', { type: 'stderr', data: buf });
494
- }
495
-
496
- this.emit('end', result);
497
- this.emit('exit', result.code);
498
-
499
- if (globalShellSettings.errexit) {
500
- throw error;
670
+ this.emit('stderr', buf);
671
+ this.emit('data', { type: 'stderr', data: buf });
672
+ }
673
+
674
+ this.emit('end', result);
675
+ this.emit('exit', result.code);
676
+
677
+ if (globalShellSettings.errexit) {
678
+ throw error;
679
+ }
680
+
681
+ return result;
682
+ }
683
+ }
684
+
685
+ async _runStreamingPipelineBun(commands) {
686
+ traceFunc('ProcessRunner', '_runStreamingPipelineBun', 'ENTER', {
687
+ commandsCount: commands.length
688
+ });
689
+
690
+ // For true streaming, we need to handle virtual and real commands differently
691
+ // but make them work together seamlessly
692
+
693
+ // First, analyze the pipeline to identify virtual vs real commands
694
+ const pipelineInfo = commands.map(command => {
695
+ const { cmd, args } = command;
696
+ const isVirtual = virtualCommandsEnabled && virtualCommands.has(cmd);
697
+ return { ...command, isVirtual };
698
+ });
699
+
700
+ trace('ProcessRunner', 'Pipeline analysis', {
701
+ virtualCount: pipelineInfo.filter(p => p.isVirtual).length,
702
+ realCount: pipelineInfo.filter(p => !p.isVirtual).length
703
+ });
704
+
705
+ // If pipeline contains virtual commands, use advanced streaming
706
+ if (pipelineInfo.some(info => info.isVirtual)) {
707
+ traceBranch('ProcessRunner', '_runStreamingPipelineBun', 'MIXED_PIPELINE', {});
708
+ return this._runMixedStreamingPipeline(commands);
709
+ }
710
+
711
+ // For pipelines with commands that buffer (like jq), use tee streaming
712
+ const needsStreamingWorkaround = commands.some(c =>
713
+ c.cmd === 'jq' || c.cmd === 'grep' || c.cmd === 'sed' || c.cmd === 'cat' || c.cmd === 'awk'
714
+ );
715
+ if (needsStreamingWorkaround) {
716
+ traceBranch('ProcessRunner', '_runStreamingPipelineBun', 'TEE_STREAMING', {
717
+ bufferedCommands: commands.filter(c =>
718
+ ['jq', 'grep', 'sed', 'cat', 'awk'].includes(c.cmd)
719
+ ).map(c => c.cmd)
720
+ });
721
+ return this._runTeeStreamingPipeline(commands);
722
+ }
723
+
724
+ // All real commands - use native pipe connections
725
+ const processes = [];
726
+ let allStderr = '';
727
+
728
+ for (let i = 0; i < commands.length; i++) {
729
+ const command = commands[i];
730
+ const { cmd, args } = command;
731
+
732
+ // Build command string
733
+ const commandParts = [cmd];
734
+ for (const arg of args) {
735
+ if (arg.value !== undefined) {
736
+ if (arg.quoted) {
737
+ commandParts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`);
738
+ } else if (arg.value.includes(' ')) {
739
+ commandParts.push(`"${arg.value}"`);
740
+ } else {
741
+ commandParts.push(arg.value);
742
+ }
743
+ } else {
744
+ if (typeof arg === 'string' && arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
745
+ commandParts.push(`"${arg}"`);
746
+ } else {
747
+ commandParts.push(arg);
748
+ }
749
+ }
750
+ }
751
+ const commandStr = commandParts.join(' ');
752
+
753
+ // Determine stdin for this process
754
+ let stdin;
755
+ let needsManualStdin = false;
756
+ let stdinData;
757
+
758
+ if (i === 0) {
759
+ // First command - use provided stdin or pipe
760
+ if (this.options.stdin && typeof this.options.stdin === 'string') {
761
+ stdin = 'pipe';
762
+ needsManualStdin = true;
763
+ stdinData = Buffer.from(this.options.stdin);
764
+ } else if (this.options.stdin && Buffer.isBuffer(this.options.stdin)) {
765
+ stdin = 'pipe';
766
+ needsManualStdin = true;
767
+ stdinData = this.options.stdin;
768
+ } else {
769
+ stdin = 'ignore';
770
+ }
771
+ } else {
772
+ // Connect to previous process stdout
773
+ stdin = processes[i - 1].stdout;
774
+ }
775
+
776
+ // Spawn the process directly (not through sh) for better streaming
777
+ // Only use sh -c for complex commands that need shell features
778
+ const needsShell = commandStr.includes('*') || commandStr.includes('$') ||
779
+ commandStr.includes('>') || commandStr.includes('<') ||
780
+ commandStr.includes('&&') || commandStr.includes('||') ||
781
+ commandStr.includes(';') || commandStr.includes('`');
782
+
783
+ const spawnArgs = needsShell
784
+ ? ['sh', '-c', commandStr]
785
+ : [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
786
+
787
+ const proc = Bun.spawn(spawnArgs, {
788
+ cwd: this.options.cwd,
789
+ env: this.options.env,
790
+ stdin: stdin,
791
+ stdout: 'pipe',
792
+ stderr: 'pipe'
793
+ });
794
+
795
+ // Write stdin data if needed for first process
796
+ if (needsManualStdin && stdinData && proc.stdin) {
797
+ (async () => {
798
+ try {
799
+ // Bun's FileSink has write and end methods
800
+ await proc.stdin.write(stdinData);
801
+ await proc.stdin.end();
802
+ } catch (e) {
803
+ console.error('Error writing stdin:', e);
804
+ }
805
+ })();
806
+ }
807
+
808
+ processes.push(proc);
809
+
810
+ // Collect stderr from all processes
811
+ (async () => {
812
+ for await (const chunk of proc.stderr) {
813
+ const buf = Buffer.from(chunk);
814
+ allStderr += buf.toString();
815
+ // Only emit stderr for the last command
816
+ if (i === commands.length - 1) {
817
+ if (this.options.mirror) {
818
+ process.stderr.write(buf);
819
+ }
820
+ this.emit('stderr', buf);
821
+ this.emit('data', { type: 'stderr', data: buf });
822
+ }
823
+ }
824
+ })();
825
+ }
826
+
827
+ // Stream output from the last process
828
+ const lastProc = processes[processes.length - 1];
829
+ let finalOutput = '';
830
+
831
+ // Stream stdout from last process
832
+ for await (const chunk of lastProc.stdout) {
833
+ const buf = Buffer.from(chunk);
834
+ finalOutput += buf.toString();
835
+ if (this.options.mirror) {
836
+ process.stdout.write(buf);
837
+ }
838
+ this.emit('stdout', buf);
839
+ this.emit('data', { type: 'stdout', data: buf });
840
+ }
841
+
842
+ // Wait for all processes to complete
843
+ const exitCodes = await Promise.all(processes.map(p => p.exited));
844
+ const lastExitCode = exitCodes[exitCodes.length - 1];
845
+
846
+ // Check for pipeline failures if pipefail is set
847
+ if (globalShellSettings.pipefail) {
848
+ const failedIndex = exitCodes.findIndex(code => code !== 0);
849
+ if (failedIndex !== -1) {
850
+ const error = new Error(`Pipeline command at index ${failedIndex} failed with exit code ${exitCodes[failedIndex]}`);
851
+ error.code = exitCodes[failedIndex];
852
+ throw error;
853
+ }
854
+ }
855
+
856
+ const result = createResult({
857
+ code: lastExitCode || 0,
858
+ stdout: finalOutput,
859
+ stderr: allStderr,
860
+ stdin: this.options.stdin && typeof this.options.stdin === 'string' ? this.options.stdin :
861
+ this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
862
+ });
863
+
864
+ this.result = result;
865
+ this.finished = true;
866
+
867
+ this.emit('end', result);
868
+ this.emit('exit', result.code);
869
+
870
+ if (globalShellSettings.errexit && result.code !== 0) {
871
+ const error = new Error(`Pipeline failed with exit code ${result.code}`);
872
+ error.code = result.code;
873
+ error.stdout = result.stdout;
874
+ error.stderr = result.stderr;
875
+ error.result = result;
876
+ throw error;
877
+ }
878
+
879
+ return result;
880
+ }
881
+
882
+ async _runTeeStreamingPipeline(commands) {
883
+ traceFunc('ProcessRunner', '_runTeeStreamingPipeline', 'ENTER', {
884
+ commandsCount: commands.length
885
+ });
886
+
887
+ // Use tee() to split streams for real-time reading
888
+ // This works around jq and similar commands that buffer when piped
889
+
890
+ const processes = [];
891
+ let allStderr = '';
892
+ let currentStream = null;
893
+
894
+ for (let i = 0; i < commands.length; i++) {
895
+ const command = commands[i];
896
+ const { cmd, args } = command;
897
+
898
+ // Build command string
899
+ const commandParts = [cmd];
900
+ for (const arg of args) {
901
+ if (arg.value !== undefined) {
902
+ if (arg.quoted) {
903
+ commandParts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`);
904
+ } else if (arg.value.includes(' ')) {
905
+ commandParts.push(`"${arg.value}"`);
906
+ } else {
907
+ commandParts.push(arg.value);
908
+ }
909
+ } else {
910
+ if (typeof arg === 'string' && arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
911
+ commandParts.push(`"${arg}"`);
912
+ } else {
913
+ commandParts.push(arg);
914
+ }
915
+ }
916
+ }
917
+ const commandStr = commandParts.join(' ');
918
+
919
+ // Determine stdin for this process
920
+ let stdin;
921
+ let needsManualStdin = false;
922
+ let stdinData;
923
+
924
+ if (i === 0) {
925
+ // First command - use provided stdin or ignore
926
+ if (this.options.stdin && typeof this.options.stdin === 'string') {
927
+ stdin = 'pipe';
928
+ needsManualStdin = true;
929
+ stdinData = Buffer.from(this.options.stdin);
930
+ } else if (this.options.stdin && Buffer.isBuffer(this.options.stdin)) {
931
+ stdin = 'pipe';
932
+ needsManualStdin = true;
933
+ stdinData = this.options.stdin;
934
+ } else {
935
+ stdin = 'ignore';
936
+ }
937
+ } else {
938
+ // Use the stream from previous process
939
+ stdin = currentStream;
940
+ }
941
+
942
+ // Spawn the process directly (not through sh) for better control
943
+ const needsShell = commandStr.includes('*') || commandStr.includes('$') ||
944
+ commandStr.includes('>') || commandStr.includes('<') ||
945
+ commandStr.includes('&&') || commandStr.includes('||') ||
946
+ commandStr.includes(';') || commandStr.includes('`');
947
+
948
+ const spawnArgs = needsShell
949
+ ? ['sh', '-c', commandStr]
950
+ : [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
951
+
952
+ const proc = Bun.spawn(spawnArgs, {
953
+ cwd: this.options.cwd,
954
+ env: this.options.env,
955
+ stdin: stdin,
956
+ stdout: 'pipe',
957
+ stderr: 'pipe'
958
+ });
959
+
960
+ // Write stdin data if needed for first process
961
+ if (needsManualStdin && stdinData && proc.stdin) {
962
+ try {
963
+ await proc.stdin.write(stdinData);
964
+ await proc.stdin.end();
965
+ } catch (e) {
966
+ // Ignore stdin errors
967
+ }
968
+ }
969
+
970
+ processes.push(proc);
971
+
972
+ // For non-last processes, tee the output so we can both pipe and read
973
+ if (i < commands.length - 1) {
974
+ const [readStream, pipeStream] = proc.stdout.tee();
975
+ currentStream = pipeStream;
976
+
977
+ // Read from the tee'd stream for real-time updates
978
+ // Always read from the first process for best streaming
979
+ if (i === 0) {
980
+ (async () => {
981
+ for await (const chunk of readStream) {
982
+ // Emit from the first process for real-time updates
983
+ const buf = Buffer.from(chunk);
984
+ if (this.options.mirror) {
985
+ process.stdout.write(buf);
986
+ }
987
+ this.emit('stdout', buf);
988
+ this.emit('data', { type: 'stdout', data: buf });
989
+ }
990
+ })();
991
+ } else {
992
+ // Consume other tee'd streams to prevent blocking
993
+ (async () => {
994
+ for await (const chunk of readStream) {
995
+ // Just consume to keep flowing
996
+ }
997
+ })();
998
+ }
999
+ } else {
1000
+ currentStream = proc.stdout;
1001
+ }
1002
+
1003
+ // Collect stderr from all processes
1004
+ (async () => {
1005
+ for await (const chunk of proc.stderr) {
1006
+ const buf = Buffer.from(chunk);
1007
+ allStderr += buf.toString();
1008
+ if (i === commands.length - 1) {
1009
+ if (this.options.mirror) {
1010
+ process.stderr.write(buf);
1011
+ }
1012
+ this.emit('stderr', buf);
1013
+ this.emit('data', { type: 'stderr', data: buf });
1014
+ }
1015
+ }
1016
+ })();
1017
+ }
1018
+
1019
+ // Read final output from the last process
1020
+ const lastProc = processes[processes.length - 1];
1021
+ let finalOutput = '';
1022
+
1023
+ // If we haven't emitted stdout yet (no tee), emit from last process
1024
+ const shouldEmitFromLast = commands.length === 1;
1025
+
1026
+ for await (const chunk of lastProc.stdout) {
1027
+ const buf = Buffer.from(chunk);
1028
+ finalOutput += buf.toString();
1029
+ if (shouldEmitFromLast) {
1030
+ if (this.options.mirror) {
1031
+ process.stdout.write(buf);
1032
+ }
1033
+ this.emit('stdout', buf);
1034
+ this.emit('data', { type: 'stdout', data: buf });
1035
+ }
1036
+ }
1037
+
1038
+ // Wait for all processes to complete
1039
+ const exitCodes = await Promise.all(processes.map(p => p.exited));
1040
+ const lastExitCode = exitCodes[exitCodes.length - 1];
1041
+
1042
+ // Check for pipeline failures if pipefail is set
1043
+ if (globalShellSettings.pipefail) {
1044
+ const failedIndex = exitCodes.findIndex(code => code !== 0);
1045
+ if (failedIndex !== -1) {
1046
+ const error = new Error(`Pipeline command at index ${failedIndex} failed with exit code ${exitCodes[failedIndex]}`);
1047
+ error.code = exitCodes[failedIndex];
1048
+ throw error;
1049
+ }
1050
+ }
1051
+
1052
+ const result = createResult({
1053
+ code: lastExitCode || 0,
1054
+ stdout: finalOutput,
1055
+ stderr: allStderr,
1056
+ stdin: this.options.stdin && typeof this.options.stdin === 'string' ? this.options.stdin :
1057
+ this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1058
+ });
1059
+
1060
+ this.result = result;
1061
+ this.finished = true;
1062
+
1063
+ this.emit('end', result);
1064
+ this.emit('exit', result.code);
1065
+
1066
+ if (globalShellSettings.errexit && result.code !== 0) {
1067
+ const error = new Error(`Pipeline failed with exit code ${result.code}`);
1068
+ error.code = result.code;
1069
+ error.stdout = result.stdout;
1070
+ error.stderr = result.stderr;
1071
+ error.result = result;
1072
+ throw error;
1073
+ }
1074
+
1075
+ return result;
1076
+ }
1077
+
1078
+
1079
+ async _runMixedStreamingPipeline(commands) {
1080
+ traceFunc('ProcessRunner', '_runMixedStreamingPipeline', 'ENTER', {
1081
+ commandsCount: commands.length
1082
+ });
1083
+
1084
+ // Handle pipelines with both virtual and real commands
1085
+ // Each stage reads from previous stage's output stream
1086
+
1087
+ let currentInputStream = null;
1088
+ let finalOutput = '';
1089
+ let allStderr = '';
1090
+
1091
+ // Set up initial input stream if provided
1092
+ if (this.options.stdin) {
1093
+ const inputData = typeof this.options.stdin === 'string'
1094
+ ? this.options.stdin
1095
+ : this.options.stdin.toString('utf8');
1096
+
1097
+ // Create a readable stream from the input
1098
+ currentInputStream = new ReadableStream({
1099
+ start(controller) {
1100
+ controller.enqueue(new TextEncoder().encode(inputData));
1101
+ controller.close();
1102
+ }
1103
+ });
1104
+ }
1105
+
1106
+ for (let i = 0; i < commands.length; i++) {
1107
+ const command = commands[i];
1108
+ const { cmd, args } = command;
1109
+ const isLastCommand = i === commands.length - 1;
1110
+
1111
+ if (virtualCommandsEnabled && virtualCommands.has(cmd)) {
1112
+ // Handle virtual command with streaming
1113
+ traceBranch('ProcessRunner', '_runMixedStreamingPipeline', 'VIRTUAL_COMMAND', {
1114
+ cmd,
1115
+ commandIndex: i
1116
+ });
1117
+ const handler = virtualCommands.get(cmd);
1118
+ const argValues = args.map(arg => arg.value !== undefined ? arg.value : arg);
1119
+
1120
+ // Read input from stream if available
1121
+ let inputData = '';
1122
+ if (currentInputStream) {
1123
+ const reader = currentInputStream.getReader();
1124
+ try {
1125
+ while (true) {
1126
+ const { done, value } = await reader.read();
1127
+ if (done) break;
1128
+ inputData += new TextDecoder().decode(value);
1129
+ }
1130
+ } finally {
1131
+ reader.releaseLock();
1132
+ }
1133
+ }
1134
+
1135
+ // Check if handler is async generator (streaming)
1136
+ if (handler.constructor.name === 'AsyncGeneratorFunction') {
1137
+ // Create output stream from generator
1138
+ const chunks = [];
1139
+ const self = this; // Capture this context
1140
+ currentInputStream = new ReadableStream({
1141
+ async start(controller) {
1142
+ for await (const chunk of handler(argValues, inputData, {})) {
1143
+ const data = Buffer.from(chunk);
1144
+ controller.enqueue(data);
1145
+
1146
+ // Emit for last command
1147
+ if (isLastCommand) {
1148
+ chunks.push(data);
1149
+ if (self.options.mirror) {
1150
+ process.stdout.write(data);
1151
+ }
1152
+ self.emit('stdout', data);
1153
+ self.emit('data', { type: 'stdout', data });
1154
+ }
1155
+ }
1156
+ controller.close();
1157
+
1158
+ if (isLastCommand) {
1159
+ finalOutput = Buffer.concat(chunks).toString('utf8');
1160
+ }
1161
+ }
1162
+ });
1163
+ } else {
1164
+ // Regular async function
1165
+ const result = await handler(argValues, inputData, {});
1166
+ const outputData = result.stdout || '';
1167
+
1168
+ if (isLastCommand) {
1169
+ finalOutput = outputData;
1170
+ const buf = Buffer.from(outputData);
1171
+ if (this.options.mirror) {
1172
+ process.stdout.write(buf);
1173
+ }
1174
+ this.emit('stdout', buf);
1175
+ this.emit('data', { type: 'stdout', data: buf });
1176
+ }
1177
+
1178
+ // Create stream from output
1179
+ currentInputStream = new ReadableStream({
1180
+ start(controller) {
1181
+ controller.enqueue(new TextEncoder().encode(outputData));
1182
+ controller.close();
1183
+ }
1184
+ });
1185
+
1186
+ if (result.stderr) {
1187
+ allStderr += result.stderr;
1188
+ }
1189
+ }
1190
+ } else {
1191
+ // Handle real command - spawn with streaming
1192
+ const commandParts = [cmd];
1193
+ for (const arg of args) {
1194
+ if (arg.value !== undefined) {
1195
+ if (arg.quoted) {
1196
+ commandParts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`);
1197
+ } else if (arg.value.includes(' ')) {
1198
+ commandParts.push(`"${arg.value}"`);
1199
+ } else {
1200
+ commandParts.push(arg.value);
1201
+ }
1202
+ } else {
1203
+ if (typeof arg === 'string' && arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
1204
+ commandParts.push(`"${arg}"`);
1205
+ } else {
1206
+ commandParts.push(arg);
1207
+ }
1208
+ }
1209
+ }
1210
+ const commandStr = commandParts.join(' ');
1211
+
1212
+ // Spawn the process
1213
+ const proc = Bun.spawn(['sh', '-c', commandStr], {
1214
+ cwd: this.options.cwd,
1215
+ env: this.options.env,
1216
+ stdin: currentInputStream ? 'pipe' : 'ignore',
1217
+ stdout: 'pipe',
1218
+ stderr: 'pipe'
1219
+ });
1220
+
1221
+ // Write input stream to process stdin if needed
1222
+ if (currentInputStream && proc.stdin) {
1223
+ const reader = currentInputStream.getReader();
1224
+ const writer = proc.stdin.getWriter ? proc.stdin.getWriter() : proc.stdin;
1225
+
1226
+ (async () => {
1227
+ try {
1228
+ while (true) {
1229
+ const { done, value } = await reader.read();
1230
+ if (done) break;
1231
+ if (writer.write) {
1232
+ await writer.write(value);
1233
+ } else if (writer.getWriter) {
1234
+ const w = writer.getWriter();
1235
+ await w.write(value);
1236
+ w.releaseLock();
1237
+ }
1238
+ }
1239
+ } finally {
1240
+ reader.releaseLock();
1241
+ if (writer.close) await writer.close();
1242
+ else if (writer.end) writer.end();
1243
+ }
1244
+ })();
1245
+ }
1246
+
1247
+ // Set up output stream
1248
+ currentInputStream = proc.stdout;
1249
+
1250
+ // Handle stderr
1251
+ (async () => {
1252
+ for await (const chunk of proc.stderr) {
1253
+ const buf = Buffer.from(chunk);
1254
+ allStderr += buf.toString();
1255
+ if (isLastCommand) {
1256
+ if (this.options.mirror) {
1257
+ process.stderr.write(buf);
1258
+ }
1259
+ this.emit('stderr', buf);
1260
+ this.emit('data', { type: 'stderr', data: buf });
1261
+ }
1262
+ }
1263
+ })();
1264
+
1265
+ // For last command, stream output
1266
+ if (isLastCommand) {
1267
+ const chunks = [];
1268
+ for await (const chunk of proc.stdout) {
1269
+ const buf = Buffer.from(chunk);
1270
+ chunks.push(buf);
1271
+ if (this.options.mirror) {
1272
+ process.stdout.write(buf);
1273
+ }
1274
+ this.emit('stdout', buf);
1275
+ this.emit('data', { type: 'stdout', data: buf });
1276
+ }
1277
+ finalOutput = Buffer.concat(chunks).toString('utf8');
1278
+ await proc.exited;
1279
+ }
501
1280
  }
502
-
503
- return result;
504
1281
  }
1282
+
1283
+ const result = createResult({
1284
+ code: 0, // TODO: Track exit codes properly
1285
+ stdout: finalOutput,
1286
+ stderr: allStderr,
1287
+ stdin: this.options.stdin && typeof this.options.stdin === 'string' ? this.options.stdin :
1288
+ this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1289
+ });
1290
+
1291
+ this.result = result;
1292
+ this.finished = true;
1293
+
1294
+ this.emit('end', result);
1295
+ this.emit('exit', result.code);
1296
+
1297
+ return result;
505
1298
  }
506
1299
 
507
- async _runPipeline(commands) {
508
- if (commands.length === 0) {
509
- return createResult({ code: 1, stdout: '', stderr: 'No commands in pipeline', stdin: '' });
510
- }
511
-
1300
+ async _runPipelineNonStreaming(commands) {
1301
+ traceFunc('ProcessRunner', '_runPipelineNonStreaming', 'ENTER', {
1302
+ commandsCount: commands.length
1303
+ });
1304
+
1305
+ // Original non-streaming implementation for fallback (e.g., virtual commands)
512
1306
  let currentOutput = '';
513
1307
  let currentInput = '';
514
1308
 
@@ -524,26 +1318,35 @@ class ProcessRunner extends StreamEmitter {
524
1318
  const command = commands[i];
525
1319
  const { cmd, args } = command;
526
1320
 
527
- // Check if this is a virtual command
1321
+ // Check if this is a virtual command (only if virtual commands are enabled)
528
1322
  if (virtualCommandsEnabled && virtualCommands.has(cmd)) {
1323
+ traceBranch('ProcessRunner', '_runPipelineNonStreaming', 'VIRTUAL_COMMAND', {
1324
+ cmd,
1325
+ argsCount: args.length
1326
+ });
1327
+
529
1328
  // Run virtual command with current input
530
1329
  const handler = virtualCommands.get(cmd);
531
1330
 
532
1331
  try {
1332
+ // Extract actual values for virtual command
1333
+ const argValues = args.map(arg => arg.value !== undefined ? arg.value : arg);
1334
+
533
1335
  // Shell tracing for virtual commands
534
1336
  if (globalShellSettings.xtrace) {
535
- console.log(`+ ${cmd} ${args.join(' ')}`);
1337
+ console.log(`+ ${cmd} ${argValues.join(' ')}`);
536
1338
  }
537
1339
  if (globalShellSettings.verbose) {
538
- console.log(`${cmd} ${args.join(' ')}`);
1340
+ console.log(`${cmd} ${argValues.join(' ')}`);
539
1341
  }
540
1342
 
541
1343
  let result;
542
1344
 
543
1345
  // Check if handler is async generator (streaming)
544
1346
  if (handler.constructor.name === 'AsyncGeneratorFunction') {
1347
+ traceBranch('ProcessRunner', '_runPipelineNonStreaming', 'ASYNC_GENERATOR', { cmd });
545
1348
  const chunks = [];
546
- for await (const chunk of handler(args, currentInput, this.options)) {
1349
+ for await (const chunk of handler(argValues, currentInput, this.options)) {
547
1350
  chunks.push(Buffer.from(chunk));
548
1351
  }
549
1352
  result = {
@@ -554,7 +1357,7 @@ class ProcessRunner extends StreamEmitter {
554
1357
  };
555
1358
  } else {
556
1359
  // Regular async function
557
- result = await handler(args, currentInput, this.options);
1360
+ result = await handler(argValues, currentInput, this.options);
558
1361
  result = {
559
1362
  code: result.code ?? 0,
560
1363
  stdout: this.options.capture ? (result.stdout ?? '') : undefined,
@@ -660,54 +1463,262 @@ class ProcessRunner extends StreamEmitter {
660
1463
  return result;
661
1464
  }
662
1465
  } else {
663
- // For system commands in pipeline, we would need to spawn processes
664
- // For now, return an error indicating this isn't supported
665
- const result = createResult({
666
- code: 1,
667
- stdout: currentOutput,
668
- stderr: `Pipeline with system command '${cmd}' not yet supported`,
669
- stdin: this.options.stdin && typeof this.options.stdin === 'string' ? this.options.stdin :
670
- this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
671
- });
672
-
673
- this.result = result;
674
- this.finished = true;
675
-
676
- const buf = Buffer.from(result.stderr);
677
- if (this.options.mirror) {
678
- process.stderr.write(buf);
1466
+ // Execute system command in pipeline
1467
+ try {
1468
+ // Build command string for this part of the pipeline
1469
+ const commandParts = [cmd];
1470
+ for (const arg of args) {
1471
+ if (arg.value !== undefined) {
1472
+ // Handle our parsed arg structure
1473
+ if (arg.quoted) {
1474
+ // Preserve original quotes
1475
+ commandParts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`);
1476
+ } else if (arg.value.includes(' ')) {
1477
+ // Quote if contains spaces
1478
+ commandParts.push(`"${arg.value}"`);
1479
+ } else {
1480
+ commandParts.push(arg.value);
1481
+ }
1482
+ } else {
1483
+ // Handle plain string args (backward compatibility)
1484
+ if (typeof arg === 'string' && arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
1485
+ commandParts.push(`"${arg}"`);
1486
+ } else {
1487
+ commandParts.push(arg);
1488
+ }
1489
+ }
1490
+ }
1491
+ const commandStr = commandParts.join(' ');
1492
+
1493
+ // Shell tracing for system commands
1494
+ if (globalShellSettings.xtrace) {
1495
+ console.log(`+ ${commandStr}`);
1496
+ }
1497
+ if (globalShellSettings.verbose) {
1498
+ console.log(commandStr);
1499
+ }
1500
+
1501
+ // Execute the system command with current input as stdin (ASYNC VERSION)
1502
+ const spawnNodeAsync = async (argv, stdin, isLastCommand = false) => {
1503
+ const require = createRequire(import.meta.url);
1504
+ const cp = require('child_process');
1505
+
1506
+ return new Promise((resolve, reject) => {
1507
+ const proc = cp.spawn(argv[0], argv.slice(1), {
1508
+ cwd: this.options.cwd,
1509
+ env: this.options.env,
1510
+ stdio: ['pipe', 'pipe', 'pipe']
1511
+ });
1512
+
1513
+ let stdout = '';
1514
+ let stderr = '';
1515
+
1516
+ proc.stdout.on('data', (chunk) => {
1517
+ stdout += chunk.toString();
1518
+ // If this is the last command, emit streaming data
1519
+ if (isLastCommand) {
1520
+ if (this.options.mirror) {
1521
+ process.stdout.write(chunk);
1522
+ }
1523
+ this.emit('stdout', chunk);
1524
+ this.emit('data', { type: 'stdout', data: chunk });
1525
+ }
1526
+ });
1527
+
1528
+ proc.stderr.on('data', (chunk) => {
1529
+ stderr += chunk.toString();
1530
+ // If this is the last command, emit streaming data
1531
+ if (isLastCommand) {
1532
+ if (this.options.mirror) {
1533
+ process.stderr.write(chunk);
1534
+ }
1535
+ this.emit('stderr', chunk);
1536
+ this.emit('data', { type: 'stderr', data: chunk });
1537
+ }
1538
+ });
1539
+
1540
+ proc.on('close', (code) => {
1541
+ resolve({
1542
+ status: code,
1543
+ stdout,
1544
+ stderr
1545
+ });
1546
+ });
1547
+
1548
+ proc.on('error', reject);
1549
+
1550
+ if (stdin) {
1551
+ proc.stdin.write(stdin);
1552
+ }
1553
+ proc.stdin.end();
1554
+ });
1555
+ };
1556
+
1557
+ // Execute using shell to handle complex commands
1558
+ const argv = ['sh', '-c', commandStr];
1559
+ const isLastCommand = (i === commands.length - 1);
1560
+ const proc = await spawnNodeAsync(argv, currentInput, isLastCommand);
1561
+
1562
+ let result = {
1563
+ code: proc.status || 0,
1564
+ stdout: proc.stdout || '',
1565
+ stderr: proc.stderr || '',
1566
+ stdin: currentInput
1567
+ };
1568
+
1569
+ // If command failed and pipefail is set, fail the entire pipeline
1570
+ if (globalShellSettings.pipefail && result.code !== 0) {
1571
+ const error = new Error(`Pipeline command '${commandStr}' failed with exit code ${result.code}`);
1572
+ error.code = result.code;
1573
+ error.stdout = result.stdout;
1574
+ error.stderr = result.stderr;
1575
+ throw error;
1576
+ }
1577
+
1578
+ // If this isn't the last command, pass stdout as stdin to next command
1579
+ if (i < commands.length - 1) {
1580
+ currentInput = result.stdout;
1581
+ // Accumulate stderr from all commands
1582
+ if (result.stderr && this.options.capture) {
1583
+ this.errChunks = this.errChunks || [];
1584
+ this.errChunks.push(Buffer.from(result.stderr));
1585
+ }
1586
+ } else {
1587
+ // This is the last command - store final result (streaming already handled during execution)
1588
+ currentOutput = result.stdout;
1589
+
1590
+ // Collect all accumulated stderr
1591
+ let allStderr = '';
1592
+ if (this.errChunks && this.errChunks.length > 0) {
1593
+ allStderr = Buffer.concat(this.errChunks).toString('utf8');
1594
+ }
1595
+ if (result.stderr) {
1596
+ allStderr += result.stderr;
1597
+ }
1598
+
1599
+ // Store final result using createResult helper for .text() method compatibility
1600
+ const finalResult = createResult({
1601
+ code: result.code,
1602
+ stdout: currentOutput,
1603
+ stderr: allStderr,
1604
+ stdin: this.options.stdin && typeof this.options.stdin === 'string' ? this.options.stdin :
1605
+ this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1606
+ });
1607
+
1608
+ this.result = finalResult;
1609
+ this.finished = true;
1610
+
1611
+ // Emit completion events
1612
+ this.emit('end', finalResult);
1613
+ this.emit('exit', finalResult.code);
1614
+
1615
+ // Handle shell settings
1616
+ if (globalShellSettings.errexit && finalResult.code !== 0) {
1617
+ const error = new Error(`Pipeline failed with exit code ${finalResult.code}`);
1618
+ error.code = finalResult.code;
1619
+ error.stdout = finalResult.stdout;
1620
+ error.stderr = finalResult.stderr;
1621
+ error.result = finalResult;
1622
+ throw error;
1623
+ }
1624
+
1625
+ return finalResult;
1626
+ }
1627
+
1628
+ } catch (error) {
1629
+ // Handle errors from system commands in pipeline
1630
+ const result = createResult({
1631
+ code: error.code ?? 1,
1632
+ stdout: currentOutput,
1633
+ stderr: error.stderr ?? error.message,
1634
+ stdin: this.options.stdin && typeof this.options.stdin === 'string' ? this.options.stdin :
1635
+ this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1636
+ });
1637
+
1638
+ this.result = result;
1639
+ this.finished = true;
1640
+
1641
+ if (result.stderr) {
1642
+ const buf = Buffer.from(result.stderr);
1643
+ if (this.options.mirror) {
1644
+ process.stderr.write(buf);
1645
+ }
1646
+ this.emit('stderr', buf);
1647
+ this.emit('data', { type: 'stderr', data: buf });
1648
+ }
1649
+
1650
+ this.emit('end', result);
1651
+ this.emit('exit', result.code);
1652
+
1653
+ if (globalShellSettings.errexit) {
1654
+ throw error;
1655
+ }
1656
+
1657
+ return result;
679
1658
  }
680
- this.emit('stderr', buf);
681
- this.emit('data', { type: 'stderr', data: buf });
682
-
683
- this.emit('end', result);
684
- this.emit('exit', result.code);
685
-
686
- return result;
687
1659
  }
688
1660
  }
689
1661
  }
690
1662
 
1663
+ async _runPipeline(commands) {
1664
+ traceFunc('ProcessRunner', '_runPipeline', 'ENTER', {
1665
+ commandsCount: commands.length
1666
+ });
1667
+
1668
+ if (commands.length === 0) {
1669
+ traceBranch('ProcessRunner', '_runPipeline', 'NO_COMMANDS', {});
1670
+ return createResult({ code: 1, stdout: '', stderr: 'No commands in pipeline', stdin: '' });
1671
+ }
1672
+
1673
+
1674
+ // For true streaming, we need to connect processes via pipes
1675
+ if (isBun) {
1676
+ traceBranch('ProcessRunner', '_runPipeline', 'BUN_STREAMING', {});
1677
+ return this._runStreamingPipelineBun(commands);
1678
+ }
1679
+
1680
+ // For Node.js, fall back to non-streaming implementation for now
1681
+ traceBranch('ProcessRunner', '_runPipeline', 'NODE_NON_STREAMING', {});
1682
+ return this._runPipelineNonStreaming(commands);
1683
+ }
1684
+
691
1685
  // Run programmatic pipeline (.pipe() method)
692
1686
  async _runProgrammaticPipeline(source, destination) {
1687
+ traceFunc('ProcessRunner', '_runProgrammaticPipeline', 'ENTER', {});
1688
+
693
1689
  try {
694
1690
  // Execute the source command first
1691
+ trace('ProcessRunner', 'Executing source command', {});
695
1692
  const sourceResult = await source;
696
1693
 
697
1694
  if (sourceResult.code !== 0) {
698
1695
  // If source failed, return its result
1696
+ traceBranch('ProcessRunner', '_runProgrammaticPipeline', 'SOURCE_FAILED', {
1697
+ code: sourceResult.code,
1698
+ stderr: sourceResult.stderr
1699
+ });
699
1700
  return sourceResult;
700
1701
  }
701
1702
 
702
- // Set the destination's stdin to the source's stdout
703
- destination.options = {
1703
+ // Create a new ProcessRunner with the correct stdin for the destination
1704
+ const destWithStdin = new ProcessRunner(destination.spec, {
704
1705
  ...destination.options,
705
1706
  stdin: sourceResult.stdout
706
- };
1707
+ });
707
1708
 
708
1709
  // Execute the destination command
709
- const destResult = await destination;
1710
+ const destResult = await destWithStdin;
710
1711
 
1712
+ // Debug: Log what destResult looks like
1713
+ trace('ProcessRunner', 'destResult debug', {
1714
+ code: destResult.code,
1715
+ codeType: typeof destResult.code,
1716
+ hasCode: 'code' in destResult,
1717
+ keys: Object.keys(destResult),
1718
+ resultType: typeof destResult,
1719
+ fullResult: JSON.stringify(destResult, null, 2).slice(0, 200)
1720
+ });
1721
+
711
1722
  // Return the final result with combined information
712
1723
  return createResult({
713
1724
  code: destResult.code,
@@ -744,13 +1755,20 @@ class ProcessRunner extends StreamEmitter {
744
1755
 
745
1756
  // Async iteration support
746
1757
  async* stream() {
1758
+ traceFunc('ProcessRunner', 'stream', 'ENTER', {
1759
+ started: this.started,
1760
+ finished: this.finished
1761
+ });
1762
+
747
1763
  if (!this.started) {
748
- this._start(); // Start but don't await
1764
+ trace('ProcessRunner', 'Auto-starting async process from stream()', {});
1765
+ this._startAsync(); // Start but don't await
749
1766
  }
750
1767
 
751
1768
  let buffer = [];
752
1769
  let resolve, reject;
753
1770
  let ended = false;
1771
+ let cleanedUp = false;
754
1772
 
755
1773
  const onData = (chunk) => {
756
1774
  buffer.push(chunk);
@@ -783,15 +1801,94 @@ class ProcessRunner extends StreamEmitter {
783
1801
  }
784
1802
  }
785
1803
  } finally {
1804
+ cleanedUp = true;
786
1805
  this.off('data', onData);
787
1806
  this.off('end', onEnd);
1807
+
1808
+ // Kill the process if it's still running when iteration is stopped
1809
+ // This happens when breaking from a for-await loop
1810
+ if (!this.finished) {
1811
+ this.kill();
1812
+ }
1813
+ }
1814
+ }
1815
+
1816
+ // Kill the running process or cancel virtual command
1817
+ kill() {
1818
+ traceFunc('ProcessRunner', 'kill', 'ENTER', {
1819
+ cancelled: this._cancelled,
1820
+ finished: this.finished,
1821
+ hasChild: !!this.child,
1822
+ hasVirtualGenerator: !!this._virtualGenerator
1823
+ });
1824
+
1825
+ // Mark as cancelled for virtual commands
1826
+ this._cancelled = true;
1827
+
1828
+ // Resolve the cancel promise to break the race in virtual command execution
1829
+ if (this._cancelResolve) {
1830
+ trace('ProcessRunner', 'Resolving cancel promise', {});
1831
+ this._cancelResolve();
1832
+ }
1833
+
1834
+ // Abort any async operations
1835
+ if (this._abortController) {
1836
+ trace('ProcessRunner', 'Aborting controller', {});
1837
+ this._abortController.abort();
1838
+ }
1839
+
1840
+ // If it's a virtual generator, try to close it
1841
+ if (this._virtualGenerator && this._virtualGenerator.return) {
1842
+ trace('ProcessRunner', 'Closing virtual generator', {});
1843
+ try {
1844
+ this._virtualGenerator.return();
1845
+ } catch (err) {
1846
+ trace('ProcessRunner', 'Error closing generator', { error: err.message });
1847
+ }
1848
+ }
1849
+
1850
+ // Kill child process if it exists
1851
+ if (this.child && !this.finished) {
1852
+ traceBranch('ProcessRunner', 'hasChild', 'killing', { pid: this.child.pid });
1853
+ try {
1854
+ // Kill the process group to ensure all child processes are terminated
1855
+ if (this.child.pid) {
1856
+ if (isBun) {
1857
+ trace('ProcessRunner', 'Killing Bun process', { pid: this.child.pid });
1858
+ this.child.kill();
1859
+ } else {
1860
+ // In Node.js, kill the process group
1861
+ trace('ProcessRunner', 'Killing Node process group', { pid: this.child.pid });
1862
+ process.kill(-this.child.pid, 'SIGTERM');
1863
+ }
1864
+ }
1865
+ this.finished = true;
1866
+ } catch (err) {
1867
+ // Process might already be dead
1868
+ trace('ProcessRunner', 'Error killing process', { error: err.message });
1869
+ console.error('Error killing process:', err.message);
1870
+ }
788
1871
  }
1872
+
1873
+ // Mark as finished
1874
+ this.finished = true;
1875
+
1876
+ traceFunc('ProcessRunner', 'kill', 'EXIT', {
1877
+ cancelled: this._cancelled,
1878
+ finished: this.finished
1879
+ });
789
1880
  }
790
1881
 
791
1882
  // Programmatic piping support
792
1883
  pipe(destination) {
1884
+ traceFunc('ProcessRunner', 'pipe', 'ENTER', {
1885
+ hasDestination: !!destination,
1886
+ destinationType: destination?.constructor?.name
1887
+ });
1888
+
793
1889
  // If destination is a ProcessRunner, create a pipeline
794
1890
  if (destination instanceof ProcessRunner) {
1891
+ traceBranch('ProcessRunner', 'pipe', 'PROCESS_RUNNER_DEST', {});
795
1892
  // Create a new ProcessRunner that represents the piped operation
796
1893
  const pipeSpec = {
797
1894
  mode: 'pipeline',
@@ -799,49 +1896,64 @@ class ProcessRunner extends StreamEmitter {
799
1896
  destination: destination
800
1897
  };
801
1898
 
802
- return new ProcessRunner(pipeSpec, {
1899
+ const pipeRunner = new ProcessRunner(pipeSpec, {
803
1900
  ...this.options,
804
1901
  capture: destination.options.capture ?? true
805
1902
  });
1903
+
1904
+ traceFunc('ProcessRunner', 'pipe', 'EXIT', { mode: 'pipeline' });
1905
+ return pipeRunner;
806
1906
  }
807
1907
 
808
1908
  // If destination is a template literal result (from $`command`), use its spec
809
1909
  if (destination && destination.spec) {
1910
+ traceBranch('ProcessRunner', 'pipe', 'TEMPLATE_LITERAL_DEST', {});
810
1911
  const destRunner = new ProcessRunner(destination.spec, destination.options);
811
1912
  return this.pipe(destRunner);
812
1913
  }
813
1914
 
1915
+ traceBranch('ProcessRunner', 'pipe', 'INVALID_DEST', {});
814
1916
  throw new Error('pipe() destination must be a ProcessRunner or $`command` result');
815
1917
  }
816
1918
 
817
1919
  // Promise interface (for await)
818
1920
  then(onFulfilled, onRejected) {
819
1921
  if (!this.promise) {
820
- this.promise = this._start();
1922
+ this.promise = this._startAsync();
821
1923
  }
822
1924
  return this.promise.then(onFulfilled, onRejected);
823
1925
  }
824
1926
 
825
1927
  catch(onRejected) {
826
1928
  if (!this.promise) {
827
- this.promise = this._start();
1929
+ this.promise = this._startAsync();
828
1930
  }
829
1931
  return this.promise.catch(onRejected);
830
1932
  }
831
1933
 
832
1934
  finally(onFinally) {
833
1935
  if (!this.promise) {
834
- this.promise = this._start();
1936
+ this.promise = this._startAsync();
835
1937
  }
836
1938
  return this.promise.finally(onFinally);
837
1939
  }
838
1940
 
839
- // Synchronous execution
840
- sync() {
1941
+ // Internal sync execution
1942
+ _startSync() {
1943
+ traceFunc('ProcessRunner', '_startSync', 'ENTER', {
1944
+ started: this.started,
1945
+ spec: this.spec
1946
+ });
1947
+
841
1948
  if (this.started) {
1949
+ traceBranch('ProcessRunner', '_startSync', 'ALREADY_STARTED', {});
842
1950
  throw new Error('Command already started - cannot run sync after async start');
843
1951
  }
844
1952
 
1953
+ this.started = true;
1954
+ this._mode = 'sync';
1955
+ trace('ProcessRunner', 'Starting sync execution', { mode: this._mode });
1956
+
845
1957
  const { cwd, env, stdin } = this.options;
846
1958
  const argv = this.spec.mode === 'shell' ? ['sh', '-lc', this.spec.command] : [this.spec.file, ...this.spec.args];
847
1959
 
@@ -960,34 +2072,79 @@ class ProcessRunner extends StreamEmitter {
960
2072
 
961
2073
  // Public APIs
962
2074
  async function sh(commandString, options = {}) {
2075
+ traceFunc('API', 'sh', 'ENTER', {
2076
+ command: commandString,
2077
+ options
2078
+ });
2079
+
963
2080
  const runner = new ProcessRunner({ mode: 'shell', command: commandString }, options);
964
- return runner._start();
2081
+ const result = await runner._startAsync();
2082
+
2083
+ traceFunc('API', 'sh', 'EXIT', { code: result.code });
2084
+ return result;
965
2085
  }
966
2086
 
967
2087
  async function exec(file, args = [], options = {}) {
2088
+ traceFunc('API', 'exec', 'ENTER', {
2089
+ file,
2090
+ argsCount: args.length,
2091
+ options
2092
+ });
2093
+
968
2094
  const runner = new ProcessRunner({ mode: 'exec', file, args }, options);
969
- return runner._start();
2095
+ const result = await runner._startAsync();
2096
+
2097
+ traceFunc('API', 'exec', 'EXIT', { code: result.code });
2098
+ return result;
970
2099
  }
971
2100
 
972
2101
  async function run(commandOrTokens, options = {}) {
2102
+ traceFunc('API', 'run', 'ENTER', {
2103
+ type: typeof commandOrTokens,
2104
+ options
2105
+ });
2106
+
973
2107
  if (typeof commandOrTokens === 'string') {
2108
+ traceBranch('API', 'run', 'STRING_COMMAND', { command: commandOrTokens });
974
2109
  return sh(commandOrTokens, { ...options, mirror: false, capture: true });
975
2110
  }
2111
+
976
2112
  const [file, ...args] = commandOrTokens;
2113
+ traceBranch('API', 'run', 'TOKEN_ARRAY', { file, argsCount: args.length });
977
2114
  return exec(file, args, { ...options, mirror: false, capture: true });
978
2115
  }
979
2116
 
980
2117
  // Enhanced tagged template that returns ProcessRunner
981
2118
  function $tagged(strings, ...values) {
2119
+ traceFunc('API', '$tagged', 'ENTER', {
2120
+ stringsLength: strings.length,
2121
+ valuesLength: values.length
2122
+ });
2123
+
982
2124
  const cmd = buildShellCommand(strings, values);
983
- return new ProcessRunner({ mode: 'shell', command: cmd }, { mirror: true, capture: true });
2125
+ const runner = new ProcessRunner({ mode: 'shell', command: cmd }, { mirror: true, capture: true });
2126
+
2127
+ traceFunc('API', '$tagged', 'EXIT', { command: cmd });
2128
+ return runner;
984
2129
  }
985
2130
 
986
2131
  function create(defaultOptions = {}) {
2132
+ traceFunc('API', 'create', 'ENTER', { defaultOptions });
2133
+
987
2134
  const tagged = (strings, ...values) => {
2135
+ traceFunc('API', 'create.tagged', 'ENTER', {
2136
+ stringsLength: strings.length,
2137
+ valuesLength: values.length
2138
+ });
2139
+
988
2140
  const cmd = buildShellCommand(strings, values);
989
- return new ProcessRunner({ mode: 'shell', command: cmd }, { mirror: true, capture: true, ...defaultOptions });
2141
+ const runner = new ProcessRunner({ mode: 'shell', command: cmd }, { mirror: true, capture: true, ...defaultOptions });
2142
+
2143
+ traceFunc('API', 'create.tagged', 'EXIT', { command: cmd });
2144
+ return runner;
990
2145
  };
2146
+
2147
+ traceFunc('API', 'create', 'EXIT', {});
991
2148
  return tagged;
992
2149
  }
993
2150
 
@@ -1058,12 +2215,21 @@ const shell = {
1058
2215
 
1059
2216
  // Virtual command registration API
1060
2217
  function register(name, handler) {
2218
+ traceFunc('VirtualCommands', 'register', 'ENTER', { name });
2219
+
1061
2220
  virtualCommands.set(name, handler);
2221
+
2222
+ traceFunc('VirtualCommands', 'register', 'EXIT', { registered: true });
1062
2223
  return virtualCommands;
1063
2224
  }
1064
2225
 
1065
2226
  function unregister(name) {
1066
- return virtualCommands.delete(name);
2227
+ traceFunc('VirtualCommands', 'unregister', 'ENTER', { name });
2228
+
2229
+ const deleted = virtualCommands.delete(name);
2230
+
2231
+ traceFunc('VirtualCommands', 'unregister', 'EXIT', { deleted });
2232
+ return deleted;
1067
2233
  }
1068
2234
 
1069
2235
  function listCommands() {
@@ -1085,10 +2251,15 @@ function registerBuiltins() {
1085
2251
  // cd - change directory
1086
2252
  register('cd', async (args) => {
1087
2253
  const target = args[0] || process.env.HOME || process.env.USERPROFILE || '/';
2254
+ trace('VirtualCommand', 'cd: changing directory', { target });
2255
+
1088
2256
  try {
1089
2257
  process.chdir(target);
1090
- return { stdout: process.cwd(), code: 0 };
2258
+ const newDir = process.cwd();
2259
+ trace('VirtualCommand', 'cd: success', { newDir });
2260
+ return { stdout: newDir, code: 0 };
1091
2261
  } catch (error) {
2262
+ trace('VirtualCommand', 'cd: failed', { error: error.message });
1092
2263
  return { stderr: `cd: ${error.message}`, code: 1 };
1093
2264
  }
1094
2265
  });
@@ -1097,14 +2268,18 @@ function registerBuiltins() {
1097
2268
  register('pwd', async (args, stdin, options) => {
1098
2269
  // If cwd option is provided, return that instead of process.cwd()
1099
2270
  const dir = options?.cwd || process.cwd();
2271
+ trace('VirtualCommand', 'pwd: getting directory', { dir });
1100
2272
  return { stdout: dir, code: 0 };
1101
2273
  });
1102
2274
 
1103
2275
  // echo - print arguments
1104
2276
  register('echo', async (args) => {
2277
+ trace('VirtualCommand', 'echo: processing', { argsCount: args.length });
2278
+
1105
2279
  let output = args.join(' ');
1106
2280
  if (args.includes('-n')) {
1107
2281
  // Don't add newline
2282
+ traceBranch('VirtualCommand', 'echo', 'NO_NEWLINE', {});
1108
2283
  output = args.filter(arg => arg !== '-n').join(' ');
1109
2284
  } else {
1110
2285
  output += '\n';
@@ -1115,10 +2290,15 @@ function registerBuiltins() {
1115
2290
  // sleep - wait for specified time
1116
2291
  register('sleep', async (args) => {
1117
2292
  const seconds = parseFloat(args[0] || 0);
2293
+ trace('VirtualCommand', 'sleep: starting', { seconds });
2294
+
1118
2295
  if (isNaN(seconds) || seconds < 0) {
2296
+ trace('VirtualCommand', 'sleep: invalid interval', { input: args[0] });
1119
2297
  return { stderr: 'sleep: invalid time interval', code: 1 };
1120
2298
  }
2299
+
1121
2300
  await new Promise(resolve => setTimeout(resolve, seconds * 1000));
2301
+ trace('VirtualCommand', 'sleep: completed', { seconds });
1122
2302
  return { stdout: '', code: 0 };
1123
2303
  });
1124
2304
 
@@ -1649,14 +2829,49 @@ function registerBuiltins() {
1649
2829
  });
1650
2830
 
1651
2831
  // yes - output a string repeatedly
1652
- register('yes', async function* (args) {
2832
+ register('yes', async function* (args, stdin, options) {
1653
2833
  const output = args.length > 0 ? args.join(' ') : 'y';
2834
+ trace('VirtualCommand', 'yes: starting infinite generator', { output });
1654
2835
 
1655
2836
  // Generate infinite stream of the output
1656
2837
  while (true) {
2838
+ // Check if cancelled via function or abort signal
2839
+ if (options) {
2840
+ if (options.isCancelled && options.isCancelled()) {
2841
+ trace('VirtualCommand', 'yes: cancelled via function', {});
2842
+ return;
2843
+ }
2844
+ if (options.signal && options.signal.aborted) {
2845
+ trace('VirtualCommand', 'yes: cancelled via abort signal', {});
2846
+ return;
2847
+ }
2848
+ }
2849
+
1657
2850
  yield output + '\n';
1658
- // Small delay to prevent overwhelming the system
1659
- await new Promise(resolve => setTimeout(resolve, 0));
2851
+
2852
+ // Small delay with abort signal support
2853
+ try {
2854
+ await new Promise((resolve, reject) => {
2855
+ const timeout = setTimeout(resolve, 0);
2856
+
2857
+ // Listen for abort signal if available
2858
+ if (options && options.signal) {
2859
+ const abortHandler = () => {
2860
+ clearTimeout(timeout);
2861
+ reject(new Error('Aborted'));
2862
+ };
2863
+
2864
+ if (options.signal.aborted) {
2865
+ abortHandler();
2866
+ } else {
2867
+ options.signal.addEventListener('abort', abortHandler, { once: true });
2868
+ }
2869
+ }
2870
+ });
2871
+ } catch (err) {
2872
+ // Aborted
2873
+ return;
2874
+ }
1660
2875
  }
1661
2876
  });
1662
2877