Skip to content
74 changes: 74 additions & 0 deletions experiments/issue-14/gnu-tee-reference.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Reference probe: record how GNU tee behaves for the cases the virtual
# implementation has to reproduce (issue #14).
set -u

workdir="$(mktemp -d)"
trap 'rm -rf "${workdir}"' EXIT
cd "${workdir}" || exit 1

echo "--- tee --version"
tee --version 2>/dev/null | head -1

echo "--- basic: stdout passthrough + file"
printf 'a\nb\n' | tee f1.txt | cat
echo "exit=$?"
echo "file: $(cat f1.txt)"

echo "--- no file operands: stdout only"
printf 'x\n' | tee
echo "exit=$?"

echo "--- append (-a)"
printf 'c\n' | tee -a f1.txt >/dev/null
echo "exit=$? file=$(tr '\n' ' ' < f1.txt)"

echo "--- truncate (default) on existing file"
printf 'new\n' | tee f1.txt >/dev/null
echo "exit=$? file=$(tr '\n' ' ' < f1.txt)"

echo "--- unwritable file only"
printf 'z\n' | tee /invalid/path/x.txt
echo "exit=$?"

echo "--- unwritable file plus writable file"
printf 'z\n' | tee /invalid/path/x.txt f2.txt >/dev/null
echo "exit=$? f2=$(cat f2.txt 2>/dev/null)"

echo "--- unknown option"
printf 'q\n' | tee --bogus f3.txt
echo "exit=$? f3-exists=$([ -e f3.txt ] && echo yes || echo no)"

echo "--- '-' operand is a file named '-'"
printf 'd\n' | tee - >/dev/null
echo "exit=$? dash-exists=$([ -e ./- ] && echo yes || echo no)"

echo "--- '--' end of options"
printf 'e\n' | tee -- -a >/dev/null
echo "exit=$? file-named-a-exists=$([ -e ./-a ] && echo yes || echo no)"

echo "--- empty input still creates/truncates the file"
printf '' | tee f4.txt >/dev/null
echo "exit=$? f4-exists=$([ -e f4.txt ] && echo yes || echo no) size=$(wc -c < f4.txt)"

echo "--- binary-ish input passthrough byte count"
head -c 1000 /dev/urandom | tee f5.txt | wc -c
echo "f5 size=$(wc -c < f5.txt)"

echo "--- clustered short options (-ai)"
printf 'g\n' | tee -ai f6.txt >/dev/null
printf 'h\n' | tee -ai f6.txt >/dev/null
echo "exit=$? f6=$(tr '\n' ' ' < f6.txt)"

echo "--- invalid short option"
printf 'q\n' | tee -x f7.txt
echo "exit=$? f7-exists=$([ -e f7.txt ] && echo yes || echo no)"

echo "--- directory as target"
mkdir -p adir
printf 'q\n' | tee adir >/dev/null
echo "exit=$?"

echo "--- same file twice"
printf 'dup\n' | tee f8.txt f8.txt >/dev/null
echo "exit=$? f8=$(tr '\n' ' ' < f8.txt) size=$(wc -c < f8.txt)"
40 changes: 40 additions & 0 deletions experiments/issue-14/stdin-inherit-blocks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Reproduces the Windows/macOS CI failure seen on PR #130 (issue #14).
//
// `bun test` evaluates js/tests/test-helper.mjs only once, so its reset hooks
// belong to whichever test file imported it first. A file such as
// js/tests/raw-function.test.mjs can therefore leave virtual commands disabled
// for every file that runs afterwards, and bun's file order is neither
// alphabetical nor stable across platforms, which is why only macOS and Windows
// failed while Linux passed.
//
// With virtual commands disabled, `cat` is a real binary, and a real command
// run with `stdin: 'inherit'` never finishes: the runner pumps the parent's
// stdin into a pipe and the child keeps waiting for an EOF that never arrives.
// That hang is pre-existing behaviour, reproducible on `main` under both Bun
// and Node, and it happens even when the parent's stdin is /dev/null.
//
// bun experiments/issue-14/stdin-inherit-blocks.mjs < /dev/null
// node experiments/issue-14/stdin-inherit-blocks.mjs < /dev/null
//
// Expected output: "blocked: no result after 5000ms".
//
// js/tests/virtual-command-stdin.test.mjs therefore enables virtual commands
// itself instead of trusting the state left behind by other files.
import { $, disableVirtualCommands } from '../../js/src/$.mjs';

