command-stream 0.0.5 → 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 +1134 -91
  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,
@@ -375,11 +493,19 @@ class ProcessRunner extends StreamEmitter {
375
493
  }
376
494
 
377
495
  async _runVirtual(cmd, args) {
496
+ traceFunc('ProcessRunner', '_runVirtual', 'ENTER', { cmd, args });
497
+
378
498
  const handler = virtualCommands.get(cmd);
379
499
  if (!handler) {
500
+ trace('ProcessRunner', 'Virtual command not found', { cmd });
380
501
  throw new Error(`Virtual command not found: ${cmd}`);
381
502
  }
382
503
 
504
+ trace('ProcessRunner', 'Found virtual command handler', {
505
+ cmd,
506
+ isGenerator: handler.constructor.name === 'AsyncGeneratorFunction'
507
+ });
508
+
383
509
  try {
384
510
  // Prepare stdin
385
511
  let stdinData = '';
@@ -405,18 +531,65 @@ class ProcessRunner extends StreamEmitter {
405
531
 
406
532
  // Check if handler is async generator (streaming)
407
533
  if (handler.constructor.name === 'AsyncGeneratorFunction') {
408
- // Handle streaming virtual command
534
+ // Handle streaming virtual command with cancellation support
409
535
  const chunks = [];
410
- for await (const chunk of handler(argValues, stdinData, this.options)) {
411
- const buf = Buffer.from(chunk);
412
- 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;
413
555
 
414
- if (this.options.mirror) {
415
- 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
+ }
416
588
  }
417
-
418
- this.emit('stdout', buf);
419
- this.emit('data', { type: 'stdout', data: buf });
589
+ } finally {
590
+ // Clean up
591
+ this._virtualGenerator = null;
592
+ this._cancelResolve = null;
420
593
  }
421
594
 
422
595
  result = {
@@ -509,11 +682,627 @@ class ProcessRunner extends StreamEmitter {
509
682
  }
510
683
  }
511
684
 
512
- async _runPipeline(commands) {
513
- if (commands.length === 0) {
514
- return createResult({ code: 1, stdout: '', stderr: 'No commands in pipeline', stdin: '' });
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;
515
877
  }
878
+
879
+ return result;
880
+ }
516
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
+ }
1280
+ }
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;
1298
+ }
1299
+
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)
517
1306
  let currentOutput = '';
518
1307
  let currentInput = '';
519
1308
 
