diff --git a/experiments/issue-14/gnu-tee-reference.sh b/experiments/issue-14/gnu-tee-reference.sh new file mode 100755 index 00000000..ae1a464b --- /dev/null +++ b/experiments/issue-14/gnu-tee-reference.sh @@ -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)" diff --git a/experiments/issue-14/stdin-inherit-blocks.mjs b/experiments/issue-14/stdin-inherit-blocks.mjs new file mode 100644 index 00000000..040721fe --- /dev/null +++ b/experiments/issue-14/stdin-inherit-blocks.mjs @@ -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}`); diff --git a/experiments/issue-14/tee-mixed-pipeline.mjs b/experiments/issue-14/tee-mixed-pipeline.mjs new file mode 100644 index 00000000..d78f43ab --- /dev/null +++ b/experiments/issue-14/tee-mixed-pipeline.mjs @@ -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, + }) + ); +} diff --git a/experiments/issue-14/tee-mixed-pipeline2.mjs b/experiments/issue-14/tee-mixed-pipeline2.mjs new file mode 100644 index 00000000..2dfbcf86 --- /dev/null +++ b/experiments/issue-14/tee-mixed-pipeline2.mjs @@ -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) +); diff --git a/experiments/issue-14/tee-virtual-probe.mjs b/experiments/issue-14/tee-virtual-probe.mjs new file mode 100644 index 00000000..a07934bb --- /dev/null +++ b/experiments/issue-14/tee-virtual-probe.mjs @@ -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)); diff --git a/js/.changeset/issue-14-tee-virtual-command.md b/js/.changeset/issue-14-tee-virtual-command.md new file mode 100644 index 00000000..cd148dec --- /dev/null +++ b/js/.changeset/issue-14-tee-virtual-command.md @@ -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. diff --git a/js/README.md b/js/README.md index cc23b9e7..3b09f708 100644 --- a/js/README.md +++ b/js/README.md @@ -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 @@ -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) | @@ -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** @@ -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** @@ -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 @@ -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: diff --git a/js/examples/tee-command.mjs b/js/examples/tee-command.mjs new file mode 100644 index 00000000..90bd9ecf --- /dev/null +++ b/js/examples/tee-command.mjs @@ -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 }); diff --git a/js/src/$.mjs b/js/src/$.mjs index bc629be7..d7bf38f2 100755 --- a/js/src/$.mjs +++ b/js/src/$.mjs @@ -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 @@ -424,6 +425,7 @@ function registerBuiltins() { register('dirname', dirnameCommand); register('yes', yesCommand); register('seq', seqCommand); + register('tee', teeCommand); register('test', testCommand); } diff --git a/js/src/$.process-runner-pipeline.mjs b/js/src/$.process-runner-pipeline.mjs index d36ef818..22709aef 100644 --- a/js/src/$.process-runner-pipeline.mjs +++ b/js/src/$.process-runner-pipeline.mjs @@ -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, @@ -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); } /** @@ -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)); } @@ -518,9 +518,9 @@ async function runVirtualHandler( }; } const result = await handler({ + ...options, args: argValues, stdin: currentInput, - ...options, }); return { ...result, diff --git a/js/src/$.process-runner-virtual.mjs b/js/src/$.process-runner-virtual.mjs index 74247063..093fb08c 100644 --- a/js/src/$.process-runner-virtual.mjs +++ b/js/src/$.process-runner-virtual.mjs @@ -2,7 +2,7 @@ // Part of the modular ProcessRunner architecture import { trace } from './$.trace.mjs'; -import { safeWrite } from './$.stream-utils.mjs'; +import { safeWrite, stdinDataFromOptions } from './$.stream-utils.mjs'; import { applyVirtualProcessContext, effectiveCwd, @@ -21,13 +21,7 @@ import { * @returns {string} Stdin data */ function getStdinData(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); } /** diff --git a/js/src/$.stream-utils.mjs b/js/src/$.stream-utils.mjs index 3c460da5..ae4689af 100644 --- a/js/src/$.stream-utils.mjs +++ b/js/src/$.stream-utils.mjs @@ -287,6 +287,30 @@ export const StreamUtils = { }, }; +/** + * Stdio mode keywords accepted by the `stdin` option. + * + * They select how stdin is wired up and are never input data, so a virtual + * command must not receive them as its stdin contents (issue #14). + */ +const STDIN_MODES = new Set(['inherit', 'ignore', 'pipe']); + +/** + * Resolve the `stdin` option into the data a command should read. + * @param {object} options - Runner options + * @returns {string} Input data, or '' when `stdin` selects a stdio mode + */ +export function stdinDataFromOptions(options = {}) { + const { stdin } = options; + if (typeof stdin === 'string') { + return STDIN_MODES.has(stdin) ? '' : stdin; + } + if (Buffer.isBuffer(stdin)) { + return stdin.toString('utf8'); + } + return ''; +} + /** * Safe write to a stream with parent stream monitoring * @param {object} stream - The stream to write to diff --git a/js/src/$.virtual-commands.mjs b/js/src/$.virtual-commands.mjs index f3c5b0dc..eb2650b1 100644 --- a/js/src/$.virtual-commands.mjs +++ b/js/src/$.virtual-commands.mjs @@ -25,6 +25,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'; /** @@ -109,5 +110,6 @@ export function registerBuiltins() { register('dirname', dirnameCommand); register('yes', yesCommand); register('seq', seqCommand); + register('tee', teeCommand); register('test', testCommand); } diff --git a/js/src/commands/$.tee.mjs b/js/src/commands/$.tee.mjs new file mode 100644 index 00000000..b90a2545 --- /dev/null +++ b/js/src/commands/$.tee.mjs @@ -0,0 +1,165 @@ +import fs from 'fs'; +import { trace, VirtualUtils } from '../$.utils.mjs'; + +/** + * Translate a file system error into the message GNU tee prints. + * @param {string} file - File operand as written by the caller + * @param {Error & { code?: string }} error - Error thrown by the write + * @returns {string} Newline-terminated stderr line + */ +function fileErrorMessage(file, error) { + if (error.code === 'ENOENT') { + return `tee: ${file}: No such file or directory\n`; + } + if (error.code === 'EISDIR') { + return `tee: ${file}: Is a directory\n`; + } + if (error.code === 'EACCES' || error.code === 'EPERM') { + return `tee: ${file}: Permission denied\n`; + } + return `tee: ${file}: ${error.message}\n`; +} + +/** + * Parse tee operands. + * + * Supports `-a`/`--append`, `-i`/`--ignore-interrupts`, clustered short + * options such as `-ai`, and `--` to end option parsing. Everything else is an + * operand, including a bare `-`, which GNU tee treats as a file named `-`. + * + * @param {string[]} args - Raw arguments + * @returns {{append: boolean, ignoreInterrupts: boolean, files: string[], error?: string}} + */ +function parseArgs(args) { + const parsed = { append: false, ignoreInterrupts: false, files: [] }; + let optionsEnded = false; + + for (const arg of args) { + if (optionsEnded || arg === '-' || !arg.startsWith('-')) { + parsed.files.push(arg); + continue; + } + + if (arg === '--') { + optionsEnded = true; + continue; + } + + if (arg === '--append') { + parsed.append = true; + continue; + } + + if (arg === '--ignore-interrupts') { + parsed.ignoreInterrupts = true; + continue; + } + + if (arg.startsWith('--')) { + return { ...parsed, error: `tee: unrecognized option '${arg}'\n` }; + } + + for (const flag of arg.slice(1)) { + if (flag === 'a') { + parsed.append = true; + } else if (flag === 'i') { + parsed.ignoreInterrupts = true; + } else { + return { ...parsed, error: `tee: invalid option -- '${flag}'\n` }; + } + } + } + + return parsed; +} + +/** + * Virtual implementation of the Unix `tee` command. + * + * Reads stdin, copies it to stdout so the pipeline keeps flowing, and writes + * the same bytes to every file operand. File operands are truncated unless + * `-a` is given. A file that cannot be written reports an error and sets the + * exit code to 1, but the remaining files and stdout are still written, which + * is what GNU tee does. + * + * @param {object} context - Virtual command context + * @param {string[]} context.args - Command arguments + * @param {string} [context.stdin] - Buffered stdin contents + * @param {string} [context.cwd] - Working directory for relative paths + * @param {function} [context.isCancelled] - Cancellation probe + * @param {AbortSignal} [context.abortSignal] - Abort signal + * @returns {Promise<{code: number, stdout: string, stderr: string}>} + */ +export default async function tee({ + args, + stdin, + cwd, + isCancelled, + abortSignal, +}) { + const { append, ignoreInterrupts, files, error } = parseArgs(args); + + if (error) { + trace('VirtualCommand', () => `tee: ${error.trim()}`); + return VirtualUtils.error(error); + } + + const input = stdin === undefined || stdin === null ? '' : String(stdin); + + trace( + 'VirtualCommand', + () => + `tee: starting | ${JSON.stringify( + { append, ignoreInterrupts, files, stdinLength: input.length }, + null, + 2 + )}` + ); + + let stderr = ''; + let code = 0; + + for (const file of files) { + if (!ignoreInterrupts && (isCancelled?.() || abortSignal?.aborted)) { + trace('VirtualCommand', () => 'tee: cancelled while writing files'); + // SIGINT exit code, with the input still forwarded to stdout. + return { code: 130, stdout: input, stderr }; + } + + const resolvedPath = VirtualUtils.resolvePath(file, cwd); + trace( + 'VirtualCommand', + () => + `tee: writing file | ${JSON.stringify( + { file: resolvedPath, append, bytes: input.length }, + null, + 2 + )}` + ); + + try { + if (append) { + fs.appendFileSync(resolvedPath, input); + } else { + fs.writeFileSync(resolvedPath, input); + } + } catch (writeError) { + // GNU tee keeps copying to the remaining files and to stdout after a + // failed target, and exits with 1 at the end. + stderr += fileErrorMessage(file, writeError); + code = 1; + } + } + + trace( + 'VirtualCommand', + () => + `tee: finished | ${JSON.stringify( + { filesWritten: files.length, code, stdoutBytes: input.length }, + null, + 2 + )}` + ); + + return { code, stdout: input, stderr }; +} diff --git a/js/src/commands/index.mjs b/js/src/commands/index.mjs index 42d62f08..c46717ee 100644 --- a/js/src/commands/index.mjs +++ b/js/src/commands/index.mjs @@ -21,4 +21,5 @@ export { default as basename } from './$.basename.mjs'; export { default as dirname } from './$.dirname.mjs'; export { default as yes } from './$.yes.mjs'; export { default as seq } from './$.seq.mjs'; +export { default as tee } from './$.tee.mjs'; export { default as test } from './$.test.mjs'; diff --git a/js/tests/builtin-commands.test.mjs b/js/tests/builtin-commands.test.mjs index 46c5892c..fdcb1c60 100644 --- a/js/tests/builtin-commands.test.mjs +++ b/js/tests/builtin-commands.test.mjs @@ -8,6 +8,7 @@ import { shell, } from '../src/$.mjs'; import { trace } from '../src/$.utils.mjs'; +import { tee as teeHandler } from '../src/commands/index.mjs'; import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; import { join } from 'path'; @@ -370,6 +371,235 @@ describe('Built-in Commands (Bun.$ compatible)', () => { }); }); + describe('Tee Command (Virtual)', () => { + test('tee should be a virtual command, not the system binary', async () => { + const result = await $`which tee`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe('tee: shell builtin\n'); + }); + + test('tee should write to file and stdout', async () => { + const testFile = join(TEST_DIR, 'tee-output.txt'); + const result = await $`echo "Hello Tee!" | tee ${testFile}`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe('Hello Tee!\n'); + expect(existsSync(testFile)).toBe(true); + + const fileContent = readFileSync(testFile, 'utf8'); + expect(fileContent).toBe('Hello Tee!\n'); + }); + + // Mirrors the `tee` pipeline example in js/README.md. + test('tee should keep a mid-pipeline stage flowing', async () => { + const testFile = join(TEST_DIR, 'tee-midpipeline.txt'); + const result = await $`echo "deploying" | tee ${testFile} | cat`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe('deploying\n'); + expect(readFileSync(testFile, 'utf8')).toBe('deploying\n'); + }); + + test('tee should support multiple output files', async () => { + const file1 = join(TEST_DIR, 'tee1.txt'); + const file2 = join(TEST_DIR, 'tee2.txt'); + const file3 = join(TEST_DIR, 'tee3.txt'); + + const result = + await $`echo "Multiple files" | tee ${file1} ${file2} ${file3}`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe('Multiple files\n'); + + [file1, file2, file3].forEach((file) => { + expect(existsSync(file)).toBe(true); + const content = readFileSync(file, 'utf8'); + expect(content).toBe('Multiple files\n'); + }); + }); + + test('tee should support append mode with -a flag', async () => { + const testFile = join(TEST_DIR, 'tee-append.txt'); + + // First write + await $`echo "First line" | tee ${testFile}`; + + // Append second line + const result = await $`echo "Second line" | tee -a ${testFile}`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe('Second line\n'); + + const fileContent = readFileSync(testFile, 'utf8'); + expect(fileContent).toBe('First line\nSecond line\n'); + }); + + test('tee should truncate existing files without -a', async () => { + const testFile = join(TEST_DIR, 'tee-truncate.txt'); + writeFileSync(testFile, 'old content that is much longer\n'); + + const result = await $({ stdin: 'new\n' })`tee ${testFile}`; + + expect(result.code).toBe(0); + expect(readFileSync(testFile, 'utf8')).toBe('new\n'); + }); + + test('tee should support long options', async () => { + const testFile = join(TEST_DIR, 'tee-long-options.txt'); + + await $({ stdin: 'first\n' })`tee ${testFile}`; + const result = await $({ + stdin: 'second\n', + })`tee --append --ignore-interrupts ${testFile}`; + + expect(result.code).toBe(0); + expect(readFileSync(testFile, 'utf8')).toBe('first\nsecond\n'); + }); + + test('tee should support clustered short options', async () => { + const testFile = join(TEST_DIR, 'tee-clustered.txt'); + + await $({ stdin: 'first\n' })`tee ${testFile}`; + const result = await $({ stdin: 'second\n' })`tee -ai ${testFile}`; + + expect(result.code).toBe(0); + expect(readFileSync(testFile, 'utf8')).toBe('first\nsecond\n'); + }); + + test('tee should stop option parsing at --', async () => { + const result = await $({ + stdin: 'literal\n', + cwd: TEST_DIR, + })`tee -- -a`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe('literal\n'); + // `-a` after `--` is a file name, not the append flag. + expect(readFileSync(join(TEST_DIR, '-a'), 'utf8')).toBe('literal\n'); + expect(existsSync(join(TEST_DIR, '--'))).toBe(false); + }); + + test('tee should treat a bare - as a file name', async () => { + // GNU tee has no special case for `-`: it is a file named `-`. + const result = await $({ stdin: 'dash\n', cwd: TEST_DIR })`tee -`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe('dash\n'); + expect(readFileSync(join(TEST_DIR, '-'), 'utf8')).toBe('dash\n'); + }); + + test('tee should work with direct stdin input', async () => { + const testFile = join(TEST_DIR, 'tee-stdin.txt'); + const inputData = 'line1\nline2\nline3\n'; + + const result = await $({ stdin: inputData })`tee ${testFile}`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe(inputData); + + const fileContent = readFileSync(testFile, 'utf8'); + expect(fileContent).toBe(inputData); + }); + + test('tee should handle empty input', async () => { + const testFile = join(TEST_DIR, 'tee-empty.txt'); + + const result = await $({ stdin: '' })`tee ${testFile}`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe(''); + expect(existsSync(testFile)).toBe(true); + + const fileContent = readFileSync(testFile, 'utf8'); + expect(fileContent).toBe(''); + }); + + test('tee without file operands should pass stdin through', async () => { + const result = await $({ stdin: 'just stdout\n' })`tee`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe('just stdout\n'); + expect(result.stderr).toBe(''); + }); + + test('tee should work in complex pipelines', async () => { + const testFile = join(TEST_DIR, 'tee-pipeline.txt'); + + const result = await $`echo "pipeline test" | tee ${testFile} | cat`; + + expect(result.code).toBe(0); + expect(result.stdout).toBe('pipeline test\n'); + + const fileContent = readFileSync(testFile, 'utf8'); + expect(fileContent).toBe('pipeline test\n'); + }); + + test('tee should report write errors and keep writing remaining targets', async () => { + const invalidPath = '/invalid/path/tee-error.txt'; + const goodFile = join(TEST_DIR, 'tee-good.txt'); + + const result = await $({ + stdin: 'error test', + })`tee ${invalidPath} ${goodFile}`; + + expect(result.code).toBe(1); + expect(result.stderr).toBe( + `tee: ${invalidPath}: No such file or directory\n` + ); + // stdout and the remaining file are still written, like GNU tee. + expect(result.stdout).toBe('error test'); + expect(readFileSync(goodFile, 'utf8')).toBe('error test'); + }); + + test('tee should reject unknown long options', async () => { + const result = await $({ stdin: 'test' })`tee --unknown-option file.txt`; + + expect(result.code).toBe(1); + expect(result.stderr).toBe( + "tee: unrecognized option '--unknown-option'\n" + ); + expect(result.stdout).toBe(''); + expect(existsSync('file.txt')).toBe(false); + }); + + test('tee should reject unknown short options', async () => { + const result = await $({ stdin: 'test' })`tee -z file.txt`; + + expect(result.code).toBe(1); + expect(result.stderr).toBe("tee: invalid option -- 'z'\n"); + expect(existsSync('file.txt')).toBe(false); + }); + + test('tee should stop writing files when cancelled', async () => { + const testFile = join(TEST_DIR, 'tee-cancelled.txt'); + + const result = await teeHandler({ + args: [testFile], + stdin: 'payload', + isCancelled: () => true, + }); + + // SIGINT exit code, with the input still forwarded to stdout. + expect(result.code).toBe(130); + expect(result.stdout).toBe('payload'); + expect(existsSync(testFile)).toBe(false); + }); + + test('tee -i should keep writing files when cancelled', async () => { + const testFile = join(TEST_DIR, 'tee-ignore-interrupts.txt'); + + const result = await teeHandler({ + args: ['-i', testFile], + stdin: 'payload', + isCancelled: () => true, + }); + + expect(result.code).toBe(0); + expect(readFileSync(testFile, 'utf8')).toBe('payload'); + }); + }); + describe('Error Handling', () => { test('commands should return proper exit codes', async () => { const success = await $`true`; diff --git a/js/tests/node-process-regressions.mjs b/js/tests/node-process-regressions.mjs index a512fbb1..d1ca3df3 100644 --- a/js/tests/node-process-regressions.mjs +++ b/js/tests/node-process-regressions.mjs @@ -5,7 +5,16 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, test } from 'node:test'; -import { exec, ProcessRunner, resetGlobalState, set } from '../src/$.mjs'; +import { + $, + enableVirtualCommands, + exec, + ProcessRunner, + register, + resetGlobalState, + set, + unregister, +} from '../src/$.mjs'; const processOptions = { capture: true, @@ -61,3 +70,43 @@ test('an in-flight launch keeps its captured errexit setting', async () => { const result = await completion; assert.equal(result.code, 127); }); + +// Node runs pipelines through the non-streaming path, where the `stdin` option +// used to overwrite the input piped from the previous stage (issue #14). +test('a stdio mode keyword never becomes virtual command input in Node.js', async () => { + // A dedicated command reports exactly what it was handed, so the assertion + // cannot fall through to a real binary blocking on inherited stdin. + enableVirtualCommands(); + register('stdin-probe', async ({ stdin }) => ({ + code: 0, + stdout: JSON.stringify(stdin), + stderr: '', + })); + try { + const result = await $({ mirror: false, stdin: 'inherit' })`stdin-probe`; + + assert.equal(result.code, 0); + assert.equal(result.stdout, '""'); + } finally { + unregister('stdin-probe'); + } +}); + +test('piped input reaches a virtual command in Node.js', async () => { + enableVirtualCommands(); + const result = await $({ mirror: false })`echo hello | cat`; + + assert.equal(result.code, 0); + assert.equal(result.stdout, 'hello\n'); +}); + +test('piped input wins over the pipeline stdin option in Node.js', async () => { + enableVirtualCommands(); + const result = await $({ + mirror: false, + stdin: 'from option\n', + })`echo piped | cat`; + + assert.equal(result.code, 0); + assert.equal(result.stdout, 'piped\n'); +}); diff --git a/js/tests/virtual-command-stdin.test.mjs b/js/tests/virtual-command-stdin.test.mjs new file mode 100644 index 00000000..f963c17d --- /dev/null +++ b/js/tests/virtual-command-stdin.test.mjs @@ -0,0 +1,102 @@ +import { test, expect, describe, beforeEach } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import './test-helper.mjs'; // Automatically sets up beforeEach/afterEach cleanup +import { $, enableVirtualCommands, register, unregister } from '../src/$.mjs'; +import { stdinDataFromOptions } from '../src/$.stream-utils.mjs'; + +// Regression coverage for issue #14: the `stdin` option carries either input +// data or one of the stdio mode keywords. Virtual commands used to receive the +// keyword itself as their input, so `echo hello | cat` resolved to "inherit". + +describe('stdinDataFromOptions', () => { + test('treats stdio mode keywords as "no input"', () => { + expect(stdinDataFromOptions({ stdin: 'inherit' })).toBe(''); + expect(stdinDataFromOptions({ stdin: 'ignore' })).toBe(''); + expect(stdinDataFromOptions({ stdin: 'pipe' })).toBe(''); + }); + + test('passes through real input data', () => { + expect(stdinDataFromOptions({ stdin: 'hello\n' })).toBe('hello\n'); + expect(stdinDataFromOptions({ stdin: Buffer.from('buffered') })).toBe( + 'buffered' + ); + }); + + test('defaults to an empty string', () => { + expect(stdinDataFromOptions()).toBe(''); + expect(stdinDataFromOptions({})).toBe(''); + expect(stdinDataFromOptions({ stdin: undefined })).toBe(''); + }); +}); + +describe('virtual commands and the stdin option', () => { + // `bun test` evaluates test-helper.mjs only once, so its reset hooks belong to + // whichever file imported it first. Another file may therefore leave virtual + // commands disabled, which would send these commands to real binaries and + // block on inherited stdin instead of exercising the code under test. + beforeEach(() => { + enableVirtualCommands(); + }); + + test('a stdio mode keyword never becomes command input', async () => { + // A dedicated command reports exactly what it was handed, so the assertion + // does not depend on any system binary. + register('stdin-probe', async ({ stdin }) => ({ + code: 0, + stdout: JSON.stringify(stdin), + stderr: '', + })); + try { + const result = await $({ + mirror: false, + stdin: 'inherit', + })`stdin-probe`; + expect(result.code).toBe(0); + expect(result.stdout).toBe('""'); + } finally { + unregister('stdin-probe'); + } + }); + + test('a stdio mode keyword leaves a built-in command with no input', async () => { + const result = await $({ mirror: false, stdin: 'inherit' })`cat`; + expect(result.code).toBe(0); + expect(result.stdout).toBe(''); + }); + + test('piped input reaches a virtual command', async () => { + const result = await $({ mirror: false })`echo hello | cat`; + expect(result.code).toBe(0); + expect(result.stdout).toBe('hello\n'); + }); + + test('explicit stdin data reaches a virtual command', async () => { + const result = await $({ mirror: false, stdin: 'from option\n' })`cat`; + expect(result.code).toBe(0); + expect(result.stdout).toBe('from option\n'); + }); + + test('piped input wins over the pipeline stdin option', async () => { + const result = await $({ + mirror: false, + stdin: 'from option\n', + })`echo piped | cat`; + expect(result.code).toBe(0); + expect(result.stdout).toBe('piped\n'); + }); + + test('tee receives piped input, not the stdio mode keyword', async () => { + const dir = mkdtempSync(join(tmpdir(), 'cs-tee-stdin-')); + try { + const file = join(dir, 'out.txt'); + const result = await $({ mirror: false })`echo streamed | tee ${file}`; + expect(result.code).toBe(0); + expect(result.stdout).toBe('streamed\n'); + expect(readFileSync(file, 'utf8')).toBe('streamed\n'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/rust/README.md b/rust/README.md index db859b59..a9dc1466 100644 --- a/rust/README.md +++ b/rust/README.md @@ -288,6 +288,36 @@ Ctrl-D; use `TerminalKey::Raw` for any other escape sequence. An interaction must contain at least one action or wait; an empty `TerminalInteraction` is rejected before the terminal is opened or input is sent. +### Built-in `tee` + +`tee` copies its input to stdout and to every file it is given, so a pipeline +can be recorded and keep flowing. 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. A write failure is reported on stderr and sets +exit code 1, while the remaining files are still written. + +```rust,no_run +use command_stream::Pipeline; + +#[tokio::main] +async fn main() { + let result = Pipeline::new() + .add("echo deploying") + .add("tee deploy.log") + .run() + .await + .expect("pipeline should run"); + + assert_eq!(result.stdout, "deploying\n"); +} +``` + +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 +`tee` is a pipeline stage, not a live terminal filter; for an interactive `tee`, +use the PTY sessions described under [Interactive sessions](#interactive-sessions). + ## Features ### Tracked compatibility corpus diff --git a/rust/changelog.d/20260915_230000_tee_virtual_command.md b/rust/changelog.d/20260915_230000_tee_virtual_command.md new file mode 100644 index 00000000..adce9fec --- /dev/null +++ b/rust/changelog.d/20260915_230000_tee_virtual_command.md @@ -0,0 +1,13 @@ +--- +bump: minor +--- + +### Added + +- `tee` built-in command, mirroring the JavaScript implementation and GNU + coreutils: `-a`/`--append`, `-i`/`--ignore-interrupts`, clustered short + flags, `--` as an option terminator, and a bare `-` treated as a file named + `-`. A write failure is reported on stderr and sets exit code 1 while the + remaining files are still written. +- Tests covering the `StdinOption` invariant that keeps stdio modes and input + content in separate variants, so a mode can never be read as command input. diff --git a/rust/src/commands/mod.rs b/rust/src/commands/mod.rs index fc90fcfc..a7f4636a 100644 --- a/rust/src/commands/mod.rs +++ b/rust/src/commands/mod.rs @@ -20,6 +20,7 @@ mod pwd; mod rm; mod seq; mod sleep; +mod tee; mod test; mod touch; mod r#true; @@ -43,6 +44,7 @@ pub use r#true::r#true; pub use rm::rm; pub use seq::seq; pub use sleep::sleep; +pub use tee::tee; pub use test::test; pub use touch::touch; pub use which::which; diff --git a/rust/src/commands/tee.rs b/rust/src/commands/tee.rs new file mode 100644 index 00000000..6cd8c2f9 --- /dev/null +++ b/rust/src/commands/tee.rs @@ -0,0 +1,284 @@ +//! Virtual `tee` command implementation + +use crate::commands::CommandContext; +use crate::utils::{trace_lazy, CommandResult, VirtualUtils}; +use std::fs::OpenOptions; +use std::io::{ErrorKind, Write}; + +/// Translate a file system error into the message GNU tee prints. +fn file_error_message(file: &str, error: &std::io::Error) -> String { + match error.kind() { + ErrorKind::NotFound => format!("tee: {}: No such file or directory\n", file), + ErrorKind::IsADirectory => format!("tee: {}: Is a directory\n", file), + ErrorKind::PermissionDenied => format!("tee: {}: Permission denied\n", file), + _ if error.to_string().contains("directory") => { + format!("tee: {}: Is a directory\n", file) + } + _ => format!("tee: {}: {}\n", file, error), + } +} + +/// Parsed `tee` operands +#[derive(Debug, Default, PartialEq)] +struct ParsedArgs { + append: bool, + ignore_interrupts: bool, + files: Vec, + error: Option, +} + +/// Parse tee operands. +/// +/// Supports `-a`/`--append`, `-i`/`--ignore-interrupts`, clustered short +/// options such as `-ai`, and `--` to end option parsing. Everything else is an +/// operand, including a bare `-`, which GNU tee treats as a file named `-`. +fn parse_args(args: &[String]) -> ParsedArgs { + let mut parsed = ParsedArgs::default(); + let mut options_ended = false; + + for arg in args { + if options_ended || arg == "-" || !arg.starts_with('-') { + parsed.files.push(arg.clone()); + continue; + } + + if arg == "--" { + options_ended = true; + continue; + } + + if arg == "--append" { + parsed.append = true; + continue; + } + + if arg == "--ignore-interrupts" { + parsed.ignore_interrupts = true; + continue; + } + + if arg.starts_with("--") { + parsed.error = Some(format!("tee: unrecognized option '{}'\n", arg)); + return parsed; + } + + for flag in arg.chars().skip(1) { + match flag { + 'a' => parsed.append = true, + 'i' => parsed.ignore_interrupts = true, + _ => { + parsed.error = Some(format!("tee: invalid option -- '{}'\n", flag)); + return parsed; + } + } + } + } + + parsed +} + +/// Execute the tee command +/// +/// Reads stdin, copies it to stdout so the pipeline keeps flowing, and writes +/// the same bytes to every file operand. File operands are truncated unless +/// `-a` is given. A file that cannot be written reports an error and sets the +/// exit code to 1, but the remaining files and stdout are still written, which +/// is what GNU tee does. +pub async fn tee(ctx: CommandContext) -> CommandResult { + let parsed = parse_args(&ctx.args); + + if let Some(error) = parsed.error { + trace_lazy("VirtualCommand", || format!("tee: {}", error.trim_end())); + return VirtualUtils::error(error); + } + + let input = ctx.stdin.clone().unwrap_or_default(); + + trace_lazy("VirtualCommand", || { + format!( + "tee: starting | append={}, ignore_interrupts={}, files={:?}, stdin_length={}", + parsed.append, + parsed.ignore_interrupts, + parsed.files, + input.len() + ) + }); + + let cwd = ctx.get_cwd(); + let mut stderr = String::new(); + let mut code = 0; + + for file in &parsed.files { + if !parsed.ignore_interrupts && ctx.is_cancelled() { + trace_lazy("VirtualCommand", || { + "tee: cancelled while writing files".to_string() + }); + // SIGINT exit code, with the input still forwarded to stdout. + return CommandResult { + stdout: input, + stderr, + code: 130, + }; + } + + let resolved_path = VirtualUtils::resolve_path(file, Some(&cwd)); + trace_lazy("VirtualCommand", || { + format!( + "tee: writing file | file={:?}, append={}, bytes={}", + resolved_path, + parsed.append, + input.len() + ) + }); + + let write_result = OpenOptions::new() + .write(true) + .create(true) + .append(parsed.append) + .truncate(!parsed.append) + .open(&resolved_path) + .and_then(|mut handle| handle.write_all(input.as_bytes())); + + if let Err(write_error) = write_result { + // GNU tee keeps copying to the remaining files and to stdout after + // a failed target, and exits with 1 at the end. + stderr.push_str(&file_error_message(file, &write_error)); + code = 1; + } + } + + trace_lazy("VirtualCommand", || { + format!( + "tee: finished | files_written={}, code={}, stdout_bytes={}", + parsed.files.len(), + code, + input.len() + ) + }); + + CommandResult { + stdout: input, + stderr, + code, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_parse_args_defaults() { + let parsed = parse_args(&args(&["a.txt", "b.txt"])); + assert!(!parsed.append); + assert!(!parsed.ignore_interrupts); + assert_eq!(parsed.files, vec!["a.txt", "b.txt"]); + assert!(parsed.error.is_none()); + } + + #[test] + fn test_parse_args_short_and_long_flags() { + let parsed = parse_args(&args(&["-a", "--ignore-interrupts", "out.txt"])); + assert!(parsed.append); + assert!(parsed.ignore_interrupts); + assert_eq!(parsed.files, vec!["out.txt"]); + } + + #[test] + fn test_parse_args_clustered_flags() { + let parsed = parse_args(&args(&["-ai", "out.txt"])); + assert!(parsed.append); + assert!(parsed.ignore_interrupts); + assert_eq!(parsed.files, vec!["out.txt"]); + } + + #[test] + fn test_parse_args_double_dash_ends_options() { + let parsed = parse_args(&args(&["--", "-a"])); + assert!(!parsed.append); + assert_eq!(parsed.files, vec!["-a"]); + } + + #[test] + fn test_parse_args_bare_dash_is_a_file() { + // GNU tee treats a lone `-` as a file named `-`, not as stdout. + let parsed = parse_args(&args(&["-"])); + assert_eq!(parsed.files, vec!["-"]); + assert!(parsed.error.is_none()); + } + + #[test] + fn test_parse_args_unrecognized_long_option() { + let parsed = parse_args(&args(&["--unknown-option", "out.txt"])); + assert_eq!( + parsed.error, + Some("tee: unrecognized option \'--unknown-option\'\n".to_string()) + ); + } + + #[test] + fn test_parse_args_invalid_short_option() { + let parsed = parse_args(&args(&["-z", "out.txt"])); + assert_eq!( + parsed.error, + Some("tee: invalid option -- \'z\'\n".to_string()) + ); + } + + #[test] + fn test_file_error_messages() { + let not_found = std::io::Error::new(ErrorKind::NotFound, "nope"); + assert_eq!( + file_error_message("missing.txt", ¬_found), + "tee: missing.txt: No such file or directory\n" + ); + + let denied = std::io::Error::new(ErrorKind::PermissionDenied, "nope"); + assert_eq!( + file_error_message("locked.txt", &denied), + "tee: locked.txt: Permission denied\n" + ); + + let is_dir = std::io::Error::new(ErrorKind::IsADirectory, "nope"); + assert_eq!( + file_error_message("adir", &is_dir), + "tee: adir: Is a directory\n" + ); + } + + #[tokio::test] + async fn test_tee_cancellation_returns_sigint_code() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("out.txt"); + + let mut ctx = CommandContext::new(vec![file.to_string_lossy().to_string()]); + ctx.stdin = Some("payload".to_string()); + ctx.is_cancelled = Some(Box::new(|| true)); + + let result = tee(ctx).await; + + assert_eq!(result.code, 130); + assert_eq!(result.stdout, "payload"); + assert!(!file.exists()); + } + + #[tokio::test] + async fn test_tee_ignore_interrupts_keeps_writing() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("out.txt"); + + let mut ctx = + CommandContext::new(vec!["-i".to_string(), file.to_string_lossy().to_string()]); + ctx.stdin = Some("payload".to_string()); + ctx.is_cancelled = Some(Box::new(|| true)); + + let result = tee(ctx).await; + + assert!(result.is_success()); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "payload"); + } +} diff --git a/rust/src/commands/which.rs b/rust/src/commands/which.rs index 671ea7e3..5fe030fa 100644 --- a/rust/src/commands/which.rs +++ b/rust/src/commands/which.rs @@ -6,7 +6,7 @@ use crate::utils::{CommandResult, VirtualUtils}; /// List of virtual (shell builtin) commands const VIRTUAL_COMMANDS: &[&str] = &[ "echo", "pwd", "cd", "true", "false", "sleep", "cat", "ls", "mkdir", "rm", "touch", "cp", "mv", - "basename", "dirname", "env", "exit", "which", "yes", "seq", "test", + "basename", "dirname", "env", "exit", "which", "yes", "seq", "tee", "test", ]; /// Execute the which command diff --git a/rust/src/lib.rs b/rust/src/lib.rs index a0fe9d49..be7b50e9 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -550,6 +550,7 @@ impl ProcessRunner { "which" => Some(commands::which(ctx).await), "yes" => Some(commands::yes(ctx).await), "seq" => Some(commands::seq(ctx).await), + "tee" => Some(commands::tee(ctx).await), "test" => Some(commands::test(ctx).await), _ => None, } diff --git a/rust/src/pipeline.rs b/rust/src/pipeline.rs index 0cc6d00c..dcf1b141 100644 --- a/rust/src/pipeline.rs +++ b/rust/src/pipeline.rs @@ -325,6 +325,7 @@ impl Pipeline { "which" => (crate::commands::which(ctx).await, None), "yes" => (crate::commands::yes(ctx).await, None), "seq" => (crate::commands::seq(ctx).await, None), + "tee" => (crate::commands::tee(ctx).await, None), "test" => (crate::commands::test(ctx).await, None), _ => return None, }; diff --git a/rust/tests/builtin_commands.rs b/rust/tests/builtin_commands.rs index 1c77cddc..c87b56f0 100644 --- a/rust/tests/builtin_commands.rs +++ b/rust/tests/builtin_commands.rs @@ -3,8 +3,8 @@ //! These tests mirror the JavaScript tests in js/tests/builtin-commands.test.mjs use command_stream::commands::{ - basename, cat, cp, dirname, echo, env, exit, ls, mkdir, mv, pwd, rm, seq, sleep, test, touch, - which, yes, CommandContext, + basename, cat, cp, dirname, echo, env, exit, ls, mkdir, mv, pwd, rm, seq, sleep, tee, test, + touch, which, yes, CommandContext, }; use std::fs; use std::path::PathBuf; @@ -508,6 +508,231 @@ async fn test_yes_with_cancel() { assert!(result.stdout.contains("y") || result.is_success()); } +// ============================================================================ +// Tee Command Tests +// ============================================================================ + +/// Helper to create a command context with stdin and cwd +fn ctx_with_stdin_and_cwd(args: Vec<&str>, stdin: &str, cwd: PathBuf) -> CommandContext { + CommandContext { + args: args.into_iter().map(String::from).collect(), + stdin: Some(stdin.to_string()), + cwd: Some(cwd), + env: None, + output_tx: None, + is_cancelled: None, + } +} + +#[tokio::test] +async fn test_tee_is_a_virtual_command() { + let result = which(ctx(vec!["tee"])).await; + assert!(result.is_success()); + assert_eq!(result.stdout, "tee: shell builtin\n"); +} + +#[tokio::test] +async fn test_tee_writes_file_and_stdout() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("tee-output.txt"); + + let result = tee(ctx_with_stdin(vec![file.to_str().unwrap()], "Hello Tee!\n")).await; + + assert!(result.is_success()); + assert_eq!(result.stdout, "Hello Tee!\n"); + assert_eq!(fs::read_to_string(&file).unwrap(), "Hello Tee!\n"); +} + +#[tokio::test] +async fn test_tee_multiple_output_files() { + let dir = TempDir::new().unwrap(); + let file1 = dir.path().join("tee1.txt"); + let file2 = dir.path().join("tee2.txt"); + let file3 = dir.path().join("tee3.txt"); + + let result = tee(ctx_with_stdin( + vec![ + file1.to_str().unwrap(), + file2.to_str().unwrap(), + file3.to_str().unwrap(), + ], + "Multiple files\n", + )) + .await; + + assert!(result.is_success()); + assert_eq!(result.stdout, "Multiple files\n"); + for file in [&file1, &file2, &file3] { + assert_eq!(fs::read_to_string(file).unwrap(), "Multiple files\n"); + } +} + +#[tokio::test] +async fn test_tee_append_flag() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("tee-append.txt"); + + tee(ctx_with_stdin(vec![file.to_str().unwrap()], "First line\n")).await; + let result = tee(ctx_with_stdin( + vec!["-a", file.to_str().unwrap()], + "Second line\n", + )) + .await; + + assert!(result.is_success()); + assert_eq!(result.stdout, "Second line\n"); + assert_eq!( + fs::read_to_string(&file).unwrap(), + "First line\nSecond line\n" + ); +} + +#[tokio::test] +async fn test_tee_truncates_without_append() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("tee-truncate.txt"); + fs::write(&file, "old content that is much longer\n").unwrap(); + + let result = tee(ctx_with_stdin(vec![file.to_str().unwrap()], "new\n")).await; + + assert!(result.is_success()); + assert_eq!(fs::read_to_string(&file).unwrap(), "new\n"); +} + +#[tokio::test] +async fn test_tee_long_options() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("tee-long-options.txt"); + + tee(ctx_with_stdin(vec![file.to_str().unwrap()], "first\n")).await; + let result = tee(ctx_with_stdin( + vec!["--append", "--ignore-interrupts", file.to_str().unwrap()], + "second\n", + )) + .await; + + assert!(result.is_success()); + assert_eq!(fs::read_to_string(&file).unwrap(), "first\nsecond\n"); +} + +#[tokio::test] +async fn test_tee_clustered_short_options() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("tee-clustered.txt"); + + tee(ctx_with_stdin(vec![file.to_str().unwrap()], "first\n")).await; + let result = tee(ctx_with_stdin( + vec!["-ai", file.to_str().unwrap()], + "second\n", + )) + .await; + + assert!(result.is_success()); + assert_eq!(fs::read_to_string(&file).unwrap(), "first\nsecond\n"); +} + +#[tokio::test] +async fn test_tee_stops_option_parsing_at_double_dash() { + let dir = TempDir::new().unwrap(); + + let result = tee(ctx_with_stdin_and_cwd( + vec!["--", "-a"], + "literal\n", + dir.path().to_path_buf(), + )) + .await; + + assert!(result.is_success()); + assert_eq!(result.stdout, "literal\n"); + // `-a` after `--` is a file name, not the append flag. + assert_eq!( + fs::read_to_string(dir.path().join("-a")).unwrap(), + "literal\n" + ); + assert!(!dir.path().join("--").exists()); +} + +#[tokio::test] +async fn test_tee_treats_bare_dash_as_a_file_name() { + let dir = TempDir::new().unwrap(); + + // GNU tee has no special case for `-`: it is a file named `-`. + let result = tee(ctx_with_stdin_and_cwd( + vec!["-"], + "dash\n", + dir.path().to_path_buf(), + )) + .await; + + assert!(result.is_success()); + assert_eq!(result.stdout, "dash\n"); + assert_eq!(fs::read_to_string(dir.path().join("-")).unwrap(), "dash\n"); +} + +#[tokio::test] +async fn test_tee_empty_input_creates_file() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("tee-empty.txt"); + + let result = tee(ctx_with_stdin(vec![file.to_str().unwrap()], "")).await; + + assert!(result.is_success()); + assert_eq!(result.stdout, ""); + assert_eq!(fs::read_to_string(&file).unwrap(), ""); +} + +#[tokio::test] +async fn test_tee_without_file_operands_passes_stdin_through() { + let result = tee(ctx_with_stdin(vec![], "just stdout\n")).await; + + assert!(result.is_success()); + assert_eq!(result.stdout, "just stdout\n"); + assert_eq!(result.stderr, ""); +} + +#[tokio::test] +async fn test_tee_reports_write_errors_and_keeps_going() { + let dir = TempDir::new().unwrap(); + let good = dir.path().join("tee-good.txt"); + + let result = tee(ctx_with_stdin( + vec!["/invalid/path/tee-error.txt", good.to_str().unwrap()], + "error test", + )) + .await; + + assert_eq!(result.code, 1); + assert_eq!( + result.stderr, + "tee: /invalid/path/tee-error.txt: No such file or directory\n" + ); + // stdout and the remaining file are still written, like GNU tee. + assert_eq!(result.stdout, "error test"); + assert_eq!(fs::read_to_string(&good).unwrap(), "error test"); +} + +#[tokio::test] +async fn test_tee_rejects_unknown_long_options() { + let result = tee(ctx_with_stdin(vec!["--unknown-option", "file.txt"], "test")).await; + + assert_eq!(result.code, 1); + assert_eq!( + result.stderr, + "tee: unrecognized option '--unknown-option'\n" + ); + assert_eq!(result.stdout, ""); + assert!(!PathBuf::from("file.txt").exists()); +} + +#[tokio::test] +async fn test_tee_rejects_unknown_short_options() { + let result = tee(ctx_with_stdin(vec!["-z", "file.txt"], "test")).await; + + assert_eq!(result.code, 1); + assert_eq!(result.stderr, "tee: invalid option -- 'z'\n"); + assert!(!PathBuf::from("file.txt").exists()); +} + // ============================================================================ // Test Command Tests // ============================================================================ diff --git a/rust/tests/virtual_commands.rs b/rust/tests/virtual_commands.rs index bd6dae71..b5dcd139 100644 --- a/rust/tests/virtual_commands.rs +++ b/rust/tests/virtual_commands.rs @@ -6,7 +6,7 @@ use command_stream::commands::{ are_virtual_commands_enabled, disable_virtual_commands, enable_virtual_commands, CommandContext, VirtualCommandRegistry, }; -use command_stream::{run, ProcessRunner, RunOptions}; +use command_stream::{run, Pipeline, ProcessRunner, RunOptions, StdinOption}; use tokio::sync::{Mutex, MutexGuard}; static VIRTUAL_COMMANDS_TEST_LOCK: Mutex<()> = Mutex::const_new(()); @@ -245,3 +245,70 @@ async fn test_process_runner_virtual_pwd() { assert!(result.is_success()); assert!(!result.stdout.is_empty()); } + +// ============================================================================ +// Virtual Command Stdin Tests +// ============================================================================ + +// `StdinOption` keeps stdio modes and input data in separate variants, so a +// mode can never be mistaken for input the way it was in JavaScript (issue #14). +#[tokio::test] +async fn test_stdin_mode_is_not_virtual_command_input() { + let _guard = lock_virtual_commands().await; + enable_virtual_commands(); + let options = RunOptions { + stdin: StdinOption::Inherit, + ..Default::default() + }; + let mut runner = ProcessRunner::new("cat", options); + let result = runner.run().await.unwrap(); + assert!(result.is_success()); + assert_eq!(result.stdout, ""); +} + +#[tokio::test] +async fn test_stdin_content_reaches_virtual_command() { + let _guard = lock_virtual_commands().await; + enable_virtual_commands(); + let options = RunOptions { + stdin: StdinOption::Content("from option\n".to_string()), + ..Default::default() + }; + let mut runner = ProcessRunner::new("cat", options); + let result = runner.run().await.unwrap(); + assert!(result.is_success()); + assert_eq!(result.stdout, "from option\n"); +} + +#[tokio::test] +async fn test_piped_input_wins_over_pipeline_stdin() { + let _guard = lock_virtual_commands().await; + enable_virtual_commands(); + let result = Pipeline::new() + .add("echo piped") + .add("cat") + .stdin("from option\n") + .run() + .await + .unwrap(); + assert!(result.is_success()); + assert_eq!(result.stdout, "piped\n"); +} + +// Mirrors the `tee` pipeline example in rust/README.md. +#[tokio::test] +async fn test_readme_tee_pipeline_example() { + let _guard = lock_virtual_commands().await; + enable_virtual_commands(); + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("deploy.log"); + let result = Pipeline::new() + .add("echo deploying") + .add(format!("tee {}", log.display())) + .run() + .await + .unwrap(); + assert!(result.is_success()); + assert_eq!(result.stdout, "deploying\n"); + assert_eq!(std::fs::read_to_string(&log).unwrap(), "deploying\n"); +}