command-stream 0.1.0 → 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 (2) hide show
  1. package/$.mjs +352 -86
  2. package/package.json +1 -1
package/$.mjs CHANGED
@@ -36,6 +36,75 @@ function traceFunc(category, funcName, phase, data = {}) {
36
36
  trace(category, `${funcName} ${phase}`, data);
37
37
  }
38
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
+
39
108
  // Global shell settings (like bash set -e / set +e)
40
109
  let globalShellSettings = {
41
110
  errexit: false, // set -e equivalent: exit on error
@@ -192,6 +261,79 @@ class ProcessRunner extends StreamEmitter {
192
261
  this._cancelled = false;
193
262
  this._virtualGenerator = null;
194
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
+ }
284
+ }
285
+
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);
195
337
  }
196
338
 
197
339
  // Unified start method that can work in both async and sync modes
@@ -304,7 +446,7 @@ class ProcessRunner extends StreamEmitter {
304
446
  // Setup stdout streaming
305
447
  const outPump = pumpReadable(this.child.stdout, async (buf) => {
306
448
  if (this.options.capture) this.outChunks.push(buf);
307
- if (this.options.mirror) process.stdout.write(buf);
449
+ if (this.options.mirror) safeWrite(process.stdout, buf);
308
450
 
309
451
  // Emit chunk events
310
452
  this.emit('stdout', buf);
@@ -314,7 +456,7 @@ class ProcessRunner extends StreamEmitter {
314
456
  // Setup stderr streaming
315
457
  const errPump = pumpReadable(this.child.stderr, async (buf) => {
316
458
  if (this.options.capture) this.errChunks.push(buf);
317
- if (this.options.mirror) process.stderr.write(buf);
459
+ if (this.options.mirror) safeWrite(process.stderr, buf);
318
460
 
319
461
  // Emit chunk events
320
462
  this.emit('stderr', buf);
@@ -394,7 +536,27 @@ class ProcessRunner extends StreamEmitter {
394
536
  const buf = asBuffer(chunk);
395
537
  captureChunks && captureChunks.push(buf);
396
538
  if (bunWriter) await bunWriter.write(buf);
397
- 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
+ }
398
560
  else if (isBun && typeof Bun.write === 'function') await Bun.write(child.stdin, buf);
399
561
  }
400
562
  if (bunWriter) await bunWriter.close();
@@ -405,8 +567,14 @@ class ProcessRunner extends StreamEmitter {
405
567
  if (isBun && this.child.stdin && typeof this.child.stdin.getWriter === 'function') {
406
568
  const w = this.child.stdin.getWriter();
407
569
  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer, buf.byteOffset ?? 0, buf.byteLength);
408
- await w.write(bytes);
409
- 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
+ }
410
578
  } else if (this.child.stdin && typeof this.child.stdin.write === 'function') {
411
579
  this.child.stdin.end(buf);
412
580
  } else if (isBun && typeof Bun.write === 'function') {
@@ -541,7 +709,7 @@ class ProcessRunner extends StreamEmitter {
541
709
  signal: this._abortController.signal
542
710
  };
543
711
 
544
- const generator = handler(argValues, stdinData, commandOptions);
712
+ const generator = handler({ args: argValues, stdin: stdinData, ...commandOptions });
545
713
  this._virtualGenerator = generator;
546
714
 
547
715
  // Create a promise that resolves when cancelled
@@ -578,7 +746,7 @@ class ProcessRunner extends StreamEmitter {
578
746
  // Only output if not cancelled
579
747
  if (!this._cancelled) {
580
748
  if (this.options.mirror) {
581
- process.stdout.write(buf);
749
+ safeWrite(process.stdout, buf);
582
750
  }
583
751
 
584
752
  this.emit('stdout', buf);
@@ -600,7 +768,7 @@ class ProcessRunner extends StreamEmitter {
600
768
  };
601
769
  } else {
602
770
  // Regular async function
603
- result = await handler(argValues, stdinData, this.options);
771
+ result = await handler({ args: argValues, stdin: stdinData, ...this.options });
604
772
 
605
773
  // Ensure result has required fields, respecting capture option
606
774
  result = {
@@ -615,7 +783,7 @@ class ProcessRunner extends StreamEmitter {
615
783
  if (result.stdout) {
616
784
  const buf = Buffer.from(result.stdout);
617
785
  if (this.options.mirror) {
618
- process.stdout.write(buf);
786
+ safeWrite(process.stdout, buf);
619
787
  }
620
788
  this.emit('stdout', buf);
621
789
  this.emit('data', { type: 'stdout', data: buf });
@@ -624,7 +792,7 @@ class ProcessRunner extends StreamEmitter {
624
792
  if (result.stderr) {
625
793
  const buf = Buffer.from(result.stderr);
626
794
  if (this.options.mirror) {
627
- process.stderr.write(buf);
795
+ safeWrite(process.stderr, buf);
628
796
  }
629
797
  this.emit('stderr', buf);
630
798
  this.emit('data', { type: 'stderr', data: buf });
@@ -665,7 +833,7 @@ class ProcessRunner extends StreamEmitter {
665
833
  if (result.stderr) {
666
834
  const buf = Buffer.from(result.stderr);
667
835
  if (this.options.mirror) {
668
- process.stderr.write(buf);
836
+ safeWrite(process.stderr, buf);
669
837
  }
670
838
  this.emit('stderr', buf);
671
839
  this.emit('data', { type: 'stderr', data: buf });
@@ -794,13 +962,26 @@ class ProcessRunner extends StreamEmitter {
794
962
 
795
963
  // Write stdin data if needed for first process
796
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
+
797
974
  (async () => {
798
975
  try {
799
976
  // Bun's FileSink has write and end methods
800
- await proc.stdin.write(stdinData);
801
- await proc.stdin.end();
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
+ }
802
981
  } catch (e) {
803
- console.error('Error writing stdin:', e);
982
+ if (e.code !== 'EPIPE') {
983
+ trace('ProcessRunner', 'Error writing stdin (Bun)', { error: e.message, code: e.code });
984
+ }
804
985
  }
805
986
  })();
806
987
  }
@@ -815,7 +996,7 @@ class ProcessRunner extends StreamEmitter {
815
996
  // Only emit stderr for the last command
816
997
  if (i === commands.length - 1) {
817
998
  if (this.options.mirror) {
818
- process.stderr.write(buf);
999
+ safeWrite(process.stderr, buf);
819
1000
  }
820
1001
  this.emit('stderr', buf);
821
1002
  this.emit('data', { type: 'stderr', data: buf });
@@ -833,7 +1014,7 @@ class ProcessRunner extends StreamEmitter {
833
1014
  const buf = Buffer.from(chunk);
834
1015
  finalOutput += buf.toString();
835
1016
  if (this.options.mirror) {
836
- process.stdout.write(buf);
1017
+ safeWrite(process.stdout, buf);
837
1018
  }
838
1019
  this.emit('stdout', buf);
839
1020
  this.emit('data', { type: 'stdout', data: buf });
@@ -959,11 +1140,24 @@ class ProcessRunner extends StreamEmitter {
959
1140
 
960
1141
  // Write stdin data if needed for first process
961
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
+
962
1152
  try {
963
- await proc.stdin.write(stdinData);
964
- await proc.stdin.end();
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
+ }
965
1157
  } catch (e) {
966
- // Ignore stdin errors
1158
+ if (e.code !== 'EPIPE') {
1159
+ trace('ProcessRunner', 'Error writing stdin (Node stream)', { error: e.message, code: e.code });
1160
+ }
967
1161
  }
968
1162
  }
969
1163
 
@@ -982,7 +1176,7 @@ class ProcessRunner extends StreamEmitter {
982
1176
  // Emit from the first process for real-time updates
983
1177
  const buf = Buffer.from(chunk);
984
1178
  if (this.options.mirror) {
985
- process.stdout.write(buf);
1179
+ safeWrite(process.stdout, buf);
986
1180
  }
987
1181
  this.emit('stdout', buf);
988
1182
  this.emit('data', { type: 'stdout', data: buf });
@@ -1007,7 +1201,7 @@ class ProcessRunner extends StreamEmitter {
1007
1201
  allStderr += buf.toString();
1008
1202
  if (i === commands.length - 1) {
1009
1203
  if (this.options.mirror) {
1010
- process.stderr.write(buf);
1204
+ safeWrite(process.stderr, buf);
1011
1205
  }
1012
1206
  this.emit('stderr', buf);
1013
1207
  this.emit('data', { type: 'stderr', data: buf });
@@ -1028,7 +1222,7 @@ class ProcessRunner extends StreamEmitter {
1028
1222
  finalOutput += buf.toString();
1029
1223
  if (shouldEmitFromLast) {
1030
1224
  if (this.options.mirror) {
1031
- process.stdout.write(buf);
1225
+ safeWrite(process.stdout, buf);
1032
1226
  }
1033
1227
  this.emit('stdout', buf);
1034
1228
  this.emit('data', { type: 'stdout', data: buf });
@@ -1139,7 +1333,8 @@ class ProcessRunner extends StreamEmitter {
1139
1333
  const self = this; // Capture this context
1140
1334
  currentInputStream = new ReadableStream({
1141
1335
  async start(controller) {
1142
- for await (const chunk of handler(argValues, inputData, {})) {
1336
+ const { stdin: _, ...optionsWithoutStdin } = self.options;
1337
+ for await (const chunk of handler({ args: argValues, stdin: inputData, ...optionsWithoutStdin })) {
1143
1338
  const data = Buffer.from(chunk);
1144
1339
  controller.enqueue(data);
1145
1340
 
@@ -1147,7 +1342,7 @@ class ProcessRunner extends StreamEmitter {
1147
1342
  if (isLastCommand) {
1148
1343
  chunks.push(data);
1149
1344
  if (self.options.mirror) {
1150
- process.stdout.write(data);
1345
+ safeWrite(process.stdout, data);
1151
1346
  }
1152
1347
  self.emit('stdout', data);
1153
1348
  self.emit('data', { type: 'stdout', data });
@@ -1162,14 +1357,15 @@ class ProcessRunner extends StreamEmitter {
1162
1357
  });
1163
1358
  } else {
1164
1359
  // Regular async function
1165
- const result = await handler(argValues, inputData, {});
1360
+ const { stdin: _, ...optionsWithoutStdin } = this.options;
1361
+ const result = await handler({ args: argValues, stdin: inputData, ...optionsWithoutStdin });
1166
1362
  const outputData = result.stdout || '';
1167
1363
 
1168
1364
  if (isLastCommand) {
1169
1365
  finalOutput = outputData;
1170
1366
  const buf = Buffer.from(outputData);
1171
1367
  if (this.options.mirror) {
1172
- process.stdout.write(buf);
1368
+ safeWrite(process.stdout, buf);
1173
1369
  }
1174
1370
  this.emit('stdout', buf);
1175
1371
  this.emit('data', { type: 'stdout', data: buf });
@@ -1229,11 +1425,25 @@ class ProcessRunner extends StreamEmitter {
1229
1425
  const { done, value } = await reader.read();
1230
1426
  if (done) break;
1231
1427
  if (writer.write) {
1232
- await writer.write(value);
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
+ }
1233
1436
  } else if (writer.getWriter) {
1234
- const w = writer.getWriter();
1235
- await w.write(value);
1236
- w.releaseLock();
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
+ }
1237
1447
  }
1238
1448
  }
1239
1449
  } finally {
@@ -1254,7 +1464,7 @@ class ProcessRunner extends StreamEmitter {
1254
1464
  allStderr += buf.toString();
1255
1465
  if (isLastCommand) {
1256
1466
  if (this.options.mirror) {
1257
- process.stderr.write(buf);
1467
+ safeWrite(process.stderr, buf);
1258
1468
  }
1259
1469
  this.emit('stderr', buf);
1260
1470
  this.emit('data', { type: 'stderr', data: buf });
@@ -1269,7 +1479,7 @@ class ProcessRunner extends StreamEmitter {
1269
1479
  const buf = Buffer.from(chunk);
1270
1480
  chunks.push(buf);
1271
1481
  if (this.options.mirror) {
1272
- process.stdout.write(buf);
1482
+ safeWrite(process.stdout, buf);
1273
1483
  }
1274
1484
  this.emit('stdout', buf);
1275
1485
  this.emit('data', { type: 'stdout', data: buf });
@@ -1346,7 +1556,7 @@ class ProcessRunner extends StreamEmitter {
1346
1556
  if (handler.constructor.name === 'AsyncGeneratorFunction') {
1347
1557
  traceBranch('ProcessRunner', '_runPipelineNonStreaming', 'ASYNC_GENERATOR', { cmd });
1348
1558
  const chunks = [];
1349
- for await (const chunk of handler(argValues, currentInput, this.options)) {
1559
+ for await (const chunk of handler({ args: argValues, stdin: currentInput, ...this.options })) {
1350
1560
  chunks.push(Buffer.from(chunk));
1351
1561
  }
1352
1562
  result = {
@@ -1357,7 +1567,7 @@ class ProcessRunner extends StreamEmitter {
1357
1567
  };
1358
1568
  } else {
1359
1569
  // Regular async function
1360
- result = await handler(argValues, currentInput, this.options);
1570
+ result = await handler({ args: argValues, stdin: currentInput, ...this.options });
1361
1571
  result = {
1362
1572
  code: result.code ?? 0,
1363
1573
  stdout: this.options.capture ? (result.stdout ?? '') : undefined,
@@ -1378,7 +1588,7 @@ class ProcessRunner extends StreamEmitter {
1378
1588
  if (result.stdout) {
1379
1589
  const buf = Buffer.from(result.stdout);
1380
1590
  if (this.options.mirror) {
1381
- process.stdout.write(buf);
1591
+ safeWrite(process.stdout, buf);
1382
1592
  }
1383
1593
  this.emit('stdout', buf);
1384
1594
  this.emit('data', { type: 'stdout', data: buf });
@@ -1387,7 +1597,7 @@ class ProcessRunner extends StreamEmitter {
1387
1597
  if (result.stderr) {
1388
1598
  const buf = Buffer.from(result.stderr);
1389
1599
  if (this.options.mirror) {
1390
- process.stderr.write(buf);
1600
+ safeWrite(process.stderr, buf);
1391
1601
  }
1392
1602
  this.emit('stderr', buf);
1393
1603
  this.emit('data', { type: 'stderr', data: buf });
@@ -1447,7 +1657,7 @@ class ProcessRunner extends StreamEmitter {
1447
1657
  if (result.stderr) {
1448
1658
  const buf = Buffer.from(result.stderr);
1449
1659
  if (this.options.mirror) {
1450
- process.stderr.write(buf);
1660
+ safeWrite(process.stderr, buf);
1451
1661
  }
1452
1662
  this.emit('stderr', buf);
1453
1663
  this.emit('data', { type: 'stderr', data: buf });
@@ -1518,7 +1728,7 @@ class ProcessRunner extends StreamEmitter {
1518
1728
  // If this is the last command, emit streaming data
1519
1729
  if (isLastCommand) {
1520
1730
  if (this.options.mirror) {
1521
- process.stdout.write(chunk);
1731
+ safeWrite(process.stdout, chunk);
1522
1732
  }
1523
1733
  this.emit('stdout', chunk);
1524
1734
  this.emit('data', { type: 'stdout', data: chunk });
@@ -1530,7 +1740,7 @@ class ProcessRunner extends StreamEmitter {
1530
1740
  // If this is the last command, emit streaming data
1531
1741
  if (isLastCommand) {
1532
1742
  if (this.options.mirror) {
1533
- process.stderr.write(chunk);
1743
+ safeWrite(process.stderr, chunk);
1534
1744
  }
1535
1745
  this.emit('stderr', chunk);
1536
1746
  this.emit('data', { type: 'stderr', data: chunk });
@@ -1547,10 +1757,68 @@ class ProcessRunner extends StreamEmitter {
1547
1757
 
1548
1758
  proc.on('error', reject);
1549
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
+
1550
1777
  if (stdin) {
1551
- proc.stdin.write(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
+ }
1552
1821
  }
1553
- proc.stdin.end();
1554
1822
  });
1555
1823
  };
1556
1824
 
@@ -1641,7 +1909,7 @@ class ProcessRunner extends StreamEmitter {
1641
1909
  if (result.stderr) {
1642
1910
  const buf = Buffer.from(result.stderr);
1643
1911
  if (this.options.mirror) {
1644
- process.stderr.write(buf);
1912
+ safeWrite(process.stderr, buf);
1645
1913
  }
1646
1914
  this.emit('stderr', buf);
1647
1915
  this.emit('data', { type: 'stderr', data: buf });
@@ -1741,7 +2009,7 @@ class ProcessRunner extends StreamEmitter {
1741
2009
 
1742
2010
  const buf = Buffer.from(result.stderr);
1743
2011
  if (this.options.mirror) {
1744
- process.stderr.write(buf);
2012
+ safeWrite(process.stderr, buf);
1745
2013
  }
1746
2014
  this.emit('stderr', buf);
1747
2015
  this.emit('data', { type: 'stderr', data: buf });
@@ -2016,8 +2284,8 @@ class ProcessRunner extends StreamEmitter {
2016
2284
 
2017
2285
  // Mirror output if requested (but always capture for result)
2018
2286
  if (this.options.mirror) {
2019
- if (result.stdout) process.stdout.write(result.stdout);
2020
- 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);
2021
2289
  }
2022
2290
 
2023
2291
  // Store chunks for events (batched after completion)
@@ -2249,7 +2517,7 @@ function disableVirtualCommands() {
2249
2517
  // Built-in commands that match Bun.$ functionality
2250
2518
  function registerBuiltins() {
2251
2519
  // cd - change directory
2252
- register('cd', async (args) => {
2520
+ register('cd', async ({ args }) => {
2253
2521
  const target = args[0] || process.env.HOME || process.env.USERPROFILE || '/';
2254
2522
  trace('VirtualCommand', 'cd: changing directory', { target });
2255
2523
 
@@ -2265,15 +2533,15 @@ function registerBuiltins() {
2265
2533
  });
2266
2534
 
2267
2535
  // pwd - print working directory
2268
- register('pwd', async (args, stdin, options) => {
2536
+ register('pwd', async ({ args, stdin, cwd }) => {
2269
2537
  // If cwd option is provided, return that instead of process.cwd()
2270
- const dir = options?.cwd || process.cwd();
2538
+ const dir = cwd || process.cwd();
2271
2539
  trace('VirtualCommand', 'pwd: getting directory', { dir });
2272
2540
  return { stdout: dir, code: 0 };
2273
2541
  });
2274
2542
 
2275
2543
  // echo - print arguments
2276
- register('echo', async (args) => {
2544
+ register('echo', async ({ args }) => {
2277
2545
  trace('VirtualCommand', 'echo: processing', { argsCount: args.length });
2278
2546
 
2279
2547
  let output = args.join(' ');
@@ -2288,7 +2556,7 @@ function registerBuiltins() {
2288
2556
  });
2289
2557
 
2290
2558
  // sleep - wait for specified time
2291
- register('sleep', async (args) => {
2559
+ register('sleep', async ({ args }) => {
2292
2560
  const seconds = parseFloat(args[0] || 0);
2293
2561
  trace('VirtualCommand', 'sleep: starting', { seconds });
2294
2562
 
@@ -2313,7 +2581,7 @@ function registerBuiltins() {
2313
2581
  });
2314
2582
 
2315
2583
  // which - locate command
2316
- register('which', async (args) => {
2584
+ register('which', async ({ args }) => {
2317
2585
  if (args.length === 0) {
2318
2586
  return { stderr: 'which: missing operand', code: 1 };
2319
2587
  }
@@ -2344,7 +2612,7 @@ function registerBuiltins() {
2344
2612
  });
2345
2613
 
2346
2614
  // exit - exit with code
2347
- register('exit', async (args) => {
2615
+ register('exit', async ({ args }) => {
2348
2616
  const code = parseInt(args[0] || 0);
2349
2617
  if (globalShellSettings.errexit || code !== 0) {
2350
2618
  // For virtual commands, we simulate exit by returning the code
@@ -2354,11 +2622,11 @@ function registerBuiltins() {
2354
2622
  });
2355
2623
 
2356
2624
  // env - print environment variables
2357
- register('env', async (args, stdin, options) => {
2625
+ register('env', async ({ args, stdin, env }) => {
2358
2626
  if (args.length === 0) {
2359
2627
  // Use custom env if provided, otherwise use process.env
2360
- const env = options?.env || process.env;
2361
- const output = Object.entries(env)
2628
+ const envVars = env || process.env;
2629
+ const output = Object.entries(envVars)
2362
2630
  .map(([key, value]) => `${key}=${value}`)
2363
2631
  .join('\n') + '\n';
2364
2632
  return { stdout: output, code: 0 };
@@ -2369,7 +2637,7 @@ function registerBuiltins() {
2369
2637
  });
2370
2638
 
2371
2639
  // cat - read and display file contents
2372
- register('cat', async (args, stdin, options) => {
2640
+ register('cat', async ({ args, stdin, cwd }) => {
2373
2641
  if (args.length === 0) {
2374
2642
  // Read from stdin if no files specified
2375
2643
  return { stdout: stdin || '', code: 0 };
@@ -2386,7 +2654,7 @@ function registerBuiltins() {
2386
2654
 
2387
2655
  try {
2388
2656
  // Resolve path relative to cwd if provided
2389
- const basePath = options?.cwd || process.cwd();
2657
+ const basePath = cwd || process.cwd();
2390
2658
  const fullPath = path.isAbsolute(filename) ? filename : path.join(basePath, filename);
2391
2659
 
2392
2660
  const content = fs.readFileSync(fullPath, 'utf8');
@@ -2409,7 +2677,7 @@ function registerBuiltins() {
2409
2677
  });
2410
2678
 
2411
2679
  // ls - list directory contents
2412
- register('ls', async (args, stdin, options) => {
2680
+ register('ls', async ({ args, stdin, cwd }) => {
2413
2681
  try {
2414
2682
  const fs = await import('fs');
2415
2683
  const path = await import('path');
@@ -2428,7 +2696,7 @@ function registerBuiltins() {
2428
2696
 
2429
2697
  for (const targetPath of targetPaths) {
2430
2698
  // Resolve path relative to cwd if provided
2431
- const basePath = options?.cwd || process.cwd();
2699
+ const basePath = cwd || process.cwd();
2432
2700
  const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(basePath, targetPath);
2433
2701
 
2434
2702
  try {
@@ -2483,7 +2751,7 @@ function registerBuiltins() {
2483
2751
  });
2484
2752
 
2485
2753
  // mkdir - create directories
2486
- register('mkdir', async (args, stdin, options) => {
2754
+ register('mkdir', async ({ args, stdin, cwd }) => {
2487
2755
  if (args.length === 0) {
2488
2756
  return { stderr: 'mkdir: missing operand', code: 1 };
2489
2757
  }
@@ -2498,7 +2766,7 @@ function registerBuiltins() {
2498
2766
 
2499
2767
  for (const dir of dirs) {
2500
2768
  try {
2501
- const basePath = options?.cwd || process.cwd();
2769
+ const basePath = cwd || process.cwd();
2502
2770
  const fullPath = path.isAbsolute(dir) ? dir : path.join(basePath, dir);
2503
2771
 
2504
2772
  if (recursive) {
@@ -2521,7 +2789,7 @@ function registerBuiltins() {
2521
2789
  });
2522
2790
 
2523
2791
  // rm - remove files and directories
2524
- register('rm', async (args, stdin, options) => {
2792
+ register('rm', async ({ args, stdin, cwd }) => {
2525
2793
  if (args.length === 0) {
2526
2794
  return { stderr: 'rm: missing operand', code: 1 };
2527
2795
  }
@@ -2537,7 +2805,7 @@ function registerBuiltins() {
2537
2805
 
2538
2806
  for (const target of targets) {
2539
2807
  try {
2540
- const basePath = options?.cwd || process.cwd();
2808
+ const basePath = cwd || process.cwd();
2541
2809
  const fullPath = path.isAbsolute(target) ? target : path.join(basePath, target);
2542
2810
 
2543
2811
  const stat = fs.statSync(fullPath);
@@ -2570,7 +2838,7 @@ function registerBuiltins() {
2570
2838
  });
2571
2839
 
2572
2840
  // mv - move/rename files and directories
2573
- register('mv', async (args, stdin, options) => {
2841
+ register('mv', async ({ args, stdin, cwd }) => {
2574
2842
  if (args.length < 2) {
2575
2843
  return { stderr: 'mv: missing destination file operand', code: 1 };
2576
2844
  }
@@ -2579,7 +2847,7 @@ function registerBuiltins() {
2579
2847
  const fs = await import('fs');
2580
2848
  const path = await import('path');
2581
2849
 
2582
- const basePath = options?.cwd || process.cwd();
2850
+ const basePath = cwd || process.cwd();
2583
2851
 
2584
2852
  if (args.length === 2) {
2585
2853
  // Simple rename/move
@@ -2651,7 +2919,7 @@ function registerBuiltins() {
2651
2919
  });
2652
2920
 
2653
2921
  // cp - copy files and directories
2654
- register('cp', async (args, stdin, options) => {
2922
+ register('cp', async ({ args, stdin, cwd }) => {
2655
2923
  if (args.length < 2) {
2656
2924
  return { stderr: 'cp: missing destination file operand', code: 1 };
2657
2925
  }
@@ -2664,7 +2932,7 @@ function registerBuiltins() {
2664
2932
  const paths = args.filter(arg => !arg.startsWith('-'));
2665
2933
  const recursive = flags.includes('-r') || flags.includes('-R');
2666
2934
 
2667
- const basePath = options?.cwd || process.cwd();
2935
+ const basePath = cwd || process.cwd();
2668
2936
 
2669
2937
  if (paths.length === 2) {
2670
2938
  // Simple copy
@@ -2748,7 +3016,7 @@ function registerBuiltins() {
2748
3016
  });
2749
3017
 
2750
3018
  // touch - create or update file timestamps
2751
- register('touch', async (args, stdin, options) => {
3019
+ register('touch', async ({ args, stdin, cwd }) => {
2752
3020
  if (args.length === 0) {
2753
3021
  return { stderr: 'touch: missing file operand', code: 1 };
2754
3022
  }
@@ -2757,7 +3025,7 @@ function registerBuiltins() {
2757
3025
  const fs = await import('fs');
2758
3026
  const path = await import('path');
2759
3027
 
2760
- const basePath = options?.cwd || process.cwd();
3028
+ const basePath = cwd || process.cwd();
2761
3029
 
2762
3030
  for (const file of args) {
2763
3031
  try {
@@ -2786,7 +3054,7 @@ function registerBuiltins() {
2786
3054
  });
2787
3055
 
2788
3056
  // basename - extract filename from path
2789
- register('basename', async (args) => {
3057
+ register('basename', async ({ args }) => {
2790
3058
  if (args.length === 0) {
2791
3059
  return { stderr: 'basename: missing operand', code: 1 };
2792
3060
  }
@@ -2811,7 +3079,7 @@ function registerBuiltins() {
2811
3079
  });
2812
3080
 
2813
3081
  // dirname - extract directory from path
2814
- register('dirname', async (args) => {
3082
+ register('dirname', async ({ args }) => {
2815
3083
  if (args.length === 0) {
2816
3084
  return { stderr: 'dirname: missing operand', code: 1 };
2817
3085
  }
@@ -2829,22 +3097,20 @@ function registerBuiltins() {
2829
3097
  });
2830
3098
 
2831
3099
  // yes - output a string repeatedly
2832
- register('yes', async function* (args, stdin, options) {
3100
+ register('yes', async function* ({ args, stdin, isCancelled, signal, ...rest }) {
2833
3101
  const output = args.length > 0 ? args.join(' ') : 'y';
2834
3102
  trace('VirtualCommand', 'yes: starting infinite generator', { output });
2835
3103
 
2836
3104
  // Generate infinite stream of the output
2837
3105
  while (true) {
2838
3106
  // 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
- }
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;
2848
3114
  }
2849
3115
 
2850
3116
  yield output + '\n';
@@ -2855,16 +3121,16 @@ function registerBuiltins() {
2855
3121
  const timeout = setTimeout(resolve, 0);
2856
3122
 
2857
3123
  // Listen for abort signal if available
2858
- if (options && options.signal) {
3124
+ if (signal) {
2859
3125
  const abortHandler = () => {
2860
3126
  clearTimeout(timeout);
2861
3127
  reject(new Error('Aborted'));
2862
3128
  };
2863
3129
 
2864
- if (options.signal.aborted) {
3130
+ if (signal.aborted) {
2865
3131
  abortHandler();
2866
3132
  } else {
2867
- options.signal.addEventListener('abort', abortHandler, { once: true });
3133
+ signal.addEventListener('abort', abortHandler, { once: true });
2868
3134
  }
2869
3135
  }
2870
3136
  });
@@ -2876,7 +3142,7 @@ function registerBuiltins() {
2876
3142
  });
2877
3143
 
2878
3144
  // seq - generate sequence of numbers
2879
- register('seq', async (args) => {
3145
+ register('seq', async ({ args }) => {
2880
3146
  if (args.length === 0) {
2881
3147
  return { stderr: 'seq: missing operand', code: 1 };
2882
3148
  }
@@ -2924,7 +3190,7 @@ function registerBuiltins() {
2924
3190
  });
2925
3191
 
2926
3192
  // test - test file conditions (basic implementation)
2927
- register('test', async (args) => {
3193
+ register('test', async ({ args }) => {
2928
3194
  if (args.length === 0) {
2929
3195
  return { stdout: '', code: 1 };
2930
3196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "command-stream",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Modern $ shell utility library with streaming, async iteration, and EventEmitter support, optimized for Bun runtime",
5
5
  "type": "module",
6
6
  "main": "$.mjs",