command-stream 0.0.5 → 0.2.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 +1522 -213
  2. package/README.md +44 -3
  3. package/package.json +1 -1
package/$.mjs CHANGED
@@ -10,6 +10,101 @@ 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
+
39
+ // Track parent stream state for graceful shutdown
40
+ let parentStreamsMonitored = false;
41
+ const activeProcessRunners = new Set();
42
+
43
+ function monitorParentStreams() {
44
+ if (parentStreamsMonitored) return;
45
+ parentStreamsMonitored = true;
46
+
47
+ // Monitor parent stdout/stderr for closure
48
+ const checkParentStream = (stream, name) => {
49
+ if (stream && typeof stream.on === 'function') {
50
+ stream.on('close', () => {
51
+ trace('ProcessRunner', `Parent ${name} closed - triggering graceful shutdown`, {
52
+ activeProcesses: activeProcessRunners.size
53
+ });
54
+ // Signal all active ProcessRunners to gracefully shutdown
55
+ for (const runner of activeProcessRunners) {
56
+ runner._handleParentStreamClosure();
57
+ }
58
+ });
59
+ }
60
+ };
61
+
62
+ checkParentStream(process.stdout, 'stdout');
63
+ checkParentStream(process.stderr, 'stderr');
64
+ }
65
+
66
+ // Safe write function that checks stream state and handles parent closure
67
+ function safeWrite(stream, data, processRunner = null) {
68
+ // Ensure parent stream monitoring is active
69
+ monitorParentStreams();
70
+
71
+ // Check if stream is writable and not destroyed/closed
72
+ if (!stream || !stream.writable || stream.destroyed || stream.closed) {
73
+ trace('ProcessRunner', 'safeWrite skipped - stream not writable', {
74
+ hasStream: !!stream,
75
+ writable: stream?.writable,
76
+ destroyed: stream?.destroyed,
77
+ closed: stream?.closed
78
+ });
79
+
80
+ // If this is a parent stream closure, signal graceful shutdown
81
+ if (processRunner && (stream === process.stdout || stream === process.stderr)) {
82
+ processRunner._handleParentStreamClosure();
83
+ }
84
+
85
+ return false;
86
+ }
87
+
88
+ try {
89
+ return stream.write(data);
90
+ } catch (error) {
91
+ trace('ProcessRunner', 'safeWrite error', {
92
+ error: error.message,
93
+ code: error.code,
94
+ writable: stream.writable,
95
+ destroyed: stream.destroyed
96
+ });
97
+
98
+ // If this is an EPIPE on parent streams, signal graceful shutdown
99
+ if (error.code === 'EPIPE' && processRunner &&
100
+ (stream === process.stdout || stream === process.stderr)) {
101
+ processRunner._handleParentStreamClosure();
102
+ }
103
+
104
+ return false;
105
+ }
106
+ }
107
+
13
108
  // Global shell settings (like bash set -e / set +e)
14
109
  let globalShellSettings = {
15
110
  errexit: false, // set -e equivalent: exit on error
@@ -50,6 +145,9 @@ class StreamEmitter {
50
145
  this.listeners.set(event, []);
51
146
  }
52
147
  this.listeners.get(event).push(listener);
148
+
149
+ // No auto-start - explicit start() or await will start the process
150
+
53
151
  return this;
54
152
  }
55
153
 
@@ -84,18 +182,28 @@ function quote(value) {
84
182
  }
85
183
 
86
184
  function buildShellCommand(strings, values) {
185
+ traceFunc('Utils', 'buildShellCommand', 'ENTER', {
186
+ stringsLength: strings.length,
187
+ valuesLength: values.length
188
+ });
189
+
87
190
  let out = '';
88
191
  for (let i = 0; i < strings.length; i++) {
89
192
  out += strings[i];
90
193
  if (i < values.length) {
91
194
  const v = values[i];
92
195
  if (v && typeof v === 'object' && Object.prototype.hasOwnProperty.call(v, 'raw')) {
196
+ traceBranch('Utils', 'buildShellCommand', 'RAW_VALUE', { value: String(v.raw) });
93
197
  out += String(v.raw);
94
198
  } else {
95
- out += quote(v);
199
+ const quoted = quote(v);
200
+ traceBranch('Utils', 'buildShellCommand', 'QUOTED_VALUE', { original: v, quoted });
201
+ out += quoted;
96
202
  }
97
203
  }
98
204
  }
205
+
206
+ traceFunc('Utils', 'buildShellCommand', 'EXIT', { command: out });
99
207
  return out;
100
208
  }
101
209
 
