command-stream 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  [![npm](https://img.shields.io/npm/v/command-stream.svg)](https://npmjs.com/command-stream)
2
2
  [![License](https://img.shields.io/badge/license-Unlicense-blue.svg)](https://github.com/link-foundation/command-stream/blob/main/LICENSE)
3
+ [![GitHub stars](https://img.shields.io/github/stars/link-foundation/command-stream?style=social)](https://github.com/link-foundation/command-stream/stargazers)
3
4
 
4
5
  [![Open in Gitpod](https://img.shields.io/badge/Gitpod-ready--to--code-f29718?logo=gitpod)](https://gitpod.io/#https://github.com/link-foundation/command-stream)
5
6
  [![Open in GitHub Codespaces](https://img.shields.io/badge/GitHub%20Codespaces-Open-181717?logo=github)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=link-foundation/command-stream)
@@ -26,39 +27,48 @@ A modern $ shell utility library with streaming, async iteration, and EventEmitt
26
27
 
27
28
  ## Comparison with Other Libraries
28
29
 
29
- | Feature | [command-stream](https://github.com/link-foundation/command-stream) | [Bun.$](https://bun.sh/docs/runtime/shell) | [execa](https://github.com/sindresorhus/execa) | [zx](https://github.com/google/zx) | [ShellJS](https://github.com/shelljs/shelljs) | [cross-spawn](https://github.com/moxystudio/node-cross-spawn) |
30
+ | Feature | [**command-stream**](https://github.com/link-foundation/command-stream) | [**execa**](https://github.com/sindresorhus/execa) | [**cross-spawn**](https://github.com/moxystudio/node-cross-spawn) | [**Bun.$**](https://github.com/oven-sh/bun) | [**ShellJS**](https://github.com/shelljs/shelljs) | [**zx**](https://github.com/google/zx) |
30
31
  |---------|----------------|-------|-------|-----|-------|-------|
31
- | **Runtime Support** | ✅ Bun + Node.js | 🟡 Bun only | ✅ Node.js | Node.js | ✅ Node.js | ✅ Node.js |
32
- | **Template Literals** | `` $`cmd` `` | `` $`cmd` `` | `` $`cmd` `` | `` $`cmd` `` | Function calls | Function calls |
33
- | **Real-time Streaming** | Live output | Buffer only | 🟡 Limited | Buffer only | Buffer only | Buffer only |
34
- | **Synchronous Execution** | `.sync()` with events | No | `execaSync` | No | Sync by default | `spawnSync` |
32
+ | **📦 NPM Package** | [![npm](https://img.shields.io/npm/v/command-stream.svg)](https://www.npmjs.com/package/command-stream) | [![npm](https://img.shields.io/npm/v/execa.svg)](https://www.npmjs.com/package/execa) | [![npm](https://img.shields.io/npm/v/cross-spawn.svg)](https://www.npmjs.com/package/cross-spawn) | N/A (Built-in) | [![npm](https://img.shields.io/npm/v/shelljs.svg)](https://www.npmjs.com/package/shelljs) | [![npm](https://img.shields.io/npm/v/zx.svg)](https://www.npmjs.com/package/zx) |
33
+ | **⭐ GitHub Stars** | [**⭐ 2** (Please us!)](https://github.com/link-foundation/command-stream) | [⭐ 7,264](https://github.com/sindresorhus/execa) | [⭐ 1,149](https://github.com/moxystudio/node-cross-spawn) | [⭐ 80,169](https://github.com/oven-sh/bun) (Full Runtime) | [⭐ 14,375](https://github.com/shelljs/shelljs) | [⭐ 44,569](https://github.com/google/zx) |
34
+ | **📊 Monthly Downloads** | **893** (New project!) | **381M** | **409M** | N/A (Built-in) | **35M** | **4.2M** |
35
+ | **📈 Total Downloads** | **Growing** | **6B+** | **5.4B** | N/A (Built-in) | **596M** | **37M** |
36
+ | **Runtime Support** | ✅ Bun + Node.js | ✅ Node.js | ✅ Node.js | 🟡 Bun only | ✅ Node.js | ✅ Node.js |
37
+ | **Template Literals** | ✅ `` $`cmd` `` | ✅ `` $`cmd` `` | ❌ Function calls | ✅ `` $`cmd` `` | ❌ Function calls | ✅ `` $`cmd` `` |
38
+ | **Real-time Streaming** | ✅ Live output | 🟡 Limited | ❌ Buffer only | ❌ Buffer only | ❌ Buffer only | ❌ Buffer only |
39
+ | **Synchronous Execution** | ✅ `.sync()` with events | ✅ `execaSync` | ✅ `spawnSync` | ❌ No | ✅ Sync by default | ❌ No |
35
40
  | **Async Iteration** | ✅ `for await (chunk of $.stream())` | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No |
36
- | **EventEmitter Pattern** | ✅ `.on('data', ...)` | No | 🟡 Limited events | ❌ No | ❌ No | 🟡 Child process events |
41
+ | **EventEmitter Pattern** | ✅ `.on('data', ...)` | 🟡 Limited events | 🟡 Child process events | ❌ No | ❌ No | No |
37
42
  | **Mixed Patterns** | ✅ Events + await/sync | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No |
38
- | **Bun.$ Compatibility** | ✅ `.text()` method support | Native API | ❌ No | No | ❌ No | ❌ No |
39
- | **Shell Injection Protection** | ✅ Auto-quoting | ✅ Built-in | ✅ Safe by default | ✅ Safe by default | 🟡 Manual escaping | ✅ Safe by default |
40
- | **Cross-platform** | ✅ macOS/Linux/Windows | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ **Specialized** cross-platform |
41
- | **Performance** | ⚡ Fast (Bun optimized) | Very fast | 🐌 Moderate | 🐌 Slow | 🐌 Moderate | Fast |
43
+ | **Bun.$ Compatibility** | ✅ `.text()` method support | No | ❌ No | Native API | ❌ No | ❌ No |
44
+ | **Shell Injection Protection** | ✅ Smart auto-quoting | ✅ Safe by default | ✅ Safe by default | ✅ Built-in | 🟡 Manual escaping | ✅ Safe by default |
45
+ | **Cross-platform** | ✅ macOS/Linux/Windows | ✅ Yes | ✅ **Specialized** cross-platform | ✅ Yes | ✅ Yes | ✅ Yes |
46
+ | **Performance** | ⚡ Fast (Bun optimized) | 🐌 Moderate | Fast | Very fast | 🐌 Moderate | 🐌 Slow |
42
47
  | **Memory Efficiency** | ✅ Streaming prevents buildup | 🟡 Buffers in memory | 🟡 Buffers in memory | 🟡 Buffers in memory | 🟡 Buffers in memory | 🟡 Buffers in memory |
43
- | **Error Handling** | ✅ Configurable (`set -e`/`set +e`, non-zero OK by default) | ✅ Throws on error | Throws on error | ✅ Throws on error | ✅ Configurable | Basic (exit codes) |
48
+ | **Error Handling** | ✅ Configurable (`set -e`/`set +e`, non-zero OK by default) | ✅ Throws on error | Basic (exit codes) | ✅ Throws on error | ✅ Configurable | Throws on error |
44
49
  | **Shell Settings** | ✅ `set -e`/`set +e` equivalent | ❌ No | ❌ No | ❌ No | 🟡 Limited (`set()`) | ❌ No |
45
- | **Stdout Support** | ✅ Real-time streaming + events | ✅ Shell redirection + buffered | ✅ Node.js streams + interleaved | ✅ Readable streams + `.pipe.stdout` | ✅ Direct output | ✅ Inherited/buffered |
46
- | **Stderr Support** | ✅ Real-time streaming + events | ✅ Redirection + `.quiet()` access | ✅ Streams + interleaved output | ✅ Readable streams + `.pipe.stderr` | ✅ Error output | ✅ Inherited/buffered |
47
- | **Stdin Support** | ✅ string/Buffer/inherit/ignore | ✅ Pipe operations | ✅ Input/output streams | ✅ Basic stdin | 🟡 Basic | ✅ Full stdio support |
48
- | **Built-in Commands** | ✅ **18 commands**: cat, ls, mkdir, rm, mv, cp, touch, basename, dirname, seq, yes + all Bun.$ commands | echo, cd, etc. | ❌ Uses system | Uses system | ✅ **20+ commands**: cat, ls, mkdir, rm, mv, cp, etc. | ❌ Uses system |
49
- | **Virtual Commands Engine** | ✅ **Revolutionary**: Register JavaScript functions as shell commands with full pipeline support | ❌ No extensibility | ❌ No custom commands | ❌ No custom commands | ❌ No custom commands | ❌ No custom commands |
50
- | **Pipeline/Piping Support** | ✅ **Advanced**: System + Built-ins + Virtual + Mixed + `.pipe()` method | ✅ Standard shell piping | ✅ Programmatic `.pipe()` + multi-destination | ✅ Shell piping + `.pipe()` method | ✅ Shell piping + `.to()` method | ❌ No piping |
51
- | **Bundle Size** | 📦 **~20KB gzipped** | 🎯 0KB (built-in) | 📦 ~400KB+ (packagephobia) | 📦 ~50KB+ (estimated) | 📦 ~15KB gzipped | 📦 ~2KB gzipped |
52
- | **Signal Handling** | ✅ **Advanced SIGINT/SIGTERM forwarding** with cleanup | 🟡 Basic | 🟡 Basic | 🟡 Basic | 🟡 Basic | **Excellent** cross-platform |
53
- | **Process Management** | ✅ **Robust child process lifecycle** with proper termination | Basic | ✅ Good | 🟡 Limited | 🟡 Limited | **Excellent** spawn wrapper |
54
- | **Debug Tracing** | ✅ **Comprehensive VERBOSE logging** for CI/debugging | No | 🟡 Limited | ❌ No | 🟡 Basic | ❌ No |
55
- | **Test Coverage** | ✅ **410 tests, 909 assertions** | 🟡 Good coverage | ✅ Excellent | 🟡 Good | ✅ Good | Good |
56
- | **CI Reliability** | ✅ **Platform-specific handling** (macOS/Ubuntu) | 🟡 Basic | ✅ Good | 🟡 Basic | ✅ Good | **Excellent** |
57
- | **Documentation** | ✅ **Comprehensive examples + guides** | ✅ Good | Excellent | 🟡 Limited | ✅ Good | 🟡 Basic |
58
- | **TypeScript** | 🔄 Coming soon | ✅ Built-in | ✅ Full support | ✅ Full support | 🟡 Community types | ✅ Built-in |
59
- | **License** | ✅ **Unlicense (Public Domain)** | 🟡 MIT (+ LGPL dependencies) | 🟡 MIT | 🟡 Apache 2.0 | 🟡 BSD-3-Clause | 🟡 MIT |
60
-
61
- **📊 Popularity (Weekly Downloads 2024):** [cross-spawn](https://www.npmjs.com/package/cross-spawn): 102M+ • [execa](https://www.npmjs.com/package/execa): 98M+ • [ShellJS](https://www.npmjs.com/package/shelljs): 9M+ • [zx](https://www.npmjs.com/package/zx): Growing fast
50
+ | **Stdout Support** | ✅ Real-time streaming + events | ✅ Node.js streams + interleaved | ✅ Inherited/buffered | ✅ Shell redirection + buffered | ✅ Direct output | ✅ Readable streams + `.pipe.stdout` |
51
+ | **Stderr Support** | ✅ Real-time streaming + events | ✅ Streams + interleaved output | ✅ Inherited/buffered | ✅ Redirection + `.quiet()` access | ✅ Error output | ✅ Readable streams + `.pipe.stderr` |
52
+ | **Stdin Support** | ✅ string/Buffer/inherit/ignore | ✅ Input/output streams | ✅ Full stdio support | ✅ Pipe operations | 🟡 Basic | ✅ Basic stdin |
53
+ | **Built-in Commands** | ✅ **18 commands**: cat, ls, mkdir, rm, mv, cp, touch, basename, dirname, seq, yes + all Bun.$ commands | Uses system | ❌ Uses system | echo, cd, etc. | ✅ **20+ commands**: cat, ls, mkdir, rm, mv, cp, etc. | ❌ Uses system |
54
+ | **Virtual Commands Engine** | ✅ **Revolutionary**: Register JavaScript functions as shell commands with full pipeline support | ❌ No custom commands | ❌ No custom commands | ❌ No extensibility | ❌ No custom commands | ❌ No custom commands |
55
+ | **Pipeline/Piping Support** | ✅ **Advanced**: System + Built-ins + Virtual + Mixed + `.pipe()` method | ✅ Programmatic `.pipe()` + multi-destination | ❌ No piping | Standard shell piping | ✅ Shell piping + `.to()` method | ✅ Shell piping + `.pipe()` method |
56
+ | **Bundle Size** | 📦 **~20KB gzipped** | 📦 ~400KB+ (packagephobia) | 📦 ~2KB gzipped | 🎯 0KB (built-in) | 📦 ~15KB gzipped | 📦 ~50KB+ (estimated) |
57
+ | **Signal Handling** | ✅ **Advanced SIGINT/SIGTERM forwarding** with cleanup | 🟡 Basic | **Excellent** cross-platform | 🟡 Basic | 🟡 Basic | 🟡 Basic |
58
+ | **Process Management** | ✅ **Robust child process lifecycle** with proper termination | Good | ✅ **Excellent** spawn wrapper | Basic | 🟡 Limited | 🟡 Limited |
59
+ | **Debug Tracing** | ✅ **Comprehensive VERBOSE logging** for CI/debugging | 🟡 Limited | No | ❌ No | 🟡 Basic | ❌ No |
60
+ | **Test Coverage** | ✅ **518+ tests, 1165+ assertions** | Excellent | ✅ Good | 🟡 Good coverage | ✅ Good | 🟡 Good |
61
+ | **CI Reliability** | ✅ **Platform-specific handling** (macOS/Ubuntu) | Good | ✅ **Excellent** | 🟡 Basic | ✅ Good | 🟡 Basic |
62
+ | **Documentation** | ✅ **Comprehensive examples + guides** | ✅ Excellent | 🟡 Basic | Good | ✅ Good | 🟡 Limited |
63
+ | **TypeScript** | 🔄 Coming soon | ✅ Full support | ✅ Built-in | ✅ Built-in | 🟡 Community types | ✅ Full support |
64
+ | **License** | ✅ **Unlicense (Public Domain)** | 🟡 MIT | 🟡 MIT | 🟡 MIT (+ LGPL dependencies) | 🟡 BSD-3-Clause | 🟡 Apache 2.0 |
65
+
66
+ **📊 Popularity & Adoption:**
67
+ - **⭐ GitHub Stars:** [Bun: 80,169](https://github.com/oven-sh/bun) • [zx: 44,569](https://github.com/google/zx) • [ShellJS: 14,375](https://github.com/shelljs/shelljs) • [execa: 7,264](https://github.com/sindresorhus/execa) • [cross-spawn: 1,149](https://github.com/moxystudio/node-cross-spawn) • [**command-stream: 2 ⭐ us!**](https://github.com/link-foundation/command-stream)
68
+ - **📈 Total Downloads:** [execa: 6B+](https://www.npmjs.com/package/execa) • [cross-spawn: 5.4B](https://www.npmjs.com/package/cross-spawn) • [ShellJS: 596M](https://www.npmjs.com/package/shelljs) • [zx: 37M](https://www.npmjs.com/package/zx) • [command-stream: Growing](https://www.npmjs.com/package/command-stream)
69
+ - **📊 Monthly Downloads:** [cross-spawn: 409M](https://www.npmjs.com/package/cross-spawn) • [execa: 381M](https://www.npmjs.com/package/execa) • [ShellJS: 35M](https://www.npmjs.com/package/shelljs) • [zx: 4.2M](https://www.npmjs.com/package/zx) • [command-stream: 893 (growing!)](https://www.npmjs.com/package/command-stream)
70
+
71
+ **⭐ Help Us Grow!** If command-stream's **revolutionary virtual commands** and **advanced streaming capabilities** help your project, [**please star us on GitHub**](https://github.com/link-foundation/command-stream) to help the project grow!
62
72
 
63
73
  ### Why Choose command-stream?
64
74
 
@@ -71,7 +81,7 @@ A modern $ shell utility library with streaming, async iteration, and EventEmitt
71
81
  - **🐚 Shell Replacement**: Dynamic error handling with `set -e`/`set +e` equivalents for .sh file replacement
72
82
  - **⚡ Bun Optimized**: Designed for Bun with Node.js fallback compatibility
73
83
  - **💾 Memory Efficient**: Streaming prevents large buffer accumulation
74
- - **🛡️ Production Ready**: **410 tests, 909 assertions** with comprehensive coverage including CI reliability
84
+ - **🛡️ Production Ready**: **518+ tests, 1165+ assertions** with comprehensive coverage including CI reliability
75
85
  - **🎯 Advanced Signal Handling**: Robust SIGINT/SIGTERM forwarding with proper child process cleanup
76
86
  - **🔍 Debug-Friendly**: Comprehensive VERBOSE tracing for CI debugging and troubleshooting
77
87
 
@@ -141,6 +151,54 @@ npm install command-stream
141
151
  bun add command-stream
142
152
  ```
143
153
 
154
+ ## Smart Quoting & Security
155
+
156
+ Command-stream provides intelligent auto-quoting to protect against shell injection while avoiding unnecessary quotes for safe strings:
157
+
158
+ ### Smart Quoting Behavior
159
+
160
+ ```javascript
161
+ import { $ } from 'command-stream';
162
+
163
+ // Safe strings are NOT quoted (performance optimization)
164
+ await $`echo ${name}`; // name = "hello" → echo hello
165
+ await $`${cmd} --version`; // cmd = "/usr/bin/node" → /usr/bin/node --version
166
+
167
+ // Dangerous strings are automatically quoted for safety
168
+ await $`echo ${userInput}`; // userInput = "test; rm -rf /" → echo 'test; rm -rf /'
169
+ await $`echo ${pathWithSpaces}`; // pathWithSpaces = "/my path/file" → echo '/my path/file'
170
+
171
+ // Special characters that trigger auto-quoting:
172
+ // Spaces, $, ;, |, &, >, <, `, *, ?, [, ], {, }, (, ), !, #, and others
173
+
174
+ // User-provided quotes are preserved
175
+ const quotedPath = "'/path with spaces/file'";
176
+ await $`cat ${quotedPath}`; // → cat '/path with spaces/file' (no double-quoting!)
177
+
178
+ const doubleQuoted = '"/path with spaces/file"';
179
+ await $`cat ${doubleQuoted}`; // → cat '"/path with spaces/file"' (preserves intent)
180
+ ```
181
+
182
+ ### Shell Injection Protection
183
+
184
+ All interpolated values are automatically secured:
185
+
186
+ ```javascript
187
+ // ✅ SAFE - All these injection attempts are neutralized
188
+ const dangerous = "'; rm -rf /; echo '";
189
+ await $`echo ${dangerous}`; // → echo ''\'' rm -rf /; echo '\'''
190
+
191
+ const cmdSubstitution = "$(whoami)";
192
+ await $`echo ${cmdSubstitution}`; // → echo '$(whoami)' (literal text, not executed)
193
+
194
+ const varExpansion = "$HOME";
195
+ await $`echo ${varExpansion}`; // → echo '$HOME' (literal text, not expanded)
196
+
197
+ // ✅ SAFE - Even complex injection attempts
198
+ const complex = "`cat /etc/passwd`";
199
+ await $`echo ${complex}`; // → echo '`cat /etc/passwd`' (literal text)
200
+ ```
201
+
144
202
  ## Usage Patterns
145
203
 
146
204
  ### Classic Await (Backward Compatible)
@@ -174,6 +232,11 @@ await $withEnv`printenv MY_VAR`; // Prints: value
174
232
  const $inTmp = $({ cwd: '/tmp' });
175
233
  await $inTmp`pwd`; // Prints: /tmp
176
234
 
235
+ // Interactive mode for TTY commands (requires TTY environment)
236
+ const $interactive = $({ interactive: true });
237
+ await $interactive`vim myfile.txt`; // Full TTY access for editor
238
+ await $interactive`less README.md`; // Proper pager interaction
239
+
177
240
  // Combine multiple options
178
241
  const $custom = $({
179
242
  stdin: 'test data',
@@ -291,6 +354,83 @@ syncCmd.on('end', result => {
291
354
  const syncResult = syncCmd.sync();
292
355
  ```
293
356
 
357
+ ### Streaming Interfaces
358
+
359
+ Advanced streaming interfaces for fine-grained process control:
360
+
361
+ ```javascript
362
+ import { $ } from 'command-stream';
363
+
364
+ // 🎯 STDIN CONTROL: Send data to interactive commands (real-time)
365
+ const grepCmd = $`grep "important"`;
366
+ const stdin = await grepCmd.streams.stdin; // Available immediately
367
+
368
+ stdin.write('ignore this line\n');
369
+ stdin.write('important message\n');
370
+ stdin.write('skip this too\n');
371
+ stdin.end();
372
+
373
+ const result = await grepCmd;
374
+ console.log(result.stdout); // "important message\n"
375
+
376
+ // 🔧 BINARY DATA: Access raw buffers (after command finishes)
377
+ const cmd = $`echo "Hello World"`;
378
+ const buffer = await cmd.buffers.stdout; // Complete snapshot
379
+ console.log(buffer.length); // 12
380
+
381
+ // 📝 TEXT DATA: Access as strings (after command finishes)
382
+ const textCmd = $`echo "Hello World"`;
383
+ const text = await textCmd.strings.stdout; // Complete snapshot
384
+ console.log(text.trim()); // "Hello World"
385
+
386
+ // ⚡ PROCESS CONTROL: Kill commands that ignore stdin
387
+ const pingCmd = $`ping google.com`;
388
+
389
+ // Some commands ignore stdin input
390
+ const pingStdin = await pingCmd.streams.stdin;
391
+ if (pingStdin) {
392
+ pingStdin.write('q\n'); // ping ignores this
393
+ }
394
+
395
+ // Use kill() for forceful termination
396
+ setTimeout(() => pingCmd.kill(), 2000);
397
+ const pingResult = await pingCmd;
398
+ console.log('Ping stopped with code:', pingResult.code); // 143 (SIGTERM)
399
+
400
+ // 🔄 MIXED STDOUT/STDERR: Handle both streams (complete snapshots)
401
+ const mixedCmd = $`sh -c 'echo "out" && echo "err" >&2'`;
402
+ const [stdout, stderr] = await Promise.all([
403
+ mixedCmd.strings.stdout, // Available after finish
404
+ mixedCmd.strings.stderr // Available after finish
405
+ ]);
406
+ console.log('Out:', stdout.trim()); // "out"
407
+ console.log('Err:', stderr.trim()); // "err"
408
+
409
+ // 🏃‍♂️ AUTO-START: Streams auto-start processes when accessed
410
+ const cmd = $`echo "test"`;
411
+ console.log('Started?', cmd.started); // false
412
+
413
+ const output = await cmd.streams.stdout; // Auto-starts, immediate access
414
+ console.log('Started?', cmd.started); // true
415
+
416
+ // 🔙 BACKWARD COMPATIBLE: Traditional await still works
417
+ const traditional = await $`echo "still works"`;
418
+ console.log(traditional.stdout); // "still works\n"
419
+ ```
420
+
421
+ **Key Features:**
422
+ - `command.streams.stdin/stdout/stderr` - Direct access to Node.js streams
423
+ - `command.buffers.stdin/stdout/stderr` - Binary data as Buffer objects
424
+ - `command.strings.stdin/stdout/stderr` - Text data as strings
425
+ - `command.kill()` - Forceful process termination
426
+ - **Auto-start behavior:** Process starts only when accessing stream properties
427
+ - **Perfect for:** Interactive commands (grep, sort, bc), data processing, real-time control
428
+ - **Network commands (ping, wget) ignore stdin** → Use `kill()` method instead
429
+
430
+ **🚀 Streams vs Buffers/Strings:**
431
+ - **`streams.*`** - Available **immediately** when command starts, for real-time interaction
432
+ - **`buffers.*` & `strings.*`** - Complete **snapshots** available only **after** command finishes
433
+
294
434
  ### Shell Replacement (.sh → .mjs)
295
435
 
296
436
  Replace bash scripts with JavaScript while keeping shell semantics:
@@ -367,7 +507,7 @@ Create custom commands that work seamlessly alongside built-ins:
367
507
  import { $, register, unregister, listCommands } from 'command-stream';
368
508
 
369
509
  // Register a custom command
370
- register('greet', async (args, stdin) => {
510
+ register('greet', async ({ args, stdin }) => {
371
511
  const name = args[0] || 'World';
372
512
  return { stdout: `Hello, ${name}!\n`, code: 0 };
373
513
  });
@@ -377,7 +517,7 @@ await $`greet Alice`; // → "Hello, Alice!"
377
517
  await $`echo "Bob" | greet`; // → "Hello, Bob!"
378
518
 
379
519
  // Streaming virtual commands with async generators
380
- register('countdown', async function* (args) {
520
+ register('countdown', async function* ({ args }) {
381
521
  const start = parseInt(args[0] || 5);
382
522
  for (let i = start; i >= 0; i--) {
383
523
  yield `${i}\n`;
@@ -414,7 +554,7 @@ await execa('node', ['script.js']); // execa: separate processes
414
554
  await $`node script.js`; // zx: shell commands only
415
555
 
416
556
  // ✅ command-stream: JavaScript functions AS shell commands
417
- register('deploy', async (args) => {
557
+ register('deploy', async ({ args }) => {
418
558
  const env = args[0] || 'staging';
419
559
  await deployToEnvironment(env);
420
560
  return { stdout: `Deployed to ${env}!\n`, code: 0 };
@@ -450,11 +590,11 @@ await $`seq 1 5 | cat > numbers.txt`;
450
590
  await $`git log --oneline | head -n 5`;
451
591
 
452
592
  // 🚀 UNIQUE: Virtual command piping
453
- register('uppercase', async (args, stdin) => {
593
+ register('uppercase', async ({ args, stdin }) => {
454
594
  return { stdout: stdin.toUpperCase(), code: 0 };
455
595
  });
456
596
 
457
- register('reverse', async (args, stdin) => {
597
+ register('reverse', async ({ args, stdin }) => {
458
598
  return { stdout: stdin.split('').reverse().join(''), code: 0 };
459
599
  });
460
600
 
@@ -482,12 +622,12 @@ import { $, register } from 'command-stream';
482
622
  const result = await $`echo "hello"`.pipe($`echo "World: $(cat)"`);
483
623
 
484
624
  // 🌟 Virtual command chaining
485
- register('add-prefix', async (args, stdin) => {
625
+ register('add-prefix', async ({ args, stdin }) => {
486
626
  const prefix = args[0] || 'PREFIX:';
487
627
  return { stdout: `${prefix} ${stdin.trim()}\n`, code: 0 };
488
628
  });
489
629
 
490
- register('add-suffix', async (args, stdin) => {
630
+ register('add-suffix', async ({ args, stdin }) => {
491
631
  const suffix = args[0] || 'SUFFIX';
492
632
  return { stdout: `${stdin.trim()} ${suffix}\n`, code: 0 };
493
633
  });
@@ -512,7 +652,7 @@ try {
512
652
  }
513
653
 
514
654
  // ✅ Complex data processing
515
- register('json-parse', async (args, stdin) => {
655
+ register('json-parse', async ({ args, stdin }) => {
516
656
  try {
517
657
  const data = JSON.parse(stdin);
518
658
  return { stdout: JSON.stringify(data, null, 2), code: 0 };
@@ -521,7 +661,7 @@ register('json-parse', async (args, stdin) => {
521
661
  }
522
662
  });
523
663
 
524
- register('extract-field', async (args, stdin) => {
664
+ register('extract-field', async ({ args, stdin }) => {
525
665
  const field = args[0];
526
666
  try {
527
667
  const data = JSON.parse(stdin);
@@ -695,9 +835,10 @@ The enhanced `$` function returns a `ProcessRunner` instance that extends `Event
695
835
 
696
836
  ```javascript
697
837
  {
698
- mirror: true, // Live output to terminal (stdout→stdout, stderr→stderr)
699
- capture: true, // Capture output for programmatic access
700
- stdin: 'inherit' // Inherit stdin from parent process
838
+ mirror: true, // Live output to terminal (stdout→stdout, stderr→stderr)
839
+ capture: true, // Capture output for programmatic access
840
+ stdin: 'inherit', // Inherit stdin from parent process
841
+ interactive: false // Explicitly request TTY forwarding for interactive commands
701
842
  }
702
843
  ```
703
844
 
@@ -705,6 +846,7 @@ The enhanced `$` function returns a `ProcessRunner` instance that extends `Event
705
846
  - `mirror: boolean` - Whether to pipe output to terminal in real-time
706
847
  - `capture: boolean` - Whether to capture output in result object
707
848
  - `stdin: 'inherit' | 'ignore' | string | Buffer` - How to handle stdin
849
+ - `interactive: boolean` - Enable TTY forwarding for interactive commands (requires `stdin: 'inherit'` and TTY environment)
708
850
  - `cwd: string` - Working directory for command
709
851
  - `env: object` - Environment variables
710
852
 
@@ -778,9 +920,9 @@ Control and extend the command system with custom JavaScript functions:
778
920
  import { $, register } from 'command-stream';
779
921
 
780
922
  // ✅ Cancellation support with AbortController
781
- register('cancellable', async function* (args, stdin, options) {
923
+ register('cancellable', async function* ({ args, stdin, abortSignal }) {
782
924
  for (let i = 0; i < 10; i++) {
783
- if (options.signal?.aborted) {
925
+ if (abortSignal?.aborted) {
784
926
  break; // Proper cancellation handling
785
927
  }
786
928
  yield `Count: ${i}\n`;
@@ -789,22 +931,28 @@ register('cancellable', async function* (args, stdin, options) {
789
931
  });
790
932
 
791
933
  // ✅ Access to all process options
792
- register('debug-info', async (args, stdin, options) => {
934
+ // All original options (built-in + custom) are available in the 'options' object
935
+ // Common options like cwd, env are also available at top level for convenience
936
+ // Runtime additions: isCancelled function, abortSignal
937
+ register('debug-info', async ({ args, stdin, cwd, env, options, isCancelled }) => {
793
938
  return {
794
939
  stdout: JSON.stringify({
795
940
  args,
796
- cwd: options.cwd,
797
- env: Object.keys(options.env || {}),
798
- stdinLength: stdin.length,
799
- mirror: options.mirror,
800
- capture: options.capture
941
+ cwd, // Available at top level for convenience
942
+ env: Object.keys(env || {}), // Available at top level for convenience
943
+ stdinLength: stdin?.length || 0,
944
+ allOptions: options, // All original options (built-in + custom)
945
+ mirror: options.mirror, // Built-in option from options object
946
+ capture: options.capture, // Built-in option from options object
947
+ customOption: options.customOption || 'not provided', // Custom option
948
+ isCancelledAvailable: typeof isCancelled === 'function'
801
949
  }, null, 2),
802
950
  code: 0
803
951
  };
804
952
  });
805
953
 
806
954
  // ✅ Error handling and non-zero exit codes
807
- register('maybe-fail', async (args) => {
955
+ register('maybe-fail', async ({ args }) => {
808
956
  if (Math.random() > 0.5) {
809
957
  return {
810
958
  stdout: 'Success!\n',
@@ -818,13 +966,27 @@ register('maybe-fail', async (args) => {
818
966
  };
819
967
  }
820
968
  });
969
+
970
+ // ✅ Example: User options flow through to virtual commands
971
+ register('show-options', async ({ args, stdin, options, cwd }) => {
972
+ return {
973
+ stdout: `Custom: ${options.customValue || 'none'}, CWD: ${cwd || options.cwd || 'default'}\n`,
974
+ code: 0
975
+ };
976
+ });
977
+
978
+ // Usage example showing options passed to virtual command:
979
+ const result = await $({ customValue: 'hello world', cwd: '/tmp' })`show-options`;
980
+ console.log(result.stdout); // Output: Custom: hello world, CWD: /tmp
821
981
  ```
822
982
 
823
983
  #### Handler Function Signature
824
984
 
825
985
  ```javascript
826
986
  // Regular async function
827
- async function handler(args, stdin, options) {
987
+ async function handler({ args, stdin, abortSignal, cwd, env, options, isCancelled }) {
988
+ // All original options available in 'options': options.mirror, options.capture, options.customValue, etc.
989
+ // Common options like cwd, env also available at top level for convenience
828
990
  return {
829
991
  code: 0, // Exit code (number)
830
992
  stdout: "output", // Standard output (string)
@@ -833,10 +995,13 @@ async function handler(args, stdin, options) {
833
995
  }
834
996
 
835
997
  // Async generator for streaming
836
- async function* streamingHandler(args, stdin, options) {
998
+ async function streamingHandler({ args, stdin, abortSignal, cwd, env, options, isCancelled }) {
999
+ // Access both built-in and custom options from 'options' object
1000
+ if (options.customFlag) {
1001
+ yield "custom behavior\n";
1002
+ }
837
1003
  yield "chunk1\n";
838
1004
  yield "chunk2\n";
839
- // Each yield sends a chunk in real-time
840
1005
  }
841
1006
  ```
842
1007
 
@@ -973,7 +1138,7 @@ try {
973
1138
 
974
1139
  - **🎯 Smart Detection**: Only forwards CTRL+C when child processes are active
975
1140
  - **🛡️ Non-Interference**: Preserves user SIGINT handlers when no children running
976
- - **⚡ Interactive Commands**: Commands like `vim`, `less`, `top` work with their own signal handling
1141
+ - **⚡ Interactive Commands**: Use `interactive: true` option for commands like `vim`, `less`, `top` to enable proper TTY forwarding and signal handling
977
1142
  - **🔄 Process Groups**: Detached spawning ensures proper signal isolation
978
1143
  - **🧹 TTY Cleanup**: Raw terminal mode properly restored on interruption
979
1144
  - **📊 Standard Exit Codes**:
@@ -1066,7 +1231,7 @@ $`npm install`
1066
1231
  ## Testing
1067
1232
 
1068
1233
  ```bash
1069
- # Run comprehensive test suite (270+ tests)
1234
+ # Run comprehensive test suite (518+ tests)
1070
1235
  bun test
1071
1236
 
1072
1237
  # Run tests with coverage report
@@ -1117,7 +1282,7 @@ bun test # Run the full test suite
1117
1282
 
1118
1283
  ### 🧪 **Running Tests**
1119
1284
  ```bash
1120
- bun test # All 266 tests
1285
+ bun test # All 518+ tests
1121
1286
  bun test tests/pipe.test.mjs # Specific test file
1122
1287
  npm run test:builtin # Built-in commands only
1123
1288
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "command-stream",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": "src/$.mjs",
package/src/$.mjs CHANGED
@@ -12,13 +12,6 @@ const isBun = typeof globalThis.Bun !== 'undefined';
12
12
 
13
13
  const VERBOSE = process.env.COMMAND_STREAM_VERBOSE === 'true' || process.env.CI === 'true';
14
14
 
15
- // Interactive commands that need TTY forwarding by default
16
- const INTERACTIVE_COMMANDS = new Set([
17
- 'top', 'htop', 'btop', 'less', 'more', 'vi', 'vim', 'nano', 'emacs',
18
- 'man', 'pager', 'watch', 'tmux', 'screen', 'ssh', 'ftp', 'sftp',
19
- 'mysql', 'psql', 'redis-cli', 'mongo', 'sqlite3', 'irb', 'python',
20
- 'node', 'repl', 'gdb', 'lldb', 'bc', 'dc', 'ed'
21
- ]);
22
15
 
23
16
  // Trace function for verbose logging
24
17
  function trace(category, messageOrFunc) {
@@ -28,24 +21,6 @@ function trace(category, messageOrFunc) {
28
21
  console.error(`[TRACE ${timestamp}] [${category}] ${message}`);
29
22
  }
30
23
 
31
- // Check if a command is interactive and needs TTY forwarding
32
- function isInteractiveCommand(command) {
33
- if (!command || typeof command !== 'string') return false;
34
-
35
- // Extract command and arguments from shell command string
36
- const parts = command.trim().split(/\s+/);
37
- const commandName = parts[0];
38
- const baseName = path.basename(commandName);
39
-
40
- // Special handling for commands that are only interactive when run without arguments/scripts
41
- if (baseName === 'node' || baseName === 'python' || baseName === 'python3') {
42
- // These are only interactive when run without a script file
43
- // If there are additional arguments (like a script file), they're not interactive
44
- return parts.length === 1;
45
- }
46
-
47
- return INTERACTIVE_COMMANDS.has(baseName);
48
- }
49
24
 
50
25
 
51
26
  // Track parent stream state for graceful shutdown
@@ -513,6 +488,14 @@ class StreamEmitter {
513
488
  return this;
514
489
  }
515
490
 
491
+ once(event, listener) {
492
+ const onceWrapper = (...args) => {
493
+ this.off(event, onceWrapper);
494
+ listener(...args);
495
+ };
496
+ return this.on(event, onceWrapper);
497
+ }
498
+
516
499
  emit(event, ...args) {
517
500
  const eventListeners = this.listeners.get(event);
518
501
  trace('StreamEmitter', () => `Emitting event | ${JSON.stringify({
@@ -521,7 +504,9 @@ class StreamEmitter {
521
504
  listenerCount: eventListeners?.length || 0
522
505
  })}`);
523
506
  if (eventListeners) {
524
- for (const listener of eventListeners) {
507
+ // Create a copy to avoid issues if listeners modify the array
508
+ const listenersToCall = [...eventListeners];
509
+ for (const listener of listenersToCall) {
525
510
  listener(...args);
526
511
  }
527
512
  }
@@ -545,6 +530,36 @@ function quote(value) {
545
530
  if (Array.isArray(value)) return value.map(quote).join(' ');
546
531
  if (typeof value !== 'string') value = String(value);
547
532
  if (value === '') return "''";
533
+
534
+ // If the value is already properly quoted and doesn't need further escaping,
535
+ // check if we can use it as-is or with simpler quoting
536
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
537
+ // If it's already single-quoted and doesn't contain unescaped single quotes in the middle,
538
+ // we can potentially use it as-is
539
+ const inner = value.slice(1, -1);
540
+ if (!inner.includes("'")) {
541
+ // The inner content has no single quotes, so the original quoting is fine
542
+ return value;
543
+ }
544
+ }
545
+
546
+ if (value.startsWith('"') && value.endsWith('"') && value.length > 2) {
547
+ // If it's already double-quoted, wrap it in single quotes to preserve it
548
+ return `'${value}'`;
549
+ }
550
+
551
+ // Check if the string needs quoting at all
552
+ // Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus
553
+ // This regex matches strings that DON'T need quoting
554
+ const safePattern = /^[a-zA-Z0-9_\-./=,+@:]+$/;
555
+
556
+ if (safePattern.test(value)) {
557
+ // The string is safe and doesn't need quoting
558
+ return value;
559
+ }
560
+
561
+ // Default behavior: wrap in single quotes and escape any internal single quotes
562
+ // This handles spaces, special shell characters, etc.
548
563
  return `'${value.replace(/'/g, "'\\''")}'`;
549
564
  }
550
565
 
@@ -604,6 +619,7 @@ class ProcessRunner extends StreamEmitter {
604
619
  stdin: 'inherit',
605
620
  cwd: undefined,
606
621
  env: undefined,
622
+ interactive: false, // Explicitly request TTY forwarding for interactive commands
607
623
  ...options
608
624
  };
609
625
 
@@ -655,6 +671,281 @@ class ProcessRunner extends StreamEmitter {
655
671
  return this.child ? this.child.stdin : null;
656
672
  }
657
673
 
674
+ // Issue #33: New streaming interfaces
675
+ _autoStartIfNeeded(reason) {
676
+ if (!this.started && !this.finished) {
677
+ trace('ProcessRunner', () => `Auto-starting process due to ${reason}`);
678
+ this.start({ mode: 'async', stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
679
+ }
680
+ }
681
+
682
+ get streams() {
683
+ const self = this;
684
+ return {
685
+ get stdin() {
686
+ trace('ProcessRunner.streams', () => `stdin access | ${JSON.stringify({
687
+ hasChild: !!self.child,
688
+ hasStdin: !!(self.child && self.child.stdin),
689
+ started: self.started,
690
+ finished: self.finished,
691
+ hasPromise: !!self.promise,
692
+ command: self.spec?.command?.slice(0, 50)
693
+ }, null, 2)}`);
694
+
695
+ self._autoStartIfNeeded('streams.stdin access');
696
+
697
+ // Streams are available immediately after spawn, or null if not piped
698
+ // Return the stream directly if available, otherwise ensure process starts
699
+ if (self.child && self.child.stdin) {
700
+ trace('ProcessRunner.streams', () => 'stdin: returning existing stream');
701
+ return self.child.stdin;
702
+ }
703
+ if (self.finished) {
704
+ trace('ProcessRunner.streams', () => 'stdin: process finished, returning null');
705
+ return null;
706
+ }
707
+
708
+ // For virtual commands, there's no child process
709
+ // Exception: virtual commands with stdin: "pipe" will fallback to real commands
710
+ const isVirtualCommand = self._virtualGenerator || (self.spec && self.spec.command && virtualCommands.has(self.spec.command.split(' ')[0]));
711
+ const willFallbackToReal = isVirtualCommand && self.options.stdin === 'pipe';
712
+
713
+ if (isVirtualCommand && !willFallbackToReal) {
714
+ trace('ProcessRunner.streams', () => 'stdin: virtual command, returning null');
715
+ return null;
716
+ }
717
+
718
+ // If not started, start it and wait for child to be created (not for completion!)
719
+ if (!self.started) {
720
+ trace('ProcessRunner.streams', () => 'stdin: not started, starting and waiting for child');
721
+ // Start the process
722
+ self._startAsync();
723
+ // Wait for child to be created using async iteration
724
+ return new Promise((resolve) => {
725
+ const checkForChild = () => {
726
+ if (self.child && self.child.stdin) {
727
+ resolve(self.child.stdin);
728
+ } else if (self.finished || self._virtualGenerator) {
729
+ resolve(null);
730
+ } else {
731
+ // Use setImmediate to check again in next event loop iteration
732
+ setImmediate(checkForChild);
733
+ }
734
+ };
735
+ setImmediate(checkForChild);
736
+ });
737
+ }
738
+
739
+ // Process is starting - wait for child to appear
740
+ if (self.promise && !self.child) {
741
+ trace('ProcessRunner.streams', () => 'stdin: process starting, waiting for child');
742
+ return new Promise((resolve) => {
743
+ const checkForChild = () => {
744
+ if (self.child && self.child.stdin) {
745
+ resolve(self.child.stdin);
746
+ } else if (self.finished || self._virtualGenerator) {
747
+ resolve(null);
748
+ } else {
749
+ setImmediate(checkForChild);
750
+ }
751
+ };
752
+ setImmediate(checkForChild);
753
+ });
754
+ }
755
+
756
+ trace('ProcessRunner.streams', () => 'stdin: returning null (no conditions met)');
757
+ return null;
758
+ },
759
+ get stdout() {
760
+ trace('ProcessRunner.streams', () => `stdout access | ${JSON.stringify({
761
+ hasChild: !!self.child,
762
+ hasStdout: !!(self.child && self.child.stdout),
763
+ started: self.started,
764
+ finished: self.finished,
765
+ hasPromise: !!self.promise,
766
+ command: self.spec?.command?.slice(0, 50)
767
+ }, null, 2)}`);
768
+
769
+ self._autoStartIfNeeded('streams.stdout access');
770
+
771
+ if (self.child && self.child.stdout) {
772
+ trace('ProcessRunner.streams', () => 'stdout: returning existing stream');
773
+ return self.child.stdout;
774
+ }
775
+ if (self.finished) {
776
+ trace('ProcessRunner.streams', () => 'stdout: process finished, returning null');
777
+ return null;
778
+ }
779
+
780
+ // For virtual commands, there's no child process
781
+ if (self._virtualGenerator || (self.spec && self.spec.command && virtualCommands.has(self.spec.command.split(' ')[0]))) {
782
+ trace('ProcessRunner.streams', () => 'stdout: virtual command, returning null');
783
+ return null;
784
+ }
785
+
786
+ if (!self.started) {
787
+ trace('ProcessRunner.streams', () => 'stdout: not started, starting and waiting for child');
788
+ self._startAsync();
789
+ return new Promise((resolve) => {
790
+ const checkForChild = () => {
791
+ if (self.child && self.child.stdout) {
792
+ resolve(self.child.stdout);
793
+ } else if (self.finished || self._virtualGenerator) {
794
+ resolve(null);
795
+ } else {
796
+ setImmediate(checkForChild);
797
+ }
798
+ };
799
+ setImmediate(checkForChild);
800
+ });
801
+ }
802
+
803
+ if (self.promise && !self.child) {
804
+ trace('ProcessRunner.streams', () => 'stdout: process starting, waiting for child');
805
+ return new Promise((resolve) => {
806
+ const checkForChild = () => {
807
+ if (self.child && self.child.stdout) {
808
+ resolve(self.child.stdout);
809
+ } else if (self.finished || self._virtualGenerator) {
810
+ resolve(null);
811
+ } else {
812
+ setImmediate(checkForChild);
813
+ }
814
+ };
815
+ setImmediate(checkForChild);
816
+ });
817
+ }
818
+
819
+ trace('ProcessRunner.streams', () => 'stdout: returning null (no conditions met)');
820
+ return null;
821
+ },
822
+ get stderr() {
823
+ trace('ProcessRunner.streams', () => `stderr access | ${JSON.stringify({
824
+ hasChild: !!self.child,
825
+ hasStderr: !!(self.child && self.child.stderr),
826
+ started: self.started,
827
+ finished: self.finished,
828
+ hasPromise: !!self.promise,
829
+ command: self.spec?.command?.slice(0, 50)
830
+ }, null, 2)}`);
831
+
832
+ self._autoStartIfNeeded('streams.stderr access');
833
+
834
+ if (self.child && self.child.stderr) {
835
+ trace('ProcessRunner.streams', () => 'stderr: returning existing stream');
836
+ return self.child.stderr;
837
+ }
838
+ if (self.finished) {
839
+ trace('ProcessRunner.streams', () => 'stderr: process finished, returning null');
840
+ return null;
841
+ }
842
+
843
+ // For virtual commands, there's no child process
844
+ if (self._virtualGenerator || (self.spec && self.spec.command && virtualCommands.has(self.spec.command.split(' ')[0]))) {
845
+ trace('ProcessRunner.streams', () => 'stderr: virtual command, returning null');
846
+ return null;
847
+ }
848
+
849
+ if (!self.started) {
850
+ trace('ProcessRunner.streams', () => 'stderr: not started, starting and waiting for child');
851
+ self._startAsync();
852
+ return new Promise((resolve) => {
853
+ const checkForChild = () => {
854
+ if (self.child && self.child.stderr) {
855
+ resolve(self.child.stderr);
856
+ } else if (self.finished || self._virtualGenerator) {
857
+ resolve(null);
858
+ } else {
859
+ setImmediate(checkForChild);
860
+ }
861
+ };
862
+ setImmediate(checkForChild);
863
+ });
864
+ }
865
+
866
+ if (self.promise && !self.child) {
867
+ trace('ProcessRunner.streams', () => 'stderr: process starting, waiting for child');
868
+ return new Promise((resolve) => {
869
+ const checkForChild = () => {
870
+ if (self.child && self.child.stderr) {
871
+ resolve(self.child.stderr);
872
+ } else if (self.finished || self._virtualGenerator) {
873
+ resolve(null);
874
+ } else {
875
+ setImmediate(checkForChild);
876
+ }
877
+ };
878
+ setImmediate(checkForChild);
879
+ });
880
+ }
881
+
882
+ trace('ProcessRunner.streams', () => 'stderr: returning null (no conditions met)');
883
+ return null;
884
+ }
885
+ };
886
+ }
887
+
888
+ get buffers() {
889
+ const self = this;
890
+ return {
891
+ get stdin() {
892
+ self._autoStartIfNeeded('buffers.stdin access');
893
+ if (self.finished && self.result) {
894
+ return Buffer.from(self.result.stdin || '', 'utf8');
895
+ }
896
+ // Return promise if not finished
897
+ return self.then ? self.then(result => Buffer.from(result.stdin || '', 'utf8')) : Promise.resolve(Buffer.alloc(0));
898
+ },
899
+ get stdout() {
900
+ self._autoStartIfNeeded('buffers.stdout access');
901
+ if (self.finished && self.result) {
902
+ return Buffer.from(self.result.stdout || '', 'utf8');
903
+ }
904
+ // Return promise if not finished
905
+ return self.then ? self.then(result => Buffer.from(result.stdout || '', 'utf8')) : Promise.resolve(Buffer.alloc(0));
906
+ },
907
+ get stderr() {
908
+ self._autoStartIfNeeded('buffers.stderr access');
909
+ if (self.finished && self.result) {
910
+ return Buffer.from(self.result.stderr || '', 'utf8');
911
+ }
912
+ // Return promise if not finished
913
+ return self.then ? self.then(result => Buffer.from(result.stderr || '', 'utf8')) : Promise.resolve(Buffer.alloc(0));
914
+ }
915
+ };
916
+ }
917
+
918
+ get strings() {
919
+ const self = this;
920
+ return {
921
+ get stdin() {
922
+ self._autoStartIfNeeded('strings.stdin access');
923
+ if (self.finished && self.result) {
924
+ return self.result.stdin || '';
925
+ }
926
+ // Return promise if not finished
927
+ return self.then ? self.then(result => result.stdin || '') : Promise.resolve('');
928
+ },
929
+ get stdout() {
930
+ self._autoStartIfNeeded('strings.stdout access');
931
+ if (self.finished && self.result) {
932
+ return self.result.stdout || '';
933
+ }
934
+ // Return promise if not finished
935
+ return self.then ? self.then(result => result.stdout || '') : Promise.resolve('');
936
+ },
937
+ get stderr() {
938
+ self._autoStartIfNeeded('strings.stderr access');
939
+ if (self.finished && self.result) {
940
+ return self.result.stderr || '';
941
+ }
942
+ // Return promise if not finished
943
+ return self.then ? self.then(result => result.stderr || '') : Promise.resolve('');
944
+ }
945
+ };
946
+ }
947
+
948
+
658
949
  // Centralized method to properly finish a process with correct event emission order
659
950
  finish(result) {
660
951
  trace('ProcessRunner', () => `finish() called | ${JSON.stringify({
@@ -814,14 +1105,15 @@ class ProcessRunner extends StreamEmitter {
814
1105
  writer.close().catch(() => { }); // Ignore close errors
815
1106
  }
816
1107
 
817
- setTimeout(() => {
1108
+ // Use setImmediate for deferred termination instead of setTimeout
1109
+ setImmediate(() => {
818
1110
  if (this.child && !this.finished) {
819
1111
  trace('ProcessRunner', () => 'Terminating child process after parent stream closure');
820
1112
  if (typeof this.child.kill === 'function') {
821
1113
  this.child.kill('SIGTERM');
822
1114
  }
823
1115
  }
824
- }, 100);
1116
+ });
825
1117
 
826
1118
  } catch (error) {
827
1119
  trace('ProcessRunner', () => `Error during graceful shutdown | ${JSON.stringify({ error: error.message }, null, 2)}`);
@@ -939,7 +1231,14 @@ class ProcessRunner extends StreamEmitter {
939
1231
  start(options = {}) {
940
1232
  const mode = options.mode || 'async';
941
1233
 
942
- trace('ProcessRunner', () => `start ENTER | ${JSON.stringify({ mode, options, started: this.started }, null, 2)}`);
1234
+ trace('ProcessRunner', () => `start ENTER | ${JSON.stringify({
1235
+ mode,
1236
+ options,
1237
+ started: this.started,
1238
+ hasPromise: !!this.promise,
1239
+ hasChild: !!this.child,
1240
+ command: this.spec?.command?.slice(0, 50)
1241
+ }, null, 2)}`);
943
1242
 
944
1243
  // Merge new options with existing options before starting
945
1244
  if (Object.keys(options).length > 0 && !this.started) {
@@ -1115,16 +1414,17 @@ class ProcessRunner extends StreamEmitter {
1115
1414
  commandCount: parsed.commands?.length
1116
1415
  }, null, 2)}`);
1117
1416
  return await this._runPipeline(parsed.commands);
1118
- } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd)) {
1417
+ } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd) && !this.options._bypassVirtual) {
1119
1418
  // For built-in virtual commands that have real counterparts (like sleep),
1120
1419
  // skip the virtual version when custom stdin is provided to ensure proper process handling
1121
1420
  const hasCustomStdin = this.options.stdin &&
1122
1421
  this.options.stdin !== 'inherit' &&
1123
1422
  this.options.stdin !== 'ignore';
1124
1423
 
1125
- // List of built-in virtual commands that should fallback to real commands with custom stdin
1126
- const builtinCommands = ['sleep', 'echo', 'pwd', 'true', 'false', 'yes', 'cat', 'ls', 'which'];
1127
- const shouldBypassVirtual = hasCustomStdin && builtinCommands.includes(parsed.cmd);
1424
+ // Only bypass for commands that truly need real process behavior with custom stdin
1425
+ // Most commands like 'echo' work fine with virtual implementations even with stdin
1426
+ const commandsThatNeedRealStdin = ['sleep', 'cat']; // Only these really need real processes for stdin
1427
+ const shouldBypassVirtual = hasCustomStdin && commandsThatNeedRealStdin.includes(parsed.cmd);
1128
1428
 
1129
1429
  if (shouldBypassVirtual) {
1130
1430
  trace('ProcessRunner', () => `Bypassing built-in virtual command due to custom stdin | ${JSON.stringify({
@@ -1168,12 +1468,12 @@ class ProcessRunner extends StreamEmitter {
1168
1468
  }
1169
1469
 
1170
1470
  // Detect if this is an interactive command that needs direct TTY access
1171
- // Only activate for interactive commands when we have a real TTY and the command is likely to need it
1471
+ // Only activate for interactive commands when we have a real TTY and interactive mode is explicitly requested
1172
1472
  const isInteractive = stdin === 'inherit' &&
1173
1473
  process.stdin.isTTY === true &&
1174
1474
  process.stdout.isTTY === true &&
1175
1475
  process.stderr.isTTY === true &&
1176
- (this.spec.mode === 'shell' ? isInteractiveCommand(this.spec.command) : isInteractiveCommand(this.spec.file));
1476
+ this.options.interactive === true;
1177
1477
 
1178
1478
  trace('ProcessRunner', () => `Interactive command detection | ${JSON.stringify({
1179
1479
  isInteractive,
@@ -1181,7 +1481,7 @@ class ProcessRunner extends StreamEmitter {
1181
1481
  stdinTTY: process.stdin.isTTY,
1182
1482
  stdoutTTY: process.stdout.isTTY,
1183
1483
  stderrTTY: process.stderr.isTTY,
1184
- commandCheck: this.spec.mode === 'shell' ? isInteractiveCommand(this.spec.command) : isInteractiveCommand(this.spec.file)
1484
+ interactiveOption: this.options.interactive
1185
1485
  }, null, 2)}`);
1186
1486
 
1187
1487
  const spawnBun = (argv) => {
@@ -1380,6 +1680,10 @@ class ProcessRunner extends StreamEmitter {
1380
1680
  this.child.stdin.end();
1381
1681
  trace('ProcessRunner', () => `stdin: Child stdin closed successfully`);
1382
1682
  }
1683
+ } else if (stdin === 'pipe') {
1684
+ trace('ProcessRunner', () => `stdin: Using pipe mode - leaving stdin open for manual control`);
1685
+ // Leave stdin open for manual writing via streams.stdin
1686
+ stdinPumpPromise = Promise.resolve();
1383
1687
  } else if (typeof stdin === 'string' || Buffer.isBuffer(stdin)) {
1384
1688
  const buf = Buffer.isBuffer(stdin) ? stdin : Buffer.from(stdin);
1385
1689
  trace('ProcessRunner', () => `stdin: Writing buffer to child | ${JSON.stringify({
@@ -1675,7 +1979,23 @@ class ProcessRunner extends StreamEmitter {
1675
1979
  try {
1676
1980
  // Prepare stdin
1677
1981
  let stdinData = '';
1678
- if (this.options.stdin && typeof this.options.stdin === 'string') {
1982
+
1983
+ // Special handling for streaming mode (stdin: "pipe")
1984
+ if (this.options.stdin === 'pipe') {
1985
+ // For streaming interfaces, virtual commands should fallback to real commands
1986
+ // because virtual commands don't support true streaming
1987
+ trace('ProcessRunner', () => `Virtual command fallback for streaming | ${JSON.stringify({ cmd }, null, 2)}`);
1988
+
1989
+ // Create a new ProcessRunner for the real command with properly merged options
1990
+ // Preserve main options but use appropriate stdin for the real command
1991
+ const modifiedOptions = {
1992
+ ...this.options,
1993
+ stdin: 'pipe', // Keep pipe but ensure it doesn't trigger virtual command fallback
1994
+ _bypassVirtual: true // Flag to prevent virtual command recursion
1995
+ };
1996
+ const realRunner = new ProcessRunner({ mode: 'shell', command: originalCommand || cmd }, modifiedOptions);
1997
+ return await realRunner._doStartAsync();
1998
+ } else if (this.options.stdin && typeof this.options.stdin === 'string') {
1679
1999
  stdinData = this.options.stdin;
1680
2000
  } else if (this.options.stdin && Buffer.isBuffer(this.options.stdin)) {
1681
2001
  stdinData = this.options.stdin.toString('utf8');
@@ -1698,22 +2018,28 @@ class ProcessRunner extends StreamEmitter {
1698
2018
  const chunks = [];
1699
2019
 
1700
2020
  const commandOptions = {
1701
- ...this.options,
1702
- isCancelled: () => this._cancelled,
1703
- signal: this._abortController?.signal
2021
+ // Commonly used options at top level for convenience
2022
+ cwd: this.options.cwd,
2023
+ env: this.options.env,
2024
+ // All original options (built-in + custom) in options object
2025
+ options: this.options,
2026
+ isCancelled: () => this._cancelled
1704
2027
  };
1705
2028
 
1706
2029
  trace('ProcessRunner', () => `_runVirtual signal details | ${JSON.stringify({
1707
2030
  cmd,
1708
2031
  hasAbortController: !!this._abortController,
1709
2032
  signalAborted: this._abortController?.signal?.aborted,
1710
- signalExists: !!commandOptions.signal,
1711
- commandOptionsSignalAborted: commandOptions.signal?.aborted,
1712
2033
  optionsSignalExists: !!this.options.signal,
1713
2034
  optionsSignalAborted: this.options.signal?.aborted
1714
2035
  }, null, 2)}`);
1715
2036
 
1716
- const generator = handler({ args: argValues, stdin: stdinData, ...commandOptions });
2037
+ const generator = handler({
2038
+ args: argValues,
2039
+ stdin: stdinData,
2040
+ abortSignal: this._abortController?.signal,
2041
+ ...commandOptions
2042
+ });
1717
2043
  this._virtualGenerator = generator;
1718
2044
 
1719
2045
  const cancelPromise = new Promise(resolve => {
@@ -1802,22 +2128,28 @@ class ProcessRunner extends StreamEmitter {
1802
2128
  } else {
1803
2129
  // Regular async function - race with abort signal
1804
2130
  const commandOptions = {
1805
- ...this.options,
1806
- isCancelled: () => this._cancelled,
1807
- signal: this._abortController?.signal
2131
+ // Commonly used options at top level for convenience
2132
+ cwd: this.options.cwd,
2133
+ env: this.options.env,
2134
+ // All original options (built-in + custom) in options object
2135
+ options: this.options,
2136
+ isCancelled: () => this._cancelled
1808
2137
  };
1809
2138
 
1810
2139
  trace('ProcessRunner', () => `_runVirtual signal details (non-generator) | ${JSON.stringify({
1811
2140
  cmd,
1812
2141
  hasAbortController: !!this._abortController,
1813
2142
  signalAborted: this._abortController?.signal?.aborted,
1814
- signalExists: !!commandOptions.signal,
1815
- commandOptionsSignalAborted: commandOptions.signal?.aborted,
1816
2143
  optionsSignalExists: !!this.options.signal,
1817
2144
  optionsSignalAborted: this.options.signal?.aborted
1818
2145
  }, null, 2)}`);
1819
2146
 
1820
- const handlerPromise = handler({ args: argValues, stdin: stdinData, ...commandOptions });
2147
+ const handlerPromise = handler({
2148
+ args: argValues,
2149
+ stdin: stdinData,
2150
+ abortSignal: this._abortController?.signal,
2151
+ ...commandOptions
2152
+ });
1821
2153
 
1822
2154
  // Create an abort promise that rejects when cancelled
1823
2155
  const abortPromise = new Promise((_, reject) => {
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import { trace, VirtualUtils } from '../$.utils.mjs';
3
3
 
4
- export default async function cat({ args, stdin, cwd, isCancelled, signal }) {
4
+ export default async function cat({ args, stdin, cwd, isCancelled, abortSignal }) {
5
5
  if (args.length === 0) {
6
6
  // Read from stdin if no files specified
7
7
  if (stdin !== undefined && stdin !== '') {
@@ -14,7 +14,7 @@ export default async function cat({ args, stdin, cwd, isCancelled, signal }) {
14
14
  const outputs = [];
15
15
  for (const file of args) {
16
16
  // Check for cancellation before processing each file
17
- if (isCancelled?.() || signal?.aborted) {
17
+ if (isCancelled?.() || abortSignal?.aborted) {
18
18
  trace('VirtualCommand', () => `cat: cancelled while processing files`);
19
19
  return { code: 130, stdout: '', stderr: '' }; // SIGINT exit code
20
20
  }
@@ -1,11 +1,11 @@
1
1
  import { trace } from '../$.utils.mjs';
2
2
 
3
- export default async function sleep({ args, signal, isCancelled }) {
3
+ export default async function sleep({ args, abortSignal, isCancelled }) {
4
4
  const seconds = parseFloat(args[0] || 0);
5
5
  trace('VirtualCommand', () => `sleep: starting | ${JSON.stringify({
6
6
  seconds,
7
- hasSignal: !!signal,
8
- signalAborted: signal?.aborted,
7
+ hasSignal: !!abortSignal,
8
+ signalAborted: abortSignal?.aborted,
9
9
  hasIsCancelled: !!isCancelled
10
10
  }, null, 2)}`);
11
11
 
@@ -18,30 +18,30 @@ export default async function sleep({ args, signal, isCancelled }) {
18
18
  await new Promise((resolve, reject) => {
19
19
  const timeoutId = setTimeout(resolve, seconds * 1000);
20
20
 
21
- // Handle cancellation via signal
22
- if (signal) {
21
+ // Handle cancellation via abort signal
22
+ if (abortSignal) {
23
23
  trace('VirtualCommand', () => `sleep: setting up abort signal listener | ${JSON.stringify({
24
- signalAborted: signal.aborted
24
+ signalAborted: abortSignal.aborted
25
25
  }, null, 2)}`);
26
26
 
27
- signal.addEventListener('abort', () => {
27
+ abortSignal.addEventListener('abort', () => {
28
28
  trace('VirtualCommand', () => `sleep: abort signal received | ${JSON.stringify({
29
29
  seconds,
30
- signalAborted: signal.aborted
30
+ signalAborted: abortSignal.aborted
31
31
  }, null, 2)}`);
32
32
  clearTimeout(timeoutId);
33
33
  reject(new Error('Sleep cancelled'));
34
34
  });
35
35
 
36
36
  // Check if already aborted
37
- if (signal.aborted) {
37
+ if (abortSignal.aborted) {
38
38
  trace('VirtualCommand', () => `sleep: signal already aborted | ${JSON.stringify({ seconds }, null, 2)}`);
39
39
  clearTimeout(timeoutId);
40
40
  reject(new Error('Sleep cancelled'));
41
41
  return;
42
42
  }
43
43
  } else {
44
- trace('VirtualCommand', () => `sleep: no signal provided | ${JSON.stringify({ seconds }, null, 2)}`);
44
+ trace('VirtualCommand', () => `sleep: no abort signal provided | ${JSON.stringify({ seconds }, null, 2)}`);
45
45
  }
46
46
 
47
47
  // Also check isCancelled periodically for quicker response
@@ -1,11 +1,11 @@
1
1
  import { trace } from '../$.utils.mjs';
2
2
 
3
- export default async function* yes({ args, stdin, isCancelled, signal, ...rest }) {
3
+ export default async function* yes({ args, stdin, isCancelled, abortSignal, ...rest }) {
4
4
  const output = args.length > 0 ? args.join(' ') : 'y';
5
5
  trace('VirtualCommand', () => `yes: starting infinite generator | ${JSON.stringify({
6
6
  output,
7
7
  hasIsCancelled: !!isCancelled,
8
- hasSignal: !!signal
8
+ hasAbortSignal: !!abortSignal
9
9
  }, null, 2)}`);
10
10
 
11
11
  let iteration = 0;
@@ -14,12 +14,12 @@ export default async function* yes({ args, stdin, isCancelled, signal, ...rest }
14
14
  while (!isCancelled?.() && iteration < MAX_ITERATIONS) {
15
15
  trace('VirtualCommand', () => `yes: iteration ${iteration} starting | ${JSON.stringify({
16
16
  isCancelled: isCancelled?.(),
17
- signalAborted: signal?.aborted
17
+ abortSignalAborted: abortSignal?.aborted
18
18
  }, null, 2)}`);
19
19
 
20
20
  // Check for abort signal
21
- if (signal?.aborted) {
22
- trace('VirtualCommand', () => `yes: aborted via signal | ${JSON.stringify({ iteration }, null, 2)}`);
21
+ if (abortSignal?.aborted) {
22
+ trace('VirtualCommand', () => `yes: aborted via abort signal | ${JSON.stringify({ iteration }, null, 2)}`);
23
23
  break;
24
24
  }
25
25
 
@@ -43,6 +43,6 @@ export default async function* yes({ args, stdin, isCancelled, signal, ...rest }
43
43
  trace('VirtualCommand', () => `yes: generator completed | ${JSON.stringify({
44
44
  iteration,
45
45
  wasCancelled: isCancelled?.(),
46
- wasAborted: signal?.aborted
46
+ wasAborted: abortSignal?.aborted
47
47
  }, null, 2)}`);
48
48
  }