@@ -531,6 +1320,11 @@ class ProcessRunner extends StreamEmitter {
531
1320
 
532
1321
  // Check if this is a virtual command (only if virtual commands are enabled)
533
1322
  if (virtualCommandsEnabled && virtualCommands.has(cmd)) {
1323
+ traceBranch('ProcessRunner', '_runPipelineNonStreaming', 'VIRTUAL_COMMAND', {
1324
+ cmd,
1325
+ argsCount: args.length
1326
+ });
1327
+
534
1328
  // Run virtual command with current input
535
1329
  const handler = virtualCommands.get(cmd);
536
1330
 
@@ -550,6 +1344,7 @@ class ProcessRunner extends StreamEmitter {
550
1344
 
551
1345
  // Check if handler is async generator (streaming)
552
1346
  if (handler.constructor.name === 'AsyncGeneratorFunction') {
1347
+ traceBranch('ProcessRunner', '_runPipelineNonStreaming', 'ASYNC_GENERATOR', { cmd });
553
1348
  const chunks = [];
554
1349
  for await (const chunk of handler(argValues, currentInput, this.options)) {
555
1350
  chunks.push(Buffer.from(chunk));
@@ -703,49 +1498,73 @@ class ProcessRunner extends StreamEmitter {
703
1498
  console.log(commandStr);
704
1499
  }
705
1500
 
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) => {
1501
+ // Execute the system command with current input as stdin (ASYNC VERSION)
1502
+ const spawnNodeAsync = async (argv, stdin, isLastCommand = false) => {
718
1503
  const require = createRequire(import.meta.url);
719
1504
  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']
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();
726
1554
  });
727
1555
  };
728
1556
 
729
1557
  // Execute using shell to handle complex commands
730
1558
  const argv = ['sh', '-c', commandStr];
731
- const proc = isBun ? spawnBun(argv, currentInput) : spawnNode(argv, currentInput);
1559
+ const isLastCommand = (i === commands.length - 1);
1560
+ const proc = await spawnNodeAsync(argv, currentInput, isLastCommand);
732
1561
 
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
- }
1562
+ let result = {
1563
+ code: proc.status || 0,
1564
+ stdout: proc.stdout || '',
1565
+ stderr: proc.stderr || '',
1566
+ stdin: currentInput
1567
+ };
749
1568
 
750
1569
  // If command failed and pipefail is set, fail the entire pipeline
751
1570
  if (globalShellSettings.pipefail && result.code !== 0) {
@@ -765,7 +1584,7 @@ class ProcessRunner extends StreamEmitter {
765
1584
  this.errChunks.push(Buffer.from(result.stderr));
766
1585
  }
767
1586
  } else {
768
- // This is the last command - emit output and store final result
1587
+ // This is the last command - store final result (streaming already handled during execution)
769
1588
  currentOutput = result.stdout;
770
1589
 
771
1590
  // Collect all accumulated stderr
@@ -777,25 +1596,6 @@ class ProcessRunner extends StreamEmitter {
777
1596
  allStderr += result.stderr;
778
1597
  }
779
1598
 
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
1599
  // Store final result using createResult helper for .text() method compatibility
800
1600
  const finalResult = createResult({
801
1601
  code: result.code,
@@ -860,26 +1660,65 @@ class ProcessRunner extends StreamEmitter {
860
1660
  }
861
1661
  }
862
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
+
863
1685
  // Run programmatic pipeline (.pipe() method)
864
1686
  async _runProgrammaticPipeline(source, destination) {
1687
+ traceFunc('ProcessRunner', '_runProgrammaticPipeline', 'ENTER', {});
1688
+
865
1689
  try {
866
1690
  // Execute the source command first
1691
+ trace('ProcessRunner', 'Executing source command', {});
867
1692
  const sourceResult = await source;
868
1693
 
869
1694
  if (sourceResult.code !== 0) {
870
1695
  // If source failed, return its result
1696
+ traceBranch('ProcessRunner', '_runProgrammaticPipeline', 'SOURCE_FAILED', {
1697
+ code: sourceResult.code,
1698
+ stderr: sourceResult.stderr
1699
+ });
871
1700
  return sourceResult;
872
1701
  }
873
1702
 
874
- // Set the destination's stdin to the source's stdout
875
- destination.options = {
1703
+ // Create a new ProcessRunner with the correct stdin for the destination
1704
+ const destWithStdin = new ProcessRunner(destination.spec, {
876
1705
  ...destination.options,
877
1706
  stdin: sourceResult.stdout
878
- };
1707
+ });
879
1708
 
880
1709
  // Execute the destination command
881
- const destResult = await destination;
1710
+ const destResult = await destWithStdin;
882
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
+
883
1722
  // Return the final result with combined information
884
1723
  return createResult({
885
1724
  code: destResult.code,
@@ -916,13 +1755,20 @@ class ProcessRunner extends StreamEmitter {
916
1755
 
917
1756
  // Async iteration support
918
1757
  async* stream() {
1758
+ traceFunc('ProcessRunner', 'stream', 'ENTER', {
1759
+ started: this.started,
1760
+ finished: this.finished
1761
+ });
1762
+
919
1763
  if (!this.started) {
920
- this._start(); // Start but don't await
1764
+ trace('ProcessRunner', 'Auto-starting async process from stream()', {});
1765
+ this._startAsync(); // Start but don't await
921
1766
  }
922
1767
 
923
1768
  let buffer = [];
924
1769
  let resolve, reject;
925
1770
  let ended = false;
1771
+ let cleanedUp = false;
926
1772
 
927
1773
  const onData = (chunk) => {
928
1774
  buffer.push(chunk);
@@ -955,15 +1801,94 @@ class ProcessRunner extends StreamEmitter {
955
1801
  }
956
1802
  }
957
1803
  } finally {
1804
+ cleanedUp = true;
958
1805
  this.off('data', onData);
959
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
+ }
960
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
+ });
961
1880
  }
962
1881
 
963
1882
  // Programmatic piping support
964
1883
  pipe(destination) {
1884
+ traceFunc('ProcessRunner', 'pipe', 'ENTER', {
1885
+ hasDestination: !!destination,
1886
+ destinationType: destination?.constructor?.name
1887
+ });
1888
+
965
1889
  // If destination is a ProcessRunner, create a pipeline
966
1890
  if (destination instanceof ProcessRunner) {
1891
+ traceBranch('ProcessRunner', 'pipe', 'PROCESS_RUNNER_DEST', {});
967
1892
  // Create a new ProcessRunner that represents the piped operation
968
1893
  const pipeSpec = {
969
1894
  mode: 'pipeline',
@@ -971,49 +1896,64 @@ class ProcessRunner extends StreamEmitter {
971
1896
  destination: destination
972
1897
  };
973
1898
 
974
- return new ProcessRunner(pipeSpec, {
1899
+ const pipeRunner = new ProcessRunner(pipeSpec, {
975
1900
  ...this.options,
976
1901
  capture: destination.options.capture ?? true
977
1902
  });
1903
+
1904
+ traceFunc('ProcessRunner', 'pipe', 'EXIT', { mode: 'pipeline' });
1905
+ return pipeRunner;
978
1906
  }
979
1907
 
980
1908
  // If destination is a template literal result (from $`command`), use its spec
981
1909
  if (destination && destination.spec) {
1910
+ traceBranch('ProcessRunner', 'pipe', 'TEMPLATE_LITERAL_DEST', {});
982
1911
  const destRunner = new ProcessRunner(destination.spec, destination.options);
983
1912
  return this.pipe(destRunner);
984
1913
  }
985
1914
 
1915
+ traceBranch('ProcessRunner', 'pipe', 'INVALID_DEST', {});
986
1916
  throw new Error('pipe() destination must be a ProcessRunner or $`command` result');
987
1917
  }
988
1918
 
989
1919
  // Promise interface (for await)
990
1920
  then(onFulfilled, onRejected) {
991
1921
  if (!this.promise) {
992
- this.promise = this._start();
1922
+ this.promise = this._startAsync();
993
1923
  }
994
1924
  return this.promise.then(onFulfilled, onRejected);
995
1925
  }
996
1926
 
997
1927
  catch(onRejected) {
998
1928
  if (!this.promise) {
999
- this.promise = this._start();
1929
+ this.promise = this._startAsync();
1000
1930
  }
1001
1931
  return this.promise.catch(onRejected);
1002
1932
  }
1003
1933
 
1004
1934
  finally(onFinally) {
1005
1935
  if (!this.promise) {
1006
- this.promise = this._start();
1936
+ this.promise = this._startAsync();
1007
1937
  }
1008
1938
  return this.promise.finally(onFinally);
1009
1939
  }
1010
1940
 
1011
- // Synchronous execution
1012
- sync() {
1941
+ // Internal sync execution
1942
+ _startSync() {
1943
+ traceFunc('ProcessRunner', '_startSync', 'ENTER', {
1944
+ started: this.started,
1945
+ spec: this.spec
1946
+ });
1947
+
1013
1948
  if (this.started) {
1949
+ traceBranch('ProcessRunner', '_startSync', 'ALREADY_STARTED', {});
1014
1950
  throw new Error('Command already started - cannot run sync after async start');
1015
1951
  }
1016
1952
 
1953
+ this.started = true;
1954
+ this._mode = 'sync';
1955
+ trace('ProcessRunner', 'Starting sync execution', { mode: this._mode });
1956
+
1017
1957
  const { cwd, env, stdin } = this.options;
1018
1958
  const argv = this.spec.mode === 'shell' ? ['sh', '-lc', this.spec.command] : [this.spec.file, ...this.spec.args];
1019
1959
 
@@ -1132,34 +2072,79 @@ class ProcessRunner extends StreamEmitter {
1132
2072
 
1133
2073
  // Public APIs
1134
2074
  async function sh(commandString, options = {}) {
2075
+ traceFunc('API', 'sh', 'ENTER', {
2076
+ command: commandString,
2077
+ options
2078
+ });
2079
+
1135
2080
  const runner = new ProcessRunner({ mode: 'shell', command: commandString }, options);
1136
- return runner._start();
2081
+ const result = await runner._startAsync();
2082
+
2083
+ traceFunc('API', 'sh', 'EXIT', { code: result.code });
2084
+ return result;
1137
2085
  }
1138
2086
 
1139
2087
  async function exec(file, args = [], options = {}) {
2088
+ traceFunc('API', 'exec', 'ENTER', {
2089
+ file,
2090
+ argsCount: args.length,
2091
+ options
2092
+ });
2093
+
1140
2094
  const runner = new ProcessRunner({ mode: 'exec', file, args }, options);
1141
- return runner._start();
2095
+ const result = await runner._startAsync();
2096
+
2097
+ traceFunc('API', 'exec', 'EXIT', { code: result.code });
2098
+ return result;
1142
2099
  }
1143
2100
 
1144
2101
  async function run(commandOrTokens, options = {}) {
2102
+ traceFunc('API', 'run', 'ENTER', {
2103
+ type: typeof commandOrTokens,
2104
+ options
2105
+ });
2106
+
1145
2107
  if (typeof commandOrTokens === 'string') {
2108
+ traceBranch('API', 'run', 'STRING_COMMAND', { command: commandOrTokens });
1146
2109
  return sh(commandOrTokens, { ...options, mirror: false, capture: true });
1147
2110
  }
2111
+
1148
2112
  const [file, ...args] = commandOrTokens;
2113
+ traceBranch('API', 'run', 'TOKEN_ARRAY', { file, argsCount: args.length });
1149
2114
  return exec(file, args, { ...options, mirror: false, capture: true });
1150
2115
  }
1151
2116
 
1152
2117
  // Enhanced tagged template that returns ProcessRunner
1153
2118
  function $tagged(strings, ...values) {
2119
+ traceFunc('API', '$tagged', 'ENTER', {
2120
+ stringsLength: strings.length,
2121
+ valuesLength: values.length
2122
+ });
2123
+
1154
2124
  const cmd = buildShellCommand(strings, values);
1155
- 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;
1156
2129
  }
1157
2130
 
1158
2131
  function create(defaultOptions = {}) {
2132
+ traceFunc('API', 'create', 'ENTER', { defaultOptions });
2133
+
1159
2134
  const tagged = (strings, ...values) => {
2135
+ traceFunc('API', 'create.tagged', 'ENTER', {
2136
+ stringsLength: strings.length,
2137
+ valuesLength: values.length
2138
+ });
2139
+
1160
2140
  const cmd = buildShellCommand(strings, values);
1161
- 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;
1162
2145
  };
2146
+
2147
+ traceFunc('API', 'create', 'EXIT', {});
1163
2148
  return tagged;
1164
2149
  }
1165
2150
 
@@ -1230,12 +2215,21 @@ const shell = {
1230
2215
 
1231
2216
  // Virtual command registration API
1232
2217
  function register(name, handler) {
2218
+ traceFunc('VirtualCommands', 'register', 'ENTER', { name });
2219
+
1233
2220
  virtualCommands.set(name, handler);
2221
+
2222
+ traceFunc('VirtualCommands', 'register', 'EXIT', { registered: true });
1234
2223
  return virtualCommands;
1235
2224
  }
1236
2225
 
1237
2226
  function unregister(name) {
1238
- 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;
1239
2233
  }
1240
2234
 
1241
2235
  function listCommands() {
@@ -1257,10 +2251,15 @@ function registerBuiltins() {
1257
2251
  // cd - change directory
1258
2252
  register('cd', async (args) => {
1259
2253
  const target = args[0] || process.env.HOME || process.env.USERPROFILE || '/';
2254
+ trace('VirtualCommand', 'cd: changing directory', { target });
2255
+
1260
2256
  try {
1261
2257
  process.chdir(target);
1262
- return { stdout: process.cwd(), code: 0 };
2258
+ const newDir = process.cwd();
2259
+ trace('VirtualCommand', 'cd: success', { newDir });
2260
+ return { stdout: newDir, code: 0 };
1263
2261
  } catch (error) {
2262
+ trace('VirtualCommand', 'cd: failed', { error: error.message });
1264
2263
  return { stderr: `cd: ${error.message}`, code: 1 };
1265
2264
  }
1266
2265
  });
@@ -1269,14 +2268,18 @@ function registerBuiltins() {
1269
2268
  register('pwd', async (args, stdin, options) => {
1270
2269
  // If cwd option is provided, return that instead of process.cwd()
1271
2270
  const dir = options?.cwd || process.cwd();
2271
+ trace('VirtualCommand', 'pwd: getting directory', { dir });
1272
2272
  return { stdout: dir, code: 0 };
1273
2273
  });
1274
2274
 
1275
2275
  // echo - print arguments
1276
2276
  register('echo', async (args) => {
2277
+ trace('VirtualCommand', 'echo: processing', { argsCount: args.length });
2278
+
1277
2279
  let output = args.join(' ');
1278
2280
  if (args.includes('-n')) {
1279
2281
  // Don't add newline
2282
+ traceBranch('VirtualCommand', 'echo', 'NO_NEWLINE', {});
1280
2283
  output = args.filter(arg => arg !== '-n').join(' ');
1281
2284
  } else {
1282
2285
  output += '\n';
@@ -1287,10 +2290,15 @@ function registerBuiltins() {
1287
2290
  // sleep - wait for specified time
1288
2291
  register('sleep', async (args) => {
1289
2292
  const seconds = parseFloat(args[0] || 0);
2293
+ trace('VirtualCommand', 'sleep: starting', { seconds });
2294
+
1290
2295
  if (isNaN(seconds) || seconds < 0) {
2296
+ trace('VirtualCommand', 'sleep: invalid interval', { input: args[0] });
1291
2297
  return { stderr: 'sleep: invalid time interval', code: 1 };
1292
2298
  }
2299
+
1293
2300
  await new Promise(resolve => setTimeout(resolve, seconds * 1000));
2301
+ trace('VirtualCommand', 'sleep: completed', { seconds });
1294
2302
  return { stdout: '', code: 0 };
1295
2303
  });
1296
2304
 
@@ -1821,14 +2829,49 @@ function registerBuiltins() {
1821
2829
  });
1822
2830
 
1823
2831
  // yes - output a string repeatedly
1824
- register('yes', async function* (args) {
2832
+ register('yes', async function* (args, stdin, options) {
1825
2833
  const output = args.length > 0 ? args.join(' ') : 'y';
2834
+ trace('VirtualCommand', 'yes: starting infinite generator', { output });
1826
2835
 
1827
2836
  // Generate infinite stream of the output
1828
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
+
1829
2850
  yield output + '\n';
1830
- // Small delay to prevent overwhelming the system
1831
- 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
+ }
1832
2875
  }
1833
2876
  });
1834
2877