@@ -116,6 +224,12 @@ async function pumpReadable(readable, onChunk) {
116
224
  class ProcessRunner extends StreamEmitter {
117
225
  constructor(spec, options = {}) {
118
226
  super();
227
+
228
+ traceFunc('ProcessRunner', 'constructor', 'ENTER', {
229
+ spec: typeof spec === 'object' ? { ...spec, command: spec.command?.slice(0, 100) } : spec,
230
+ options
231
+ });
232
+
119
233
  this.spec = spec;
120
234
  this.options = {
121
235
  mirror: true,
@@ -139,27 +253,165 @@ class ProcessRunner extends StreamEmitter {
139
253
 
140
254
  // Promise for awaiting final result
141
255
  this.promise = null;
256
+
257
+ // Track the execution mode
258
+ this._mode = null; // 'async' or 'sync'
259
+
260
+ // Cancellation support for virtual commands
261
+ this._cancelled = false;
262
+ this._virtualGenerator = null;
263
+ this._abortController = new AbortController();
264
+
265
+ // Register this ProcessRunner for parent stream monitoring
266
+ activeProcessRunners.add(this);
267
+
268
+ // Track finished state changes to trigger cleanup
269
+ this._finished = false;
270
+ }
271
+
272
+ // Override finished property to trigger cleanup when set to true
273
+ get finished() {
274
+ return this._finished;
275
+ }
276
+
277
+ set finished(value) {
278
+ if (value === true && this._finished === false) {
279
+ this._finished = true;
280
+ this._cleanup(); // Trigger cleanup when process finishes
281
+ } else {
282
+ this._finished = value;
283
+ }
142
284
  }
143
285
 
144
- async _start() {
145
- if (this.started) return;
286
+ // Handle parent stream closure by gracefully shutting down child processes
287
+ _handleParentStreamClosure() {
288
+ if (this.finished || this._cancelled) return;
289
+
290
+ trace('ProcessRunner', 'Handling parent stream closure', {
291
+ started: this.started,
292
+ hasChild: !!this.child,
293
+ command: this.spec.command?.slice(0, 50) || this.spec.file
294
+ });
295
+
296
+ // Mark as cancelled to prevent further operations
297
+ this._cancelled = true;
298
+
299
+ // Cancel abort controller for virtual commands
300
+ if (this._abortController) {
301
+ this._abortController.abort();
302
+ }
303
+
304
+ // Gracefully close child process if it exists
305
+ if (this.child) {
306
+ try {
307
+ // Close stdin first to signal completion
308
+ if (this.child.stdin && typeof this.child.stdin.end === 'function') {
309
+ this.child.stdin.end();
310
+ } else if (isBun && this.child.stdin && typeof this.child.stdin.getWriter === 'function') {
311
+ const writer = this.child.stdin.getWriter();
312
+ writer.close().catch(() => {}); // Ignore close errors
313
+ }
314
+
315
+ // Give the process a moment to exit gracefully, then terminate
316
+ setTimeout(() => {
317
+ if (this.child && !this.finished) {
318
+ trace('ProcessRunner', 'Terminating child process after parent stream closure', {});
319
+ if (typeof this.child.kill === 'function') {
320
+ this.child.kill('SIGTERM');
321
+ }
322
+ }
323
+ }, 100);
324
+
325
+ } catch (error) {
326
+ trace('ProcessRunner', 'Error during graceful shutdown', { error: error.message });
327
+ }
328
+ }
329
+
330
+ // Remove from active set
331
+ activeProcessRunners.delete(this);
332
+ }
333
+
334
+ // Cleanup method to remove from active set when process completes normally
335
+ _cleanup() {
336
+ activeProcessRunners.delete(this);
337
+ }
338
+
339
+ // Unified start method that can work in both async and sync modes
340
+ start(options = {}) {
341
+ const mode = options.mode || 'async';
342
+
343
+ traceFunc('ProcessRunner', 'start', 'ENTER', { mode, options });
344
+
345
+ if (mode === 'sync') {
346
+ traceBranch('ProcessRunner', 'mode', 'sync', {});
347
+ return this._startSync();
348
+ } else {
349
+ traceBranch('ProcessRunner', 'mode', 'async', {});
350
+ return this._startAsync();
351
+ }
352
+ }
353
+
354
+ // Shortcut for sync mode
355
+ sync() {
356
+ return this.start({ mode: 'sync' });
357
+ }
358
+
359
+ // Shortcut for async mode
360
+ async() {
361
+ return this.start({ mode: 'async' });
362
+ }
363
+
364
+ async _startAsync() {
365
+ if (this.started) return this.promise;
366
+ if (this.promise) return this.promise;
367
+
368
+ this.promise = this._doStartAsync();
369
+ return this.promise;
370
+ }
371
+
372
+ async _doStartAsync() {
373
+ traceFunc('ProcessRunner', '_doStartAsync', 'ENTER', {
374
+ mode: this.spec.mode,
375
+ command: this.spec.command?.slice(0, 100)
376
+ });
377
+
146
378
  this.started = true;
379
+ this._mode = 'async';
147
380
 
148
381
  const { cwd, env, stdin } = this.options;
149
382
 
150
383
  // Handle programmatic pipeline mode
151
384
  if (this.spec.mode === 'pipeline') {
385
+ traceBranch('ProcessRunner', 'spec.mode', 'pipeline', {
386
+ hasSource: !!this.spec.source,
387
+ hasDestination: !!this.spec.destination
388
+ });
152
389
  return await this._runProgrammaticPipeline(this.spec.source, this.spec.destination);
153
390
  }
154
391
 
155
392
  // Check if this is a virtual command first
156
393
  if (this.spec.mode === 'shell') {
394
+ traceBranch('ProcessRunner', 'spec.mode', 'shell', {});
395
+
157
396
  // Parse the command to check for virtual commands or pipelines
158
397
  const parsed = this._parseCommand(this.spec.command);
398
+ trace('ProcessRunner', 'Parsed command', {
399
+ type: parsed?.type,
400
+ cmd: parsed?.cmd,
401
+ argsCount: parsed?.args?.length
402
+ });
403
+
159
404
  if (parsed) {
160
405
  if (parsed.type === 'pipeline') {
406
+ traceBranch('ProcessRunner', 'parsed.type', 'pipeline', {
407
+ commandCount: parsed.commands?.length
408
+ });
161
409
  return await this._runPipeline(parsed.commands);
162
410
  } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd)) {
411
+ traceBranch('ProcessRunner', 'virtualCommand', parsed.cmd, {
412
+ isVirtual: true,
413
+ args: parsed.args
414
+ });
163
415
  return await this._runVirtual(parsed.cmd, parsed.args);
164
416
  }
165
417
  }
@@ -194,7 +446,7 @@ class ProcessRunner extends StreamEmitter {
194
446
  // Setup stdout streaming
195
447
  const outPump = pumpReadable(this.child.stdout, async (buf) => {
196
448
  if (this.options.capture) this.outChunks.push(buf);
197
- if (this.options.mirror) process.stdout.write(buf);
449
+ if (this.options.mirror) safeWrite(process.stdout, buf);
198
450
 
199
451
  // Emit chunk events
200
452
  this.emit('stdout', buf);
@@ -204,7 +456,7 @@ class ProcessRunner extends StreamEmitter {
204
456
  // Setup stderr streaming
205
457
  const errPump = pumpReadable(this.child.stderr, async (buf) => {
206
458
  if (this.options.capture) this.errChunks.push(buf);
207
- if (this.options.mirror) process.stderr.write(buf);
459
+ if (this.options.mirror) safeWrite(process.stderr, buf);
208
460
 
209
461
  // Emit chunk events
210
462
  this.emit('stderr', buf);
@@ -236,8 +488,16 @@ class ProcessRunner extends StreamEmitter {
236
488
  const code = await exited;
237
489
  await Promise.all([outPump, errPump, stdinPumpPromise]);
238
490
 
239
- const resultData = {
491
+ // Debug: Check the raw exit code
492
+ trace('ProcessRunner', 'Raw exit code from child', {
240
493
  code,
494
+ codeType: typeof code,
495
+ childExitCode: this.child?.exitCode,
496
+ isBun
497
+ });
498
+
499
+ const resultData = {
500
+ code: code ?? 0, // Default to 0 if exit code is null/undefined
241
501
  stdout: this.options.capture ? Buffer.concat(this.outChunks).toString('utf8') : undefined,
242
502
  stderr: this.options.capture ? Buffer.concat(this.errChunks).toString('utf8') : undefined,
243
503
  stdin: this.options.capture && this.inChunks ? Buffer.concat(this.inChunks).toString('utf8') : undefined,
@@ -276,7 +536,27 @@ class ProcessRunner extends StreamEmitter {
276
536
  const buf = asBuffer(chunk);
277
537
  captureChunks && captureChunks.push(buf);
278
538
  if (bunWriter) await bunWriter.write(buf);
279
- else if (typeof child.stdin.write === 'function') child.stdin.write(buf);
539
+ else if (typeof child.stdin.write === 'function') {
540
+ // Add error handler to prevent unhandled error events
541
+ if (child.stdin && typeof child.stdin.on === 'function') {
542
+ child.stdin.on('error', (error) => {
543
+ if (error.code !== 'EPIPE') {
544
+ trace('ProcessRunner', 'child stdin buffer error event', { error: error.message, code: error.code });
545
+ }
546
+ });
547
+ }
548
+
549
+ // Safe write to handle EPIPE errors
550
+ if (child.stdin && child.stdin.writable && !child.stdin.destroyed && !child.stdin.closed) {
551
+ try {
552
+ child.stdin.write(buf);
553
+ } catch (error) {
554
+ if (error.code !== 'EPIPE') {
555
+ trace('ProcessRunner', 'Error writing stdin buffer', { error: error.message, code: error.code });
556
+ }
557
+ }
558
+ }
559
+ }
280
560
  else if (isBun && typeof Bun.write === 'function') await Bun.write(child.stdin, buf);
281
561
  }
282
562
  if (bunWriter) await bunWriter.close();
@@ -287,8 +567,14 @@ class ProcessRunner extends StreamEmitter {
287
567
  if (isBun && this.child.stdin && typeof this.child.stdin.getWriter === 'function') {
288
568
  const w = this.child.stdin.getWriter();
289
569
  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer, buf.byteOffset ?? 0, buf.byteLength);
290
- await w.write(bytes);
291
- await w.close();
570
+ try {
571
+ await w.write(bytes);
572
+ await w.close();
573
+ } catch (error) {
574
+ if (error.code !== 'EPIPE') {
575
+ trace('ProcessRunner', 'Error writing to Bun writer', { error: error.message, code: error.code });
576
+ }
577
+ }
292
578
  } else if (this.child.stdin && typeof this.child.stdin.write === 'function') {
293
579
  this.child.stdin.end(buf);
294
580
  } else if (isBun && typeof Bun.write === 'function') {
@@ -375,11 +661,19 @@ class ProcessRunner extends StreamEmitter {
375
661
  }
376
662
 
377
663
  async _runVirtual(cmd, args) {
664
+ traceFunc('ProcessRunner', '_runVirtual', 'ENTER', { cmd, args });
665
+
378
666
  const handler = virtualCommands.get(cmd);
379
667
  if (!handler) {
668
+ trace('ProcessRunner', 'Virtual command not found', { cmd });
380
669
  throw new Error(`Virtual command not found: ${cmd}`);
381
670
  }
382
671
 
672
+ trace('ProcessRunner', 'Found virtual command handler', {
673
+ cmd,
674
+ isGenerator: handler.constructor.name === 'AsyncGeneratorFunction'
675
+ });
676
+
383
677
  try {
384
678
  // Prepare stdin
385
679
  let stdinData = '';
@@ -405,115 +699,820 @@ class ProcessRunner extends StreamEmitter {
405
699
 
406
700
  // Check if handler is async generator (streaming)
407
701
  if (handler.constructor.name === 'AsyncGeneratorFunction') {
408
- // Handle streaming virtual command
702
+ // Handle streaming virtual command with cancellation support
409
703
  const chunks = [];
410
- for await (const chunk of handler(argValues, stdinData, this.options)) {
704
+
705
+ // Create options with cancellation check and abort signal
706
+ const commandOptions = {
707
+ ...this.options,
708
+ isCancelled: () => this._cancelled,
709
+ signal: this._abortController.signal
710
+ };
711
+
712
+ const generator = handler({ args: argValues, stdin: stdinData, ...commandOptions });
713
+ this._virtualGenerator = generator;
714
+
715
+ // Create a promise that resolves when cancelled
716
+ const cancelPromise = new Promise(resolve => {
717
+ this._cancelResolve = resolve;
718
+ });
719
+
720
+ try {
721
+ const iterator = generator[Symbol.asyncIterator]();
722
+ let done = false;
723
+
724
+ while (!done && !this._cancelled) {
725
+ // Race between getting next value and cancellation
726
+ const result = await Promise.race([
727
+ iterator.next(),
728
+ cancelPromise.then(() => ({ done: true, cancelled: true }))
729
+ ]);
730
+
731
+ if (result.cancelled || this._cancelled) {
732
+ // Cancelled - close the generator
733
+ if (iterator.return) {
734
+ await iterator.return();
735
+ }
736
+ break;
737
+ }
738
+
739
+ done = result.done;
740
+
741
+ if (!done) {
742
+ const chunk = result.value;
743
+ const buf = Buffer.from(chunk);
744
+ chunks.push(buf);
745
+
746
+ // Only output if not cancelled
747
+ if (!this._cancelled) {
748
+ if (this.options.mirror) {
749
+ safeWrite(process.stdout, buf);
750
+ }
751
+
752
+ this.emit('stdout', buf);
753
+ this.emit('data', { type: 'stdout', data: buf });
754
+ }
755
+ }
756
+ }
757
+ } finally {
758
+ // Clean up
759
+ this._virtualGenerator = null;
760
+ this._cancelResolve = null;
761
+ }
762
+
763
+ result = {
764
+ code: 0,
765
+ stdout: this.options.capture ? Buffer.concat(chunks).toString('utf8') : undefined,
766
+ stderr: this.options.capture ? '' : undefined,
767
+ stdin: this.options.capture ? stdinData : undefined
768
+ };
769
+ } else {
770
+ // Regular async function
771
+ result = await handler({ args: argValues, stdin: stdinData, ...this.options });
772
+
773
+ // Ensure result has required fields, respecting capture option
774
+ result = {
775
+ code: result.code ?? 0,
776
+ stdout: this.options.capture ? (result.stdout ?? '') : undefined,
777
+ stderr: this.options.capture ? (result.stderr ?? '') : undefined,
778
+ stdin: this.options.capture ? stdinData : undefined,
779
+ ...result
780
+ };
781
+
782
+ // Mirror and emit output
783
+ if (result.stdout) {
784
+ const buf = Buffer.from(result.stdout);
785
+ if (this.options.mirror) {
786
+ safeWrite(process.stdout, buf);
787
+ }
788
+ this.emit('stdout', buf);
789
+ this.emit('data', { type: 'stdout', data: buf });
790
+ }
791
+
792
+ if (result.stderr) {
793
+ const buf = Buffer.from(result.stderr);
794
+ if (this.options.mirror) {
795
+ safeWrite(process.stderr, buf);
796
+ }
797
+ this.emit('stderr', buf);
798
+ this.emit('data', { type: 'stderr', data: buf });
799
+ }
800
+ }
801
+
802
+ // Store result
803
+ this.result = result;
804
+ this.finished = true;
805
+
806
+ // Emit completion events
807
+ this.emit('end', result);
808
+ this.emit('exit', result.code);
809
+
810
+ // Handle shell settings
811
+ if (globalShellSettings.errexit && result.code !== 0) {
812
+ const error = new Error(`Command failed with exit code ${result.code}`);
813
+ error.code = result.code;
814
+ error.stdout = result.stdout;
815
+ error.stderr = result.stderr;
816
+ error.result = result;
817
+ throw error;
818
+ }
819
+
820
+ return result;
821
+ } catch (error) {
822
+ // Handle errors from virtual commands
823
+ const result = {
824
+ code: error.code ?? 1,
825
+ stdout: error.stdout ?? '',
826
+ stderr: error.stderr ?? error.message,
827
+ stdin: ''
828
+ };
829
+
830
+ this.result = result;
831
+ this.finished = true;
832
+
833
+ if (result.stderr) {
834
+ const buf = Buffer.from(result.stderr);
835
+ if (this.options.mirror) {
836
+ safeWrite(process.stderr, buf);
837
+ }
838
+ this.emit('stderr', buf);
839
+ this.emit('data', { type: 'stderr', data: buf });
840
+ }
841
+
842
+ this.emit('end', result);
843
+ this.emit('exit', result.code);
844
+
845
+ if (globalShellSettings.errexit) {
846
+ throw error;
847
+ }
848
+
849
+ return result;
850
+ }
851
+ }
852
+
853
+ async _runStreamingPipelineBun(commands) {
854
+ traceFunc('ProcessRunner', '_runStreamingPipelineBun', 'ENTER', {
855
+ commandsCount: commands.length
856
+ });
857
+
858
+ // For true streaming, we need to handle virtual and real commands differently
859
+ // but make them work together seamlessly
860
+
861
+ // First, analyze the pipeline to identify virtual vs real commands
862
+ const pipelineInfo = commands.map(command => {
863
+ const { cmd, args } = command;
864
+ const isVirtual = virtualCommandsEnabled && virtualCommands.has(cmd);
865
+ return { ...command, isVirtual };
866
+ });
867
+
868
+ trace('ProcessRunner', 'Pipeline analysis', {
869
+ virtualCount: pipelineInfo.filter(p => p.isVirtual).length,
870
+ realCount: pipelineInfo.filter(p => !p.isVirtual).length
871
+ });
872
+
873
+ // If pipeline contains virtual commands, use advanced streaming
874
+ if (pipelineInfo.some(info => info.isVirtual)) {
875
+ traceBranch('ProcessRunner', '_runStreamingPipelineBun', 'MIXED_PIPELINE', {});
876
+ return this._runMixedStreamingPipeline(commands);
877
+ }
878
+
879
+ // For pipelines with commands that buffer (like jq), use tee streaming
880
+ const needsStreamingWorkaround = commands.some(c =>
881
+ c.cmd === 'jq' || c.cmd === 'grep' || c.cmd === 'sed' || c.cmd === 'cat' || c.cmd === 'awk'
882
+ );
883
+ if (needsStreamingWorkaround) {
884
+ traceBranch('ProcessRunner', '_runStreamingPipelineBun', 'TEE_STREAMING', {
885
+ bufferedCommands: commands.filter(c =>
886
+ ['jq', 'grep', 'sed', 'cat', 'awk'].includes(c.cmd)
887
+ ).map(c => c.cmd)
888
+ });
889
+ return this._runTeeStreamingPipeline(commands);
890
+ }
891
+
892
+ // All real commands - use native pipe connections
893
+ const processes = [];
894
+ let allStderr = '';
895
+
896
+ for (let i = 0; i < commands.length; i++) {
897
+ const command = commands[i];
898
+ const { cmd, args } = command;
899
+
900
+ // Build command string
901
+ const commandParts = [cmd];
902
+ for (const arg of args) {
903
+ if (arg.value !== undefined) {
904
+ if (arg.quoted) {
905
+ commandParts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`);
906
+ } else if (arg.value.includes(' ')) {
907
+ commandParts.push(`"${arg.value}"`);
908
+ } else {
909
+ commandParts.push(arg.value);
910
+ }
911
+ } else {
912
+ if (typeof arg === 'string' && arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
913
+ commandParts.push(`"${arg}"`);
914
+ } else {
915
+ commandParts.push(arg);
916
+ }
917
+ }
918
+ }
919
+ const commandStr = commandParts.join(' ');
920
+
921
+ // Determine stdin for this process
922
+ let stdin;
923
+ let needsManualStdin = false;
924
+ let stdinData;
925
+
926
+ if (i === 0) {
927
+ // First command - use provided stdin or pipe
928
+ if (this.options.stdin && typeof this.options.stdin === 'string') {
929
+ stdin = 'pipe';
930
+ needsManualStdin = true;
931
+ stdinData = Buffer.from(this.options.stdin);
932
+ } else if (this.options.stdin && Buffer.isBuffer(this.options.stdin)) {
933
+ stdin = 'pipe';
934
+ needsManualStdin = true;
935
+ stdinData = this.options.stdin;
936
+ } else {
937
+ stdin = 'ignore';
938
+ }
939
+ } else {
940
+ // Connect to previous process stdout
941
+ stdin = processes[i - 1].stdout;
942
+ }
943
+
944
+ // Spawn the process directly (not through sh) for better streaming
945
+ // Only use sh -c for complex commands that need shell features
946
+ const needsShell = commandStr.includes('*') || commandStr.includes('$') ||
947
+ commandStr.includes('>') || commandStr.includes('<') ||
948
+ commandStr.includes('&&') || commandStr.includes('||') ||
949
+ commandStr.includes(';') || commandStr.includes('`');
950
+
951
+ const spawnArgs = needsShell
952
+ ? ['sh', '-c', commandStr]
953
+ : [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
954
+
955
+ const proc = Bun.spawn(spawnArgs, {
956
+ cwd: this.options.cwd,
957
+ env: this.options.env,
958
+ stdin: stdin,
959
+ stdout: 'pipe',
960
+ stderr: 'pipe'
961
+ });
962
+
963
+ // Write stdin data if needed for first process
964
+ if (needsManualStdin && stdinData && proc.stdin) {
965
+ // Add error handler for Bun stdin
966
+ if (proc.stdin && typeof proc.stdin.on === 'function') {
967
+ proc.stdin.on('error', (error) => {
968
+ if (error.code !== 'EPIPE') {
969
+ trace('ProcessRunner', 'Bun stdin error event', { error: error.message, code: error.code });
970
+ }
971
+ });
972
+ }
973
+
974
+ (async () => {
975
+ try {
976
+ // Bun's FileSink has write and end methods
977
+ if (proc.stdin && proc.stdin.writable && !proc.stdin.destroyed && !proc.stdin.closed) {
978
+ await proc.stdin.write(stdinData);
979
+ await proc.stdin.end();
980
+ }
981
+ } catch (e) {
982
+ if (e.code !== 'EPIPE') {
983
+ trace('ProcessRunner', 'Error writing stdin (Bun)', { error: e.message, code: e.code });
984
+ }
985
+ }
986
+ })();
987
+ }
988
+
989
+ processes.push(proc);
990
+
991
+ // Collect stderr from all processes
992
+ (async () => {
993
+ for await (const chunk of proc.stderr) {
411
994
  const buf = Buffer.from(chunk);
412
- chunks.push(buf);
995
+ allStderr += buf.toString();
996
+ // Only emit stderr for the last command
997
+ if (i === commands.length - 1) {
998
+ if (this.options.mirror) {
999
+ safeWrite(process.stderr, buf);
1000
+ }
1001
+ this.emit('stderr', buf);
1002
+ this.emit('data', { type: 'stderr', data: buf });
1003
+ }
1004
+ }
1005
+ })();
1006
+ }
1007
+
1008
+ // Stream output from the last process
1009
+ const lastProc = processes[processes.length - 1];
1010
+ let finalOutput = '';
1011
+
1012
+ // Stream stdout from last process
1013
+ for await (const chunk of lastProc.stdout) {
1014
+ const buf = Buffer.from(chunk);
1015
+ finalOutput += buf.toString();
1016
+ if (this.options.mirror) {
1017
+ safeWrite(process.stdout, buf);
1018
+ }
1019
+ this.emit('stdout', buf);
1020
+ this.emit('data', { type: 'stdout', data: buf });
1021
+ }
1022
+
1023
+ // Wait for all processes to complete
1024
+ const exitCodes = await Promise.all(processes.map(p => p.exited));
1025
+ const lastExitCode = exitCodes[exitCodes.length - 1];
1026
+
1027
+ // Check for pipeline failures if pipefail is set
1028
+ if (globalShellSettings.pipefail) {
1029
+ const failedIndex = exitCodes.findIndex(code => code !== 0);
1030
+ if (failedIndex !== -1) {
1031
+ const error = new Error(`Pipeline command at index ${failedIndex} failed with exit code ${exitCodes[failedIndex]}`);
1032
+ error.code = exitCodes[failedIndex];
1033
+ throw error;
1034
+ }
1035
+ }
1036
+
1037
+ const result = createResult({
1038
+ code: lastExitCode || 0,
1039
+ stdout: finalOutput,
1040
+ stderr: allStderr,
1041
+ stdin: this.options.stdin && typeof this.options.stdin === 'string' ? this.options.stdin :
1042
+ this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1043
+ });
1044
+
1045
+ this.result = result;
1046
+ this.finished = true;
1047
+
1048
+ this.emit('end', result);
1049
+ this.emit('exit', result.code);
1050
+
1051
+ if (globalShellSettings.errexit && result.code !== 0) {
1052
+ const error = new Error(`Pipeline failed with exit code ${result.code}`);
1053
+ error.code = result.code;
1054
+ error.stdout = result.stdout;
1055
+ error.stderr = result.stderr;
1056
+ error.result = result;
1057
+ throw error;
1058
+ }
1059
+
1060
+ return result;
1061
+ }
1062
+
1063
+ async _runTeeStreamingPipeline(commands) {
1064
+ traceFunc('ProcessRunner', '_runTeeStreamingPipeline', 'ENTER', {
1065
+ commandsCount: commands.length
1066
+ });
1067
+
1068
+ // Use tee() to split streams for real-time reading
1069
+ // This works around jq and similar commands that buffer when piped
1070
+
1071
+ const processes = [];
1072
+ let allStderr = '';
1073
+ let currentStream = null;
1074
+
1075
+ for (let i = 0; i < commands.length; i++) {
1076
+ const command = commands[i];
1077
+ const { cmd, args } = command;
1078
+
1079
+ // Build command string
1080
+ const commandParts = [cmd];
1081
+ for (const arg of args) {
1082
+ if (arg.value !== undefined) {
1083
+ if (arg.quoted) {
1084
+ commandParts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`);
1085
+ } else if (arg.value.includes(' ')) {
1086
+ commandParts.push(`"${arg.value}"`);
1087
+ } else {
1088
+ commandParts.push(arg.value);
1089
+ }
1090
+ } else {
1091
+ if (typeof arg === 'string' && arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
1092
+ commandParts.push(`"${arg}"`);
1093
+ } else {
1094
+ commandParts.push(arg);
1095
+ }
1096
+ }
1097
+ }
1098
+ const commandStr = commandParts.join(' ');
1099
+
1100
+ // Determine stdin for this process
1101
+ let stdin;
1102
+ let needsManualStdin = false;
1103
+ let stdinData;
1104
+
1105
+ if (i === 0) {
1106
+ // First command - use provided stdin or ignore
1107
+ if (this.options.stdin && typeof this.options.stdin === 'string') {
1108
+ stdin = 'pipe';
1109
+ needsManualStdin = true;
1110
+ stdinData = Buffer.from(this.options.stdin);
1111
+ } else if (this.options.stdin && Buffer.isBuffer(this.options.stdin)) {
1112
+ stdin = 'pipe';
1113
+ needsManualStdin = true;
1114
+ stdinData = this.options.stdin;
1115
+ } else {
1116
+ stdin = 'ignore';
1117
+ }
1118
+ } else {
1119
+ // Use the stream from previous process
1120
+ stdin = currentStream;
1121
+ }
1122
+
1123
+ // Spawn the process directly (not through sh) for better control
1124
+ const needsShell = commandStr.includes('*') || commandStr.includes('$') ||
1125
+ commandStr.includes('>') || commandStr.includes('<') ||
1126
+ commandStr.includes('&&') || commandStr.includes('||') ||
1127
+ commandStr.includes(';') || commandStr.includes('`');
1128
+
1129
+ const spawnArgs = needsShell
1130
+ ? ['sh', '-c', commandStr]
1131
+ : [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
1132
+
1133
+ const proc = Bun.spawn(spawnArgs, {
1134
+ cwd: this.options.cwd,
1135
+ env: this.options.env,
1136
+ stdin: stdin,
1137
+ stdout: 'pipe',
1138
+ stderr: 'pipe'
1139
+ });
1140
+
1141
+ // Write stdin data if needed for first process
1142
+ if (needsManualStdin && stdinData && proc.stdin) {
1143
+ // Add error handler for Node stdin
1144
+ if (proc.stdin && typeof proc.stdin.on === 'function') {
1145
+ proc.stdin.on('error', (error) => {
1146
+ if (error.code !== 'EPIPE') {
1147
+ trace('ProcessRunner', 'Node stdin error event', { error: error.message, code: error.code });
1148
+ }
1149
+ });
1150
+ }
1151
+
1152
+ try {
1153
+ if (proc.stdin && proc.stdin.writable && !proc.stdin.destroyed && !proc.stdin.closed) {
1154
+ await proc.stdin.write(stdinData);
1155
+ await proc.stdin.end();
1156
+ }
1157
+ } catch (e) {
1158
+ if (e.code !== 'EPIPE') {
1159
+ trace('ProcessRunner', 'Error writing stdin (Node stream)', { error: e.message, code: e.code });
1160
+ }
1161
+ }
1162
+ }
1163
+
1164
+ processes.push(proc);
1165
+
1166
+ // For non-last processes, tee the output so we can both pipe and read
1167
+ if (i < commands.length - 1) {
1168
+ const [readStream, pipeStream] = proc.stdout.tee();
1169
+ currentStream = pipeStream;
1170
+
1171
+ // Read from the tee'd stream for real-time updates
1172
+ // Always read from the first process for best streaming
1173
+ if (i === 0) {
1174
+ (async () => {
1175
+ for await (const chunk of readStream) {
1176
+ // Emit from the first process for real-time updates
1177
+ const buf = Buffer.from(chunk);
1178
+ if (this.options.mirror) {
1179
+ safeWrite(process.stdout, buf);
1180
+ }
1181
+ this.emit('stdout', buf);
1182
+ this.emit('data', { type: 'stdout', data: buf });
1183
+ }
1184
+ })();
1185
+ } else {
1186
+ // Consume other tee'd streams to prevent blocking
1187
+ (async () => {
1188
+ for await (const chunk of readStream) {
1189
+ // Just consume to keep flowing
1190
+ }
1191
+ })();
1192
+ }
1193
+ } else {
1194
+ currentStream = proc.stdout;
1195
+ }
1196
+
1197
+ // Collect stderr from all processes
1198
+ (async () => {
1199
+ for await (const chunk of proc.stderr) {
1200
+ const buf = Buffer.from(chunk);
1201
+ allStderr += buf.toString();
1202
+ if (i === commands.length - 1) {
1203
+ if (this.options.mirror) {
1204
+ safeWrite(process.stderr, buf);
1205
+ }
1206
+ this.emit('stderr', buf);
1207
+ this.emit('data', { type: 'stderr', data: buf });
1208
+ }
1209
+ }
1210
+ })();
1211
+ }
1212
+
1213
+ // Read final output from the last process
1214
+ const lastProc = processes[processes.length - 1];
1215
+ let finalOutput = '';
1216
+
1217
+ // If we haven't emitted stdout yet (no tee), emit from last process
1218
+ const shouldEmitFromLast = commands.length === 1;
1219
+
1220
+ for await (const chunk of lastProc.stdout) {
1221
+ const buf = Buffer.from(chunk);
1222
+ finalOutput += buf.toString();
1223
+ if (shouldEmitFromLast) {
1224
+ if (this.options.mirror) {
1225
+ safeWrite(process.stdout, buf);
1226
+ }
1227
+ this.emit('stdout', buf);
1228
+ this.emit('data', { type: 'stdout', data: buf });
1229
+ }
1230
+ }
1231
+
1232
+ // Wait for all processes to complete
1233
+ const exitCodes = await Promise.all(processes.map(p => p.exited));
1234
+ const lastExitCode = exitCodes[exitCodes.length - 1];
1235
+
1236
+ // Check for pipeline failures if pipefail is set
1237
+ if (globalShellSettings.pipefail) {
1238
+ const failedIndex = exitCodes.findIndex(code => code !== 0);
1239
+ if (failedIndex !== -1) {
1240
+ const error = new Error(`Pipeline command at index ${failedIndex} failed with exit code ${exitCodes[failedIndex]}`);
1241
+ error.code = exitCodes[failedIndex];
1242
+ throw error;
1243
+ }
1244
+ }
1245
+
1246
+ const result = createResult({
1247
+ code: lastExitCode || 0,
1248
+ stdout: finalOutput,
1249
+ stderr: allStderr,
1250
+ stdin: this.options.stdin && typeof this.options.stdin === 'string' ? this.options.stdin :
1251
+ this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1252
+ });
1253
+
1254
+ this.result = result;
1255
+ this.finished = true;
1256
+
1257
+ this.emit('end', result);
1258
+ this.emit('exit', result.code);
1259
+
1260
+ if (globalShellSettings.errexit && result.code !== 0) {
1261
+ const error = new Error(`Pipeline failed with exit code ${result.code}`);
1262
+ error.code = result.code;
1263
+ error.stdout = result.stdout;
1264
+ error.stderr = result.stderr;
1265
+ error.result = result;
1266
+ throw error;
1267
+ }
1268
+
1269
+ return result;
1270
+ }
1271
+
1272
+
1273
+ async _runMixedStreamingPipeline(commands) {
1274
+ traceFunc('ProcessRunner', '_runMixedStreamingPipeline', 'ENTER', {
1275
+ commandsCount: commands.length
1276
+ });
1277
+
1278
+ // Handle pipelines with both virtual and real commands
1279
+ // Each stage reads from previous stage's output stream
1280
+
1281
+ let currentInputStream = null;
1282
+ let finalOutput = '';
1283
+ let allStderr = '';
1284
+
1285
+ // Set up initial input stream if provided
1286
+ if (this.options.stdin) {
1287
+ const inputData = typeof this.options.stdin === 'string'
1288
+ ? this.options.stdin
1289
+ : this.options.stdin.toString('utf8');
1290
+
1291
+ // Create a readable stream from the input
1292
+ currentInputStream = new ReadableStream({
1293
+ start(controller) {
1294
+ controller.enqueue(new TextEncoder().encode(inputData));
1295
+ controller.close();
1296
+ }
1297
+ });
1298
+ }
1299
+
1300
+ for (let i = 0; i < commands.length; i++) {
1301
+ const command = commands[i];
1302
+ const { cmd, args } = command;
1303
+ const isLastCommand = i === commands.length - 1;
1304
+
1305
+ if (virtualCommandsEnabled && virtualCommands.has(cmd)) {
1306
+ // Handle virtual command with streaming
1307
+ traceBranch('ProcessRunner', '_runMixedStreamingPipeline', 'VIRTUAL_COMMAND', {
1308
+ cmd,
1309
+ commandIndex: i
1310
+ });
1311
+ const handler = virtualCommands.get(cmd);
1312
+ const argValues = args.map(arg => arg.value !== undefined ? arg.value : arg);
1313
+
1314
+ // Read input from stream if available
1315
+ let inputData = '';
1316
+ if (currentInputStream) {
1317
+ const reader = currentInputStream.getReader();
1318
+ try {
1319
+ while (true) {
1320
+ const { done, value } = await reader.read();
1321
+ if (done) break;
1322
+ inputData += new TextDecoder().decode(value);
1323
+ }
1324
+ } finally {
1325
+ reader.releaseLock();
1326
+ }
1327
+ }
1328
+
1329
+ // Check if handler is async generator (streaming)
1330
+ if (handler.constructor.name === 'AsyncGeneratorFunction') {
1331
+ // Create output stream from generator
1332
+ const chunks = [];
1333
+ const self = this; // Capture this context
1334
+ currentInputStream = new ReadableStream({
1335
+ async start(controller) {
1336
+ const { stdin: _, ...optionsWithoutStdin } = self.options;
1337
+ for await (const chunk of handler({ args: argValues, stdin: inputData, ...optionsWithoutStdin })) {
1338
+ const data = Buffer.from(chunk);
1339
+ controller.enqueue(data);
1340
+
1341
+ // Emit for last command
1342
+ if (isLastCommand) {
1343
+ chunks.push(data);
1344
+ if (self.options.mirror) {
1345
+ safeWrite(process.stdout, data);
1346
+ }
1347
+ self.emit('stdout', data);
1348
+ self.emit('data', { type: 'stdout', data });
1349
+ }
1350
+ }
1351
+ controller.close();
1352
+
1353
+ if (isLastCommand) {
1354
+ finalOutput = Buffer.concat(chunks).toString('utf8');
1355
+ }
1356
+ }
1357
+ });
1358
+ } else {
1359
+ // Regular async function
1360
+ const { stdin: _, ...optionsWithoutStdin } = this.options;
1361
+ const result = await handler({ args: argValues, stdin: inputData, ...optionsWithoutStdin });
1362
+ const outputData = result.stdout || '';
413
1363
 
414
- if (this.options.mirror) {
415
- process.stdout.write(buf);
1364
+ if (isLastCommand) {
1365
+ finalOutput = outputData;
1366
+ const buf = Buffer.from(outputData);
1367
+ if (this.options.mirror) {
1368
+ safeWrite(process.stdout, buf);
1369
+ }
1370
+ this.emit('stdout', buf);
1371
+ this.emit('data', { type: 'stdout', data: buf });
416
1372
  }
417
1373
 
418
- this.emit('stdout', buf);
419
- this.emit('data', { type: 'stdout', data: buf });
1374
+ // Create stream from output
1375
+ currentInputStream = new ReadableStream({
1376
+ start(controller) {
1377
+ controller.enqueue(new TextEncoder().encode(outputData));
1378
+ controller.close();
1379
+ }
1380
+ });
1381
+
1382
+ if (result.stderr) {
1383
+ allStderr += result.stderr;
1384
+ }
420
1385
  }
421
-
422
- result = {
423
- code: 0,
424
- stdout: this.options.capture ? Buffer.concat(chunks).toString('utf8') : undefined,
425
- stderr: this.options.capture ? '' : undefined,
426
- stdin: this.options.capture ? stdinData : undefined
427
- };
428
1386
  } else {
429
- // Regular async function
430
- result = await handler(argValues, stdinData, this.options);
1387
+ // Handle real command - spawn with streaming
1388
+ const commandParts = [cmd];
1389
+ for (const arg of args) {
1390
+ if (arg.value !== undefined) {
1391
+ if (arg.quoted) {
1392
+ commandParts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`);
1393
+ } else if (arg.value.includes(' ')) {
1394
+ commandParts.push(`"${arg.value}"`);
1395
+ } else {
1396
+ commandParts.push(arg.value);
1397
+ }
1398
+ } else {
1399
+ if (typeof arg === 'string' && arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
1400
+ commandParts.push(`"${arg}"`);
1401
+ } else {
1402
+ commandParts.push(arg);
1403
+ }
1404
+ }
1405
+ }
1406
+ const commandStr = commandParts.join(' ');
431
1407
 
432
- // Ensure result has required fields, respecting capture option
433
- result = {
434
- code: result.code ?? 0,
435
- stdout: this.options.capture ? (result.stdout ?? '') : undefined,
436
- stderr: this.options.capture ? (result.stderr ?? '') : undefined,
437
- stdin: this.options.capture ? stdinData : undefined,
438
- ...result
439
- };
1408
+ // Spawn the process
1409
+ const proc = Bun.spawn(['sh', '-c', commandStr], {
1410
+ cwd: this.options.cwd,
1411
+ env: this.options.env,
1412
+ stdin: currentInputStream ? 'pipe' : 'ignore',
1413
+ stdout: 'pipe',
1414
+ stderr: 'pipe'
1415
+ });
440
1416
 
441
- // Mirror and emit output
442
- if (result.stdout) {
443
- const buf = Buffer.from(result.stdout);
444
- if (this.options.mirror) {
445
- process.stdout.write(buf);
446
- }
447
- this.emit('stdout', buf);
448
- this.emit('data', { type: 'stdout', data: buf });
1417
+ // Write input stream to process stdin if needed
1418
+ if (currentInputStream && proc.stdin) {
1419
+ const reader = currentInputStream.getReader();
1420
+ const writer = proc.stdin.getWriter ? proc.stdin.getWriter() : proc.stdin;
1421
+
1422
+ (async () => {
1423
+ try {
1424
+ while (true) {
1425
+ const { done, value } = await reader.read();
1426
+ if (done) break;
1427
+ if (writer.write) {
1428
+ try {
1429
+ await writer.write(value);
1430
+ } catch (error) {
1431
+ if (error.code !== 'EPIPE') {
1432
+ trace('ProcessRunner', 'Error writing to stream writer', { error: error.message, code: error.code });
1433
+ }
1434
+ break; // Stop streaming if write fails
1435
+ }
1436
+ } else if (writer.getWriter) {
1437
+ try {
1438
+ const w = writer.getWriter();
1439
+ await w.write(value);
1440
+ w.releaseLock();
1441
+ } catch (error) {
1442
+ if (error.code !== 'EPIPE') {
1443
+ trace('ProcessRunner', 'Error writing to stream writer (getWriter)', { error: error.message, code: error.code });
1444
+ }
1445
+ break; // Stop streaming if write fails
1446
+ }
1447
+ }
1448
+ }
1449
+ } finally {
1450
+ reader.releaseLock();
1451
+ if (writer.close) await writer.close();
1452
+ else if (writer.end) writer.end();
1453
+ }
1454
+ })();
449
1455
  }
450
1456
 
451
- if (result.stderr) {
452
- const buf = Buffer.from(result.stderr);
453
- if (this.options.mirror) {
454
- process.stderr.write(buf);
1457
+ // Set up output stream
1458
+ currentInputStream = proc.stdout;
1459
+
1460
+ // Handle stderr
1461
+ (async () => {
1462
+ for await (const chunk of proc.stderr) {
1463
+ const buf = Buffer.from(chunk);
1464
+ allStderr += buf.toString();
1465
+ if (isLastCommand) {
1466
+ if (this.options.mirror) {
1467
+ safeWrite(process.stderr, buf);
1468
+ }
1469
+ this.emit('stderr', buf);
1470
+ this.emit('data', { type: 'stderr', data: buf });
1471
+ }
455
1472
  }
456
- this.emit('stderr', buf);
457
- this.emit('data', { type: 'stderr', data: buf });
458
- }
459
- }
460
-
461
- // Store result
462
- this.result = result;
463
- this.finished = true;
464
-
465
- // Emit completion events
466
- this.emit('end', result);
467
- this.emit('exit', result.code);
468
-
469
- // Handle shell settings
470
- if (globalShellSettings.errexit && result.code !== 0) {
471
- const error = new Error(`Command failed with exit code ${result.code}`);
472
- error.code = result.code;
473
- error.stdout = result.stdout;
474
- error.stderr = result.stderr;
475
- error.result = result;
476
- throw error;
477
- }
478
-
479
- return result;
480
- } catch (error) {
481
- // Handle errors from virtual commands
482
- const result = {
483
- code: error.code ?? 1,
484
- stdout: error.stdout ?? '',
485
- stderr: error.stderr ?? error.message,
486
- stdin: ''
487
- };
488
-
489
- this.result = result;
490
- this.finished = true;
491
-
492
- if (result.stderr) {
493
- const buf = Buffer.from(result.stderr);
494
- if (this.options.mirror) {
495
- process.stderr.write(buf);
1473
+ })();
1474
+
1475
+ // For last command, stream output
1476
+ if (isLastCommand) {
1477
+ const chunks = [];
1478
+ for await (const chunk of proc.stdout) {
1479
+ const buf = Buffer.from(chunk);
1480
+ chunks.push(buf);
1481
+ if (this.options.mirror) {
1482
+ safeWrite(process.stdout, buf);
1483
+ }
1484
+ this.emit('stdout', buf);
1485
+ this.emit('data', { type: 'stdout', data: buf });
1486
+ }
1487
+ finalOutput = Buffer.concat(chunks).toString('utf8');
1488
+ await proc.exited;
496
1489
  }
497
- this.emit('stderr', buf);
498
- this.emit('data', { type: 'stderr', data: buf });
499
1490
  }
500
-
501
- this.emit('end', result);
502
- this.emit('exit', result.code);
503
-
504
- if (globalShellSettings.errexit) {
505
- throw error;
506
- }
507
-
508
- return result;
509
1491
  }
1492
+
1493
+ const result = createResult({
1494
+ code: 0, // TODO: Track exit codes properly
1495
+ stdout: finalOutput,
1496
+ stderr: allStderr,
1497
+ stdin: this.options.stdin && typeof this.options.stdin === 'string' ? this.options.stdin :
1498
+ this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1499
+ });
1500
+
1501
+ this.result = result;
1502
+ this.finished = true;
1503
+
1504
+ this.emit('end', result);
1505
+ this.emit('exit', result.code);
1506
+
1507
+ return result;
510
1508
  }
511
1509
 
512
- async _runPipeline(commands) {
513
- if (commands.length === 0) {
514
- return createResult({ code: 1, stdout: '', stderr: 'No commands in pipeline', stdin: '' });
515
- }
516
-
1510
+ async _runPipelineNonStreaming(commands) {
1511
+ traceFunc('ProcessRunner', '_runPipelineNonStreaming', 'ENTER', {
1512
+ commandsCount: commands.length
1513
+ });
1514
+
1515
+ // Original non-streaming implementation for fallback (e.g., virtual commands)
517
1516
  let currentOutput = '';
518
1517
  let currentInput = '';
519
1518
 
@@ -531,6 +1530,11 @@ class ProcessRunner extends StreamEmitter {
531
1530
 
532
1531
  // Check if this is a virtual command (only if virtual commands are enabled)
533
1532
  if (virtualCommandsEnabled && virtualCommands.has(cmd)) {
1533
+ traceBranch('ProcessRunner', '_runPipelineNonStreaming', 'VIRTUAL_COMMAND', {
1534
+ cmd,
1535
+ argsCount: args.length
1536
+ });
1537
+
534
1538
  // Run virtual command with current input
535
1539
  const handler = virtualCommands.get(cmd);
536
1540
 
@@ -550,8 +1554,9 @@ class ProcessRunner extends StreamEmitter {
550
1554
 
551
1555
  // Check if handler is async generator (streaming)
552
1556
  if (handler.constructor.name === 'AsyncGeneratorFunction') {
1557
+ traceBranch('ProcessRunner', '_runPipelineNonStreaming', 'ASYNC_GENERATOR', { cmd });
553
1558
  const chunks = [];
554
- for await (const chunk of handler(argValues, currentInput, this.options)) {
1559
+ for await (const chunk of handler({ args: argValues, stdin: currentInput, ...this.options })) {
555
1560
  chunks.push(Buffer.from(chunk));
556
1561
  }
557
1562
  result = {
@@ -562,7 +1567,7 @@ class ProcessRunner extends StreamEmitter {
562
1567
  };
563
1568
  } else {
564
1569
  // Regular async function
565
- result = await handler(argValues, currentInput, this.options);
1570
+ result = await handler({ args: argValues, stdin: currentInput, ...this.options });
566
1571
  result = {
567
1572
  code: result.code ?? 0,
568
1573
  stdout: this.options.capture ? (result.stdout ?? '') : undefined,
@@ -583,7 +1588,7 @@ class ProcessRunner extends StreamEmitter {
583
1588
  if (result.stdout) {
584
1589
  const buf = Buffer.from(result.stdout);
585
1590
  if (this.options.mirror) {
586
- process.stdout.write(buf);
1591
+ safeWrite(process.stdout, buf);
587
1592
  }
588
1593
  this.emit('stdout', buf);
589
1594
  this.emit('data', { type: 'stdout', data: buf });
@@ -592,7 +1597,7 @@ class ProcessRunner extends StreamEmitter {
592
1597
  if (result.stderr) {
593
1598
  const buf = Buffer.from(result.stderr);
594
1599
  if (this.options.mirror) {
595
- process.stderr.write(buf);
1600
+ safeWrite(process.stderr, buf);
596
1601
  }
597
1602
  this.emit('stderr', buf);
598
1603
  this.emit('data', { type: 'stderr', data: buf });
@@ -652,7 +1657,7 @@ class ProcessRunner extends StreamEmitter {
652
1657
  if (result.stderr) {
653
1658
  const buf = Buffer.from(result.stderr);
654
1659
  if (this.options.mirror) {
655
- process.stderr.write(buf);
1660
+ safeWrite(process.stderr, buf);
656
1661
  }
657
1662
  this.emit('stderr', buf);
658
1663
  this.emit('data', { type: 'stderr', data: buf });
@@ -703,49 +1708,131 @@ class ProcessRunner extends StreamEmitter {
703
1708
  console.log(commandStr);
704
1709
  }
705
1710
 
706
- // Execute the system command with current input as stdin
707
- const spawnBun = (argv, stdin) => {
708
- return Bun.spawnSync(argv, {
709
- cwd: this.options.cwd,
710
- env: this.options.env,
711
- stdin: stdin ? Buffer.from(stdin) : undefined,
712
- stdout: 'pipe',
713
- stderr: 'pipe'
714
- });
715
- };
716
-
717
- const spawnNode = (argv, stdin) => {
1711
+ // Execute the system command with current input as stdin (ASYNC VERSION)
1712
+ const spawnNodeAsync = async (argv, stdin, isLastCommand = false) => {
718
1713
  const require = createRequire(import.meta.url);
719
1714
  const cp = require('child_process');
720
- return cp.spawnSync(argv[0], argv.slice(1), {
721
- cwd: this.options.cwd,
722
- env: this.options.env,
723
- input: stdin || undefined,
724
- encoding: 'utf8',
725
- stdio: ['pipe', 'pipe', 'pipe']
1715
+
1716
+ return new Promise((resolve, reject) => {
1717
+ const proc = cp.spawn(argv[0], argv.slice(1), {
1718
+ cwd: this.options.cwd,
1719
+ env: this.options.env,
1720
+ stdio: ['pipe', 'pipe', 'pipe']
1721
+ });
1722
+
1723
+ let stdout = '';
1724
+ let stderr = '';
1725
+
1726
+ proc.stdout.on('data', (chunk) => {
1727
+ stdout += chunk.toString();
1728
+ // If this is the last command, emit streaming data
1729
+ if (isLastCommand) {
1730
+ if (this.options.mirror) {
1731
+ safeWrite(process.stdout, chunk);
1732
+ }
1733
+ this.emit('stdout', chunk);
1734
+ this.emit('data', { type: 'stdout', data: chunk });
1735
+ }
1736
+ });
1737
+
1738
+ proc.stderr.on('data', (chunk) => {
1739
+ stderr += chunk.toString();
1740
+ // If this is the last command, emit streaming data
1741
+ if (isLastCommand) {
1742
+ if (this.options.mirror) {
1743
+ safeWrite(process.stderr, chunk);
1744
+ }
1745
+ this.emit('stderr', chunk);
1746
+ this.emit('data', { type: 'stderr', data: chunk });
1747
+ }
1748
+ });
1749
+
1750
+ proc.on('close', (code) => {
1751
+ resolve({
1752
+ status: code,
1753
+ stdout,
1754
+ stderr
1755
+ });
1756
+ });
1757
+
1758
+ proc.on('error', reject);
1759
+
1760
+ // Add error handler to stdin to prevent unhandled error events
1761
+ if (proc.stdin) {
1762
+ proc.stdin.on('error', (error) => {
1763
+ trace('ProcessRunner', 'stdin error event', {
1764
+ error: error.message,
1765
+ code: error.code,
1766
+ isEPIPE: error.code === 'EPIPE'
1767
+ });
1768
+
1769
+ // Only reject on non-EPIPE errors
1770
+ if (error.code !== 'EPIPE') {
1771
+ reject(error);
1772
+ }
1773
+ // EPIPE errors are expected when pipe is closed, so we ignore them
1774
+ });
1775
+ }
1776
+
1777
+ if (stdin) {
1778
+ // Use safe write to handle potential EPIPE errors
1779
+ trace('ProcessRunner', 'Attempting to write stdin', {
1780
+ hasStdin: !!proc.stdin,
1781
+ writable: proc.stdin?.writable,
1782
+ destroyed: proc.stdin?.destroyed,
1783
+ closed: proc.stdin?.closed,
1784
+ stdinLength: stdin.length
1785
+ });
1786
+
1787
+ if (proc.stdin && proc.stdin.writable && !proc.stdin.destroyed && !proc.stdin.closed) {
1788
+ try {
1789
+ proc.stdin.write(stdin);
1790
+ trace('ProcessRunner', 'Successfully wrote to stdin', { stdinLength: stdin.length });
1791
+ } catch (error) {
1792
+ trace('ProcessRunner', 'Error writing to stdin', {
1793
+ error: error.message,
1794
+ code: error.code,
1795
+ isEPIPE: error.code === 'EPIPE'
1796
+ });
1797
+ if (error.code !== 'EPIPE') {
1798
+ throw error; // Re-throw non-EPIPE errors
1799
+ }
1800
+ }
1801
+ } else {
1802
+ trace('ProcessRunner', 'Skipped writing to stdin - stream not writable', {
1803
+ hasStdin: !!proc.stdin,
1804
+ writable: proc.stdin?.writable,
1805
+ destroyed: proc.stdin?.destroyed,
1806
+ closed: proc.stdin?.closed
1807
+ });
1808
+ }
1809
+ }
1810
+
1811
+ // Safely end the stdin stream
1812
+ if (proc.stdin && typeof proc.stdin.end === 'function' &&
1813
+ proc.stdin.writable && !proc.stdin.destroyed && !proc.stdin.closed) {
1814
+ try {
1815
+ proc.stdin.end();
1816
+ } catch (error) {
1817
+ if (error.code !== 'EPIPE') {
1818
+ trace('ProcessRunner', 'Error ending stdin', { error: error.message });
1819
+ }
1820
+ }
1821
+ }
726
1822
  });
727
1823
  };
728
1824
 
729
1825
  // Execute using shell to handle complex commands
730
1826
  const argv = ['sh', '-c', commandStr];
731
- const proc = isBun ? spawnBun(argv, currentInput) : spawnNode(argv, currentInput);
1827
+ const isLastCommand = (i === commands.length - 1);
1828
+ const proc = await spawnNodeAsync(argv, currentInput, isLastCommand);
732
1829
 
733
- let result;
734
- if (isBun) {
735
- result = {
736
- code: proc.exitCode || 0,
737
- stdout: proc.stdout?.toString('utf8') || '',
738
- stderr: proc.stderr?.toString('utf8') || '',
739
- stdin: currentInput
740
- };
741
- } else {
742
- result = {
743
- code: proc.status || 0,
744
- stdout: proc.stdout || '',
745
- stderr: proc.stderr || '',
746
- stdin: currentInput
747
- };
748
- }
1830
+ let result = {
1831
+ code: proc.status || 0,
1832
+ stdout: proc.stdout || '',
1833
+ stderr: proc.stderr || '',
1834
+ stdin: currentInput
1835
+ };
749
1836
 
750
1837
  // If command failed and pipefail is set, fail the entire pipeline
751
1838
  if (globalShellSettings.pipefail && result.code !== 0) {
@@ -765,7 +1852,7 @@ class ProcessRunner extends StreamEmitter {
765
1852
  this.errChunks.push(Buffer.from(result.stderr));
766
1853
  }
767
1854
  } else {
768
- // This is the last command - emit output and store final result
1855
+ // This is the last command - store final result (streaming already handled during execution)
769
1856
  currentOutput = result.stdout;
770
1857
 
771
1858
  // Collect all accumulated stderr
@@ -777,25 +1864,6 @@ class ProcessRunner extends StreamEmitter {
777
1864
  allStderr += result.stderr;
778
1865
  }
779
1866
 
780
- // Mirror and emit output for final command
781
- if (result.stdout) {
782
- const buf = Buffer.from(result.stdout);
783
- if (this.options.mirror) {
784
- process.stdout.write(buf);
785
- }
786
- this.emit('stdout', buf);
787
- this.emit('data', { type: 'stdout', data: buf });
788
- }
789
-
790
- if (allStderr) {
791
- const buf = Buffer.from(allStderr);
792
- if (this.options.mirror) {
793
- process.stderr.write(buf);
794
- }
795
- this.emit('stderr', buf);
796
- this.emit('data', { type: 'stderr', data: buf });
797
- }
798
-
799
1867
  // Store final result using createResult helper for .text() method compatibility
800
1868
  const finalResult = createResult({
801
1869
  code: result.code,
@@ -841,7 +1909,7 @@ class ProcessRunner extends StreamEmitter {
841
1909
  if (result.stderr) {
842
1910
  const buf = Buffer.from(result.stderr);
843
1911
  if (this.options.mirror) {
844
- process.stderr.write(buf);
1912
+ safeWrite(process.stderr, buf);
845
1913
  }
846
1914
  this.emit('stderr', buf);
847
1915
  this.emit('data', { type: 'stderr', data: buf });
@@ -860,26 +1928,65 @@ class ProcessRunner extends StreamEmitter {
860
1928
  }
861
1929
  }
862
1930
 
1931
+ async _runPipeline(commands) {
1932
+ traceFunc('ProcessRunner', '_runPipeline', 'ENTER', {
1933
+ commandsCount: commands.length
1934
+ });
1935
+
1936
+ if (commands.length === 0) {
1937
+ traceBranch('ProcessRunner', '_runPipeline', 'NO_COMMANDS', {});
1938
+ return createResult({ code: 1, stdout: '', stderr: 'No commands in pipeline', stdin: '' });
1939
+ }
1940
+
1941
+
1942
+ // For true streaming, we need to connect processes via pipes
1943
+ if (isBun) {
1944
+ traceBranch('ProcessRunner', '_runPipeline', 'BUN_STREAMING', {});
1945
+ return this._runStreamingPipelineBun(commands);
1946
+ }
1947
+
1948
+ // For Node.js, fall back to non-streaming implementation for now
1949
+ traceBranch('ProcessRunner', '_runPipeline', 'NODE_NON_STREAMING', {});
1950
+ return this._runPipelineNonStreaming(commands);
1951
+ }
1952
+
863
1953
  // Run programmatic pipeline (.pipe() method)
864
1954
  async _runProgrammaticPipeline(source, destination) {
1955
+ traceFunc('ProcessRunner', '_runProgrammaticPipeline', 'ENTER', {});
1956
+
865
1957
  try {
866
1958
  // Execute the source command first
1959
+ trace('ProcessRunner', 'Executing source command', {});
867
1960
  const sourceResult = await source;
868
1961
 
869
1962
  if (sourceResult.code !== 0) {
870
1963
  // If source failed, return its result
1964
+ traceBranch('ProcessRunner', '_runProgrammaticPipeline', 'SOURCE_FAILED', {
1965
+ code: sourceResult.code,
1966
+ stderr: sourceResult.stderr
1967
+ });
871
1968
  return sourceResult;
872
1969
  }
873
1970
 
874
- // Set the destination's stdin to the source's stdout
875
- destination.options = {
1971
+ // Create a new ProcessRunner with the correct stdin for the destination
1972
+ const destWithStdin = new ProcessRunner(destination.spec, {
876
1973
  ...destination.options,
877
1974
  stdin: sourceResult.stdout
878
- };
1975
+ });
879
1976
 
880
1977
  // Execute the destination command
881
- const destResult = await destination;
1978
+ const destResult = await destWithStdin;
882
1979
 
1980
+ // Debug: Log what destResult looks like
1981
+ trace('ProcessRunner', 'destResult debug', {
1982
+ code: destResult.code,
1983
+ codeType: typeof destResult.code,
1984
+ hasCode: 'code' in destResult,
1985
+ keys: Object.keys(destResult),
1986
+ resultType: typeof destResult,
1987
+ fullResult: JSON.stringify(destResult, null, 2).slice(0, 200)
1988
+ });
1989
+
883
1990
  // Return the final result with combined information
884
1991
  return createResult({
885
1992
  code: destResult.code,
@@ -902,7 +2009,7 @@ class ProcessRunner extends StreamEmitter {
902
2009
 
903
2010
  const buf = Buffer.from(result.stderr);
904
2011
  if (this.options.mirror) {
905
- process.stderr.write(buf);
2012
+ safeWrite(process.stderr, buf);
906
2013
  }
907
2014
  this.emit('stderr', buf);
908
2015
  this.emit('data', { type: 'stderr', data: buf });
@@ -916,13 +2023,20 @@ class ProcessRunner extends StreamEmitter {
916
2023
 
917
2024
  // Async iteration support
918
2025
  async* stream() {
2026
+ traceFunc('ProcessRunner', 'stream', 'ENTER', {
2027
+ started: this.started,
2028
+ finished: this.finished
2029
+ });
2030
+
919
2031
  if (!this.started) {
920
- this._start(); // Start but don't await
2032
+ trace('ProcessRunner', 'Auto-starting async process from stream()', {});
2033
+ this._startAsync(); // Start but don't await
921
2034
  }
922
2035
 
923
2036
  let buffer = [];
924
2037
  let resolve, reject;
925
2038
  let ended = false;
2039
+ let cleanedUp = false;
926
2040
 
927
2041
  const onData = (chunk) => {
928
2042
  buffer.push(chunk);
@@ -955,15 +2069,94 @@ class ProcessRunner extends StreamEmitter {
955
2069
  }
956
2070
  }
957
2071
  } finally {
2072
+ cleanedUp = true;
958
2073
  this.off('data', onData);
959
2074
  this.off('end', onEnd);
2075
+
2076
+ // Kill the process if it's still running when iteration is stopped
2077
+ // This happens when breaking from a for-await loop
2078
+ if (!this.finished) {
2079
+ this.kill();
2080
+ }
2081
+ }
2082
+ }
2083
+
2084
+ // Kill the running process or cancel virtual command
2085
+ kill() {
2086
+ traceFunc('ProcessRunner', 'kill', 'ENTER', {
2087
+ cancelled: this._cancelled,
2088
+ finished: this.finished,
2089
+ hasChild: !!this.child,
2090
+ hasVirtualGenerator: !!this._virtualGenerator
2091
+ });
2092
+
2093
+ // Mark as cancelled for virtual commands
2094
+ this._cancelled = true;
2095
+
2096
+ // Resolve the cancel promise to break the race in virtual command execution
2097
+ if (this._cancelResolve) {
2098
+ trace('ProcessRunner', 'Resolving cancel promise', {});
2099
+ this._cancelResolve();
2100
+ }
2101
+
2102
+ // Abort any async operations
2103
+ if (this._abortController) {
2104
+ trace('ProcessRunner', 'Aborting controller', {});
2105
+ this._abortController.abort();
2106
+ }
2107
+
2108
+ // If it's a virtual generator, try to close it
2109
+ if (this._virtualGenerator && this._virtualGenerator.return) {
2110
+ trace('ProcessRunner', 'Closing virtual generator', {});
2111
+ try {
2112
+ this._virtualGenerator.return();
2113
+ } catch (err) {
2114
+ trace('ProcessRunner', 'Error closing generator', { error: err.message });
2115
+ }
2116
+ }
2117
+
2118
+ // Kill child process if it exists
2119
+ if (this.child && !this.finished) {
2120
+ traceBranch('ProcessRunner', 'hasChild', 'killing', { pid: this.child.pid });
2121
+ try {
2122
+ // Kill the process group to ensure all child processes are terminated
2123
+ if (this.child.pid) {
2124
+ if (isBun) {
2125
+ trace('ProcessRunner', 'Killing Bun process', { pid: this.child.pid });
2126
+ this.child.kill();
2127
+ } else {
2128
+ // In Node.js, kill the process group
2129
+ trace('ProcessRunner', 'Killing Node process group', { pid: this.child.pid });
2130
+ process.kill(-this.child.pid, 'SIGTERM');
2131
+ }
2132
+ }
2133
+ this.finished = true;
2134
+ } catch (err) {
2135
+ // Process might already be dead
2136
+ trace('ProcessRunner', 'Error killing process', { error: err.message });
2137
+ console.error('Error killing process:', err.message);
2138
+ }
960
2139
  }
2140
+
2141
+ // Mark as finished
2142
+ this.finished = true;
2143
+
2144
+ traceFunc('ProcessRunner', 'kill', 'EXIT', {
2145
+ cancelled: this._cancelled,
2146
+ finished: this.finished
2147
+ });
961
2148
  }
962
2149
 
963
2150
  // Programmatic piping support
964
2151
  pipe(destination) {
2152
+ traceFunc('ProcessRunner', 'pipe', 'ENTER', {
2153
+ hasDestination: !!destination,
2154
+ destinationType: destination?.constructor?.name
2155
+ });
2156
+
965
2157
  // If destination is a ProcessRunner, create a pipeline
966
2158
  if (destination instanceof ProcessRunner) {
2159
+ traceBranch('ProcessRunner', 'pipe', 'PROCESS_RUNNER_DEST', {});
967
2160
  // Create a new ProcessRunner that represents the piped operation
968
2161
  const pipeSpec = {
969
2162
  mode: 'pipeline',
@@ -971,49 +2164,64 @@ class ProcessRunner extends StreamEmitter {
971
2164
  destination: destination
972
2165
  };
973
2166
 
974
- return new ProcessRunner(pipeSpec, {
2167
+ const pipeRunner = new ProcessRunner(pipeSpec, {
975
2168
  ...this.options,
976
2169
  capture: destination.options.capture ?? true
977
2170
  });
2171
+
2172
+ traceFunc('ProcessRunner', 'pipe', 'EXIT', { mode: 'pipeline' });
2173
+ return pipeRunner;
978
2174
  }
979
2175
 
980
2176
  // If destination is a template literal result (from $`command`), use its spec
981
2177
  if (destination && destination.spec) {
2178
+ traceBranch('ProcessRunner', 'pipe', 'TEMPLATE_LITERAL_DEST', {});
982
2179
  const destRunner = new ProcessRunner(destination.spec, destination.options);
983
2180
  return this.pipe(destRunner);
984
2181
  }
985
2182
 
2183
+ traceBranch('ProcessRunner', 'pipe', 'INVALID_DEST', {});
986
2184
  throw new Error('pipe() destination must be a ProcessRunner or $`command` result');
987
2185
  }
988
2186
 
989
2187
  // Promise interface (for await)
990
2188
  then(onFulfilled, onRejected) {
991
2189
  if (!this.promise) {
992
- this.promise = this._start();
2190
+ this.promise = this._startAsync();
993
2191
  }
994
2192
  return this.promise.then(onFulfilled, onRejected);
995
2193
  }
996
2194
 
997
2195
  catch(onRejected) {
998
2196
  if (!this.promise) {
999
- this.promise = this._start();
2197
+ this.promise = this._startAsync();
1000
2198
  }
1001
2199
  return this.promise.catch(onRejected);
1002
2200
  }
1003
2201
 
1004
2202
  finally(onFinally) {
1005
2203
  if (!this.promise) {
1006
- this.promise = this._start();
2204
+ this.promise = this._startAsync();
1007
2205
  }
1008
2206
  return this.promise.finally(onFinally);
1009
2207
  }
1010
2208
 
1011
- // Synchronous execution
1012
- sync() {
2209
+ // Internal sync execution
2210
+ _startSync() {
2211
+ traceFunc('ProcessRunner', '_startSync', 'ENTER', {
2212
+ started: this.started,
2213
+ spec: this.spec
2214
+ });
2215
+
1013
2216
  if (this.started) {
2217
+ traceBranch('ProcessRunner', '_startSync', 'ALREADY_STARTED', {});
1014
2218
  throw new Error('Command already started - cannot run sync after async start');
1015
2219
  }
1016
2220
 
2221
+ this.started = true;
2222
+ this._mode = 'sync';
2223
+ trace('ProcessRunner', 'Starting sync execution', { mode: this._mode });
2224
+
1017
2225
  const { cwd, env, stdin } = this.options;
1018
2226
  const argv = this.spec.mode === 'shell' ? ['sh', '-lc', this.spec.command] : [this.spec.file, ...this.spec.args];
1019
2227
 
@@ -1076,8 +2284,8 @@ class ProcessRunner extends StreamEmitter {
1076
2284
 
1077
2285
  // Mirror output if requested (but always capture for result)
1078
2286
  if (this.options.mirror) {
1079
- if (result.stdout) process.stdout.write(result.stdout);
1080
- if (result.stderr) process.stderr.write(result.stderr);
2287
+ if (result.stdout) safeWrite(process.stdout, result.stdout);
2288
+ if (result.stderr) safeWrite(process.stderr, result.stderr);
1081
2289
  }
1082
2290
 
1083
2291
  // Store chunks for events (batched after completion)
@@ -1132,34 +2340,79 @@ class ProcessRunner extends StreamEmitter {
1132
2340
 
1133
2341
  // Public APIs
1134
2342
  async function sh(commandString, options = {}) {
2343
+ traceFunc('API', 'sh', 'ENTER', {
2344
+ command: commandString,
2345
+ options
2346
+ });
2347
+
1135
2348
  const runner = new ProcessRunner({ mode: 'shell', command: commandString }, options);
1136
- return runner._start();
2349
+ const result = await runner._startAsync();
2350
+
2351
+ traceFunc('API', 'sh', 'EXIT', { code: result.code });
2352
+ return result;
1137
2353
  }
1138
2354
 
1139
2355
  async function exec(file, args = [], options = {}) {
2356
+ traceFunc('API', 'exec', 'ENTER', {
2357
+ file,
2358
+ argsCount: args.length,
2359
+ options
2360
+ });
2361
+
1140
2362
  const runner = new ProcessRunner({ mode: 'exec', file, args }, options);
1141
- return runner._start();
2363
+ const result = await runner._startAsync();
2364
+
2365
+ traceFunc('API', 'exec', 'EXIT', { code: result.code });
2366
+ return result;
1142
2367
  }
1143
2368
 
1144
2369
  async function run(commandOrTokens, options = {}) {
2370
+ traceFunc('API', 'run', 'ENTER', {
2371
+ type: typeof commandOrTokens,
2372
+ options
2373
+ });
2374
+
1145
2375
  if (typeof commandOrTokens === 'string') {
2376
+ traceBranch('API', 'run', 'STRING_COMMAND', { command: commandOrTokens });
1146
2377
  return sh(commandOrTokens, { ...options, mirror: false, capture: true });
1147
2378
  }
2379
+
1148
2380
  const [file, ...args] = commandOrTokens;
2381
+ traceBranch('API', 'run', 'TOKEN_ARRAY', { file, argsCount: args.length });
1149
2382
  return exec(file, args, { ...options, mirror: false, capture: true });
1150
2383
  }
1151
2384
 
1152
2385
  // Enhanced tagged template that returns ProcessRunner
1153
2386
  function $tagged(strings, ...values) {
2387
+ traceFunc('API', '$tagged', 'ENTER', {
2388
+ stringsLength: strings.length,
2389
+ valuesLength: values.length
2390
+ });
2391
+
1154
2392
  const cmd = buildShellCommand(strings, values);
1155
- return new ProcessRunner({ mode: 'shell', command: cmd }, { mirror: true, capture: true });
2393
+ const runner = new ProcessRunner({ mode: 'shell', command: cmd }, { mirror: true, capture: true });
2394
+
2395
+ traceFunc('API', '$tagged', 'EXIT', { command: cmd });
2396
+ return runner;
1156
2397
  }
1157
2398
 
1158
2399
  function create(defaultOptions = {}) {
2400
+ traceFunc('API', 'create', 'ENTER', { defaultOptions });
2401
+
1159
2402
  const tagged = (strings, ...values) => {
2403
+ traceFunc('API', 'create.tagged', 'ENTER', {
2404
+ stringsLength: strings.length,
2405
+ valuesLength: values.length
2406
+ });
2407
+
1160
2408
  const cmd = buildShellCommand(strings, values);
1161
- return new ProcessRunner({ mode: 'shell', command: cmd }, { mirror: true, capture: true, ...defaultOptions });
2409
+ const runner = new ProcessRunner({ mode: 'shell', command: cmd }, { mirror: true, capture: true, ...defaultOptions });
2410
+
2411
+ traceFunc('API', 'create.tagged', 'EXIT', { command: cmd });
2412
+ return runner;
1162
2413
  };
2414
+
2415
+ traceFunc('API', 'create', 'EXIT', {});
1163
2416
  return tagged;
1164
2417
  }
1165
2418
 
@@ -1230,12 +2483,21 @@ const shell = {
1230
2483
 
1231
2484
  // Virtual command registration API
1232
2485
  function register(name, handler) {
2486
+ traceFunc('VirtualCommands', 'register', 'ENTER', { name });
2487
+
1233
2488
  virtualCommands.set(name, handler);
2489
+
2490
+ traceFunc('VirtualCommands', 'register', 'EXIT', { registered: true });
1234
2491
  return virtualCommands;
1235
2492
  }
1236
2493
 
1237
2494
  function unregister(name) {
1238
- return virtualCommands.delete(name);
2495
+ traceFunc('VirtualCommands', 'unregister', 'ENTER', { name });
2496
+
2497
+ const deleted = virtualCommands.delete(name);
2498
+
2499
+ traceFunc('VirtualCommands', 'unregister', 'EXIT', { deleted });
2500
+ return deleted;
1239
2501
  }
1240
2502
 
1241
2503
  function listCommands() {
@@ -1255,28 +2517,37 @@ function disableVirtualCommands() {
1255
2517
  // Built-in commands that match Bun.$ functionality
1256
2518
  function registerBuiltins() {
1257
2519
  // cd - change directory
1258
- register('cd', async (args) => {
2520
+ register('cd', async ({ args }) => {
1259
2521
  const target = args[0] || process.env.HOME || process.env.USERPROFILE || '/';
2522
+ trace('VirtualCommand', 'cd: changing directory', { target });
2523
+
1260
2524
  try {
1261
2525
  process.chdir(target);
1262
- return { stdout: process.cwd(), code: 0 };
2526
+ const newDir = process.cwd();
2527
+ trace('VirtualCommand', 'cd: success', { newDir });
2528
+ return { stdout: newDir, code: 0 };
1263
2529
  } catch (error) {
2530
+ trace('VirtualCommand', 'cd: failed', { error: error.message });
1264
2531
  return { stderr: `cd: ${error.message}`, code: 1 };
1265
2532
  }
1266
2533
  });
1267
2534
 
1268
2535
  // pwd - print working directory
1269
- register('pwd', async (args, stdin, options) => {
2536
+ register('pwd', async ({ args, stdin, cwd }) => {
1270
2537
  // If cwd option is provided, return that instead of process.cwd()
1271
- const dir = options?.cwd || process.cwd();
2538
+ const dir = cwd || process.cwd();
2539
+ trace('VirtualCommand', 'pwd: getting directory', { dir });
1272
2540
  return { stdout: dir, code: 0 };
1273
2541
  });
1274
2542
 
1275
2543
  // echo - print arguments
1276
- register('echo', async (args) => {
2544
+ register('echo', async ({ args }) => {
2545
+ trace('VirtualCommand', 'echo: processing', { argsCount: args.length });
2546
+
1277
2547
  let output = args.join(' ');
1278
2548
  if (args.includes('-n')) {
1279
2549
  // Don't add newline
2550
+ traceBranch('VirtualCommand', 'echo', 'NO_NEWLINE', {});
1280
2551
  output = args.filter(arg => arg !== '-n').join(' ');
1281
2552
  } else {
1282
2553
  output += '\n';
@@ -1285,12 +2556,17 @@ function registerBuiltins() {
1285
2556
  });
1286
2557
 
1287
2558
  // sleep - wait for specified time
1288
- register('sleep', async (args) => {
2559
+ register('sleep', async ({ args }) => {
1289
2560
  const seconds = parseFloat(args[0] || 0);
2561
+ trace('VirtualCommand', 'sleep: starting', { seconds });
2562
+
1290
2563
  if (isNaN(seconds) || seconds < 0) {
2564
+ trace('VirtualCommand', 'sleep: invalid interval', { input: args[0] });
1291
2565
  return { stderr: 'sleep: invalid time interval', code: 1 };
1292
2566
  }
2567
+
1293
2568
  await new Promise(resolve => setTimeout(resolve, seconds * 1000));
2569
+ trace('VirtualCommand', 'sleep: completed', { seconds });
1294
2570
  return { stdout: '', code: 0 };
1295
2571
  });
1296
2572
 
@@ -1305,7 +2581,7 @@ function registerBuiltins() {
1305
2581
  });
1306
2582
 
1307
2583
  // which - locate command
1308
- register('which', async (args) => {
2584
+ register('which', async ({ args }) => {
1309
2585
  if (args.length === 0) {
1310
2586
  return { stderr: 'which: missing operand', code: 1 };
1311
2587
  }
@@ -1336,7 +2612,7 @@ function registerBuiltins() {
1336
2612
  });
1337
2613
 
1338
2614
  // exit - exit with code
1339
- register('exit', async (args) => {
2615
+ register('exit', async ({ args }) => {
1340
2616
  const code = parseInt(args[0] || 0);
1341
2617
  if (globalShellSettings.errexit || code !== 0) {
1342
2618
  // For virtual commands, we simulate exit by returning the code
@@ -1346,11 +2622,11 @@ function registerBuiltins() {
1346
2622
  });
1347
2623
 
1348
2624
  // env - print environment variables
1349
- register('env', async (args, stdin, options) => {
2625
+ register('env', async ({ args, stdin, env }) => {
1350
2626
  if (args.length === 0) {
1351
2627
  // Use custom env if provided, otherwise use process.env
1352
- const env = options?.env || process.env;
1353
- const output = Object.entries(env)
2628
+ const envVars = env || process.env;
2629
+ const output = Object.entries(envVars)
1354
2630
  .map(([key, value]) => `${key}=${value}`)
1355
2631
  .join('\n') + '\n';
1356
2632
  return { stdout: output, code: 0 };
@@ -1361,7 +2637,7 @@ function registerBuiltins() {
1361
2637
  });
1362
2638
 
1363
2639
  // cat - read and display file contents
1364
- register('cat', async (args, stdin, options) => {
2640
+ register('cat', async ({ args, stdin, cwd }) => {
1365
2641
  if (args.length === 0) {
1366
2642
  // Read from stdin if no files specified
1367
2643
  return { stdout: stdin || '', code: 0 };
@@ -1378,7 +2654,7 @@ function registerBuiltins() {
1378
2654
 
1379
2655
  try {
1380
2656
  // Resolve path relative to cwd if provided
1381
- const basePath = options?.cwd || process.cwd();
2657
+ const basePath = cwd || process.cwd();
1382
2658
  const fullPath = path.isAbsolute(filename) ? filename : path.join(basePath, filename);
1383
2659
 
1384
2660
  const content = fs.readFileSync(fullPath, 'utf8');
@@ -1401,7 +2677,7 @@ function registerBuiltins() {
1401
2677
  });
1402
2678
 
1403
2679
  // ls - list directory contents
1404
- register('ls', async (args, stdin, options) => {
2680
+ register('ls', async ({ args, stdin, cwd }) => {
1405
2681
  try {
1406
2682
  const fs = await import('fs');
1407
2683
  const path = await import('path');
@@ -1420,7 +2696,7 @@ function registerBuiltins() {
1420
2696
 
1421
2697
  for (const targetPath of targetPaths) {
1422
2698
  // Resolve path relative to cwd if provided
1423
- const basePath = options?.cwd || process.cwd();
2699
+ const basePath = cwd || process.cwd();
1424
2700
  const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(basePath, targetPath);
1425
2701
 
1426
2702
  try {
@@ -1475,7 +2751,7 @@ function registerBuiltins() {
1475
2751
  });
1476
2752
 
1477
2753
  // mkdir - create directories
1478
- register('mkdir', async (args, stdin, options) => {
2754
+ register('mkdir', async ({ args, stdin, cwd }) => {
1479
2755
  if (args.length === 0) {
1480
2756
  return { stderr: 'mkdir: missing operand', code: 1 };
1481
2757
  }
@@ -1490,7 +2766,7 @@ function registerBuiltins() {
1490
2766
 
1491
2767
  for (const dir of dirs) {
1492
2768
  try {
1493
- const basePath = options?.cwd || process.cwd();
2769
+ const basePath = cwd || process.cwd();
1494
2770
  const fullPath = path.isAbsolute(dir) ? dir : path.join(basePath, dir);
1495
2771
 
1496
2772
  if (recursive) {
@@ -1513,7 +2789,7 @@ function registerBuiltins() {
1513
2789
  });
1514
2790
 
1515
2791
  // rm - remove files and directories
1516
- register('rm', async (args, stdin, options) => {
2792
+ register('rm', async ({ args, stdin, cwd }) => {
1517
2793
  if (args.length === 0) {
1518
2794
  return { stderr: 'rm: missing operand', code: 1 };
1519
2795
  }
@@ -1529,7 +2805,7 @@ function registerBuiltins() {
1529
2805
 
1530
2806
  for (const target of targets) {
1531
2807
  try {
1532
- const basePath = options?.cwd || process.cwd();
2808
+ const basePath = cwd || process.cwd();
1533
2809
  const fullPath = path.isAbsolute(target) ? target : path.join(basePath, target);
1534
2810
 
1535
2811
  const stat = fs.statSync(fullPath);
@@ -1562,7 +2838,7 @@ function registerBuiltins() {
1562
2838
  });
1563
2839
 
1564
2840
  // mv - move/rename files and directories
1565
- register('mv', async (args, stdin, options) => {
2841
+ register('mv', async ({ args, stdin, cwd }) => {
1566
2842
  if (args.length < 2) {
1567
2843
  return { stderr: 'mv: missing destination file operand', code: 1 };
1568
2844
  }
@@ -1571,7 +2847,7 @@ function registerBuiltins() {
1571
2847
  const fs = await import('fs');
1572
2848
  const path = await import('path');
1573
2849
 
1574
- const basePath = options?.cwd || process.cwd();
2850
+ const basePath = cwd || process.cwd();
1575
2851
 
1576
2852
  if (args.length === 2) {
1577
2853
  // Simple rename/move
@@ -1643,7 +2919,7 @@ function registerBuiltins() {
1643
2919
  });
1644
2920
 
1645
2921
  // cp - copy files and directories
1646
- register('cp', async (args, stdin, options) => {
2922
+ register('cp', async ({ args, stdin, cwd }) => {
1647
2923
  if (args.length < 2) {
1648
2924
  return { stderr: 'cp: missing destination file operand', code: 1 };
1649
2925
  }
@@ -1656,7 +2932,7 @@ function registerBuiltins() {
1656
2932
  const paths = args.filter(arg => !arg.startsWith('-'));
1657
2933
  const recursive = flags.includes('-r') || flags.includes('-R');
1658
2934
 
1659
- const basePath = options?.cwd || process.cwd();
2935
+ const basePath = cwd || process.cwd();
1660
2936
 
1661
2937
  if (paths.length === 2) {
1662
2938
  // Simple copy
@@ -1740,7 +3016,7 @@ function registerBuiltins() {
1740
3016
  });
1741
3017
 
1742
3018
  // touch - create or update file timestamps
1743
- register('touch', async (args, stdin, options) => {
3019
+ register('touch', async ({ args, stdin, cwd }) => {
1744
3020
  if (args.length === 0) {
1745
3021
  return { stderr: 'touch: missing file operand', code: 1 };
1746
3022
  }
@@ -1749,7 +3025,7 @@ function registerBuiltins() {
1749
3025
  const fs = await import('fs');
1750
3026
  const path = await import('path');
1751
3027
 
1752
- const basePath = options?.cwd || process.cwd();
3028
+ const basePath = cwd || process.cwd();
1753
3029
 
1754
3030
  for (const file of args) {
1755
3031
  try {
@@ -1778,7 +3054,7 @@ function registerBuiltins() {
1778
3054
  });
1779
3055
 
1780
3056
  // basename - extract filename from path
1781
- register('basename', async (args) => {
3057
+ register('basename', async ({ args }) => {
1782
3058
  if (args.length === 0) {
1783
3059
  return { stderr: 'basename: missing operand', code: 1 };
1784
3060
  }
@@ -1803,7 +3079,7 @@ function registerBuiltins() {
1803
3079
  });
1804
3080
 
1805
3081
  // dirname - extract directory from path
1806
- register('dirname', async (args) => {
3082
+ register('dirname', async ({ args }) => {
1807
3083
  if (args.length === 0) {
1808
3084
  return { stderr: 'dirname: missing operand', code: 1 };
1809
3085
  }
@@ -1821,19 +3097,52 @@ function registerBuiltins() {
1821
3097
  });
1822
3098
 
1823
3099
  // yes - output a string repeatedly
1824
- register('yes', async function* (args) {
3100
+ register('yes', async function* ({ args, stdin, isCancelled, signal, ...rest }) {
1825
3101
  const output = args.length > 0 ? args.join(' ') : 'y';
3102
+ trace('VirtualCommand', 'yes: starting infinite generator', { output });
1826
3103
 
1827
3104
  // Generate infinite stream of the output
1828
3105
  while (true) {
3106
+ // Check if cancelled via function or abort signal
3107
+ if (isCancelled && isCancelled()) {
3108
+ trace('VirtualCommand', 'yes: cancelled via function', {});
3109
+ return;
3110
+ }
3111
+ if (signal && signal.aborted) {
3112
+ trace('VirtualCommand', 'yes: cancelled via abort signal', {});
3113
+ return;
3114
+ }
3115
+
1829
3116
  yield output + '\n';
1830
- // Small delay to prevent overwhelming the system
1831
- await new Promise(resolve => setTimeout(resolve, 0));
3117
+
3118
+ // Small delay with abort signal support
3119
+ try {
3120
+ await new Promise((resolve, reject) => {
3121
+ const timeout = setTimeout(resolve, 0);
3122
+
3123
+ // Listen for abort signal if available
3124
+ if (signal) {
3125
+ const abortHandler = () => {
3126
+ clearTimeout(timeout);
3127
+ reject(new Error('Aborted'));
3128
+ };
3129
+
3130
+ if (signal.aborted) {
3131
+ abortHandler();
3132
+ } else {
3133
+ signal.addEventListener('abort', abortHandler, { once: true });
3134
+ }
3135
+ }
3136
+ });
3137
+ } catch (err) {
3138
+ // Aborted
3139
+ return;
3140
+ }
1832
3141
  }
1833
3142
  });
1834
3143
 
1835
3144
  // seq - generate sequence of numbers
1836
- register('seq', async (args) => {
3145
+ register('seq', async ({ args }) => {
1837
3146
  if (args.length === 0) {
1838
3147
  return { stderr: 'seq: missing operand', code: 1 };
1839
3148
  }
@@ -1881,7 +3190,7 @@ function registerBuiltins() {
1881
3190
  });
1882
3191
 
1883
3192
  // test - test file conditions (basic implementation)
1884
- register('test', async (args) => {
3193
+ register('test', async ({ args }) => {
1885
3194
  if (args.length === 0) {
1886
3195
  return { stdout: '', code: 1 };
1887
3196
  }