const TIMEOUT_MS = 5000;

disableVirtualCommands(); // simulates the state leaked by an earlier test file

const blocked = Symbol('blocked');
const outcome = await Promise.race([
$({ mirror: false, stdin: 'inherit' })`cat`,
new Promise((resolve) => setTimeout(() => resolve(blocked), TIMEOUT_MS)),
]);

if (outcome === blocked) {
console.log(`blocked: no result after ${TIMEOUT_MS}ms`);
process.exit(1);
}

console.log(`completed: code=${outcome.code}`);
23 changes: 23 additions & 0 deletions experiments/issue-14/tee-mixed-pipeline.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Probe: virtual command followed by a real process in a shell pipeline (issue #14)
import { $ } from '../../js/src/$.mjs';

const cases = [
'echo hello | tr a-z A-Z',
'echo hello | tee /tmp/tee-probe-1.txt | tr a-z A-Z',
'echo hello | cat | tr a-z A-Z',
'echo hello | tee /tmp/tee-probe-2.txt | cat',
'echo hello | tee /tmp/tee-probe-3.txt',
];

for (const cmd of cases) {
const result = await $({ mirror: false })`${{ raw: cmd }}`;
console.log(
cmd,
'=>',
JSON.stringify({
code: result.code,
stdout: result.stdout,
stderr: result.stderr,
})
);
}
27 changes: 27 additions & 0 deletions experiments/issue-14/tee-mixed-pipeline2.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { $ } from '../../js/src/$.mjs';

const f = '/tmp/tee-probe-a.txt';
console.log(
'1:',
JSON.stringify(
(await $({ mirror: false })`echo hello | tee ${f} | tr a-z A-Z`).stdout
)
);
console.log(
'2:',
JSON.stringify(
(await $({ mirror: false })`echo hello | cat | tr a-z A-Z`).stdout
)
);
console.log(
'3:',
JSON.stringify((await $({ mirror: false })`echo hello | tee ${f}`).stdout)
);
console.log(
'4:',
JSON.stringify((await $`echo hello | tee ${f} | tr a-z A-Z`).stdout)
);
console.log(
'5:',
JSON.stringify((await $`echo hello | cat | tr a-z A-Z`).stdout)
);
14 changes: 14 additions & 0 deletions experiments/issue-14/tee-virtual-probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Probe: is `tee` resolved as a virtual command? (issue #14)
import { $, listCommands, enableVirtualCommands } from '../../js/src/$.mjs';

console.log('registered:', listCommands().includes('tee'));
enableVirtualCommands();

const which = await $({ mirror: false })`which tee`;
console.log('which tee:', JSON.stringify(which.stdout));

const unknown = await $({
stdin: 'test',
mirror: false,
})`tee --unknown-option file.txt`;
console.log('unknown option:', JSON.stringify(unknown));
16 changes: 16 additions & 0 deletions js/.changeset/issue-14-tee-virtual-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'command-stream': minor
---

Add `tee` as a built-in virtual command. It was implemented but never
registered, so `` $`tee ...` `` fell through to the system binary. Follows GNU
coreutils: `-a`/`--append`, `-i`/`--ignore-interrupts`, clustered short flags,
`--` as an option terminator, a bare `-` treated as a file named `-`, and a
write failure reported on stderr with exit code 1 while the remaining files are
still written.

Also stop stdio mode keywords from becoming virtual command input. The `stdin`
option carries either input data or one of `inherit`, `ignore` and `pipe`, but
both virtual command runners treated any string as data, so `` await $`cat` ``
returned the literal `"inherit"`. Piped input now also wins over the pipeline's
own `stdin` option instead of being overwritten by it.
38 changes: 33 additions & 5 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ A modern $ shell utility library with streaming, async iteration, and EventEmitt
- ⚡ **Performance**: Memory-efficient streaming prevents large buffer accumulation
- 🎯 **Backward Compatible**: Existing `await $` syntax continues to work + Bun.$ `.text()` method
- 🛡️ **Type Safe**: Full TypeScript support (coming soon)
- 🔧 **Built-in Commands**: 18 essential commands work identically across platforms
- 🔧 **Built-in Commands**: 22 essential commands work identically across platforms

## Comparison with Other Libraries

Expand All @@ -51,7 +51,7 @@ A modern $ shell utility library with streaming, async iteration, and EventEmitt
| **Stdout Support** | ✅ Real-time streaming + events | ✅ Node.js streams + interleaved | ✅ Inherited/buffered | ✅ Shell redirection + buffered | ✅ Direct output | ✅ Readable streams + `.pipe.stdout` |
| **Stderr Support** | ✅ Real-time streaming + events | ✅ Streams + interleaved output | ✅ Inherited/buffered | ✅ Redirection + `.quiet()` access | ✅ Error output | ✅ Readable streams + `.pipe.stderr` |
| **Stdin Support** | ✅ string/Buffer/inherit/ignore | ✅ Input/output streams | ✅ Full stdio support | ✅ Pipe operations | 🟡 Basic | ✅ Basic stdin |
| **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 |
| **Built-in Commands** | ✅ **22 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 |
| **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 |
| **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 |
| **Bundle Size** | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) |
Expand Down Expand Up @@ -104,7 +104,7 @@ Run the focused executable corpus with `bun run test:competitors`.

## Built-in Commands (🚀 NEW!)

command-stream now includes **18 built-in commands** that work identically to their bash/sh counterparts, providing true cross-platform shell scripting without system dependencies:
command-stream now includes **22 built-in commands** that work identically to their bash/sh counterparts, providing true cross-platform shell scripting without system dependencies:

### 📁 **File System Commands**

Expand All @@ -121,6 +121,7 @@ command-stream now includes **18 built-in commands** that work identically to th
- `basename` - Extract filename from path
- `dirname` - Extract directory from path
- `seq` - Generate number sequences
- `tee` - Copy input to stdout and to files (supports `-a`, `-i`)
- `yes` - Output string repeatedly (streaming)

### ⚡ **System Commands**
Expand Down Expand Up @@ -161,6 +162,33 @@ await $`seq 1 5 | cat > numbers.txt`;
await $`basename /path/to/file.txt .txt`; // → "file"
```

### 🔀 `tee`: splitting a pipeline

`tee` copies its input to stdout and to every file it is given, so a pipeline
can be recorded and kept flowing at the same time. It follows GNU coreutils:
`-a`/`--append` appends instead of truncating, `-i`/`--ignore-interrupts`
keeps writing when the pipeline is cancelled, `--` ends option parsing, and a
bare `-` is a file named `-` rather than stdout.

```javascript
// Record a step without consuming it
await $`echo "deploying" | tee deploy.log | cat`;

// Fan out to several files, appending to each
await $`echo "second run" | tee -a deploy.log audit.log`;
```

A write failure is reported on stderr and sets exit code 1, but the remaining
files are still written and the input still reaches stdout, exactly as
coreutils does.

**On interactive use:** built-in commands receive their stdin as one completed
buffer, because a pipeline reads each upstream stage to the end before handing
the result on. So this `tee` is a pipeline stage, not a live terminal filter --
it cannot echo keystrokes back as you type them. The `interactive: true` option
applies to spawned system processes; for a live `tee`, disable virtual commands
and let the system binary run.

## Installation

```bash
Expand Down Expand Up @@ -1825,10 +1853,10 @@ await $`${raw(trustedCommand)}`;

### Built-in Commands

18 cross-platform commands that work identically everywhere:
22 cross-platform commands that work identically everywhere:

**File System**: `cat`, `ls`, `mkdir`, `rm`, `mv`, `cp`, `touch`
**Utilities**: `basename`, `dirname`, `seq`, `yes`
**Utilities**: `basename`, `dirname`, `seq`, `tee`, `yes`
**System**: `cd`, `pwd`, `echo`, `sleep`, `true`, `false`, `which`, `exit`, `env`, `test`

All built-in commands support:
Expand Down
39 changes: 39 additions & 0 deletions js/examples/tee-command.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env node
// Virtual `tee`: copy a command's output to files while it keeps flowing
// through the pipeline (issue #14).
import { $ } from '../src/$.mjs';
import { mkdtempSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';

const dir = mkdtempSync(join(tmpdir(), 'tee-example-'));
const log = join(dir, 'build.log');
const audit = join(dir, 'audit.log');

// 1. Capture output to a file and keep it on stdout.
const build = await $`echo "build finished"`.pipe($`tee ${log}`);
console.log('stdout :', JSON.stringify(build.stdout));
console.log('file :', JSON.stringify(readFileSync(log, 'utf8')));

// 2. Append a second run instead of truncating, and fan out to two files.
await $`echo "second run"`.pipe($`tee -a ${log} ${audit}`);
console.log('appended:', JSON.stringify(readFileSync(log, 'utf8')));
console.log('audit :', JSON.stringify(readFileSync(audit, 'utf8')));

// 3. tee sits in the middle of a pipeline: downstream still receives the data.
const piped = await $`echo "hello tee" | tee ${log} | tr a-z A-Z`;
console.log('piped :', JSON.stringify(piped.stdout));

// 4. A target that cannot be written reports an error, but the remaining
// targets and stdout are still written and the exit code becomes 1.
const partial = await $({
stdin: 'still delivered\n',
})`tee /invalid/path/nope.log ${audit}`;
console.log('code :', partial.code);
console.log('stderr :', JSON.stringify(partial.stderr));
console.log('stdout :', JSON.stringify(partial.stdout));

// Virtual commands receive stdin as a completed buffer, so this `tee` is a
// pipeline stage rather than a live terminal filter. Use `interactive: true`
// with the system binary when you need keystroke-by-keystroke behaviour.
rmSync(dir, { recursive: true, force: true });
2 changes: 2 additions & 0 deletions js/src/$.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ import basenameCommand from './commands/$.basename.mjs';
import dirnameCommand from './commands/$.dirname.mjs';
import yesCommand from './commands/$.yes.mjs';
import seqCommand from './commands/$.seq.mjs';
import teeCommand from './commands/$.tee.mjs';
import testCommand from './commands/$.test.mjs';

// Built-in commands that match Bun.$ functionality
Expand Down Expand Up @@ -424,6 +425,7 @@ function registerBuiltins() {
register('dirname', dirnameCommand);
register('yes', yesCommand);
register('seq', seqCommand);
register('tee', teeCommand);
register('test', testCommand);
}

Expand Down
20 changes: 10 additions & 10 deletions js/src/$.process-runner-pipeline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
import cp from 'child_process';
import { trace } from './$.trace.mjs';
import { findAvailableShell, withExportedProcessContext } from './$.shell.mjs';
import { StreamUtils, safeWrite } from './$.stream-utils.mjs';
import {
StreamUtils,
safeWrite,
stdinDataFromOptions,
} from './$.stream-utils.mjs';
import { createCommandError, createResult } from './$.result.mjs';
import {
applyVirtualProcessContext,
Expand Down Expand Up @@ -170,13 +174,7 @@ function getFirstCommandStdin(options) {
* @returns {string}
*/
function getStdinString(options) {
if (options.stdin && typeof options.stdin === 'string') {
return options.stdin;
}
if (options.stdin && Buffer.isBuffer(options.stdin)) {
return options.stdin.toString('utf8');
}
return '';
return stdinDataFromOptions(options);
}

/**
Expand Down Expand Up @@ -502,9 +500,11 @@ async function runVirtualHandler(
if (handler.constructor.name === 'AsyncGeneratorFunction') {
const chunks = [];
for await (const chunk of handler({
...options,
args: argValues,
// The piped input wins over `options.stdin`, which only configures the
// pipeline's own input (issue #14).
stdin: currentInput,
...options,
})) {
chunks.push(Buffer.from(chunk));
}
Expand All @@ -518,9 +518,9 @@ async function runVirtualHandler(
};
}
const result = await handler({
...options,
args: argValues,
stdin: currentInput,
...options,
});
return {
...result,
Expand Down
Loading
Loading