diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 3740af10..21538718 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -330,6 +330,7 @@ jobs: node --test js/tests/node-terminal-artifacts.mjs node --test js/tests/terminal-changes.test.mjs node --test js/tests/core-entry.test.mjs + node --test js/tests/command-builder.node.test.mjs node --test js/tests/node-commonjs-entry.mjs node --test js/tests/node-process-regressions.mjs node --test js/tests/windows-tree-kill.test.mjs diff --git a/README.md b/README.md index 6981fb72..76ca7623 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ The [stream processing guide](./docs/stream-processing-guide.md) covers both languages, deadline batching, backpressure, live analytics, and comparisons with existing streaming APIs. +The [complete API and feature comparison](./js/docs/COMPARISON.md) covers the +tracked JavaScript and Rust alternatives; the [builder guide](./js/docs/COMMAND-BUILDER.md) +documents the command construction and execution styles. + ## Repository Layout | Path | Purpose | diff --git a/dev/log/issues/34/pulls/113/analysis/requirements.md b/dev/log/issues/34/pulls/113/analysis/requirements.md new file mode 100644 index 00000000..8e3937db --- /dev/null +++ b/dev/log/issues/34/pulls/113/analysis/requirements.md @@ -0,0 +1,39 @@ +# Issue 34 / PR 113 acceptance evidence + +Sources reviewed: the complete [issue](https://github.com/link-foundation/command-stream/issues/34), its empty comment collection, the PR description, all conversation comments (including the [latest quality/comparison request](https://github.com/link-foundation/command-stream/pull/113#issuecomment-6383907892)), inline comments and reviews, and merged PRs 225 and 236. There are no required sub-issues. The implementation is reconciled with main commit `2f8cea139a1b00f3067b11785616f3f2ea00ecc4`. + +| Requirement | Implementation and verification | +| --- | --- | +| Exact `$.command('cat', path).pipe($.command.stdout('inherit'), $.command.exitCode).run()` example | `js/src/command-builder.mjs`; test `the exact issue example inherits stdout and returns an exit code` in `js/tests/command-builder.test.mjs` executes a real temporary file. | +| Avoid quoting and shell injection; produce a runnable string | Native exec specification, no shell reparsing; quote executable and each argument for POSIX rendering. Tests round-trip empty strings, spaces, quotes, newlines, operators and command substitutions, plus executable paths containing special characters. Windows runs argv directly; rendered text is explicitly POSIX. | +| Immediate execution and reusable descriptions | Await/run, synchronous execution, and runner/stream access; immutable copies retain argument/configuration state. Tests execute descriptions repeatedly and verify result/exit-code selection. | +| Support API styles and configurations from the PR feedback | Variadic and argv-array factories, constructor, fluent methods, functional transforms, option objects, nested factory defaults, configured tags and `create`; all ProcessOptions pass through the existing runner. Tests cover environment precedence/removal, cwd, buffered/live stdin, stdio, capture/mirror, overrides, deadlines, abort, events, pipelines and streams. Node tests cover full/core ESM/CommonJS; strict TypeScript tests cover the public exports and signatures. | +| Rust counterpart and the referenced Rust Command patterns | Owned fluent/functional composition, env/envs/clear/removal, stdio, cwd, async and sync execution using one native command configuration. A 160 KiB stdin/stdout test verifies concurrent draining instead of blocking on a full pipe. | +| Resolve conflicts without removing current features | Merge main into the prepared branch, retain the monorepo layout and current core/compatibility implementations; only the obsolete draft builder tests/examples are replaced. Compare the final diff against main and run the existing suites. | +| Clear docs and comparison across all features | `js/docs/COMMAND-BUILDER.md` documents every builder operation and runner option, precedence, execution contracts and safety boundaries. `js/docs/COMPARISON.md` covers process, shell, streams, lifecycle, compatibility, terminal, translation and development features with primary-source links, covering all twelve projects in the pinned JavaScript audit, both issue references, and the fourteen-project Rust audit. `rust/docs/COMPARISON.md` now gives a per-project construction, I/O, configuration, lifecycle and tooling comparison for all fourteen Rust projects. Generated feature examples provide further per-feature alternatives. Both audits identify implemented features accurately and preserve exact upstream conformance limits. | +| Competitive claims supported by evidence | Credit competitors' argv safety, fluent options, streams, transforms, plugins and native process controls. Link import-budget checks, compatibility corpora, feature examples and benchmarks. No unmeasured claim that one library universally outperforms every competitor; the comparison records specialized contracts and runtime limits explicitly. | +| Prepare release and review | JavaScript minor changeset and Rust minor changelog fragment; update PR title/body, preserve `Fixes #34`, verify current-SHA checks and mark ready without merging. | + +## Reproduced failures and root causes + +Before implementation, the focused JavaScript suite failed on missing fluent methods, array/options construction, preset factories and per-run configuration. The main builder exposed only a subset of those entry styles and ignored run arguments. Preset env objects also remained caller-owned until command construction; a separate failing regression demonstrated that mutation leak before snapshots were added. + +Review found another env-preset failure: `command.with({env: {A: 'a'}})('echo', [])` dereferenced an absent options argument. The array/preset regression was run failing before `mergeOptions` received its default override object. + +The Rust reproduction initially failed to compile with E0599 at `.pipe` and `.env_clear`. Adding synchronous execution and sharing native command configuration across sync/async resolves those missing contracts while retaining literal OsString arguments and signal exit status normalization. + +All regression cases remain in the normal automated suites. Investigation logs are kept under `.git/issue-34/` locally; fresh remote failures, if any, are downloaded under `ci-logs/` and checked against commit timestamps and SHA. + +## Follow-up quality audit + +- New failing runtime tests demonstrated that synchronous native execution always piped stdio, ignored `capture:false`, and failed to forward inherited stdin. The extracted shared sync module now applies native stdio modes, emits only piped data, and discards disabled capture. Node subprocess tests verify output appears exactly once and parent stdin reaches the child; Bun tests cover ignored output and events with capture disabled. +- A failing mutation regression showed that `preferLocal` directory arrays remained caller-owned. Options snapshots now also copy/freeze those arrays and ANSI configuration, with regressions for presets and direct construction. Buffers and AbortSignals remain explicitly shared resources. +- Failing strict TypeScript assertions exposed the old variadic `any` fallback: long pipelines could erase exit-code selection and join incompatible result types. Typed overloads now validate up to twenty transforms, including generic configuration after selection; homogeneous transforms can be composed without that limit or pipelines can be chained. A separate failing test at the twentieth-transform boundary caught a shadowed type parameter in the draft overload; its input and output types now remain independent. +- Rust's environment changes now replay in configuration order through native Command rather than a case-sensitive unordered map. A cross-platform fixture verifies replacement/removal, re-addition and Windows case-insensitive versus Unix case-sensitive names in sync and async executions. The Windows branch is verified by the hosted Windows matrix, not inferred from a Linux run. +- Environment-clear/removal accounting was stale in both audits. Three Deno units and ten Rust units now map to executable public invariants in the paired decisions/dispositions ledgers. Other entries state which core/adapter implementations exist and which exact upstream contracts are still unported; adding an option is not treated as proof of full upstream conformance. + +The complete comparison uses the published twelve-project JavaScript and fourteen-project Rust discovery sets, plus Effect as the issue reference. Universal superiority over every open-source project is not established: specialized native handles, expect sessions, persistent result caching and domain return adapters remain documented differences. No unmeasured performance ranking or complete upstream API conformance is claimed. + +Validation logs from this continuation live in `.git/issue-34/`. The first full Rust attempt failed at `rust/tests/cancelled_output.rs:105` (exit -1 versus 0), while its immediate focused rerun passed; the same intermittent cancellation-test behavior is recorded in `dev/log/issues/219/pulls/222/README.md`. The full serial rerun passed 962 tests with 7 ignored, without increasing that test's deadlines. Rust formatting, all-target/all-feature Clippy with warnings denied, and rustdoc with warnings denied also pass. JavaScript lint, formatting, duplication and strict type checks pass, including the twentieth-transform regression. All 37 feature examples pass parity checks in JavaScript and Rust, generated documentation is current (39 files), and documentation validation passes all 251 checks including the new Rust comparison. + +The final full Bun run passed 2,899 tests with 10 skipped and no failures (66,897 assertions across 183 files). The standalone Node builder suite passed all 7 tests. Hosted checks must be verified against the new commit SHA before the PR is marked ready; the PR description records their final status without treating the earlier commit's passing checks as current evidence. diff --git a/docs/README.md b/docs/README.md index e7110412..8092726e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -88,7 +88,7 @@ All 37 examples were executed in JavaScript and Rust. JavaScript was checked in | [Execa compatibility mode](features/execa-compat.md) | ✓ | — | — | ✓ | — | — | | [ShellJS compatibility mode](features/shelljs-compat.md) | ✓ | — | — | — | ✓ | — | | [Native head, tail, sort and uniq](features/native-text.md) | ✓ | — | ✓ | ✓ | ✓ | ✓ | -| [Build commands with literal arguments](features/command-builder.md) | ✓ | — | — | ✓ | — | ✓ | +| [Build commands with literal arguments](features/command-builder.md) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | [Process streams incrementally](features/stream-processing.md) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | [Track virtual terminal frames](features/terminal-buffer.md) | ✓ | — | — | — | — | — | | [Compose and extend virtual commands](features/command-packages.md) | ✓ | — | — | — | — | — | diff --git a/docs/features/command-builder.md b/docs/features/command-builder.md index bee9a32c..9893cf96 100644 --- a/docs/features/command-builder.md +++ b/docs/features/command-builder.md @@ -88,11 +88,15 @@ exit code: 7 ### [Bun.$](https://bun.com/docs/runtime/shell) -Not supported — No command-stream transform API; invoke the executable with separate arguments. +```js +await Bun.spawn(['printf', '%s', 'literal ; text']).exited; // exact argv +``` ### [zx](https://github.com/google/zx) -Not supported — No command-stream transform API; invoke the executable with separate arguments. +```js +await $`printf %s ${'literal ; text'}`; // escaped shell interpolation +``` ### [execa](https://github.com/sindresorhus/execa) @@ -102,7 +106,9 @@ await execa('printf', ['%s', 'literal ; text']); ### [ShellJS](https://github.com/shelljs/shelljs) -Not supported — No command-stream transform API; invoke the executable with separate arguments. +```js +shell.cmd('printf', '%s', 'literal ; text'); // exact argv +``` ### [node:child_process](https://nodejs.org/api/child_process.html) diff --git a/docs/screenshots/command-builder-after.png b/docs/screenshots/command-builder-after.png new file mode 100644 index 00000000..f6b32653 Binary files /dev/null and b/docs/screenshots/command-builder-after.png differ diff --git a/docs/screenshots/command-builder-before.png b/docs/screenshots/command-builder-before.png new file mode 100644 index 00000000..0961b46a Binary files /dev/null and b/docs/screenshots/command-builder-before.png differ diff --git a/docs/site/index.html b/docs/site/index.html index fccbdef5..98352dc0 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -1111,16 +1111,10 @@

command-stream — feature comparison

"file": "js/examples/features/command-builder.mjs", "api": ["command", "CommandBuilder"], "alternatives": { - "bun-shell": { - "unsupported": "No command-stream transform API; invoke the executable with separate arguments" - }, - "zx": { - "unsupported": "No command-stream transform API; invoke the executable with separate arguments" - }, + "bun-shell": "await Bun.spawn(['printf', '%s', 'literal ; text']).exited; // exact argv", + "zx": "await $`printf %s ${\"literal ; text\"}`; // escaped shell interpolation", "execa": "await execa('printf', ['%s', 'literal ; text']);", - "shelljs": { - "unsupported": "No command-stream transform API; invoke the executable with separate arguments" - }, + "shelljs": "shell.cmd('printf', '%s', 'literal ; text'); // exact argv", "child_process": "spawn('printf', ['%s', 'literal ; text']);" }, "source": "import { command } from '../../src/$.mjs';\nimport { example } from './_harness.mjs';\nawait example(\n { id: 'command-builder', title: 'Build commands with literal arguments' },\n async ({ record }) => {\n const builder = command(\n process.execPath,\n '-e',\n 'console.log(process.argv[1])',\n 'hello ; $HOME'\n );\n record('literal argument', (await (await builder).text()).trim());\n record(\n 'exit code',\n await command(process.execPath, '-e', 'process.exit(7)')\n .pipe(command.exitCode)\n .run()\n );\n }\n);", diff --git a/js/.changeset/complete-command-builder.md b/js/.changeset/complete-command-builder.md new file mode 100644 index 00000000..82f79880 --- /dev/null +++ b/js/.changeset/complete-command-builder.md @@ -0,0 +1,9 @@ +--- +'command-stream': minor +--- + +Complete literal command builders with immutable fluent methods, argv arrays, +factory and shell presets, merged environment layers, per-run overrides and +synchronous execution. Honor inherited/ignored stdio and disabled capture in +sync calls, snapshot nested configuration, and preserve long pipeline types. +Document every API style and correct the library-wide competitor comparison. diff --git a/js/README.md b/js/README.md index a3e88254..89907977 100644 --- a/js/README.md +++ b/js/README.md @@ -16,6 +16,9 @@ A modern $ shell utility library with streaming, async iteration, and EventEmitt ## Features +The [command builder guide](docs/COMMAND-BUILDER.md) documents literal argv, +fluent and functional configuration, presets, run/await/sync and streams. + - 🐚 **Shell-like by Default**: Commands behave exactly like running in terminal (stdout→stdout, stderr→stderr, stdin→stdin) - 🎛️ **Fully Controllable**: Override default behavior with options (`mirror`, `capture`, `stdin`) - 🚀 **Multiple Usage Patterns**: Classic await, async iteration, EventEmitter, .pipe() method, and mixed patterns @@ -30,54 +33,15 @@ A modern $ shell utility library with streaming, async iteration, and EventEmitt ## Comparison with Other Libraries -| 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) | -| ------------------------------ | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| **📦 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) | -| **⭐ 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) | -| **📊 Monthly Downloads** | **893** (New project!) | **381M** | **409M** | N/A (Built-in) | **35M** | **4.2M** | -| **📈 Total Downloads** | **Growing** | **6B+** | **5.4B** | N/A (Built-in) | **596M** | **37M** | -| **Runtime Support** | ✅ Bun + Node.js (+ Deno for `command-stream/bun`) | ✅ Node.js | ✅ Node.js | 🟡 Bun only | ✅ Node.js | ✅ Node.js | -| **Template Literals** | ✅ `` $`cmd` `` | ✅ `` $`cmd` `` | ❌ Function calls | ✅ `` $`cmd` `` | ❌ Function calls | ✅ `` $`cmd` `` | -| **Real-time Streaming** | ✅ Live output | 🟡 Limited | ✅ Native ChildProcess stdout/stderr streams | ❌ Buffer only | ❌ Buffer only | ❌ Buffer only | -| **Synchronous Execution** | ✅ `.sync()` with events | ✅ `execaSync` | ✅ `spawnSync` | ❌ No | ✅ Sync by default | ❌ No | -| **Async Iteration** | ✅ `for await (chunk of $.stream())` | ❌ No | ✅ Node readable streams support async iteration | ❌ No | ❌ No | ✅ Stdout lines: ``for await (const line of $`cmd`)`` | -| **EventEmitter Pattern** | ✅ `.on('data', ...)` | 🟡 Limited events | ✅ Native ChildProcess and stream events | ❌ No | ❌ No | ❌ No | -| **Mixed Patterns** | ✅ Events + await/sync | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No | -| **Bun.$ Compatibility** | ✅ **Full API** in [`command-stream/bun`](docs/BUN_SHELL.md), tested with Bun's own tests | ❌ No | ❌ No | ✅ Native API | ❌ No | ❌ No | -| **Shell Injection Protection** | ✅ Smart auto-quoting | ✅ Safe by default | ✅ Safe by default | ✅ Built-in | 🟡 Manual escaping | ✅ Safe by default | -| **Cross-platform** | ✅ macOS/Linux/Windows | ✅ Yes | ✅ **Specialized** cross-platform | ✅ Yes | ✅ Yes | ✅ Yes | -| **Performance** | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | -| **Memory Efficiency** | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | -| **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 | -| **Shell Settings** | ✅ `set -e`/`set +e` equivalent | ❌ No | ❌ No | ❌ No | 🟡 Limited (`set()`) | ❌ No | -| **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** | ✅ **26 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 | ✅ Child streams can be piped | ✅ 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) | -| **Signal Handling** | ✅ **Advanced SIGINT/SIGTERM forwarding** with cleanup | 🟡 Basic | ✅ **Excellent** cross-platform | 🟡 Basic | 🟡 Basic | 🟡 Basic | -| **Process Management** | ✅ **Robust child process lifecycle** with proper termination | ✅ Good | ✅ **Excellent** spawn wrapper | ❌ Basic | 🟡 Limited | 🟡 Limited | -| **Debug Tracing** | ✅ **Comprehensive VERBOSE logging** for CI/debugging | 🟡 Limited | ❌ No | ❌ No | 🟡 Basic | ❌ No | -| **Test Coverage** | ✅ **518+ tests, 1165+ assertions** | ✅ Excellent | ✅ Good | 🟡 Good coverage | ✅ Good | 🟡 Good | -| **CI Reliability** | ✅ **Platform-specific handling** (macOS/Ubuntu) | ✅ Good | ✅ **Excellent** | 🟡 Basic | ✅ Good | 🟡 Basic | -| **Documentation** | ✅ **Comprehensive examples + guides** | ✅ Excellent | 🟡 Basic | ✅ Good | ✅ Good | 🟡 Limited | -| **TypeScript** | ✅ **Bundled strict declarations** (ESM + CommonJS), typed events, streams and virtual commands | ✅ Full support | ✅ Built-in | ✅ Built-in | 🟡 Community types | ✅ Full support | -| **License** | ✅ **Unlicense (Public Domain)** | 🟡 MIT | 🟡 MIT | 🟡 MIT (+ LGPL dependencies) | 🟡 BSD-3-Clause | 🟡 Apache 2.0 | - -Performance, memory, and package-size values depend on the runtime and host. -Use the [reproducible benchmark playground](benchmarks/README.md) for measured -same-host comparisons, raw statistics, and an interactive report rather than -static estimates. - -**📊 Popularity & Adoption:** - -- **⭐ 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) -- **📈 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) -- **📊 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) - -**⭐ 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! +The [complete feature and API comparison](docs/COMPARISON.md) covers command +construction, all execution styles, streaming, configuration, process control, +compatibility layers and development tools across the tracked competitors. +It credits competitors' literal argv, streams, async iteration and plugin APIs, +and links to primary references and the executable compatibility audits. + +Use the [benchmark playground](benchmarks/README.md) for measured process latency, +memory and package size on the same host. The [core entry](docs/CORE.md) keeps +shell-only imports below the enforced module budget. ### Tracked compatibility corpus @@ -91,18 +55,11 @@ Run the focused executable corpus with `bun run test:competitors`. ### Why Choose command-stream? -- **🆓 Truly Free**: **Unlicense (Public Domain)** - No restrictions, no attribution required, use however you want -- **🚀 Revolutionary Virtual Commands**: **World's first** fully customizable virtual commands engine - register JavaScript functions as shell commands! -- **🔗 Advanced Pipeline System**: **Only library** where virtual commands work seamlessly in pipelines with built-ins and system commands -- **🔧 Built-in Commands**: **18 essential commands** work identically across all platforms - no system dependencies! -- **📡 Real-time Processing**: Typed stdout, stderr, and exit chunks through async iteration -- **🔄 Flexible Patterns**: Multiple usage patterns (await, events, iteration, mixed) -- **🐚 Shell Replacement**: Dynamic error handling with `set -e`/`set +e` equivalents for .sh file replacement -- **⚡ Bun Optimized**: Designed for Bun with Node.js fallback compatibility -- **💾 Memory Efficient**: Streaming prevents large buffer accumulation -- **🛡️ Production Ready**: **518+ tests, 1165+ assertions** with comprehensive coverage including CI reliability -- **🎯 Advanced Signal Handling**: Robust SIGINT/SIGTERM forwarding with proper child process cleanup -- **🔍 Debug-Friendly**: Comprehensive VERBOSE tracing for CI debugging and troubleshooting +- Combine literal command builders, shell templates, events, sync execution and + async iteration through a shared runner. +- Reuse scripts through the Bun, zx, ShellJS, Execa and cross-spawn APIs. +- Add virtual commands, packages, stream analytics, terminal tools and a debugger. +- Use the Unlicense and maintained JavaScript and Rust implementations. ## Built-in Commands (🚀 NEW!) diff --git a/js/docs/ADVANCED-WORKFLOWS.md b/js/docs/ADVANCED-WORKFLOWS.md index 4e99e67b..f5980558 100644 --- a/js/docs/ADVANCED-WORKFLOWS.md +++ b/js/docs/ADVANCED-WORKFLOWS.md @@ -2,6 +2,9 @@ ## Literal command builders +See the [complete command builder API](COMMAND-BUILDER.md) for fluent methods, +functional transforms, argv arrays, presets, sync execution and overrides. + ```js import $ from 'command-stream'; const cat = $.command('cat', 'file with spaces'); diff --git a/js/docs/COMMAND-BUILDER.md b/js/docs/COMMAND-BUILDER.md new file mode 100644 index 00000000..827e6c2f --- /dev/null +++ b/js/docs/COMMAND-BUILDER.md @@ -0,0 +1,163 @@ +# Command builder API + +`$.command`, the named `command` factory, and `CommandBuilder` construct lazy, +immutable descriptions. Execution passes the executable and arguments directly +to ProcessRunner in `exec` mode. No shell parses them. This API is available from +`command-stream`, `command-stream/core`, and `command-stream/process-runner`; +ESM and CommonJS share the same implementation. + +## Build and execute + +```js +import { $, command, CommandBuilder } from 'command-stream'; + +// The API from issue #34: inherit output and resolve to the integer exit code. +const code = await $.command('cat', './some-file.txt') + .pipe($.command.stdout('inherit'), $.command.exitCode) + .run(); + +const base = command('node', '-e', 'console.log(process.argv[1])'); +const configured = base.arg('hello ; $HOME').stdout('pipe').mirror(false); +const result = await configured.run(); +console.log(await result.text()); // hello ; $HOME\n + +// Equivalent construction styles. +const argv = command('node', ['-e', 'console.log(42)'], { capture: true }); +const explicit = new CommandBuilder('node', ['-e', 'console.log(42)']); +const status = explicit.exitCode().sync(); +``` + +Every configuration method returns a new builder. Keep the return value when +adding arguments or options. `base` above remains unchanged. Each `await builder`, +`.run()` or `.sync()` starts a fresh execution, so running a description twice +executes it twice. `.run()` returns a promise of the result; `.runner()` exposes +the ProcessRunner when events, live stdin, killing or process pipelines are needed. + +| Construction or operation | Behavior | +| --------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `command(file, ...args)` | Variadic literal arguments | +| `command(file, args, options?)` | An argv array plus optional ProcessOptions | +| `new CommandBuilder(file, args?, options?)` | Explicit construction | +| `.arg(...args)` / `command.arg(...args)` | Append arguments, preserving empty strings | +| `.with(options)` / `command.options(options)` | Configure any ProcessOptions | +| `.stdin(input)` / `command.stdin(input)` | String, Buffer, `pipe`, `inherit`, or `ignore` | +| `.stdout(mode)`, `.stderr(mode)` / matching transforms | `pipe`, `inherit`, or `ignore` | +| `.cwd(path)`, `.env(vars)` / matching transforms | Set directory; merge successive explicit environment layers | +| `.capture(enabled = true)`, `.mirror(enabled = true)` / matching transforms | Collect output; independently mirror piped output live | +| `.pipe(...transforms)` | Apply synchronous builder-to-builder configuration functions | +| `.exitCode()` / `command.exitCode` | Select an integer status instead of the full result | +| `.run(options?)`, `await builder` | Execute asynchronously | +| `.sync(options?)` | Execute synchronously, applying the same result selection | +| `.runner(options?)` | Get a fresh, lazy ProcessRunner | +| `.stream(queueOptions?)` | Iterate raw chunks or use line-processing operators | +| `.toString()`, `String(builder)`, `.buildCommand()` | POSIX-quoted executable and argv | +| `.file` (`.cmd` alias), `.args`, `.options` | Inspect the description | + +Arguments may be strings, numbers, booleans or bigints; scalars are converted to +strings. Objects, null, undefined, nested arrays, NUL bytes and empty executable +names are rejected before spawning. Executable names are path strings. + +## Options, presets and precedence + +```js +const local = command.with({ cwd: '/workspace', mirror: false }); +const child = local.with({ env: { ...process.env, MODE: 'test' } }); +const builder = child('node', ['-e', 'console.log(process.env.MODE)']) + .env({ EXTRA: 'value' }) + .pipe(command.options({ timeout: 5000 }), command.capture()); +const result = await builder.run({ env: { MODE: 'production' } }); + +// Shell presets also expose a command factory with the same defaults. +const shell = $({ mirror: false, cwd: '/workspace' }); +await shell.command('node', '--version'); +``` + +Precedence is builder defaults, factory presets, array-call options, chained +configuration, then per-execution overrides. Later values win. Explicit `env` +layers merge with each other; setting a key to `undefined` removes it. When `env` +is supplied, it replaces the inherited process environment: include +`...process.env` to extend the parent. An initial `env: {}` starts with an empty environment. +Options, their env and ANSI maps, and `preferLocal` directory arrays are copied +and frozen when bound; later caller edits cannot change them. Buffers and +AbortSignals remain shared resources. + +Builders default to `capture:true` and `mirror:false`. ProcessRunner's other +settings apply, including inherited stdin. Inherit/ignore stdout or stderr is +never captured or emitted as data. To both capture and print output, use +`stdout('pipe').capture(true).mirror(true)`. With `capture:false`, events and +stream iteration still receive piped output, while the result retains no output. + +All ProcessOptions are accepted through `.with()`, factory presets and run/runner +options: stdio, `cwd`, `env`, `capture`, `mirror`, `preferLocal`, `interactive`, +`signal`, `timeout`, `killSignal`, `killGrace`, `exitPumpGrace`, `detached`, `ansi`, +and `trace`. Shell-specific `loginShell` and `shellOperators` do not turn a +literal builder into a shell command. For shell grammar use the tagged template +API or explicitly execute an interpreter. Synchronous execution follows +ProcessRunner's sync limitations, including unsupported async virtual handlers. +AbortSignal cancellation applies to asynchronous execution; use `timeout` for +synchronous deadlines. Sync calls honor the same inherit/ignore output and +capture policies. Sync events fire after the child exits, and native sync spawn +buffers piped output even with `capture:false`; use asynchronous streaming for +bounded memory. Inherited output bypasses the runner and is never mirrored twice. + +```js +const runner = command('node', ['-e', 'process.stdin.pipe(process.stdout)']) + .stdin('pipe') + .runner(); +runner.on('stdout', (chunk) => console.log(chunk.toString())); +runner.start(); +runner.stdin.end('live input'); +await runner; + +const source = command('node', '-e', 'console.log("data")').runner(); +const sink = command( + 'node', + '-e', + 'process.stdin.pipe(process.stdout)' +).runner(); +const result = await source.pipe(sink); +``` + +Builder `.pipe()` configures a description. Runner `.pipe()` connects process +streams. `.stream({highWaterMark, highWaterBytes})` configures queue capacity; +configure process options on the builder first. Use `capture:false` for bounded +streaming. The [stream guide](../../docs/stream-processing-guide.md) explains +line framing, backpressure and cancellation. + +## Runnable strings and safety + +`String(builder)` quotes the executable as well as every argument for a POSIX +shell, preserving empty arguments, apostrophes, whitespace, newlines, `$`, and +shell operators. It renders argv only: environment, cwd, stdio, deadlines and +result selection are execution configuration, not text in the rendered string. +Use the builder directly for portable execution; the rendered string is not +Windows command-line syntax. + +Literal argv prevents the shell from interpreting an argument. A target program +may still interpret its own options or code arguments. In particular, calling +`command('sh', '-c', untrustedText)` explicitly asks a shell to execute that text. + +## Rust + +`command_stream::CommandBuilder` offers owned reusable descriptions with +`arg`, `args`, `cwd`, `env`, `envs`, `env_clear`, `env_remove`, `stdin`, `stdout`, +`stderr`, and `.pipe(|builder| ...)` transforms. `.run().await` and `.run_sync()` +return CommandResult; `.exit_code().await` selects status. `.shell_string()` +renders POSIX argv. Arguments and environment use OsString; configured stdin and +captured results are UTF-8 strings. Native non-UTF-8 values execute losslessly; +the shell-string renderer uses lossy text conversion for them. +Use the Rust ProcessRunner, stream, and compatibility APIs for events, +cancellation, pipelines and additional execution options. + +## Verification and alternatives + +Run `bun test js/tests/command-builder.test.mjs js/tests/advanced-command.test.mjs`, +`node --test js/tests/command-builder.node.test.mjs` and +`cargo test --manifest-path rust/Cargo.toml --test command_builder`. +The tests execute hostile argv and executable paths, the issue example, immutable +presets and env layers, overrides, stdio policies, sync/async selection, events, +pipelines, streaming, timeouts and cancellation. + +See the [JavaScript comparison](COMPARISON.md) and [Rust comparison](../../rust/docs/COMPARISON.md) +for competitor APIs across the library's features, and the pinned compatibility +corpora for coverage and limits. diff --git a/js/docs/COMPARISON.md b/js/docs/COMPARISON.md new file mode 100644 index 00000000..13843d13 --- /dev/null +++ b/js/docs/COMPARISON.md @@ -0,0 +1,121 @@ +# Command execution API comparison + +This comparison covers the complete library: command construction, execution, +streams, shell behavior, process control, compatibility APIs, and development +tools. It describes public capabilities, with links to executable evidence. +A differently named API still counts as support. Process APIs that offer safe +argv, streams or plugins are credited for those features. + +## Execution and output + +The source links in the column headings are the upstream references, reviewed +2026-10-24. “Via” names an additional public API or adapter needed for the behavior. +“Manual” means application code must compose the underlying primitives. A dash +means no corresponding API is documented in that reference, not that application +code could never implement the behavior. + +| Capability | command-stream | [Execa](https://github.com/sindresorhus/execa) | [cross-spawn](https://github.com/moxystudio/node-cross-spawn) | [Bun Shell](https://bun.com/docs/runtime/shell) | [ShellJS](https://github.com/shelljs/shelljs) | [zx](https://google.github.io/zx/api) | +| --------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- | ------------------------------------- | +| Literal executable + argv | `command`, `exec`, `spawn` | `execa(file, args)` | `spawn(file, args)` | Via `Bun.spawn` | `cmd(file, ...args)` and shell built-ins | Escaped templates / argv-array calls | +| Tagged templates | `$`, `bun.$`, `zx.$`, Execa `$` | `$`, `execa` | — | `$` | — | `$` | +| Lazy reusable command description | Immutable `command` builder | Bound defaults; subprocess execution API | Manual | ShellPromise with fluent options | Manual | Lazy process/presets (`halt`) | +| Functional builder transforms | `builder.pipe(command.stdout(...), command.exitCode)` | Options and pipeline API | Manual | Fluent ShellPromise | Fluent ShellString | Fluent ProcessPromise | +| Async / sync execution | `run` / `sync`, compatibility entries | `execa` / `execaSync` | `spawn` / `sync` | Async ShellPromise; sync via Bun.spawnSync | Sync or callback `exec` | `$` / `$.sync` | +| Captured text / bytes | `text`, captured streams / raw stream chunks | Configurable text or bytes | Collect child streams | ShellOutput / `text`, `bytes` | ShellString | ProcessOutput / bytes and text | +| Live output | Events, Node streams, async chunks | Node/Web streams, events | Child stdout/stderr streams | Inherited output; `lines()` | Async exec child streams | ProcessPromise streams | +| Async line iteration | `.stream().lines()` / fluent operators | Progressive iteration | Readable stream + framing | `lines()` | Manual framing | `lines()` / iterator | +| Events and awaited result | ProcessRunner; `runner()` from builder | Child events + promise | Child events; manual promise | Promise and line API | Async exec events + callback | ProcessPromise events + promise | +| stdin policies and data | Inherit/ignore/pipe, strings, Buffer, live writable; richer inputs via Execa | Files, streams, iterables, objects, text, bytes | Node stdio | Shell redirects and JS objects | ShellString pipes; exec child stdin | Input data, streams, process results | +| Separate / interleaved stderr | Separate core streams; `all` via Execa | Separate and `all` | Separate streams; manual interleave | Redirection, ShellOutput stderr | ShellString stderr | Separate and combined ProcessOutput | +| Capture and print together | `capture:true`, `mirror:true` | Configurable stdout/stderr | Manual tee | Default inherited output plus result | Configurable silent mode | `quiet:false` and ProcessOutput | +| Real process pipelines | Runner `.pipe`, shell pipes, compatibility APIs | `.pipe`, multiple sources/destinations | Pipe child streams | Shell `\|` | ShellString methods / shell pipes | `.pipe`, shell pipes | +| Output transforms / backpressure | Async map/filter/reduce, batch/window/split/merge, bounded queues | Transform functions, streams | Node streams | Shell stages / line iteration | String/array transforms | Node stream transforms | +| Stream statistics | Incremental throughput/error/latency statistics | Application transform | Application stream | Application iteration | Application code | Application stream | + +## Configuration, shell behavior and lifecycle + +| Capability | command-stream | Execa | cross-spawn | Bun Shell | ShellJS | zx | +| ------------------------------------ | ----------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------ | -------------------------------- | ------------------------------------------------ | ------------------------------ | +| cwd / environment / presets | Builder methods/transforms/options, `create`, `$({...})` | Options and bound defaults | Spawn options | `cwd`, `env`, defaults | `cd`, `env`, config | Options and chained presets | +| Argument safety | Exact argv builder; escaped templates; explicit raw escape hatch | Exact argv by default | Exact argv with shell disabled | Escaped interpolation | Literal `cmd`; shell-source `exec` needs quoting | Escaped interpolation | +| Shell grammar and redirects | Tagged core parser; full Bun compatibility entry | Opt-in shell | Opt-in shell | Cross-platform shell interpreter | Host shell exec + built-ins | Host shell | +| Portable shell commands | 26 core commands; Bun and ShellJS entries | System utilities | System utilities | Built-in utilities | Portable Unix commands | System utilities | +| Custom commands | Registry, generators, middleware and packages | JS transforms | Child programs | JS objects in redirections | Plugin API | Custom spawn and helpers | +| Nonzero status | Core returns code; shell errexit and adapter policies | Rejects by default, configurable | Exit event / status | Throws by default, configurable | Configurable fatal policy | Throws by default, nothrow | +| Timeout and abort | ProcessOptions; builder overrides; adapter options | timeout, signal, graceful cancel | Spawn timeout/signal | Via Bun process controls | exec options/child control | timeout, signal, kill controls | +| Signals and process trees | Configurable signal/grace and tree cleanup | Graceful termination and cleanup | ChildProcess lifecycle | Runtime process controls | ChildProcess for async exec | ProcessPromise kill controls | +| Detached process / live stdin / PID | Core and builder runner | Process API options | ChildProcess | Bun.spawn | Async exec child | ProcessPromise | +| Local executable resolution | `preferLocal`, spawn/Execa entries | preferLocal | PATH, PATHEXT, shebang support | Runtime resolution | PATH / which | preferLocal | +| Low-level fd / IPC / credentials | `spawn` or Execa entry | Rich spawn/IPC options | Node spawn options | Bun.spawn options | Exec child/options | Spawn options/hooks | +| ANSI, terminal snapshots / recording | ANSI config, headless terminal, recording | Output options; external terminal tooling | External terminal tooling | Runtime terminal APIs | External terminal tooling | External terminal tooling | +| Tracing and diagnostics | Disabled-by-default trace, process/stream/debugger tools | Verbose/custom logging | Child events | Runtime diagnostics | Verbose/config | Verbose/custom log | +| Typed ESM / CommonJS | Bundled strict declarations; shared entry identity | Typed package exports | Node/@types integration | Runtime types | @types/shelljs | Bundled types | +| Runtime and platform checks | Node/Bun Linux/macOS/Windows CI; Deno CDN compatibility probes; separate Rust crate | Node library; runtime behavior varies | Node spawn contract | Bun runtime | Node library | Node/Bun/Deno | + +Core builder options are documented in [COMMAND-BUILDER.md](COMMAND-BUILDER.md). +Core ProcessRunner, Bun compatibility, zx compatibility, ShellJS compatibility, +Execa compatibility and cross-spawn have distinct option/result contracts. Use +[the migration guides](../README.md#features) when running existing scripts. +For example, Execa's stream inputs and configurable output encoding belong to +`command-stream/execa`; passing those names to a core builder does not silently +implement an Execa option. The full package integrates the actual Execa method; +its runtime limits are recorded in [EXECA_MIGRATION.md](EXECA_MIGRATION.md). + +## Other tracked command APIs and the issue's references + +All twelve projects selected by the repository's published discovery rule are +covered here or above. The [pinned audit](COMPETITOR_TEST_AUDIT.md) records their +exact commits, portable process invariants, API-specific exclusions and gaps. +Effect and Rust Command are included because issue #34 explicitly references +them, regardless of their inclusion in that discovery set. + +| Project and primary reference | API and strengths | Corresponding command-stream surface | +| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| [Node child_process](https://nodejs.org/api/child_process.html) | argv, streams/events, sync calls, fd/IPC, process credentials and lifecycle | Core exec/runner; cross-spawn export for the native spawn contract | +| [Deno.Command](https://docs.deno.com/api/deno/subprocess/) | Reusable argv/options object, output/outputSync/spawn, Web streams, clearEnv | Literal builder, runner/stream, explicit replacement env; Deno CDN support is integration-tested | +| [Dax](https://github.com/dsherret/dax) and [@david/shell](https://github.com/dsherret/shell) | Shell builders, portable built-ins, presets, streaming; Dax adds requests/prompts | Builder, Bun interpreter, command registry and Node/Bun APIs; no Dax import shim | +| [nano-spawn](https://github.com/sindresorhus/nano-spawn) | Small dependency-free process API, line iterator, piping and interleaved output | Builder/runner/stream; interleaving via Execa; core subpath reduces loaded modules but is not dependency-free | +| [@actions/exec](https://github.com/actions/toolkit/tree/main/packages/exec) | argv, listeners, CI output and captured status | Runner events/results and mirror; no Actions-specific logger shim | +| [cross-env](https://github.com/kentcdodds/cross-env) | Portable inline environment assignments in package scripts | Explicit env options; tagged Bun interpreter for shell assignments; no cross-env CLI replacement | +| [Effect platform Command](https://effect.website/docs/v3/platform/command) | Immutable `make`, functional pipes, environment/stdio, scoped effect execution and streaming | Immutable `command`, configuration transforms, exit-code selection, runner streams; no Effect runtime dependency | +| [Rust std::process::Command](https://doc.rust-lang.org/std/process/struct.Command.html) | Native argv/OsString, env clear/removal, stdio handles, status/output/spawn | Rust CommandBuilder and ProcessRunner, plus reusable async/sync descriptions; native std handles remain available directly | + +## Features beyond a process wrapper + +command-stream combines several layers in one package, and provides portable +counterparts in Rust. The following links lead to runnable examples and output: + +| Feature area | Public guide / executable evidence | +| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Every catalog feature and equivalent competitor snippets | [Generated feature guide](../../docs/README.md) | +| Exact argv, templates, sync, callbacks/events, iteration and pipelines | [Builder guide](COMMAND-BUILDER.md), [core guide](CORE.md), [compatibility tests](../tests/competitor-compatibility.test.mjs) | +| ShellJS, zx, Execa and Bun script APIs | [ShellJS](SHELLJS_MIGRATION.md), [zx](ZX_MIGRATION.md), [Execa](EXECA_MIGRATION.md), [Bun](BUN_SHELL.md) | +| Virtual commands, typed packages, composition, inheritance and middleware | [Advanced workflows](ADVANCED-WORKFLOWS.md) | +| Stream batches/windows, backpressure, monitoring and statistics | [Stream guide](../../docs/stream-processing-guide.md) | +| ANSI processing, live terminal buffers and recordings | [Feature catalog](../../docs/README.md) | +| Shell-to-module translation and executable scripts | [Translation guide](FY.md) | +| REPL, watch/reload, browser pipeline debugger and VS Code tools | [Development guide](INTERACTIVE-DEVELOPMENT.md) | +| Command mocks, pipeline assertions and snapshots | [Advanced workflows](ADVANCED-WORKFLOWS.md) | +| Cold import/module budget | [Core entry](CORE.md), [automated module-budget tests](../tests/core-entry.test.mjs) | +| Process latency, memory, package size, features and real workloads | [JavaScript benchmarks](../benchmarks/README.md), [Rust benchmarks](../../rust/benchmarks/README.md) | + +The [Rust comparison](../../rust/docs/COMPARISON.md) compares construction, +execution, output, configuration, lifecycle and tooling for all fourteen Rust +projects. Rust's [competitor audit](../../rust/docs/COMPETITOR_TEST_AUDIT.md) covers +Tokio, async-process, duct, subprocess, xshell, rust_cmd_lib, rust-shell, +run_script, shellfn, bkt, assert_cmd, rexpect and expectrl. The audit records +contracts such as binary collection, arbitrary handles, expect sessions, +persistent result caching and typed domain adapters that the core does not +claim to replace. JavaScript compatibility imports are not Rust modules. + +## Assessing the comparison + +The combination of immutable literal builders, multiple compatibility APIs, +custom shell commands, stream analytics and development tools is the useful +advantage to evaluate. A global “faster/smaller/better than every competitor” +claim is not established by API presence or passing tests. The repository's +benchmarks validate identical output before recording timings, publish raw +measurements and compare base/head on the same runner. Use those reports for +a specific workload and the missing-feature ledgers for specialized contracts. +Neither upstream test accounting nor adapter availability is a claim that every +upstream test or every platform contract runs unchanged against the core API. diff --git a/js/docs/COMPETITOR_TEST_AUDIT.md b/js/docs/COMPETITOR_TEST_AUDIT.md index 032bf46d..d3cb150b 100644 --- a/js/docs/COMPETITOR_TEST_AUDIT.md +++ b/js/docs/COMPETITOR_TEST_AUDIT.md @@ -66,8 +66,8 @@ frameworks. Copying their files byte-for-byte would mostly test that Execa, Bun, or zx exists. Instead, every scoped test belongs to exactly one disposition: 1. A portable public process invariant is adapted into the executable suite. -2. A public invariant needs a feature command-stream does not have and is listed - in the missing-feature ledger below. +2. A public upstream contract is not fully ported and is recorded in the + remaining-contract ledger below, with its current implementation status. 3. A test is inapplicable because it verifies competitor API shape, private internals, unrelated utilities, runtime behavior, or harness mechanics. @@ -93,32 +93,33 @@ summary-level assertion. ## Executable behavior ports -| Case ID | Public invariant | -| ----------------------------- | ------------------------------------------------------------------------------------------------ | -| `direct-exact-argv` | Direct execution bypasses shell tokenization. | -| `argument-edge-cases` | Empty, whitespace, multiline, Unicode, quote, and shell-metacharacter arguments survive exactly. | -| `safe-template-interpolation` | An untrusted interpolated value remains one literal argument. | -| `array-interpolation` | An interpolated array expands to separate, safely quoted arguments. | -| `cwd-string` | An explicit string working directory reaches the child. | -| `environment` | An explicit environment reaches the child without value corruption. | -| `stdout-stderr-separation` | Standard output and error remain separate. | -| `newline-preservation` | Captured output preserves final and repeated newlines. | -| `unicode-output` | UTF-8 output is decoded without loss. | -| `large-output` | One MiB of output is captured without truncation or deadlock. | -| `nonzero-exit` | Non-zero status reads through `code` and `exitCode`, on results and on errors. | -| `result-text` | `text()` returns captured stdout. | -| `stdin-string` | String input is written completely and stdin is closed. | -| `stdin-buffer` | Buffer input is written without textual coercion. | -| `lazy-execution` | A runner starts only when consumed. | -| `concurrent-execution` | Concurrent children keep their results isolated. | -| `streamed-before-exit` | Output events arrive before a delayed child exits. | -| `events-and-await` | Events and the awaited result observe identical bytes. | -| `bound-options` | Options bound to a tagged command apply when the command executes. | -| `sync-execution` | Synchronous direct execution preserves exact argv. | -| `programmatic-pipeline` | Source stdout becomes destination stdin. | -| `spawn-error-result` | Sync and async unavailable executables produce results instead of uncaught errors. | -| `abort-signal` | An external `AbortSignal` terminates a running child. | -| `zx-compatible-api` | Every zx unit runs one-to-one against the zx-compatible layer, `command-stream/zx`. | +| Case ID | Public invariant | +| ------------------------------ | ------------------------------------------------------------------------------------------------ | +| `direct-exact-argv` | Direct execution bypasses shell tokenization. | +| `argument-edge-cases` | Empty, whitespace, multiline, Unicode, quote, and shell-metacharacter arguments survive exactly. | +| `safe-template-interpolation` | An untrusted interpolated value remains one literal argument. | +| `array-interpolation` | An interpolated array expands to separate, safely quoted arguments. | +| `cwd-string` | An explicit string working directory reaches the child. | +| `environment` | An explicit environment reaches the child without value corruption. | +| `environment-clear-and-remove` | Sync and async execution replace inherited env and omit undefined values. | +| `stdout-stderr-separation` | Standard output and error remain separate. | +| `newline-preservation` | Captured output preserves final and repeated newlines. | +| `unicode-output` | UTF-8 output is decoded without loss. | +| `large-output` | One MiB of output is captured without truncation or deadlock. | +| `nonzero-exit` | Non-zero status reads through `code` and `exitCode`, on results and on errors. | +| `result-text` | `text()` returns captured stdout. | +| `stdin-string` | String input is written completely and stdin is closed. | +| `stdin-buffer` | Buffer input is written without textual coercion. | +| `lazy-execution` | A runner starts only when consumed. | +| `concurrent-execution` | Concurrent children keep their results isolated. | +| `streamed-before-exit` | Output events arrive before a delayed child exits. | +| `events-and-await` | Events and the awaited result observe identical bytes. | +| `bound-options` | Options bound to a tagged command apply when the command executes. | +| `sync-execution` | Synchronous direct execution preserves exact argv. | +| `programmatic-pipeline` | Source stdout becomes destination stdin. | +| `spawn-error-result` | Sync and async unavailable executables produce results instead of uncaught errors. | +| `abort-signal` | An external `AbortSignal` terminates a running child. | +| `zx-compatible-api` | Every zx unit runs one-to-one against the zx-compatible layer, `command-stream/zx`. | ### zx-compatible layer @@ -139,44 +140,61 @@ the invariant appears upstream. Existing focused command-stream suites continue to exercise shell operators, virtual commands, built-ins, terminal capture, signals, cleanup, CommonJS, and runtime-specific paths in greater depth. -## Missing-feature ledger +## Remaining upstream contracts -These are comparison inputs for later work, not promises that every competitor -choice fits command-stream. Stability of the existing API takes priority. A -future implementation should add a failing focused test and update the manifest -in the same change. +The stable `missing` disposition IDs below track upstream contracts that have +not been ported completely into this corpus. They are not a claim that every +named feature is absent from the current package: later core implementations +and compatibility entries are identified explicitly. A supported option alone +does not establish all upstream error, timing or platform semantics. The +[current comparison](COMPARISON.md) identifies the public surface to use. +Environment replacement/removal is now executable in both sync and async ports; +its three Deno units have moved from missing to ported in both exact ledgers. ### timeout-option Execa, zx, Dax, and nano-spawn provide a per-command deadline option. -command-stream accepts external abort signals but has no public timeout option. +Core ProcessOptions and literal builders support `timeout`, `signal`, +`killSignal` and `killGrace`; zx and Execa entries expose their own deadlines. +This corpus has not ported every upstream deadline/error contract, including +specialized shell/script behavior. ### ipc-and-fork Node, Bun, Deno's Node compatibility layer, and Execa expose IPC channels or -fork helpers. command-stream currently exposes only stdin/stdout/stderr process -communication. +fork helpers. The core builder has three stdio channels; use the native +cross-spawn or Execa entry for IPC. These corpus units have not been ported +against those entries. ### custom-stdio-descriptors Node-derived APIs accept arbitrary stdio arrays, extra file descriptors, files, -and inherited streams. command-stream has fixed stdin/stdout/stderr policies. +and inherited streams. The core builder has fixed stdin/stdout/stderr policies; +the native spawn and Execa entries support richer descriptors. Their complete +upstream descriptor conformance is outside these portable core ports. ### configurable-encoding Several competitors support caller-selected encodings or raw binary output. command-stream results currently expose strings, with Buffer access available -through streaming interfaces rather than a result encoding option. +through streaming interfaces rather than a result encoding option. The Execa +entry supports encoding/byte results under its own result contract. ### max-buffer-policy Node `exec`, Execa, and ShellJS allow a maximum captured-output policy. -command-stream streams and captures without a configurable upper bound. +The core builder has no configurable maximum capture size (Node synchronous +spawn also has its native buffer limit). Use asynchronous `capture:false` with +bounded stream queues for bounded collection, or Execa's capture policy when +its semantics are required. ### output-transforms-and-line-iteration Execa, zx, Dax, and nano-spawn provide line iteration, transforms, generators, -or verbose output hooks beyond command-stream's typed chunk stream. +or verbose output hooks. Core streams now provide line framing, map/filter, +batching/windows, statistics and bounded queues; zx and Execa have additional +adapter-specific transforms. These units still require per-contract checks +rather than a blanket equivalence with those interfaces. ### local-binary-resolution @@ -190,7 +208,8 @@ audit. cross-spawn and nano-spawn normalize Windows shebangs, `PATHEXT`, and `cmd.exe` escaping. command-stream relies on the runtime and platform shell for those -rules and does not provide a dedicated resolver. +rules in the core runner. The cross-spawn export uses the actual cross-spawn +resolver; selecting that export does not change core-builder resolution. ### cross-platform-inline-environment-syntax @@ -199,21 +218,18 @@ variable references for Windows command processors. command-stream accepts an explicit environment object, but it does not provide this command-line rewrite layer. -### environment-clear-and-remove - -Deno can clear inherited environment values before spawning. command-stream -can add or replace values but has no environment clear/remove policy. - ### process-credentials Node and Deno can select child user and group IDs on supported platforms. -command-stream has no public process-credential options. +The core builder has no credential options; native spawn/Execa entries accept +their platform-specific process options. The corpus does not claim those +privileged platform contracts have been ported. ### windows-process-window-options Node and Deno expose Windows-only process creation controls such as hidden -windows and raw command-line arguments. command-stream delegates those choices -to the JavaScript runtime. +windows and raw command-line arguments. Use the native spawn entry for those +Node options; the core ProcessOptions surface does not expose them. ### child-process-handle-lifecycle @@ -234,19 +250,23 @@ command-stream's current virtual-command set. ### rich-error-and-timing-metadata Competitors expose fields such as command text, escaped command, failed stage, -signal description, duration, and timing. command-stream's result is limited to -status and captured streams. +signal description, duration, and timing. Core results include status, signal, +PID, timeout state and streams; richer error/timing fields belong to the +compatibility entries. This corpus does not assert every upstream metadata +field or failed-stage contract. ### graceful-termination Execa, zx, and Dax provide configurable graceful cancellation and forced-kill -fallbacks. command-stream supports a kill signal but no public grace-period -policy. +fallbacks. Core async execution now supports `killSignal` and `killGrace`, with +process-tree cleanup; sync native spawn has its own termination behavior. The +complete upstream cancellation protocols remain distinct from this core policy. ### combined-all-output Execa and nano-spawn can preserve interleaved stdout and stderr in a combined -result. command-stream deliberately keeps the two captured streams separate. +result. The core builder keeps captured streams separate; Execa's `all` and +zx's combined ProcessOutput use their respective compatibility entries. ### iterable-and-stream-input-options @@ -256,9 +276,11 @@ ignore, or an interactively accessed stdin pipe. ### layered-bound-option-merging -Execa can bind several option layers and merge selected nested fields while -letting invocation-level values override defaults. command-stream supports one -bound tagged-template option object but not layered or per-invocation merging. +Literal builders support nested `command.with` presets, fluent/functional +configuration, explicit env merging and per-run overrides. Execa's own binding +contract also treats undefined/default and selected nested options according to +its API. Use the Execa entry for that exact contract; these upstream units have +not been reclassified merely because builder presets now exist. ### url-working-directory diff --git a/js/examples/command-builder-demo.mjs b/js/examples/command-builder-demo.mjs new file mode 100644 index 00000000..76e92fb2 --- /dev/null +++ b/js/examples/command-builder-demo.mjs @@ -0,0 +1,21 @@ +import { command } from '../src/$.mjs'; + +// Run from any working directory with Node.js or Bun. +const node = command.with({ mirror: false }); +const base = node(process.execPath, ['-e', 'console.log(process.argv[1])']); +const literal = base.arg("spaces; $HOME and 'quotes'"); +console.log('Literal output:', (await (await literal).text()).trim()); +console.log('POSIX argv:', String(literal)); +console.log('Sync status:', literal.exitCode().sync()); +const configured = literal.pipe(command.stdout('inherit'), command.exitCode); +console.log('Inherited output status:', await configured.run()); +const lines = await node( + process.execPath, + '-e', + 'console.log("first\\nsecond")' +) + .capture(false) + .stream() + .map((line) => line.toUpperCase()) + .toArray(); +console.log('Streamed lines:', lines); diff --git a/js/examples/features/catalog.mjs b/js/examples/features/catalog.mjs index a0614fd8..2466451f 100644 --- a/js/examples/features/catalog.mjs +++ b/js/examples/features/catalog.mjs @@ -695,19 +695,11 @@ export const features = [ file: 'js/examples/features/command-builder.mjs', api: ['command', 'CommandBuilder'], alternatives: { - 'bun-shell': { - unsupported: - 'No command-stream transform API; invoke the executable with separate arguments', - }, - zx: { - unsupported: - 'No command-stream transform API; invoke the executable with separate arguments', - }, + 'bun-shell': + "await Bun.spawn(['printf', '%s', 'literal ; text']).exited; // exact argv", + zx: 'await $`printf %s ${"literal ; text"}`; // escaped shell interpolation', execa: "await execa('printf', ['%s', 'literal ; text']);", - shelljs: { - unsupported: - 'No command-stream transform API; invoke the executable with separate arguments', - }, + shelljs: "shell.cmd('printf', '%s', 'literal ; text'); // exact argv", child_process: "spawn('printf', ['%s', 'literal ; text']);", }, }, diff --git a/js/examples/test-command-builder-result.mjs b/js/examples/test-command-builder-result.mjs new file mode 100644 index 00000000..66083d9b --- /dev/null +++ b/js/examples/test-command-builder-result.mjs @@ -0,0 +1,7 @@ +import { command } from '../src/$.mjs'; + +const builder = command(process.execPath, '-e', 'console.log("hello")'); +const result = await builder.run(); +console.log('Output:', await result.text()); +console.log('Exit code:', result.code); +console.log('Selected exit code:', await builder.pipe(command.exitCode)); diff --git a/js/src/$.process-runner-execution.mjs b/js/src/$.process-runner-execution.mjs index 791df8f1..634cc21c 100644 --- a/js/src/$.process-runner-execution.mjs +++ b/js/src/$.process-runner-execution.mjs @@ -3,7 +3,6 @@ import { pumpInheritedStdin } from './$.process-runner-stdin.mjs'; import cp from 'child_process'; import { toStreamResult } from './$.result-streams.mjs'; import { trace } from './$.trace.mjs'; -import { normalizeBunExitSignal } from './$.process-runner-signal.mjs'; import { normalizeTimeout, setupCommandTimeout, @@ -21,12 +20,9 @@ import { attachExitCodeAlias, createCancelledResult, createCommandError, - createExecutionErrorResult, createResult, determineFinalExitCode, finishExecutionError, - getStdinString, - getSyncStdinInput, isProcessLaunchError, prepareSpawnErrorResult, } from './$.result.mjs'; @@ -41,6 +37,10 @@ import { drainPumpsAfterExit, } from './$.process-runner-exit.mjs'; import { effectiveCwd, effectiveEnv } from './$.process-context.mjs'; +import { + executeSyncProcess, + processSyncResult, +} from './$.process-runner-sync.mjs'; import { startDetached, startDetachedSync, @@ -498,143 +498,6 @@ function throwErrexitIfNeeded(runner, globalShellSettings) { ); } -/** - * Execute sync process using Bun - * @param {Array} argv - Command arguments - * @param {object} options - Spawn options - * @returns {object} Result object - */ -function executeSyncBun(argv, options) { - const { cwd, env, stdin, timeout, killSignal } = options; - let proc; - try { - proc = Bun.spawnSync(argv, { - cwd, - env, - stdin: getSyncStdinInput(stdin), - stdout: 'pipe', - stderr: 'pipe', - timeout, - killSignal, - }); - } catch (error) { - return createExecutionErrorResult(error); - } - - const signal = normalizeBunExitSignal(proc.exitCode, proc.signalCode); - const result = createResult({ - code: determineFinalExitCode(proc.exitCode, signal), - signal, - stdout: proc.stdout?.toString('utf8') || '', - stderr: proc.stderr?.toString('utf8') || '', - stdin: getStdinString(stdin), - }); - result.timedOut = Boolean(proc.exitedDueToTimeout); - result.child = proc; - return result; -} - -/** - * Execute sync process using Node - * @param {Array} argv - Command arguments - * @param {object} options - Spawn options - * @returns {object} Result object - */ -function executeSyncNode(argv, options) { - const { cwd, env, stdin, shell, timeout, killSignal } = options; - const proc = cp.spawnSync(argv[0], argv.slice(1), { - cwd, - env, - shell, - input: getSyncStdinInput(stdin), - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout, - killSignal, - }); - // A `timeout` kill sets `error` (ETIMEDOUT) next to the kill signal; report - // it as a signal exit like the async path does, not as a spawn failure. - const timedOut = proc.error?.code === 'ETIMEDOUT'; - - if (proc.error && !timedOut) { - const result = createExecutionErrorResult(proc.error); - result.child = proc; - return result; - } - - const signal = isBun - ? normalizeBunExitSignal(proc.status, proc.signal) - : (proc.signal ?? null); - const result = createResult({ - code: determineFinalExitCode(proc.status, signal), - signal, - stdout: proc.stdout || '', - stderr: proc.stderr || '', - stdin: getStdinString(stdin), - }); - result.timedOut = timedOut; - result.child = proc; - return result; -} - -/** - * Execute sync process with appropriate runtime - * @param {Array} argv - Command arguments - * @param {object} options - Spawn options - * @returns {object} Result object - */ -function executeSyncProcess(argv, options) { - // Guard against a deleted/inaccessible working directory (issue #44). - options = { ...options, cwd: resolveSpawnCwd(options.cwd) }; - return isBun && !options.shell - ? executeSyncBun(argv, options) - : executeSyncNode(argv, options); -} - -/** - * Handle sync result processing - * @param {object} runner - ProcessRunner instance - * @param {object} result - Result object - * @param {object} globalShellSettings - Shell settings - * @returns {object} Result - */ -function processSyncResult(runner, result, globalShellSettings) { - // The sync spawn has already exited, but reports the pid it ran under. - runner._pid = result.child?.pid ?? runner._pid; - - if (runner.options.mirror) { - if (result.stdout) { - safeWrite(process.stdout, result.stdout); - } - if (result.stderr) { - safeWrite(process.stderr, result.stderr); - } - } - - runner.outChunks = result.stdout ? [Buffer.from(result.stdout)] : []; - runner.errChunks = result.stderr ? [Buffer.from(result.stderr)] : []; - - if (result.stdout) { - runner._emitProcessedData('stdout', Buffer.from(result.stdout)); - } - if (result.stderr) { - runner._emitProcessedData('stderr', Buffer.from(result.stderr)); - } - - runner.finish(result); - - if (globalShellSettings.errexit && result.code !== 0) { - throw createCommandError(`Command failed with exit code ${result.code}`, { - code: result.code, - stdout: result.stdout, - stderr: result.stderr, - result, - }); - } - - return result; -} - /** * Stop a runner for an aborted `signal` or an expired `timeout`. Honors the * configured killSignal (defaults to SIGTERM) so both use the same stop signal @@ -1439,6 +1302,8 @@ export function attachExecutionMethods(ProcessRunner, deps) { shell: shellArgv, timeout: normalizeTimeout(this.options.timeout) || undefined, killSignal: this.options.killSignal, + stdout: this.options.stdout, + stderr: this.options.stderr, }); return processSyncResult(this, result, shellSettings); }; diff --git a/js/src/$.process-runner-sync.mjs b/js/src/$.process-runner-sync.mjs new file mode 100644 index 00000000..aaabd03e --- /dev/null +++ b/js/src/$.process-runner-sync.mjs @@ -0,0 +1,187 @@ +// Native synchronous execution shared by shell runners and literal builders. +import cp from 'node:child_process'; +import { trace } from './$.trace.mjs'; +import { normalizeBunExitSignal } from './$.process-runner-signal.mjs'; +import { resolveSpawnCwd } from './$.shell.mjs'; +import { safeWrite } from './$.stream-utils.mjs'; +import { + createCommandError, + createExecutionErrorResult, + createResult, + determineFinalExitCode, + getStdinString, + getSyncStdinInput, +} from './$.result.mjs'; + +const isBun = typeof globalThis.Bun !== 'undefined'; + +/** + * Execute sync process using Bun + * @param {Array} argv - Command arguments + * @param {object} options - Spawn options + * @returns {object} Result object + */ +function executeSyncBun(argv, options) { + const { cwd, env, stdin, timeout, killSignal } = options; + let proc; + try { + proc = Bun.spawnSync(argv, { + cwd, + env, + stdin: + getSyncStdinInput(stdin) ?? + (stdin === 'inherit' ? 'inherit' : 'ignore'), + stdout: options.stdout ?? 'pipe', + stderr: options.stderr ?? 'pipe', + timeout, + killSignal, + }); + } catch (error) { + return createExecutionErrorResult(error); + } + + const signal = normalizeBunExitSignal(proc.exitCode, proc.signalCode); + const result = createResult({ + code: determineFinalExitCode(proc.exitCode, signal), + signal, + stdout: proc.stdout?.toString('utf8') || '', + stderr: proc.stderr?.toString('utf8') || '', + stdin: getStdinString(stdin), + }); + result.timedOut = Boolean(proc.exitedDueToTimeout); + result.child = proc; + return result; +} + +/** + * Execute sync process using Node + * @param {Array} argv - Command arguments + * @param {object} options - Spawn options + * @returns {object} Result object + */ +function executeSyncNode(argv, options) { + const { cwd, env, stdin, shell, timeout, killSignal } = options; + const proc = cp.spawnSync(argv[0], argv.slice(1), { + cwd, + env, + shell, + input: getSyncStdinInput(stdin), + encoding: 'utf8', + stdio: [ + stdin === 'inherit' || stdin === 'ignore' ? stdin : 'pipe', + options.stdout ?? 'pipe', + options.stderr ?? 'pipe', + ], + timeout, + killSignal, + }); + // A `timeout` kill sets `error` (ETIMEDOUT) next to the kill signal; report + // it as a signal exit like the async path does, not as a spawn failure. + const timedOut = proc.error?.code === 'ETIMEDOUT'; + + if (proc.error && !timedOut) { + const result = createExecutionErrorResult(proc.error); + result.child = proc; + return result; + } + + const signal = isBun + ? normalizeBunExitSignal(proc.status, proc.signal) + : (proc.signal ?? null); + const result = createResult({ + code: determineFinalExitCode(proc.status, signal), + signal, + stdout: proc.stdout || '', + stderr: proc.stderr || '', + stdin: getStdinString(stdin), + }); + result.timedOut = timedOut; + result.child = proc; + return result; +} + +/** + * Execute sync process with appropriate runtime + * @param {Array} argv - Command arguments + * @param {object} options - Spawn options + * @returns {object} Result object + */ +export function executeSyncProcess(argv, options) { + // Guard against a deleted/inaccessible working directory (issue #44). + options = { ...options, cwd: resolveSpawnCwd(options.cwd) }; + trace( + 'ProcessRunner', + () => + `sync stdio | ${JSON.stringify({ + stdin: + getSyncStdinInput(options.stdin) === undefined + ? options.stdin + : 'buffer', + stdout: options.stdout ?? 'pipe', + stderr: options.stderr ?? 'pipe', + })}`, + { options } + ); + return isBun && !options.shell + ? executeSyncBun(argv, options) + : executeSyncNode(argv, options); +} + +/** + * Handle sync result processing + * @param {object} runner - ProcessRunner instance + * @param {object} result - Result object + * @param {object} globalShellSettings - Shell settings + * @returns {object} Result + */ +export function processSyncResult(runner, result, globalShellSettings) { + // The sync spawn has already exited, but reports the pid it ran under. + runner._pid = result.child?.pid ?? runner._pid; + + if (runner.options.mirror) { + if (result.stdout) { + safeWrite(process.stdout, result.stdout); + } + if (result.stderr) { + safeWrite(process.stderr, result.stderr); + } + } + + runner.outChunks = runner.options.capture + ? result.stdout + ? [Buffer.from(result.stdout)] + : [] + : null; + runner.errChunks = runner.options.capture + ? result.stderr + ? [Buffer.from(result.stderr)] + : [] + : null; + + if (result.stdout) { + runner._emitProcessedData('stdout', Buffer.from(result.stdout)); + } + if (result.stderr) { + runner._emitProcessedData('stderr', Buffer.from(result.stderr)); + } + + if (!runner.options.capture) { + result.stdout = undefined; + result.stderr = undefined; + result.stdin = undefined; + result.text = () => Promise.resolve(''); + } + + runner.finish(result); + + if (globalShellSettings.errexit && result.code !== 0) { + throw createCommandError(`Command failed with exit code ${result.code}`, { + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + result, + }); + } + + return result; +} diff --git a/js/src/command-builder.mjs b/js/src/command-builder.mjs index cf5b0a5c..6000ab89 100644 --- a/js/src/command-builder.mjs +++ b/js/src/command-builder.mjs @@ -1,7 +1,47 @@ import { ProcessRunner } from './process-runner.mjs'; +import { trace } from './$.trace.mjs'; const identity = (value) => value; const posixQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`; +const stdioModes = ['pipe', 'inherit', 'ignore']; + +function argument(value) { + if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value)) { + throw new TypeError('Command arguments must be strings or scalar values'); + } + const text = String(value); + if (text.includes('\0')) { + throw new TypeError('Command arguments must not contain NUL'); + } + return text; +} + +function stdio(stream, mode) { + if (!stdioModes.includes(mode)) { + throw new TypeError(`Invalid ${stream} mode: ${mode}`); + } + return mode; +} + +function mergeOptions(base, overrides = {}) { + return { + ...base, + ...overrides, + ...(base.env && + overrides.env && { env: { ...base.env, ...overrides.env } }), + }; +} + +function snapshotOptions(options) { + return Object.freeze({ + ...options, + ...(options.env && { env: Object.freeze({ ...options.env }) }), + ...(options.ansi && { ansi: Object.freeze({ ...options.ansi }) }), + ...(Array.isArray(options.preferLocal) && { + preferLocal: Object.freeze([...options.preferLocal]), + }), + }); +} /** An immutable, lazy exact-argv command specification. */ export class CommandBuilder { @@ -9,43 +49,112 @@ export class CommandBuilder { if (typeof file !== 'string' || !file || file.includes('\0')) { throw new TypeError('Command file must be a nonempty string without NUL'); } - if (args.some((arg) => typeof arg !== 'string' || arg.includes('\0'))) { - throw new TypeError('Command arguments must be strings without NUL'); + if (!Array.isArray(args)) { + throw new TypeError('Command arguments must be an array'); + } + for (const stream of ['stdout', 'stderr']) { + if (options[stream] !== undefined) { + stdio(stream, options[stream]); + } } this.file = file; - this.args = Object.freeze([...args]); - this.options = Object.freeze({ + this.args = Object.freeze(args.map(argument)); + this.options = snapshotOptions({ mirror: false, capture: true, ...options, - ...(options.env && { env: Object.freeze({ ...options.env }) }), }); this.select = select; Object.freeze(this); } + get cmd() { + return this.file; + } + + arg(...args) { + return new CommandBuilder( + this.file, + [...this.args, ...args], + this.options, + this.select + ); + } + with(options) { return new CommandBuilder( this.file, this.args, - { ...this.options, ...options }, + mergeOptions(this.options, options), this.select ); } + stdin(input) { + return this.with({ stdin: input }); + } + + stdout(mode) { + return this.with({ stdout: stdio('stdout', mode) }); + } + + stderr(mode) { + return this.with({ stderr: stdio('stderr', mode) }); + } + + env(vars) { + return this.with({ env: vars }); + } + + cwd(dir) { + return this.with({ cwd: dir }); + } + + capture(enabled = true) { + return this.with({ capture: enabled }); + } + + mirror(enabled = true) { + return this.with({ mirror: enabled }); + } + + exitCode() { + return new CommandBuilder( + this.file, + this.args, + this.options, + (result) => result.code + ); + } + pipe(...transforms) { - return transforms.reduce((builder, transform) => transform(builder), this); + return transforms.reduce((builder, transform) => { + if (typeof transform !== 'function') { + throw new TypeError('Command pipe() expects builder transforms'); + } + const next = transform(builder); + if (!(next instanceof CommandBuilder)) { + throw new TypeError('Command transforms must return a CommandBuilder'); + } + return next; + }, this); } - runner() { + runner(options = {}) { + const configured = this.with(options); + trace('CommandBuilder', () => `runner() | file: ${this.file}`); return new ProcessRunner( { mode: 'exec', file: this.file, args: [...this.args] }, - this.options + configured.options ); } - async run() { - return this.select(await this.runner()); + async run(options) { + return this.select(await this.runner(options)); + } + + sync(options) { + return this.select(this.runner(options).sync()); } then(resolve, reject) { @@ -59,27 +168,41 @@ export class CommandBuilder { toString() { return [this.file, ...this.args].map(posixQuote).join(' '); } -} -export function command(file, ...args) { - return new CommandBuilder(file, args); + buildCommand() { + return this.toString(); + } } -for (const stream of ['stdout', 'stderr']) { - command[stream] = (mode) => { - if (!['pipe', 'inherit', 'ignore'].includes(mode)) { - throw new TypeError(`Invalid ${stream} mode: ${mode}`); +function factory(options = {}) { + const defaults = snapshotOptions(options); + const make = (file, ...args) => { + if (Array.isArray(args[0])) { + if (args.length > 2) { + throw new TypeError('Use command(file, args, options) for argv arrays'); + } + return new CommandBuilder(file, args[0], mergeOptions(defaults, args[1])); } - return (builder) => builder.with({ [stream]: mode }); + return new CommandBuilder(file, args, defaults); }; + for (const stream of ['stdout', 'stderr']) { + make[stream] = (mode) => { + stdio(stream, mode); + return (builder) => builder[stream](mode); + }; + } + for (const method of ['arg', 'cwd', 'env', 'stdin', 'capture', 'mirror']) { + make[method] = + (...args) => + (builder) => + builder[method](...args); + } + // Factory presets and pipe transforms share the same options vocabulary. + // `with` on the factory binds defaults; use `options` as a pipe transform. + make.options = (options) => (builder) => builder.with(options); + make.with = (options) => factory(mergeOptions(defaults, options)); + make.exitCode = (builder) => builder.exitCode(); + return make; } -command.cwd = (cwd) => (builder) => builder.with({ cwd }); -command.env = (env) => (builder) => builder.with({ env }); -command.stdin = (stdin) => (builder) => builder.with({ stdin }); -command.exitCode = (builder) => - new CommandBuilder( - builder.file, - builder.args, - builder.options, - (result) => result.code - ); + +export const command = factory(); diff --git a/js/src/core.mjs b/js/src/core.mjs index 5dc446ba..6a019e65 100644 --- a/js/src/core.mjs +++ b/js/src/core.mjs @@ -165,7 +165,7 @@ function $tagged(strings, ...values) { ); // Return a new tagged template function with those options - return (innerStrings, ...innerValues) => { + const tagged = (innerStrings, ...innerValues) => { trace( 'API', () => @@ -193,6 +193,8 @@ function $tagged(strings, ...values) { ); return runner; }; + tagged.command = command.with(options); + return tagged; } // Normal tagged template literal usage @@ -258,6 +260,7 @@ function create(defaultOptions = {}, handler, metadata) { return runner; }; + tagged.command = command.with(defaultOptions); trace('API', () => `create EXIT | ${JSON.stringify({}, null, 2)}`); return tagged; } diff --git a/js/tests/command-builder.node.test.mjs b/js/tests/command-builder.node.test.mjs new file mode 100644 index 00000000..463f6006 --- /dev/null +++ b/js/tests/command-builder.node.test.mjs @@ -0,0 +1,84 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { $, command } from '../src/$.mjs'; + +const require = createRequire(import.meta.url); +for (const [format, api] of [ + ['ESM', { $, command }], + ['CommonJS', require('../src/$.cjs')], + ['core ESM', await import('../src/core.mjs')], + ['core CommonJS', require('../src/core.cjs')], +]) { + test( + `${format} builders share literal argv, presets, selection and sync behavior`, + { timeout: 10000 }, + async () => { + const args = ['', "a'b", 'a b', '$HOME; echo injected', '\n']; + const builder = api.command(process.execPath, [ + '-e', + 'console.log(JSON.stringify(process.argv.slice(1)))', + ...args, + ]); + assert.deepEqual(JSON.parse(await (await builder).text()), args); + assert.deepEqual(JSON.parse(await builder.sync().text()), args); + assert.equal(builder.exitCode().sync(), 0); + assert.equal(await builder.pipe(api.command.exitCode), 0); + const preset = api.$({ stdin: 'preset', mirror: false }).command; + assert.equal( + await ( + await preset( + process.execPath, + '-e', + 'process.stdin.pipe(process.stdout)' + ) + ).text(), + 'preset' + ); + } + ); +} + +for (const execution of ['run', 'sync']) { + test( + `${execution}: inherit writes exactly once while ignore discards output`, + { timeout: 10000 }, + () => { + const source = ` + const { command } = await import('./js/src/command-builder.mjs'); + const builder = command(process.execPath, '-e', "console.log('visible');console.error('hidden')") + .stdout('inherit').stderr('ignore').mirror(true); + const result = await builder.${execution}(); + if (String(result.stdout) !== '' || String(result.stderr) !== '') process.exit(9); + `; + const result = spawnSync( + process.execPath, + ['--input-type=module', '-e', source], + { encoding: 'utf8' } + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'visible\n'); + assert.equal(result.stderr, ''); + } + ); +} + +test('sync inherits piped stdin from the parent', { timeout: 10000 }, () => { + const source = ` + const { command } = await import('./js/src/command-builder.mjs'); + const builder = command(process.execPath, '-e', 'process.stdin.pipe(process.stdout)'); + const result = builder.stdin('inherit').sync(); + process.stdout.write(String(result.stdout)); + `; + const result = spawnSync( + process.execPath, + ['--input-type=module', '-e', source], + { + input: 'inherited input', + encoding: 'utf8', + } + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'inherited input'); +}); diff --git a/js/tests/command-builder.test.mjs b/js/tests/command-builder.test.mjs new file mode 100644 index 00000000..f7806609 --- /dev/null +++ b/js/tests/command-builder.test.mjs @@ -0,0 +1,309 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { beforeTestCleanup, afterTestCleanup } from './test-cleanup.mjs'; +import { $, command, CommandBuilder, create, sh } from '../src/$.mjs'; +import { mkdtempSync, writeFileSync, rmSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +beforeEach(beforeTestCleanup); +afterEach(afterTestCleanup); +const argvScript = 'console.log(JSON.stringify(process.argv.slice(1)))'; +const readInput = 'process.stdin.pipe(process.stdout)'; +const node = (...args) => command(process.execPath, '-e', ...args); + +describe('command builder API styles', () => { + test('fluent configuration preserves immutable reusable descriptions', async () => { + const base = node(argvScript).arg('a b', '', 42, true, 4n); + const configured = base + .stdout('pipe') + .stderr('ignore') + .capture() + .mirror(false); + expect(base.args).toEqual(['-e', argvScript, 'a b', '', '42', 'true', '4']); + expect(base.options.stderr).toBeUndefined(); + expect(configured).toBeInstanceOf(CommandBuilder); + expect(Object.isFrozen(configured)).toBe(true); + expect(JSON.parse(await (await configured).text())).toEqual([ + 'a b', + '', + '42', + 'true', + '4', + ]); + expect(await (await configured.run()).text()).toBe( + await (await base).text() + ); + }); + + test('argv array and constructor styles accept the same options', async () => { + const args = ['-e', argvScript, '', 'a b']; + const builder = command(process.execPath, args, { stderr: 'ignore' }); + args.push('late mutation'); + expect(builder.args).toEqual(['-e', argvScript, '', 'a b']); + expect(await (await builder).text()).toBe( + await ( + await new CommandBuilder( + process.execPath, + builder.args, + builder.options + ) + ).text() + ); + expect(command('echo', 42).args).toEqual(['42']); + expect(command('echo', []).args).toEqual([]); + expect( + command.with({ env: { BUILDER_VALUE: 'preset' } })('echo', []).options.env + .BUILDER_VALUE + ).toBe('preset'); + }); + + test('functional transforms and fluent methods select the same exit code', async () => { + const base = node('process.exit(7)'); + const functional = base.pipe( + command.stdout('ignore'), + command.stderr('ignore'), + command.capture(false), + command.mirror(false), + command.exitCode + ); + expect(await functional.run()).toBe(7); + expect(base.stdout('ignore').stderr('ignore').exitCode().sync()).toBe(7); + expect((await base).code).toBe(7); + }); + + test('each option style merges env and execution overrides without mutation', async () => { + const env = { ...process.env, BUILDER_A: 'a', BUILDER_DROP: 'remove' }; + const builder = node( + 'console.log(JSON.stringify([process.env.BUILDER_A, process.env.BUILDER_B, process.env.BUILDER_DROP]))' + ) + .env(env) + .pipe(command.env({ BUILDER_B: 'b' })); + env.BUILDER_A = 'mutated'; + const result = await builder.run({ + env: { BUILDER_DROP: undefined, BUILDER_B: 'override' }, + }); + expect(JSON.parse(await result.text())).toEqual(['a', 'override', null]); + expect(JSON.parse(await (await builder).text())).toEqual([ + 'a', + 'b', + 'remove', + ]); + }); + + test('configured factories and tagged presets retain builder helpers', async () => { + for (const factory of [ + command.with({ stdin: 'factory' }), + create({ stdin: 'factory' }).command, + $({ stdin: 'factory' }).command, + ]) { + const builder = factory(process.execPath, ['-e', readInput]); + expect(await (await builder).text()).toBe('factory'); + expect( + await builder + .pipe(factory.stdin('transform')) + .run() + .then((result) => result.text()) + ).toBe('transform'); + expect( + await factory + .with({ stdin: 'nested' })(process.execPath, '-e', readInput) + .run() + .then((result) => result.text()) + ).toBe('nested'); + } + }); + + test('factory defaults snapshot caller-owned environment objects', () => { + const env = { BUILDER_VALUE: 'before' }; + const defaults = { env, mirror: false }; + const preset = command.with(defaults); + env.BUILDER_VALUE = 'after'; + defaults.mirror = true; + expect(preset('echo').options.env.BUILDER_VALUE).toBe('before'); + expect(preset('echo').options.mirror).toBe(false); + }); + + test('stdin, cwd and run options work through fluent and functional styles', async () => { + const dir = mkdtempSync(join(tmpdir(), 'command-builder-')); + try { + const builder = node( + 'console.log(process.cwd()); process.stdin.pipe(process.stdout)' + ) + .cwd(dir) + .stdin(Buffer.from('input')) + .pipe(command.capture(), command.mirror(false)); + const result = await builder.run(); + expect(await result.text()).toBe(`${realpathSync(dir)}\ninput`); + expect(await (await builder.run({ stdin: 'override' })).text()).toBe( + `${realpathSync(dir)}\noverride` + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('the exact issue example inherits stdout and returns an exit code', async () => { + const dir = mkdtempSync(join(tmpdir(), 'command-builder-cat-')); + try { + const file = join(dir, 'some file.txt'); + writeFileSync(file, 'builder example\n'); + expect( + await $.command('cat', file) + .pipe($.command.stdout('inherit'), $.command.exitCode) + .run() + ).toBe(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('runner retains events and real process pipelines', async () => { + const source = node("process.stdout.write('pipeline')").runner(); + let output = ''; + source.on('stdout', (chunk) => { + output += chunk; + }); + const destination = node(readInput).runner(); + const result = await source.pipe(destination); + expect(output).toBe('pipeline'); + expect(await result.text()).toBe('pipeline'); + }); + + test('streaming retains raw output and fluent line processing', async () => { + const builder = node("process.stdout.write('first\\nsecond\\n')").capture( + false + ); + expect( + await builder + .stream() + .map((line) => line.toUpperCase()) + .toArray() + ).toEqual(['FIRST', 'SECOND']); + }); + + test('run overrides support timeout and cancellation', async () => { + const builder = node('setInterval(() => {}, 1000)'); + expect((await builder.run({ timeout: 100 })).timedOut).toBe(true); + const controller = new AbortController(); + controller.abort(); + expect((await builder.run({ signal: controller.signal })).code).not.toBe(0); + }, 5000); + + test('sync honors ignored stdio without capture, events or mirroring', async () => { + const builder = node("console.log('hidden'); console.error('hidden')") + .stdout('ignore') + .stderr('ignore') + .mirror(true); + const runner = builder.runner(); + const events = []; + runner.on('data', (chunk) => events.push(chunk)); + const result = runner.sync(); + expect(result.code).toBe(0); + expect(await result.text()).toBe(''); + expect(String(result.stderr)).toBe(''); + expect(events).toEqual([]); + }); + + test('sync capture:false keeps events but discards retained output', async () => { + const runner = node("console.log('visible'); console.error('error')") + .capture(false) + .runner(); + let stdout = ''; + let stderr = ''; + runner.on('stdout', (chunk) => { + stdout += chunk; + }); + runner.on('stderr', (chunk) => { + stderr += chunk; + }); + const result = runner.sync(); + expect(stdout).toBe('visible\n'); + expect(stderr).toBe('error\n'); + expect(result.stdout).toBeUndefined(); + expect(result.stderr).toBeUndefined(); + expect(await result.text()).toBe(''); + expect(runner.outChunks).toBeNull(); + expect(runner.errChunks).toBeNull(); + }); + + test('presets and builders snapshot nested configuration', () => { + const directories = ['/initial']; + const ansi = { preserveAnsi: false }; + const options = { preferLocal: directories, ansi }; + const preset = command.with(options); + const builder = command('echo', [], options); + directories.push('/mutated'); + ansi.preserveAnsi = true; + for (const configured of [preset('echo'), builder]) { + expect(configured.options.preferLocal).toEqual(['/initial']); + expect(configured.options.ansi.preserveAnsi).toBe(false); + expect(Object.isFrozen(configured.options.preferLocal)).toBe(true); + expect(Object.isFrozen(configured.options.ansi)).toBe(true); + } + }); +}); + +describe('command builder safety and rendering', () => { + test('literal argv stays literal with shell parsing and virtual commands enabled', async () => { + const args = [ + '', + 'a b', + "a'b", + '"quoted"', + '\n', + '$HOME', + '$(echo injected)', + '`echo injected`', + '*', + '; exit 9', + '&&', + '|', + ]; + const builder = node(argvScript).arg(...args); + expect(JSON.parse(await (await builder).text())).toEqual(args); + expect(JSON.parse(await builder.sync().text())).toEqual(args); + expect(builder.cmd).toBe(builder.file); + expect(builder.buildCommand()).toBe(String(builder)); + if (process.platform !== 'win32') { + expect(await (await sh(String(builder), { mirror: false })).text()).toBe( + await (await builder).text() + ); + } + }); + + test('quotes executable paths as well as arguments', async () => { + if (process.platform === 'win32') { + return; + } + const dir = mkdtempSync(join(tmpdir(), "builder ' executable ")); + try { + const executable = join(dir, "test ' $HOME"); + writeFileSync(executable, '#!/bin/sh\nprintf "%s" "$1"\n', { + mode: 0o755, + }); + const builder = command(executable, 'literal ; $HOME'); + expect(await (await builder).text()).toBe('literal ; $HOME'); + expect(await (await sh(String(builder), { mirror: false })).text()).toBe( + 'literal ; $HOME' + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('rejects invalid argv and stdio modes before spawning', () => { + for (const file of ['', '\0', null, 12]) { + expect(() => command(file)).toThrow(TypeError); + } + for (const arg of ['\0', null, undefined, {}, ['nested']]) { + expect(() => node(argvScript).arg(arg)).toThrow(TypeError); + } + expect(() => command('echo', ['ok', '\0'])).toThrow(TypeError); + expect(() => command('echo').with({ stdout: 'invalid' })).toThrow( + TypeError + ); + expect(() => command('echo').stderr('invalid')).toThrow(TypeError); + expect(() => command.stdout('invalid')).toThrow(TypeError); + expect(() => command('echo').pipe(() => null)).toThrow(TypeError); + }); +}); diff --git a/js/tests/competitor-compatibility.test.mjs b/js/tests/competitor-compatibility.test.mjs index 52844309..21ca3722 100644 --- a/js/tests/competitor-compatibility.test.mjs +++ b/js/tests/competitor-compatibility.test.mjs @@ -451,6 +451,32 @@ describe('ported public process behavior', () => { } ); + port( + 'environment-clear-and-remove', + 'replaces inherited environment and removes explicitly undefined keys', + async () => { + const [parentKey] = Object.keys(process.env); + expect(parentKey).toBeDefined(); + const env = { + COMMAND_STREAM_CORPUS_ALPHA: 'one', + [parentKey]: undefined, + }; + for (const sync of [false, true]) { + const runner = fixtureRunner( + 'env', + [parentKey, 'COMMAND_STREAM_CORPUS_ALPHA'], + { env } + ); + const result = sync ? runner.sync() : await runner; + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + [parentKey]: null, + COMMAND_STREAM_CORPUS_ALPHA: 'one', + }); + } + } + ); + port( 'stdout-stderr-separation', 'captures stdout-only, stderr-only, and mixed output independently', diff --git a/js/tests/competitor-corpus.mjs b/js/tests/competitor-corpus.mjs index 9d697ba6..799459ec 100644 --- a/js/tests/competitor-corpus.mjs +++ b/js/tests/competitor-corpus.mjs @@ -488,6 +488,14 @@ export const portedCases = [ 'mod.test.ts', ], }, + { + id: 'environment-clear-and-remove', + competitors: ['deno-command'], + upstream: [ + 'tests/unit/command_test.ts', + 'tests/unit_node/child_process_test.ts', + ], + }, { // Every zx unit is ported one-to-one under js/tests/zx against the // zx-compatible layer (`command-stream/zx`, issue #26). @@ -528,10 +536,6 @@ export const missingFeatures = [ id: 'cross-platform-inline-environment-syntax', competitors: ['cross-env'], }, - { - id: 'environment-clear-and-remove', - competitors: ['deno-command'], - }, { id: 'process-credentials', competitors: ['node-child-process', 'deno-command'], diff --git a/js/tests/competitor-decisions.jsonl b/js/tests/competitor-decisions.jsonl index b090b31e..e14d4f67 100644 --- a/js/tests/competitor-decisions.jsonl +++ b/js/tests/competitor-decisions.jsonl @@ -571,7 +571,7 @@ {"id":"deno-command:tests/unit_node/child_process_test.ts:880:1:registration","disposition":{"kind":"ported","id":"abort-signal"}} {"id":"deno-command:tests/unit_node/child_process_test.ts:901:1:registration","disposition":{"kind":"ported","id":"abort-signal"}} {"id":"deno-command:tests/unit_node/child_process_test.ts:917:1:registration","disposition":{"kind":"ported","id":"environment"}} -{"id":"deno-command:tests/unit_node/child_process_test.ts:950:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} +{"id":"deno-command:tests/unit_node/child_process_test.ts:950:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} {"id":"deno-command:tests/unit_node/child_process_test.ts:985:1:registration","disposition":{"kind":"ported","id":"environment"}} {"id":"deno-command:tests/unit_node/child_process_test.ts:1020:1:registration","disposition":{"kind":"ported","id":"environment"}} {"id":"deno-command:tests/unit_node/child_process_test.ts:1039:1:registration","disposition":{"kind":"ported","id":"sync-execution"}} @@ -639,8 +639,8 @@ {"id":"deno-command:tests/unit/command_test.ts:751:1:registration","disposition":{"kind":"ported","id":"environment"}} {"id":"deno-command:tests/unit/command_test.ts:769:1:registration","disposition":{"kind":"ported","id":"environment"}} {"id":"deno-command:tests/unit/command_test.ts:793:1:registration","disposition":{"kind":"ported","id":"environment"}} -{"id":"deno-command:tests/unit/command_test.ts:811:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} -{"id":"deno-command:tests/unit/command_test.ts:838:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} +{"id":"deno-command:tests/unit/command_test.ts:811:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} +{"id":"deno-command:tests/unit/command_test.ts:838:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} {"id":"deno-command:tests/unit/command_test.ts:865:1:registration","disposition":{"kind":"missing","id":"process-credentials"}} {"id":"deno-command:tests/unit/command_test.ts:893:1:registration","disposition":{"kind":"missing","id":"windows-shebang-and-pathext-resolution"}} {"id":"deno-command:tests/unit/command_test.ts:924:1:registration","disposition":{"kind":"missing","id":"process-credentials"}} diff --git a/js/tests/competitor-dispositions.jsonl b/js/tests/competitor-dispositions.jsonl index 774397a8..fea43965 100644 --- a/js/tests/competitor-dispositions.jsonl +++ b/js/tests/competitor-dispositions.jsonl @@ -583,7 +583,7 @@ {"record":"unit","id":"deno-command:tests/unit_node/child_process_test.ts:880:1:registration","source":"deno-command","unit":"registration","path":"tests/unit_node/child_process_test.ts","line":880,"column":1,"label":"[node/child_process spawn] AbortSignal aborts the child and emits AbortError","disposition":{"kind":"ported","id":"abort-signal"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit_node/child_process_test.ts#L880"} {"record":"unit","id":"deno-command:tests/unit_node/child_process_test.ts:901:1:registration","source":"deno-command","unit":"registration","path":"tests/unit_node/child_process_test.ts","line":901,"column":1,"label":"[node/child_process] kill() on an exited process returns false and does not throw","disposition":{"kind":"ported","id":"abort-signal"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit_node/child_process_test.ts#L901"} {"record":"unit","id":"deno-command:tests/unit_node/child_process_test.ts:917:1:registration","source":"deno-command","unit":"registration","path":"tests/unit_node/child_process_test.ts","line":917,"column":1,"label":"[node/child_process spawn] child inherits Deno.env when options.env is not provided","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit_node/child_process_test.ts#L917"} -{"record":"unit","id":"deno-command:tests/unit_node/child_process_test.ts:950:1:registration","source":"deno-command","unit":"registration","path":"tests/unit_node/child_process_test.ts","line":950,"column":1,"label":"[node/child_process spawn] child doesn","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit_node/child_process_test.ts#L950"} +{"record":"unit","id":"deno-command:tests/unit_node/child_process_test.ts:950:1:registration","source":"deno-command","unit":"registration","path":"tests/unit_node/child_process_test.ts","line":950,"column":1,"label":"[node/child_process spawn] child doesn","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit_node/child_process_test.ts#L950"} {"record":"unit","id":"deno-command:tests/unit_node/child_process_test.ts:985:1:registration","source":"deno-command","unit":"registration","path":"tests/unit_node/child_process_test.ts","line":985,"column":1,"label":"undefinedValueInEnvVar","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit_node/child_process_test.ts#L985"} {"record":"unit","id":"deno-command:tests/unit_node/child_process_test.ts:1020:1:registration","source":"deno-command","unit":"registration","path":"tests/unit_node/child_process_test.ts","line":1020,"column":1,"label":"spawnSyncUndefinedValueInEnvVar","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit_node/child_process_test.ts#L1020"} {"record":"unit","id":"deno-command:tests/unit_node/child_process_test.ts:1039:1:registration","source":"deno-command","unit":"registration","path":"tests/unit_node/child_process_test.ts","line":1039,"column":1,"label":"spawnSyncStdioUndefined","disposition":{"kind":"ported","id":"sync-execution"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit_node/child_process_test.ts#L1039"} @@ -651,8 +651,8 @@ {"record":"unit","id":"deno-command:tests/unit/command_test.ts:751:1:registration","source":"deno-command","unit":"registration","path":"tests/unit/command_test.ts","line":751,"column":1,"label":"commandEnv","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit/command_test.ts#L751"} {"record":"unit","id":"deno-command:tests/unit/command_test.ts:769:1:registration","source":"deno-command","unit":"registration","path":"tests/unit/command_test.ts","line":769,"column":1,"label":"commandLoaderEnvValueValidation","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit/command_test.ts#L769"} {"record":"unit","id":"deno-command:tests/unit/command_test.ts:793:1:registration","source":"deno-command","unit":"registration","path":"tests/unit/command_test.ts","line":793,"column":1,"label":"commandSyncEnv","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit/command_test.ts#L793"} -{"record":"unit","id":"deno-command:tests/unit/command_test.ts:811:1:registration","source":"deno-command","unit":"registration","path":"tests/unit/command_test.ts","line":811,"column":1,"label":"commandClearEnv","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit/command_test.ts#L811"} -{"record":"unit","id":"deno-command:tests/unit/command_test.ts:838:1:registration","source":"deno-command","unit":"registration","path":"tests/unit/command_test.ts","line":838,"column":1,"label":"commandSyncClearEnv","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit/command_test.ts#L838"} +{"record":"unit","id":"deno-command:tests/unit/command_test.ts:811:1:registration","source":"deno-command","unit":"registration","path":"tests/unit/command_test.ts","line":811,"column":1,"label":"commandClearEnv","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit/command_test.ts#L811"} +{"record":"unit","id":"deno-command:tests/unit/command_test.ts:838:1:registration","source":"deno-command","unit":"registration","path":"tests/unit/command_test.ts","line":838,"column":1,"label":"commandSyncClearEnv","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit/command_test.ts#L838"} {"record":"unit","id":"deno-command:tests/unit/command_test.ts:865:1:registration","source":"deno-command","unit":"registration","path":"tests/unit/command_test.ts","line":865,"column":1,"label":"commandUid","disposition":{"kind":"missing","id":"process-credentials"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit/command_test.ts#L865"} {"record":"unit","id":"deno-command:tests/unit/command_test.ts:893:1:registration","source":"deno-command","unit":"registration","path":"tests/unit/command_test.ts","line":893,"column":1,"label":"rejectBatAndCmdFiles","disposition":{"kind":"missing","id":"windows-shebang-and-pathext-resolution"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit/command_test.ts#L893"} {"record":"unit","id":"deno-command:tests/unit/command_test.ts:924:1:registration","source":"deno-command","unit":"registration","path":"tests/unit/command_test.ts","line":924,"column":1,"label":"commandSyncUid","disposition":{"kind":"missing","id":"process-credentials"},"url":"https://github.com/denoland/deno/blob/336da420f4343cbb1dcbd5eed9d075ff555ed6ee/tests/unit/command_test.ts#L924"} diff --git a/js/tests/sync.test.mjs b/js/tests/sync.test.mjs index e16a8abc..99a916fe 100644 --- a/js/tests/sync.test.mjs +++ b/js/tests/sync.test.mjs @@ -110,9 +110,8 @@ describe('Synchronous Execution (.sync())', () => { cmd.options.capture = false; const result = cmd.sync(); - // capture option doesn't affect sync mode - always captures - // This is different from async mode - expect(result.stdout.trim()).toBe('test'); + expect(result.stdout).toBeUndefined(); + expect(result.stderr).toBeUndefined(); }); test('should handle stdin options', () => { diff --git a/js/tests/types/command-builder.ts b/js/tests/types/command-builder.ts new file mode 100644 index 00000000..1f79e30e --- /dev/null +++ b/js/tests/types/command-builder.ts @@ -0,0 +1,100 @@ +import { $, command, CommandBuilder, create } from 'command-stream'; +import { command as coreCommand } from 'command-stream/core'; +import type { StreamResult } from '../../types/index.js'; + +const literal = command('echo', ['a b', 42, true, 4n] as const, { + capture: true, +}); +const fluent: CommandBuilder = literal + .arg('more') + .stdout('ignore') + .stderr('pipe') + .env({ A: 'a' }) + .cwd('.') + .capture(false) + .mirror() + .exitCode(); +const sync: number = fluent.sync({ stdin: 'ignore' }); +const run: Promise = fluent.run({ timeout: 100 }); +const configured: CommandBuilder = $({ mirror: false }).command( + 'echo', + 'preset' +); +const created: CommandBuilder = create({ capture: true }).command('echo'); +const preset = coreCommand.with({ stdin: 'input' }).with({ mirror: false }); +const result: StreamResult = preset('cat').sync(); +const transformed: CommandBuilder = literal.pipe( + command.options({ timeout: 100 }), + command.arg('extra'), + command.capture(), + command.mirror(false), + command.exitCode +); +const inferred = literal.pipe( + command.stdout('ignore'), + command.capture(), + command.exitCode +); +// @ts-expect-error Multi-transform pipelines retain exit-code selection. +const wrongSelection: string = inferred.sync(); +void wrongSelection; +void [sync, run, configured, created, result, transformed]; +// @ts-expect-error Objects are not command arguments. +command('echo', {}); +// @ts-expect-error Stdio has a closed vocabulary. +literal.stdout('bad'); +// @ts-expect-error Exit-code builders return a number. +const invalid: StreamResult = fluent.sync(); +void invalid; + +const manyTransforms = literal.pipe( + command.capture(), + command.mirror(false), + command.arg('six'), + command.stdout('ignore'), + command.stderr('ignore'), + command.exitCode, + command.options({ timeout: 100 }), + command.capture(false) +); +const manyResult: number = manyTransforms.sync(); +void manyResult; +// @ts-expect-error Incompatible result types cannot be joined in a pipeline. +literal.pipe(command.exitCode, (builder: CommandBuilder) => builder); +literal.pipe( + command.capture(), + command.mirror(), + command.arg('a'), + command.cwd('.'), + // @ts-expect-error Long pipelines must also check intermediate result types. + command.exitCode, + (builder: CommandBuilder) => builder +); +// @ts-expect-error Long pipelines preserve selection after generic transforms. +const erasedSelection: string = manyTransforms.sync(); +void erasedSelection; + +const twentyTransforms = literal.pipe( + (builder: CommandBuilder) => builder, + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.capture(), + command.exitCode +); +const twentiethResult: number = twentyTransforms.sync(); +void twentiethResult; diff --git a/js/tests/types/esm.types.ts b/js/tests/types/esm.types.ts index 0853cdaa..c5e75ef8 100644 --- a/js/tests/types/esm.types.ts +++ b/js/tests/types/esm.types.ts @@ -68,7 +68,7 @@ import $, { type AnsiConfig, type CommandError, type CommandResult, - type CommandTag, + type ConfiguredCommandTag, type DetachedProcess, type OutputChunk, type ParsedAsciicast, @@ -114,7 +114,7 @@ export async function taggedTemplates(): Promise { timeout: 5000, loginShell: false, }); - expectType>(); + expectType>(); expectType, ProcessRunner>>(); const tag = create({ capture: true, env: { PATH: '/bin' } }); diff --git a/js/types/advanced.d.cts b/js/types/advanced.d.cts index 77b9332c..878bda9c 100644 --- a/js/types/advanced.d.cts +++ b/js/types/advanced.d.cts @@ -16,17 +16,31 @@ import type { Readable, Writable } from 'node:stream'; import { EventEmitter } from 'node:events'; import type { ChildProcess, SpawnOptions } from 'node:child_process'; +export type CommandArgument = string | number | bigint | boolean; +export type CommandStdio = 'pipe' | 'inherit' | 'ignore'; + export class CommandBuilder implements PromiseLike { constructor( file: string, - args?: readonly string[], + args?: readonly CommandArgument[], options?: ProcessOptions, select?: (result: StreamResult) => T ); readonly file: string; + readonly cmd: string; readonly args: readonly string[]; readonly options: Readonly; + arg(...args: CommandArgument[]): CommandBuilder; with(options: ProcessOptions): CommandBuilder; + stdin(input: ProcessOptions['stdin']): CommandBuilder; + stdout(mode: CommandStdio): CommandBuilder; + stderr(mode: CommandStdio): CommandBuilder; + cwd(path: string): CommandBuilder; + env(vars: NodeJS.ProcessEnv): CommandBuilder; + capture(enabled?: boolean): CommandBuilder; + mirror(enabled?: boolean): CommandBuilder; + exitCode(): CommandBuilder; + pipe(): CommandBuilder; pipe( a: (builder: CommandBuilder) => CommandBuilder ): CommandBuilder; @@ -34,25 +48,282 @@ export class CommandBuilder implements PromiseLike { a: (builder: CommandBuilder) => CommandBuilder, b: (builder: CommandBuilder) => CommandBuilder ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder, + k: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder, + k: (builder: CommandBuilder) => CommandBuilder, + l: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder, + k: (builder: CommandBuilder) => CommandBuilder, + l: (builder: CommandBuilder) => CommandBuilder, + m: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder, + k: (builder: CommandBuilder) => CommandBuilder, + l: (builder: CommandBuilder) => CommandBuilder, + m: (builder: CommandBuilder) => CommandBuilder, + n: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder, + k: (builder: CommandBuilder) => CommandBuilder, + l: (builder: CommandBuilder) => CommandBuilder, + m: (builder: CommandBuilder) => CommandBuilder, + n: (builder: CommandBuilder) => CommandBuilder, + o: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder, + k: (builder: CommandBuilder) => CommandBuilder, + l: (builder: CommandBuilder) => CommandBuilder, + m: (builder: CommandBuilder) => CommandBuilder, + n: (builder: CommandBuilder) => CommandBuilder, + o: (builder: CommandBuilder) => CommandBuilder, + p: (builder: CommandBuilder) => CommandBuilder

+ ): CommandBuilder

, + q: (builder: CommandBuilder

) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder, + k: (builder: CommandBuilder) => CommandBuilder, + l: (builder: CommandBuilder) => CommandBuilder, + m: (builder: CommandBuilder) => CommandBuilder, + n: (builder: CommandBuilder) => CommandBuilder, + o: (builder: CommandBuilder) => CommandBuilder, + p: (builder: CommandBuilder) => CommandBuilder

, + q: (builder: CommandBuilder

) => CommandBuilder, + r: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder, + k: (builder: CommandBuilder) => CommandBuilder, + l: (builder: CommandBuilder) => CommandBuilder, + m: (builder: CommandBuilder) => CommandBuilder, + n: (builder: CommandBuilder) => CommandBuilder, + o: (builder: CommandBuilder) => CommandBuilder, + p: (builder: CommandBuilder) => CommandBuilder

, + q: (builder: CommandBuilder

) => CommandBuilder, + r: (builder: CommandBuilder) => CommandBuilder, + s: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder, + k: (builder: CommandBuilder) => CommandBuilder, + l: (builder: CommandBuilder) => CommandBuilder, + m: (builder: CommandBuilder) => CommandBuilder, + n: (builder: CommandBuilder) => CommandBuilder, + o: (builder: CommandBuilder) => CommandBuilder, + p: (builder: CommandBuilder) => CommandBuilder

, + q: (builder: CommandBuilder

) => CommandBuilder, + r: (builder: CommandBuilder) => CommandBuilder, + s: (builder: CommandBuilder) => CommandBuilder, + t: (builder: CommandBuilder) => CommandBuilder + ): CommandBuilder; pipe( - ...transforms: Array<(builder: CommandBuilder) => CommandBuilder> - ): CommandBuilder; - runner(): ProcessRunner; - run(): Promise; + ...transforms: Array<(builder: CommandBuilder) => CommandBuilder> + ): CommandBuilder; + runner(options?: ProcessOptions): ProcessRunner; + run(options?: ProcessOptions): Promise; + sync(options?: ProcessOptions): T; then( resolve?: ((value: T) => R1 | PromiseLike) | null, reject?: ((error: any) => R2 | PromiseLike) | null ): Promise; stream(options?: StreamOptions): CommandStream; toString(): string; + buildCommand(): string; } export interface CommandFactory { - (file: string, ...args: string[]): CommandBuilder; + (file: string, ...args: CommandArgument[]): CommandBuilder; + ( + file: string, + args: readonly CommandArgument[], + options?: ProcessOptions + ): CommandBuilder; + with(options: ProcessOptions): CommandFactory; + options( + options: ProcessOptions + ): (builder: CommandBuilder) => CommandBuilder; + arg( + ...args: CommandArgument[] + ): (builder: CommandBuilder) => CommandBuilder; stdout( - mode: 'pipe' | 'inherit' | 'ignore' + mode: CommandStdio ): (builder: CommandBuilder) => CommandBuilder; stderr( - mode: 'pipe' | 'inherit' | 'ignore' + mode: CommandStdio ): (builder: CommandBuilder) => CommandBuilder; cwd(path: string): (builder: CommandBuilder) => CommandBuilder; env( @@ -61,6 +332,12 @@ export interface CommandFactory { stdin( stdin: ProcessOptions['stdin'] ): (builder: CommandBuilder) => CommandBuilder; + capture( + enabled?: boolean + ): (builder: CommandBuilder) => CommandBuilder; + mirror( + enabled?: boolean + ): (builder: CommandBuilder) => CommandBuilder; exitCode(builder: CommandBuilder): CommandBuilder; } export const command: CommandFactory; diff --git a/js/types/api.d.cts b/js/types/api.d.cts index 562ded20..22469534 100644 --- a/js/types/api.d.cts +++ b/js/types/api.d.cts @@ -465,6 +465,11 @@ export interface CommandTag { (strings: TemplateStringsArray, ...values: TemplateValue[]): ProcessRunner; } +/** Tagged shell preset with the same exact-argv builder defaults. */ +export interface ConfiguredCommandTag extends CommandTag { + command: import('./advanced.cjs').CommandFactory; +} + /** `cross-spawn`, re-exported as `spawn` and `$.spawn`. */ export interface CrossSpawn { ( @@ -511,7 +516,7 @@ export interface Dollar extends CommandTag { readonly execa: ExecaExports['execa']; /** Access all Execa methods and bind reusable defaults. */ readonly execaCompat: typeof execaCompat; - (options: ProcessOptions): CommandTag; + (options: ProcessOptions): ConfiguredCommandTag; spawn: CrossSpawn; /** zx compatibility mode: the `$` of `command-stream/zx`. */ readonly zx: typeof import('./zx-api.cjs').$; @@ -562,7 +567,9 @@ export declare function spawnDetached( ): Promise; /** Create a tagged template with default options. */ -export declare function create(defaultOptions?: ProcessOptions): CommandTag; +export declare function create( + defaultOptions?: ProcessOptions +): ConfiguredCommandTag; export declare function create( name: string, handler: import('./advanced.cjs').CommandHandler, @@ -1037,6 +1044,8 @@ export { testing, } from './advanced.cjs'; export type { + CommandArgument, + CommandStdio, CommandFactory, CommandStream, CommandHandler, diff --git a/js/types/core-api.d.cts b/js/types/core-api.d.cts index 55f7a762..b4de159b 100644 --- a/js/types/core-api.d.cts +++ b/js/types/core-api.d.cts @@ -44,9 +44,13 @@ export { } from './api.cjs'; export type { + CommandArgument, + CommandStdio, + CommandFactory, CommandResult, CommandSpec, CommandTag, + ConfiguredCommandTag, ProcessOptions, ProcessRunnerEvents, StartOptions, @@ -79,7 +83,7 @@ export interface Dollar | 'compose' | 'spawn' > { - (options: Api.ProcessOptions): Api.CommandTag; + (options: Api.ProcessOptions): Api.ConfiguredCommandTag; } export declare const $: Dollar; diff --git a/js/types/index.d.cts b/js/types/index.d.cts index 01c0da99..5da4e372 100644 --- a/js/types/index.d.cts +++ b/js/types/index.d.cts @@ -19,6 +19,8 @@ declare const $: CommonJsDollar; declare namespace $ { export type CommandBuilder = import('./advanced.cjs').CommandBuilder; + export type CommandArgument = CommandStream.CommandArgument; + export type CommandStdio = CommandStream.CommandStdio; export type CommandFactory = import('./advanced.cjs').CommandFactory; export type StreamOptions = import('./advanced.cjs').StreamOptions; export type StreamStats = import('./advanced.cjs').StreamStats; @@ -52,6 +54,7 @@ declare namespace $ { export type CommandError = CommandStream.CommandError; export type CommandResult = CommandStream.CommandResult; export type DetachedProcess = CommandStream.DetachedProcess; + export type ConfiguredCommandTag = CommandStream.ConfiguredCommandTag; export type CommandSpec = CommandStream.CommandSpec; export type CommandTag = CommandStream.CommandTag; export type CrossSpawn = CommandStream.CrossSpawn; diff --git a/rust/README.md b/rust/README.md index 0f61061b..9b833c0f 100644 --- a/rust/README.md +++ b/rust/README.md @@ -17,6 +17,8 @@ The shared advanced APIs include `CommandBuilder`, `DataStream`, `terminal::TerminalBuffer`, and `CommandEnvironment`. See the [advanced workflows guide](../js/docs/ADVANCED-WORKFLOWS.md#rust-equivalents) and `examples/language_features.rs` for executable examples. +[Compare the complete Rust APIs](docs/COMPARISON.md) with all fourteen projects +in the pinned competitor audit, including their specialized capabilities. [Source translation and competitor migration](../js/docs/FY.md) use the same options in both implementations. diff --git a/rust/changelog.d/20261024_103000_command_builder.md b/rust/changelog.d/20261024_103000_command_builder.md new file mode 100644 index 00000000..834a8d36 --- /dev/null +++ b/rust/changelog.d/20261024_103000_command_builder.md @@ -0,0 +1,9 @@ +--- +bump: minor +--- + +### Added + +- Complete reusable command builders with environment layers and removal, + functional configuration and synchronous execution alongside async execution. + Preserve native environment key rules by applying changes in configuration order. diff --git a/rust/docs/COMPARISON.md b/rust/docs/COMPARISON.md new file mode 100644 index 00000000..c1a832ab --- /dev/null +++ b/rust/docs/COMPARISON.md @@ -0,0 +1,80 @@ +# Rust command execution comparison + +This comparison covers all fourteen projects in the [pinned Rust +audit](COMPETITOR_TEST_AUDIT.md), using their linked public documentation and +the audited source revisions. Reviewed 2026-10-24. It covers execution, output, +configuration, process control, shell syntax, testing and development tools. +The [JavaScript comparison](../../js/docs/COMPARISON.md) covers the separate +JavaScript APIs; a JavaScript compatibility import is not a Rust implementation. + +## Construction, execution and output + +| Project / primary reference | Construction and execution | I/O and composition | Configuration and lifecycle | +| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| command-stream | Reusable owned `CommandBuilder`, `.arg/.args`, functional `.pipe`, async `.run` and `.run_sync`; separate macros and runners | Builder collects text; `StreamingRunner` yields bytes; `Pipeline` connects processes; Execa module provides byte results | Builder cwd, native environment, clear/remove and three stdio modes; runner exposes cancellation, deadlines and process cleanup | +| [std::process](https://doc.rust-lang.org/std/process/struct.Command.html) | Reusable mutable native argv builder; `output`, `status`, `spawn` | Byte results, native handles; construct pipelines using handles | Native cwd/env/clear/remove, platform extension traits, child wait/try_wait/kill and native ExitStatus | +| [Tokio process](https://docs.rs/tokio/latest/tokio/process/index.html) | Native-style command builder, async status/output/wait | Async native stdin/stdout/stderr and byte output; compose with Tokio I/O | Native configuration and platform extensions, kill_on_drop; deadlines via Tokio time primitives | +| [async-process](https://github.com/smol-rs/async-process) | Async native-style command builder, output/status/spawn | Async I/O traits, byte results and line reading through buffered readers | Native configuration; background reaper; child lifecycle controls | +| [duct](https://github.com/oconnor663/duct.rs) | Reusable command expressions; run/read/start; nonzero exits checked by default | Expression pipelines, redirects, stderr merging, incremental readers | cwd/env/clear/remove, handles and unchecked status policy | +| [subprocess](https://github.com/hniksic/rust-subprocess) | Exec and pipeline builders; join/capture/start | OS pipelines, arbitrary reader/byte input, merged streams, concurrent communicate with size/time limits | cwd/env, redirects, process groups, nonblocking polling and shared process handles | +| [xshell](https://github.com/matklad/xshell) | Shell context and `cmd!` macro with scalar/collection interpolation; run/read/output | Captured output and pipe input; scripting and filesystem helpers | Context cwd/env, per-command settings, checked execution and deadline support | +| [rust_cmd_lib](https://github.com/rust-shell-script/rust_cmd_lib) | Compile-time command macros, vector arguments, command groups and custom functions | Pipelines, redirections, captured function results and background process groups | Scoped cwd/env, logging, checked pipeline execution; no external shell required for macro grammar | +| [run_script](https://github.com/sagiegurari/run_script) | Script text plus args/options, run/spawn functions and macros | Script output or native spawned child streams; grammar supplied by an interpreter | Interpreter and script options; native child lifecycle via spawn | +| [rust-shell](https://github.com/google/rust-shell) | ShellCommand macros, checked run, native child and thread helpers | UTF-8 convenience capture and native process output | Environment expansion and signals; upstream documents Unix-only support | +| [shellfn](https://github.com/synek317/shellfn) | Attribute macros turn scripts into Rust functions | Arguments mapped through environment; output converted into declared Rust return types | Interpreter/script attributes and domain-specific parsing/error conversion | +| [bkt](https://github.com/dimo414/bkt) | Command invocation with persistent caching | Cached stdout, stderr and status | TTL, stale refresh and cache keys incorporating selected command state; a different execution policy from always running a description | +| [assert_cmd](https://github.com/assert-rs/assert_cmd) | Native command wrapper for CLI integration tests and Cargo binary discovery | Buffered stdin/output, predicates and readable assertion failures | Native command settings, deadlines and test-oriented status checks | +| [rexpect](https://github.com/rust-cli/rexpect) | Interactive sessions and shell helpers | PTY send/read, string/regex/EOF expectations and prompt synchronization | Session/read deadlines and interactive signal handling | +| [expectrl](https://github.com/zhiburt/expectrl) | Interactive session API with optional async support | Pattern expectations, logging, control codes and interactive forwarding | Session controls and timeouts; upstream documents Windows support | + +The table names each library's useful surface, not interchangeable signatures. +For example, duct's `.pipe` connects expressions; CommandBuilder's `.pipe` +applies a configuration transform. The latter's process pipelines use a runner +or `Pipeline`. Nonzero exit policies also differ: CommandBuilder returns status; +call `error_for_status()` when failure should become an error. + +## Builder guarantees and boundaries + +`CommandBuilder` executes native `OsString` executable names, arguments and +environment keys/values. Configuration is owned and cloning preserves a saved +description. Environment changes are replayed in order through native Command, +including Windows case-insensitive names. Async and sync executions share the +same native configuration. Buffered text stdin is written while stdout/stderr +are drained, including input larger than a pipe buffer. + +| Requirement | Public command-stream surface and verification | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| Literal argv, configuration transforms, repeated sync/async runs | [Builder tests](../tests/command_builder.rs), [builder source](../src/command_builder.rs) | +| Empty/hostile arguments, cwd, environment replacement/removal and platform key rules | [Portable compatibility tests](../tests/competitor_compatibility/behavior.rs) | +| Byte input and lossless collected output, richer process options | [Rust Execa guide](../../js/docs/EXECA_MIGRATION.md#rust); core builder stdin/results remain text | +| Streaming, cancellation, deadlines, signals and descendants | [Rust guide](../README.md#streaming), `StreamingRunner`, ProcessRunner and RunOptions; these are separate from builder configuration | +| Arbitrary caller-supplied stdio handles or native child inspection | Use native std/Tokio process APIs; the builder has fixed pipe/inherit/ignore output policies and returns CommandResult | +| Array/splat macros and typed expression pipelines | Builder `.args` accepts iterators; core macros and pipelines have their own grammar and do not implement xshell/cmd_lib/duct macros | +| Pattern-based interactive PTY sessions | Terminal capture records scripted sessions; it does not implement rexpect/expectrl's expect protocol | +| Persistent subprocess cache or script-to-domain return conversion | Application code or specialized bkt/shellfn APIs; running a builder always executes the command | + +`.shell_string()` renders POSIX argv, omitting execution configuration. It uses +lossy text conversion for non-UTF-8 native values; execute the builder to retain +native bytes. It is not a Windows command-line serializer. + +## Features beyond the process builder + +The combined command, stream and tooling APIs are the main breadth advantage. +Native process crates remain useful for direct OS control; specialized crates +offer contracts beyond this builder. + +| Area | command-stream evidence | Corresponding competitor approach | +| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Shell parsing, redirects and portable built-ins | [Rust guide](../README.md), [generated feature catalog](../../docs/README.md) | cmd_lib macro grammar; xshell scripting helpers; run_script/shellfn host interpreters; native runtimes require application composition | +| Custom commands, middleware and command environments | [Advanced workflows](../../js/docs/ADVANCED-WORKFLOWS.md#rust-equivalents) | cmd_lib custom command functions; ordinary Rust functions around the other APIs | +| Async transforms, batching, windows, bounded queues and statistics | [Stream processing guide](../../docs/stream-processing-guide.md) | Tokio/async-process I/O plus stream combinators; duct/subprocess readers plus application transforms | +| ANSI processing, terminal snapshots and recording | [Feature catalog](../../docs/README.md) | rexpect/expectrl specialize in interactive expectations; native APIs supply the underlying I/O | +| Shell translation, examples and development workflows | [Translation guide](../../js/docs/FY.md), [native Cargo watching and Evcxr REPL](../../js/docs/ADVANCED-WORKFLOWS.md#rust-equivalents) | Script macros and integration with external development tools; browser/VS Code tools belong to the JavaScript package | +| CLI assertions and mocks | [Portable integration tests](../tests/competitor_compatibility/behavior.rs) use ordinary Rust assertions; the JS mock/snapshot helper API is separate | assert_cmd provides Cargo-aware command discovery and predicate assertions; expect libraries test interactive protocols | +| Performance and memory | [Rust benchmark guide](../benchmarks/README.md) | Compare the same output, workload, platform and build configuration before making a performance claim | + +The audit accounts for every test unit in its published discovery set. It does +not establish superiority over every open-source project or full conformance to +every upstream API. The missing-contract entries identify specialized behavior +and verification limits; passing a portable invariant does not prove all of a +competitor's implementation or platform contracts. diff --git a/rust/docs/COMPETITOR_TEST_AUDIT.md b/rust/docs/COMPETITOR_TEST_AUDIT.md index 9487218c..eb320015 100644 --- a/rust/docs/COMPETITOR_TEST_AUDIT.md +++ b/rust/docs/COMPETITOR_TEST_AUDIT.md @@ -71,8 +71,8 @@ assumptions. Copying their files byte-for-byte would mostly test those crates. Instead, every scoped test belongs to one disposition: 1. A portable public process invariant is adapted into the executable suite. -2. A public invariant needs a feature command-stream does not have and is in - the missing-feature ledger below. +2. A public upstream contract is not fully ported and is recorded in the + remaining-contract ledger below, with its current implementation status. 3. A test verifies competitor API shape, private internals, unrelated helpers, upstream runtime behavior, or harness mechanics and is inapplicable. @@ -95,36 +95,41 @@ inventory total, and ensure all fourteen selected projects are represented. ## Executable behavior ports -| Case ID | Public invariant | -| ----------------------------- | ------------------------------------------------------------------------------------------ | -| `direct-exact-argv` | Direct execution bypasses shell tokenization and preserves argument boundaries. | -| `argument-edge-cases` | Empty, whitespace, multiline, Unicode, quote, and metacharacter arguments survive exactly. | -| `safe-template-interpolation` | On POSIX shells, an untrusted interpolation remains one literal argument. | -| `cwd-string` | An explicit working directory reaches the child. | -| `environment` | Explicit environment values reach the child without corruption. | -| `stdout-stderr-separation` | Standard output and error remain separate. | -| `newline-preservation` | Captured output preserves final and repeated newlines. | -| `unicode-output` | UTF-8 output is captured without loss. | -| `large-output` | One MiB is captured without truncation or deadlock. | -| `nonzero-exit` | A non-zero child status is returned, and reads through `code` and `exit_code`. | -| `stdin-string` | String input is written completely and stdin closes. | -| `lazy-execution` | Constructing a runner does not spawn it. | -| `concurrent-execution` | Concurrent children keep results isolated. | -| `streamed-before-exit` | Output arrives before a delayed child exits. | -| `sync-execution` | Synchronous execution returns captured output and status. | -| `programmatic-pipeline` | One process's stdout becomes the next process's stdin. | -| `spawn-error-propagation` | An unavailable exact executable returns an error, never a false success. | -| `stream-kill` | Killing an active stream terminates the child and reports termination. | +| Case ID | Public invariant | +| ------------------------------ | ------------------------------------------------------------------------------------------- | +| `direct-exact-argv` | Direct execution bypasses shell tokenization and preserves argument boundaries. | +| `argument-edge-cases` | Empty, whitespace, multiline, Unicode, quote, and metacharacter arguments survive exactly. | +| `safe-template-interpolation` | On POSIX shells, an untrusted interpolation remains one literal argument. | +| `cwd-string` | An explicit working directory reaches the child. | +| `environment` | Explicit environment values reach the child without corruption. | +| `environment-clear-and-remove` | Builder clear/removal, ordering and native platform key rules hold for sync and async runs. | +| `stdout-stderr-separation` | Standard output and error remain separate. | +| `newline-preservation` | Captured output preserves final and repeated newlines. | +| `unicode-output` | UTF-8 output is captured without loss. | +| `large-output` | One MiB is captured without truncation or deadlock. | +| `nonzero-exit` | A non-zero child status is returned, and reads through `code` and `exit_code`. | +| `stdin-string` | String input is written completely and stdin closes. | +| `lazy-execution` | Constructing a runner does not spawn it. | +| `concurrent-execution` | Concurrent children keep results isolated. | +| `streamed-before-exit` | Output arrives before a delayed child exits. | +| `sync-execution` | Synchronous execution returns captured output and status. | +| `programmatic-pipeline` | One process's stdout becomes the next process's stdin. | +| `spawn-error-propagation` | An unavailable exact executable returns an error, never a false success. | +| `stream-kill` | Killing an active stream terminates the child and reports termination. | Existing focused Rust tests continue to cover shell parsing, virtual commands, signals, process groups, terminal capture, redirection, state, and built-ins in greater depth. -## Missing-feature ledger +## Remaining upstream contracts -These are comparison inputs for later work, not promises that every competitor -choice fits command-stream. A future implementation should start with a failing -focused test and update the corpus in the same change. +Stable `missing` disposition IDs below identify contracts not completely ported +by this corpus. Current implementations in a separate runner or compatibility +module are described where applicable; they do not automatically prove every +upstream contract. See the [complete Rust comparison](COMPARISON.md) for API +selection and the [JavaScript comparison](../../js/docs/COMPARISON.md) for the +separate JavaScript surfaces. The ten environment-clear/removal units now have +an executable builder invariant and are ported in both exact ledgers. ### array-and-splat-interpolation @@ -136,20 +141,19 @@ display values only. The native, Tokio, async-process, assert_cmd, and subprocess APIs accept platform `OsStr` values throughout. `StreamingRunner::from_argv` accepts native -arguments, but command-stream's environment and captured result APIs require -UTF-8 strings, so it cannot preserve this contract end to end. +arguments, and CommandBuilder now accepts native executable, argument and +environment values. Core RunOptions environment and collected text results +still require UTF-8; use the builder for native env and the Execa byte result +API for lossless collection. The complete lower-level upstream contracts are +not inferred from those separate surfaces. ### binary-input-and-lossless-output Several competitors accept byte input and return raw output bytes. command-stream streaming chunks are bytes, but collected output and configured -stdin are strings and use lossy UTF-8 decoding. - -### environment-clear-and-remove - -Native process builders and several wrappers can clear the inherited -environment or remove one variable. command-stream can add or replace values -but has no clear/remove policy. +stdin are strings and use lossy UTF-8 decoding in the core builder/runner. +The separate Rust Execa module accepts and collects bytes; it is documented in +the [Execa guide](../../js/docs/EXECA_MIGRATION.md#rust). ### custom-stdio-and-file-handles @@ -166,7 +170,10 @@ command-stream currently captures without a configurable maximum-buffer policy. assert_cmd, xshell, run_script, rexpect, and expectrl expose deadlines in their assertion, script, or session layers. command-stream can be cancelled through -its stream handle but has no per-command timeout option. +its stream handle, and ProcessRunner's `RunOptions::timeout` supplies a +per-command deadline with configured termination signal/grace. CommandBuilder +does not expose that runner option, and a process deadline does not implement +an expect session's per-read deadline. Those contracts remain separate. ### try-wait-and-shared-child-handle diff --git a/rust/src/command_builder.rs b/rust/src/command_builder.rs index 2c8331a7..a3a04475 100644 --- a/rust/src/command_builder.rs +++ b/rust/src/command_builder.rs @@ -1,6 +1,5 @@ //! Lazy exact-argv command descriptions, parallel to JavaScript `$.command`. use crate::{CommandResult, Result, StdinOption}; -use std::collections::HashMap; use std::ffi::OsString; use std::path::PathBuf; use std::process::Stdio; @@ -30,7 +29,10 @@ pub struct CommandBuilder { pub file: OsString, pub arguments: Vec, cwd: Option, - env: HashMap, + // Replay changes in order so native Command applies platform-specific + // key equality (notably case-insensitive environment names on Windows). + env: Vec<(OsString, Option)>, + clear_env: bool, stdout: CommandStdio, stderr: CommandStdio, stdin: StdinOption, @@ -42,7 +44,8 @@ impl CommandBuilder { file: file.into(), arguments: Vec::new(), cwd: None, - env: HashMap::new(), + env: Vec::new(), + clear_env: false, stdout: CommandStdio::Pipe, stderr: CommandStdio::Pipe, stdin: StdinOption::Null, @@ -68,7 +71,27 @@ impl CommandBuilder { self } pub fn env(mut self, key: impl Into, value: impl Into) -> Self { - self.env.insert(key.into(), value.into()); + self.env.push((key.into(), Some(value.into()))); + self + } + pub fn envs(mut self, variables: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + for (key, value) in variables { + self = self.env(key, value); + } + self + } + pub fn env_remove(mut self, key: impl Into) -> Self { + self.env.push((key.into(), None)); + self + } + pub fn env_clear(mut self) -> Self { + self.env.clear(); + self.clear_env = true; self } pub fn stdout(mut self, mode: CommandStdio) -> Self { @@ -84,22 +107,41 @@ impl CommandBuilder { self } - pub async fn run(&self) -> Result { - let mut command = tokio::process::Command::new(&self.file); + /// Apply an owned builder transformation without executing the command. + pub fn pipe(self, transform: impl FnOnce(Self) -> Self) -> Self { + transform(self) + } + + fn native_command(&self) -> std::process::Command { + let mut command = std::process::Command::new(&self.file); command .args(&self.arguments) - .envs(&self.env) .stdin(match &self.stdin { StdinOption::Inherit => Stdio::inherit(), StdinOption::Null => Stdio::null(), _ => Stdio::piped(), }) .stdout(self.stdout.native()) - .stderr(self.stderr.native()) - .kill_on_drop(true); + .stderr(self.stderr.native()); + if self.clear_env { + command.env_clear(); + } + for (key, value) in &self.env { + if let Some(value) = value { + command.env(key, value); + } else { + command.env_remove(key); + } + } if let Some(cwd) = &self.cwd { command.current_dir(cwd); } + command + } + + pub async fn run(&self) -> Result { + let mut command = tokio::process::Command::from(self.native_command()); + command.kill_on_drop(true); let mut child = command.spawn()?; let input = child.stdin.take(); let write = async { @@ -114,18 +156,46 @@ impl CommandBuilder { }; let (output, written) = tokio::join!(child.wait_with_output(), write); written?; - let output = output?; + Ok(Self::result(output?)) + } + + /// Execute synchronously, draining output while supplying buffered stdin. + pub fn run_sync(&self) -> Result { + let mut child = self.native_command().spawn()?; + let input = child.stdin.take(); + let output = std::thread::scope(|scope| { + let writer = scope.spawn(|| { + if let (Some(mut input), StdinOption::Content(data)) = (input, &self.stdin) { + use std::io::Write; + if let Err(error) = input.write_all(data.as_bytes()) { + if error.kind() != std::io::ErrorKind::BrokenPipe { + return Err(error); + } + } + } + Ok::<(), std::io::Error>(()) + }); + let output = child.wait_with_output(); + writer + .join() + .map_err(|_| std::io::Error::other("command stdin writer panicked"))??; + output + })?; + Ok(Self::result(output)) + } + + fn result(output: std::process::Output) -> CommandResult { let code = output.status.code().unwrap_or(1); #[cfg(unix)] let code = { use std::os::unix::process::ExitStatusExt; output.status.signal().map_or(code, |signal| 128 + signal) }; - Ok(CommandResult::new( + CommandResult::new( String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned(), code, - )) + ) } pub async fn exit_code(&self) -> Result { diff --git a/rust/tests/command_builder.rs b/rust/tests/command_builder.rs index 16b92b54..4c456194 100644 --- a/rust/tests/command_builder.rs +++ b/rust/tests/command_builder.rs @@ -52,3 +52,56 @@ fn fy_cli_creates_a_module_and_refuses_to_overwrite() { .success()); assert_eq!(std::fs::read_to_string(output).unwrap(), module); } + +#[test] +fn builder_composition_and_sync_execution() { + let builder = CommandBuilder::new(if cfg!(windows) { "cmd" } else { "sh" }) + .args(if cfg!(windows) { + vec!["/C", "exit 7"] + } else { + vec!["-c", "exit 7"] + }) + .pipe(|builder| builder.stderr(command_stream::CommandStdio::Ignore)); + assert_eq!(builder.run_sync().unwrap().code, 7); + assert_eq!(builder.run_sync().unwrap().code, 7); +} + +#[cfg(unix)] +#[tokio::test] +async fn builder_env_layers_and_large_stdin() { + let builder = CommandBuilder::new("sh") + .args([ + "-c", + "printf '%s|%s|' \"$BUILDER_A\" \"${BUILDER_B-unset}\"; cat", + ]) + .env_clear() + .envs([("BUILDER_A", "a"), ("BUILDER_B", "removed")]) + .env_remove("BUILDER_B") + .stdin(command_stream::StdinOption::Content("input".repeat(32768))); + let expected = format!("a|unset|{}", "input".repeat(32768)); + assert_eq!(builder.run().await.unwrap().stdout, expected); + assert_eq!(builder.run_sync().unwrap().stdout, expected); +} + +#[cfg(unix)] +#[tokio::test] +async fn cwd_and_stdio_policies_match_sync_and_async() { + use command_stream::CommandStdio; + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("input.txt"), "from cwd").unwrap(); + let builder = CommandBuilder::new("sh") + .args(["-c", "cat input.txt; printf hidden >&2"]) + .cwd(directory.path()) + .stderr(CommandStdio::Ignore); + let result = builder.run().await.unwrap(); + assert_eq!(result.stdout, "from cwd"); + assert_eq!(result.stderr, ""); + let result = builder + .clone() + .stdout(CommandStdio::Ignore) + .run_sync() + .unwrap(); + assert_eq!(result.stdout, ""); + assert_eq!(result.stderr, ""); + assert_eq!(result.code, 0); +} diff --git a/rust/tests/competitor_compatibility.rs b/rust/tests/competitor_compatibility.rs index 86a871bb..4823d607 100644 --- a/rust/tests/competitor_compatibility.rs +++ b/rust/tests/competitor_compatibility.rs @@ -264,7 +264,7 @@ fn every_selected_project_and_case_is_accounted_for() { } assert_eq!(referenced, known); - assert_eq!(PORTED_CASES.len(), 18); + assert_eq!(PORTED_CASES.len(), 19); assert_eq!( PORTED_CASES .iter() @@ -272,7 +272,7 @@ fn every_selected_project_and_case_is_accounted_for() { .collect::>(), BEHAVIOR_CASE_IDS.iter().copied().collect::>() ); - assert_eq!(MISSING_FEATURES.len(), 13); + assert_eq!(MISSING_FEATURES.len(), 12); assert_eq!(EXCLUDED_TEST_CLASSES.len(), 5); for class in EXCLUDED_TEST_CLASSES { assert!(!class.id.is_empty()); diff --git a/rust/tests/competitor_compatibility/behavior.rs b/rust/tests/competitor_compatibility/behavior.rs index 389ab2b5..74350cc5 100644 --- a/rust/tests/competitor_compatibility/behavior.rs +++ b/rust/tests/competitor_compatibility/behavior.rs @@ -1,9 +1,8 @@ -#[cfg(not(windows))] use super::support::fixture_path; use super::support::{decode_hex_lines, fixture_runner, hex, run_fixture, shell_fixture_command}; #[cfg(not(windows))] use command_stream::cmd; -use command_stream::{run_sync, OutputChunk, Pipeline, StreamingRunner}; +use command_stream::{run_sync, CommandBuilder, OutputChunk, Pipeline, StreamingRunner}; use std::collections::HashMap; use std::ffi::OsString; use std::time::Duration; @@ -30,6 +29,7 @@ pub const BEHAVIOR_CASE_IDS: &[&str] = &[ "safe-template-interpolation", "cwd-string", "environment", + "environment-clear-and-remove", "stdout-stderr-separation", "newline-preservation", "unicode-output", @@ -131,6 +131,44 @@ async fn environment_passes_explicit_values_to_the_child() { assert_eq!(decode_hex_lines(&result.stdout), ["one", "héllø☃"]); } +#[tokio::test] +async fn builder_environment_clear_remove_and_native_key_rules() { + let parent_key = std::env::vars_os() + .map(|(key, _)| key) + .find(|key| key != "COMMAND_STREAM_ENV_TEST" && key != "command_stream_env_test") + .expect("the test runner has an inherited environment"); + let builder = || { + CommandBuilder::new(fixture_path()) + .arg("env-state") + .arg(&parent_key) + .args(["COMMAND_STREAM_ENV_TEST", "command_stream_env_test"]) + .env("DISCARDED_BEFORE_CLEAR", "discarded") + .env_clear() + .env("COMMAND_STREAM_ENV_TEST", "first") + .env("command_stream_env_test", "second") + .env_remove("COMMAND_STREAM_ENV_TEST") + }; + // On Windows environment names are case-insensitive; on Unix they are + // distinct. Preserve native ordering and key comparison on both platforms. + let expected = if cfg!(windows) { + "absent\nabsent\nabsent\n" + } else { + "absent\nabsent\npresent:7365636f6e64\n" + }; + for _ in 0..16 { + let command = builder(); + assert_eq!(command.run().await.unwrap().stdout, expected); + assert_eq!(command.run_sync().unwrap().stdout, expected); + } + let restored = builder().env("COMMAND_STREAM_ENV_TEST", "restored"); + assert!(restored + .run() + .await + .unwrap() + .stdout + .contains("726573746f726564")); +} + #[tokio::test] async fn stdout_and_stderr_are_captured_separately() { for (stdout, stderr) in [ diff --git a/rust/tests/competitor_compatibility/corpus.rs b/rust/tests/competitor_compatibility/corpus.rs index c1ff39c3..d5cf6f5a 100644 --- a/rust/tests/competitor_compatibility/corpus.rs +++ b/rust/tests/competitor_compatibility/corpus.rs @@ -190,6 +190,22 @@ pub const COMPETITORS: &[Competitor] = &[ ]; pub const PORTED_CASES: &[CorpusEntry] = &[ + CorpusEntry { + id: "environment-clear-and-remove", + competitors: &[ + "rust-std-process", + "tokio-process", + "async-process", + "assert-cmd", + "duct", + "xshell", + "subprocess", + ], + upstream: &[ + "tests/ui/process/process-remove-from-env.rs", + "tests/std.rs", + ], + }, CorpusEntry { id: "direct-exact-argv", competitors: &[ @@ -516,22 +532,6 @@ pub const MISSING_FEATURES: &[CorpusEntry] = &[ ], upstream: &["tests/std.rs", "src/test.rs", "src/tests/communicate.rs"], }, - CorpusEntry { - id: "environment-clear-and-remove", - competitors: &[ - "rust-std-process", - "tokio-process", - "async-process", - "assert-cmd", - "duct", - "xshell", - "subprocess", - ], - upstream: &[ - "tests/ui/process/process-remove-from-env.rs", - "tests/std.rs", - ], - }, CorpusEntry { id: "custom-stdio-and-file-handles", competitors: &[ diff --git a/rust/tests/competitor_compatibility/fixture.rs b/rust/tests/competitor_compatibility/fixture.rs index fbfa4e12..6ef96700 100644 --- a/rust/tests/competitor_compatibility/fixture.rs +++ b/rust/tests/competitor_compatibility/fixture.rs @@ -24,6 +24,14 @@ fn main() { println!("{}", hex(env::var(&key).unwrap_or_default().as_bytes())); } } + "env-state" => { + for key in args { + match env::var_os(key) { + Some(value) => println!("present:{}", hex(value.to_string_lossy().as_bytes())), + None => println!("absent"), + } + } + } "output" => { let stdout = args.next().unwrap_or_default(); let stderr = args.next().unwrap_or_default(); diff --git a/rust/tests/competitor_decisions.jsonl b/rust/tests/competitor_decisions.jsonl index a8ae7200..8d8644a9 100644 --- a/rust/tests/competitor_decisions.jsonl +++ b/rust/tests/competitor_decisions.jsonl @@ -24,7 +24,7 @@ {"id":"rust-std-process:library/std/src/process/tests.rs:501:1:registration","disposition":{"kind":"ported","id":"environment"}} {"id":"rust-std-process:library/std/src/process/tests.rs:512:1:registration","disposition":{"kind":"ported","id":"environment"}} {"id":"rust-std-process:library/std/src/process/tests.rs:524:1:registration","disposition":{"kind":"inapplicable","id":"competitor-api-shape"}} -{"id":"rust-std-process:library/std/src/process/tests.rs:533:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} +{"id":"rust-std-process:library/std/src/process/tests.rs:533:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} {"id":"rust-std-process:library/std/src/process/tests.rs:539:1:registration","disposition":{"kind":"inapplicable","id":"competitor-api-shape"}} {"id":"rust-std-process:library/std/src/process/tests.rs:680:1:registration","disposition":{"kind":"missing","id":"shell-expression-composition-and-redirection"}} {"id":"rust-std-process:library/std/src/process/tests.rs:699:1:registration","disposition":{"kind":"missing","id":"shell-expression-composition-and-redirection"}} @@ -50,7 +50,7 @@ {"id":"rust-std-process:tests/ui/process/process-envs.rs:1:1:file","disposition":{"kind":"inapplicable","id":"upstream-runtime-regressions"}} {"id":"rust-std-process:tests/ui/process/process-exit.rs:1:1:file","disposition":{"kind":"inapplicable","id":"upstream-runtime-regressions"}} {"id":"rust-std-process:tests/ui/process/process-panic-after-fork.rs:1:1:file","disposition":{"kind":"inapplicable","id":"upstream-runtime-regressions"}} -{"id":"rust-std-process:tests/ui/process/process-remove-from-env.rs:1:1:file","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} +{"id":"rust-std-process:tests/ui/process/process-remove-from-env.rs:1:1:file","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} {"id":"rust-std-process:tests/ui/process/process-sigpipe.rs:1:1:file","disposition":{"kind":"missing","id":"custom-stdio-and-file-handles"}} {"id":"rust-std-process:tests/ui/process/process-spawn-failure.rs:1:1:file","disposition":{"kind":"ported","id":"spawn-error-propagation"}} {"id":"rust-std-process:tests/ui/process/process-spawn-nonexistent.rs:1:1:file","disposition":{"kind":"ported","id":"spawn-error-propagation"}} @@ -94,7 +94,7 @@ {"id":"async-process:tests/std.rs:245:1:registration","disposition":{"kind":"missing","id":"try-wait-and-shared-child-handle"}} {"id":"async-process:tests/std.rs:257:1:registration","disposition":{"kind":"missing","id":"try-wait-and-shared-child-handle"}} {"id":"async-process:tests/std.rs:270:1:registration","disposition":{"kind":"missing","id":"try-wait-and-shared-child-handle"}} -{"id":"async-process:tests/std.rs:319:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} +{"id":"async-process:tests/std.rs:319:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} {"id":"async-process:tests/std.rs:341:1:registration","disposition":{"kind":"ported","id":"environment"}} {"id":"async-process:tests/std.rs:358:1:registration","disposition":{"kind":"ported","id":"environment"}} {"id":"async-process:tests/std.rs:383:1:registration","disposition":{"kind":"ported","id":"stream-kill"}} @@ -112,7 +112,7 @@ {"id":"assert-cmd:tests/assert.rs:81:1:registration","disposition":{"kind":"ported","id":"stdout-stderr-separation"}} {"id":"assert-cmd:tests/assert.rs:119:1:registration","disposition":{"kind":"ported","id":"stdout-stderr-separation"}} {"id":"assert-cmd:tests/cargo.rs:6:1:registration","disposition":{"kind":"missing","id":"binary-input-and-lossless-output"}} -{"id":"assert-cmd:tests/cargo.rs:13:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} +{"id":"assert-cmd:tests/cargo.rs:13:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} {"id":"assert-cmd:tests/cargo.rs:20:1:registration","disposition":{"kind":"inapplicable","id":"competitor-api-shape"}} {"id":"assert-cmd:tests/cargo.rs:41:1:registration","disposition":{"kind":"inapplicable","id":"competitor-api-shape"}} {"id":"assert-cmd:tests/cargo.rs:50:1:registration","disposition":{"kind":"inapplicable","id":"competitor-api-shape"}} @@ -141,9 +141,9 @@ {"id":"duct:src/test.rs:320:1:registration","disposition":{"kind":"ported","id":"stdout-stderr-separation"}} {"id":"duct:src/test.rs:333:1:registration","disposition":{"kind":"ported","id":"cwd-string"}} {"id":"duct:src/test.rs:361:1:registration","disposition":{"kind":"ported","id":"environment"}} -{"id":"duct:src/test.rs:370:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} -{"id":"duct:src/test.rs:397:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} -{"id":"duct:src/test.rs:417:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} +{"id":"duct:src/test.rs:370:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} +{"id":"duct:src/test.rs:397:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} +{"id":"duct:src/test.rs:417:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} {"id":"duct:src/test.rs:455:1:registration","disposition":{"kind":"ported","id":"environment"}} {"id":"duct:src/test.rs:579:1:registration","disposition":{"kind":"missing","id":"custom-stdio-and-file-handles"}} {"id":"duct:src/test.rs:588:1:registration","disposition":{"kind":"inapplicable","id":"competitor-internals"}} @@ -173,7 +173,7 @@ {"id":"xshell:tests/it/compile_failures.rs:109:1:registration","disposition":{"kind":"ported","id":"safe-template-interpolation"}} {"id":"xshell:tests/it/compile_failures.rs:119:1:registration","disposition":{"kind":"missing","id":"array-and-splat-interpolation"}} {"id":"xshell:tests/it/env.rs:6:1:registration","disposition":{"kind":"ported","id":"environment"}} -{"id":"xshell:tests/it/env.rs:58:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} +{"id":"xshell:tests/it/env.rs:58:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} {"id":"xshell:tests/it/main.rs:30:1:registration","disposition":{"kind":"ported","id":"direct-exact-argv"}} {"id":"xshell:tests/it/main.rs:39:1:registration","disposition":{"kind":"inapplicable","id":"competitor-api-shape"}} {"id":"xshell:tests/it/main.rs:47:1:registration","disposition":{"kind":"ported","id":"direct-exact-argv"}} @@ -391,8 +391,8 @@ {"id":"subprocess:src/tests/posix.rs:74:1:registration","disposition":{"kind":"ported","id":"stream-kill"}} {"id":"subprocess:src/tests/posix.rs:83:1:registration","disposition":{"kind":"missing","id":"try-wait-and-shared-child-handle"}} {"id":"subprocess:src/tests/posix.rs:98:1:registration","disposition":{"kind":"missing","id":"native-exit-status-and-signal-metadata"}} -{"id":"subprocess:src/tests/posix.rs:106:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} -{"id":"subprocess:src/tests/posix.rs:118:1:registration","disposition":{"kind":"missing","id":"environment-clear-and-remove"}} +{"id":"subprocess:src/tests/posix.rs:106:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} +{"id":"subprocess:src/tests/posix.rs:118:1:registration","disposition":{"kind":"ported","id":"environment-clear-and-remove"}} {"id":"subprocess:src/tests/posix.rs:132:1:registration","disposition":{"kind":"missing","id":"custom-stdio-and-file-handles"}} {"id":"subprocess:src/tests/posix.rs:147:1:registration","disposition":{"kind":"missing","id":"native-exit-status-and-signal-metadata"}} {"id":"subprocess:src/tests/posix.rs:162:1:registration","disposition":{"kind":"missing","id":"native-exit-status-and-signal-metadata"}} diff --git a/rust/tests/competitor_dispositions.jsonl b/rust/tests/competitor_dispositions.jsonl index 236e694a..6e7b2bf3 100644 --- a/rust/tests/competitor_dispositions.jsonl +++ b/rust/tests/competitor_dispositions.jsonl @@ -38,7 +38,7 @@ {"record":"unit","id":"rust-std-process:library/std/src/process/tests.rs:501:1:registration","source":"rust-std-process","unit":"registration","path":"library/std/src/process/tests.rs","line":501,"column":1,"label":"test_interior_nul_in_env_key_is_error","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/library/std/src/process/tests.rs#L501"} {"record":"unit","id":"rust-std-process:library/std/src/process/tests.rs:512:1:registration","source":"rust-std-process","unit":"registration","path":"library/std/src/process/tests.rs","line":512,"column":1,"label":"test_interior_nul_in_env_value_is_error","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/library/std/src/process/tests.rs#L512"} {"record":"unit","id":"rust-std-process:library/std/src/process/tests.rs:524:1:registration","source":"rust-std-process","unit":"registration","path":"library/std/src/process/tests.rs","line":524,"column":1,"label":"test_command_implements_send_sync","disposition":{"kind":"inapplicable","id":"competitor-api-shape"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/library/std/src/process/tests.rs#L524"} -{"record":"unit","id":"rust-std-process:library/std/src/process/tests.rs:533:1:registration","source":"rust-std-process","unit":"registration","path":"library/std/src/process/tests.rs","line":533,"column":1,"label":"env_empty","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/library/std/src/process/tests.rs#L533"} +{"record":"unit","id":"rust-std-process:library/std/src/process/tests.rs:533:1:registration","source":"rust-std-process","unit":"registration","path":"library/std/src/process/tests.rs","line":533,"column":1,"label":"env_empty","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/library/std/src/process/tests.rs#L533"} {"record":"unit","id":"rust-std-process:library/std/src/process/tests.rs:539:1:registration","source":"rust-std-process","unit":"registration","path":"library/std/src/process/tests.rs","line":539,"column":1,"label":"debug_print","disposition":{"kind":"inapplicable","id":"competitor-api-shape"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/library/std/src/process/tests.rs#L539"} {"record":"unit","id":"rust-std-process:library/std/src/process/tests.rs:680:1:registration","source":"rust-std-process","unit":"registration","path":"library/std/src/process/tests.rs","line":680,"column":1,"label":"run_bat_script","disposition":{"kind":"missing","id":"shell-expression-composition-and-redirection"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/library/std/src/process/tests.rs#L680"} {"record":"unit","id":"rust-std-process:library/std/src/process/tests.rs:699:1:registration","source":"rust-std-process","unit":"registration","path":"library/std/src/process/tests.rs","line":699,"column":1,"label":"run_canonical_bat_script","disposition":{"kind":"missing","id":"shell-expression-composition-and-redirection"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/library/std/src/process/tests.rs#L699"} @@ -64,7 +64,7 @@ {"record":"unit","id":"rust-std-process:tests/ui/process/process-envs.rs:1:1:file","source":"rust-std-process","unit":"file","path":"tests/ui/process/process-envs.rs","line":1,"column":1,"label":"","disposition":{"kind":"inapplicable","id":"upstream-runtime-regressions"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/tests/ui/process/process-envs.rs#L1"} {"record":"unit","id":"rust-std-process:tests/ui/process/process-exit.rs:1:1:file","source":"rust-std-process","unit":"file","path":"tests/ui/process/process-exit.rs","line":1,"column":1,"label":"","disposition":{"kind":"inapplicable","id":"upstream-runtime-regressions"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/tests/ui/process/process-exit.rs#L1"} {"record":"unit","id":"rust-std-process:tests/ui/process/process-panic-after-fork.rs:1:1:file","source":"rust-std-process","unit":"file","path":"tests/ui/process/process-panic-after-fork.rs","line":1,"column":1,"label":"","disposition":{"kind":"inapplicable","id":"upstream-runtime-regressions"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/tests/ui/process/process-panic-after-fork.rs#L1"} -{"record":"unit","id":"rust-std-process:tests/ui/process/process-remove-from-env.rs:1:1:file","source":"rust-std-process","unit":"file","path":"tests/ui/process/process-remove-from-env.rs","line":1,"column":1,"label":"","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/tests/ui/process/process-remove-from-env.rs#L1"} +{"record":"unit","id":"rust-std-process:tests/ui/process/process-remove-from-env.rs:1:1:file","source":"rust-std-process","unit":"file","path":"tests/ui/process/process-remove-from-env.rs","line":1,"column":1,"label":"","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/tests/ui/process/process-remove-from-env.rs#L1"} {"record":"unit","id":"rust-std-process:tests/ui/process/process-sigpipe.rs:1:1:file","source":"rust-std-process","unit":"file","path":"tests/ui/process/process-sigpipe.rs","line":1,"column":1,"label":"","disposition":{"kind":"missing","id":"custom-stdio-and-file-handles"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/tests/ui/process/process-sigpipe.rs#L1"} {"record":"unit","id":"rust-std-process:tests/ui/process/process-spawn-failure.rs:1:1:file","source":"rust-std-process","unit":"file","path":"tests/ui/process/process-spawn-failure.rs","line":1,"column":1,"label":"","disposition":{"kind":"ported","id":"spawn-error-propagation"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/tests/ui/process/process-spawn-failure.rs#L1"} {"record":"unit","id":"rust-std-process:tests/ui/process/process-spawn-nonexistent.rs:1:1:file","source":"rust-std-process","unit":"file","path":"tests/ui/process/process-spawn-nonexistent.rs","line":1,"column":1,"label":"","disposition":{"kind":"ported","id":"spawn-error-propagation"},"url":"https://github.com/rust-lang/rust/blob/24d472027454741e74f8e913755fbc7e03f02af5/tests/ui/process/process-spawn-nonexistent.rs#L1"} @@ -108,7 +108,7 @@ {"record":"unit","id":"async-process:tests/std.rs:245:1:registration","source":"async-process","unit":"registration","path":"tests/std.rs","line":245,"column":1,"label":"test_finish_once","disposition":{"kind":"missing","id":"try-wait-and-shared-child-handle"},"url":"https://github.com/smol-rs/async-process/blob/f4485f156f9294b86a5be37f7236bcf0cf93c76b/tests/std.rs#L245"} {"record":"unit","id":"async-process:tests/std.rs:257:1:registration","source":"async-process","unit":"registration","path":"tests/std.rs","line":257,"column":1,"label":"test_finish_twice","disposition":{"kind":"missing","id":"try-wait-and-shared-child-handle"},"url":"https://github.com/smol-rs/async-process/blob/f4485f156f9294b86a5be37f7236bcf0cf93c76b/tests/std.rs#L257"} {"record":"unit","id":"async-process:tests/std.rs:270:1:registration","source":"async-process","unit":"registration","path":"tests/std.rs","line":270,"column":1,"label":"test_wait_with_output_once","disposition":{"kind":"missing","id":"try-wait-and-shared-child-handle"},"url":"https://github.com/smol-rs/async-process/blob/f4485f156f9294b86a5be37f7236bcf0cf93c76b/tests/std.rs#L270"} -{"record":"unit","id":"async-process:tests/std.rs:319:1:registration","source":"async-process","unit":"registration","path":"tests/std.rs","line":319,"column":1,"label":"test_override_env","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/smol-rs/async-process/blob/f4485f156f9294b86a5be37f7236bcf0cf93c76b/tests/std.rs#L319"} +{"record":"unit","id":"async-process:tests/std.rs:319:1:registration","source":"async-process","unit":"registration","path":"tests/std.rs","line":319,"column":1,"label":"test_override_env","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/smol-rs/async-process/blob/f4485f156f9294b86a5be37f7236bcf0cf93c76b/tests/std.rs#L319"} {"record":"unit","id":"async-process:tests/std.rs:341:1:registration","source":"async-process","unit":"registration","path":"tests/std.rs","line":341,"column":1,"label":"test_add_to_env","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/smol-rs/async-process/blob/f4485f156f9294b86a5be37f7236bcf0cf93c76b/tests/std.rs#L341"} {"record":"unit","id":"async-process:tests/std.rs:358:1:registration","source":"async-process","unit":"registration","path":"tests/std.rs","line":358,"column":1,"label":"test_capture_env_at_spawn","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/smol-rs/async-process/blob/f4485f156f9294b86a5be37f7236bcf0cf93c76b/tests/std.rs#L358"} {"record":"unit","id":"async-process:tests/std.rs:383:1:registration","source":"async-process","unit":"registration","path":"tests/std.rs","line":383,"column":1,"label":"child_status_preserved_with_kill_on_drop","disposition":{"kind":"ported","id":"stream-kill"},"url":"https://github.com/smol-rs/async-process/blob/f4485f156f9294b86a5be37f7236bcf0cf93c76b/tests/std.rs#L383"} @@ -126,7 +126,7 @@ {"record":"unit","id":"assert-cmd:tests/assert.rs:81:1:registration","source":"assert-cmd","unit":"registration","path":"tests/assert.rs","line":81,"column":1,"label":"stdout_example","disposition":{"kind":"ported","id":"stdout-stderr-separation"},"url":"https://github.com/assert-rs/assert_cmd/blob/a57ef45a33986390be3057c192c6bdbe61b8912d/tests/assert.rs#L81"} {"record":"unit","id":"assert-cmd:tests/assert.rs:119:1:registration","source":"assert-cmd","unit":"registration","path":"tests/assert.rs","line":119,"column":1,"label":"stderr_example","disposition":{"kind":"ported","id":"stdout-stderr-separation"},"url":"https://github.com/assert-rs/assert_cmd/blob/a57ef45a33986390be3057c192c6bdbe61b8912d/tests/assert.rs#L119"} {"record":"unit","id":"assert-cmd:tests/cargo.rs:6:1:registration","source":"assert-cmd","unit":"registration","path":"tests/cargo.rs","line":6,"column":1,"label":"cargo_binary","disposition":{"kind":"missing","id":"binary-input-and-lossless-output"},"url":"https://github.com/assert-rs/assert_cmd/blob/a57ef45a33986390be3057c192c6bdbe61b8912d/tests/cargo.rs#L6"} -{"record":"unit","id":"assert-cmd:tests/cargo.rs:13:1:registration","source":"assert-cmd","unit":"registration","path":"tests/cargo.rs","line":13,"column":1,"label":"cargo_binary_with_empty_env","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/assert-rs/assert_cmd/blob/a57ef45a33986390be3057c192c6bdbe61b8912d/tests/cargo.rs#L13"} +{"record":"unit","id":"assert-cmd:tests/cargo.rs:13:1:registration","source":"assert-cmd","unit":"registration","path":"tests/cargo.rs","line":13,"column":1,"label":"cargo_binary_with_empty_env","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/assert-rs/assert_cmd/blob/a57ef45a33986390be3057c192c6bdbe61b8912d/tests/cargo.rs#L13"} {"record":"unit","id":"assert-cmd:tests/cargo.rs:20:1:registration","source":"assert-cmd","unit":"registration","path":"tests/cargo.rs","line":20,"column":1,"label":"mod_example","disposition":{"kind":"inapplicable","id":"competitor-api-shape"},"url":"https://github.com/assert-rs/assert_cmd/blob/a57ef45a33986390be3057c192c6bdbe61b8912d/tests/cargo.rs#L20"} {"record":"unit","id":"assert-cmd:tests/cargo.rs:41:1:registration","source":"assert-cmd","unit":"registration","path":"tests/cargo.rs","line":41,"column":1,"label":"trait_example","disposition":{"kind":"inapplicable","id":"competitor-api-shape"},"url":"https://github.com/assert-rs/assert_cmd/blob/a57ef45a33986390be3057c192c6bdbe61b8912d/tests/cargo.rs#L41"} {"record":"unit","id":"assert-cmd:tests/cargo.rs:50:1:registration","source":"assert-cmd","unit":"registration","path":"tests/cargo.rs","line":50,"column":1,"label":"cargo_bin_example_1","disposition":{"kind":"inapplicable","id":"competitor-api-shape"},"url":"https://github.com/assert-rs/assert_cmd/blob/a57ef45a33986390be3057c192c6bdbe61b8912d/tests/cargo.rs#L50"} @@ -155,9 +155,9 @@ {"record":"unit","id":"duct:src/test.rs:320:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":320,"column":1,"label":"test_capture_both","disposition":{"kind":"ported","id":"stdout-stderr-separation"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L320"} {"record":"unit","id":"duct:src/test.rs:333:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":333,"column":1,"label":"test_dir","disposition":{"kind":"ported","id":"cwd-string"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L333"} {"record":"unit","id":"duct:src/test.rs:361:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":361,"column":1,"label":"test_env","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L361"} -{"record":"unit","id":"duct:src/test.rs:370:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":370,"column":1,"label":"test_full_env","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L370"} -{"record":"unit","id":"duct:src/test.rs:397:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":397,"column":1,"label":"test_env_remove","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L397"} -{"record":"unit","id":"duct:src/test.rs:417:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":417,"column":1,"label":"test_env_remove_case_sensitivity","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L417"} +{"record":"unit","id":"duct:src/test.rs:370:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":370,"column":1,"label":"test_full_env","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L370"} +{"record":"unit","id":"duct:src/test.rs:397:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":397,"column":1,"label":"test_env_remove","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L397"} +{"record":"unit","id":"duct:src/test.rs:417:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":417,"column":1,"label":"test_env_remove_case_sensitivity","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L417"} {"record":"unit","id":"duct:src/test.rs:455:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":455,"column":1,"label":"test_env_case_preserving","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L455"} {"record":"unit","id":"duct:src/test.rs:579:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":579,"column":1,"label":"test_broken_pipe","disposition":{"kind":"missing","id":"custom-stdio-and-file-handles"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L579"} {"record":"unit","id":"duct:src/test.rs:588:1:registration","source":"duct","unit":"registration","path":"src/test.rs","line":588,"column":1,"label":"test_silly","disposition":{"kind":"inapplicable","id":"competitor-internals"},"url":"https://github.com/oconnor663/duct.rs/blob/0195544c9d963d94348e8bc94fc60b8519e5516b/src/test.rs#L588"} @@ -187,7 +187,7 @@ {"record":"unit","id":"xshell:tests/it/compile_failures.rs:109:1:registration","source":"xshell","unit":"registration","path":"tests/it/compile_failures.rs","line":109,"column":1,"label":"interpolate_integer","disposition":{"kind":"ported","id":"safe-template-interpolation"},"url":"https://github.com/matklad/xshell/blob/52f71bac326aaac291d07146ea790ad886dd8131/tests/it/compile_failures.rs#L109"} {"record":"unit","id":"xshell:tests/it/compile_failures.rs:119:1:registration","source":"xshell","unit":"registration","path":"tests/it/compile_failures.rs","line":119,"column":1,"label":"splat_fn_pointer","disposition":{"kind":"missing","id":"array-and-splat-interpolation"},"url":"https://github.com/matklad/xshell/blob/52f71bac326aaac291d07146ea790ad886dd8131/tests/it/compile_failures.rs#L119"} {"record":"unit","id":"xshell:tests/it/env.rs:6:1:registration","source":"xshell","unit":"registration","path":"tests/it/env.rs","line":6,"column":1,"label":"test_env","disposition":{"kind":"ported","id":"environment"},"url":"https://github.com/matklad/xshell/blob/52f71bac326aaac291d07146ea790ad886dd8131/tests/it/env.rs#L6"} -{"record":"unit","id":"xshell:tests/it/env.rs:58:1:registration","source":"xshell","unit":"registration","path":"tests/it/env.rs","line":58,"column":1,"label":"test_env_clear","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/matklad/xshell/blob/52f71bac326aaac291d07146ea790ad886dd8131/tests/it/env.rs#L58"} +{"record":"unit","id":"xshell:tests/it/env.rs:58:1:registration","source":"xshell","unit":"registration","path":"tests/it/env.rs","line":58,"column":1,"label":"test_env_clear","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/matklad/xshell/blob/52f71bac326aaac291d07146ea790ad886dd8131/tests/it/env.rs#L58"} {"record":"unit","id":"xshell:tests/it/main.rs:30:1:registration","source":"xshell","unit":"registration","path":"tests/it/main.rs","line":30,"column":1,"label":"smoke","disposition":{"kind":"ported","id":"direct-exact-argv"},"url":"https://github.com/matklad/xshell/blob/52f71bac326aaac291d07146ea790ad886dd8131/tests/it/main.rs#L30"} {"record":"unit","id":"xshell:tests/it/main.rs:39:1:registration","source":"xshell","unit":"registration","path":"tests/it/main.rs","line":39,"column":1,"label":"into_command","disposition":{"kind":"inapplicable","id":"competitor-api-shape"},"url":"https://github.com/matklad/xshell/blob/52f71bac326aaac291d07146ea790ad886dd8131/tests/it/main.rs#L39"} {"record":"unit","id":"xshell:tests/it/main.rs:47:1:registration","source":"xshell","unit":"registration","path":"tests/it/main.rs","line":47,"column":1,"label":"multiline","disposition":{"kind":"ported","id":"direct-exact-argv"},"url":"https://github.com/matklad/xshell/blob/52f71bac326aaac291d07146ea790ad886dd8131/tests/it/main.rs#L47"} @@ -405,8 +405,8 @@ {"record":"unit","id":"subprocess:src/tests/posix.rs:74:1:registration","source":"subprocess","unit":"registration","path":"src/tests/posix.rs","line":74,"column":1,"label":"err_terminate","disposition":{"kind":"ported","id":"stream-kill"},"url":"https://github.com/hniksic/rust-subprocess/blob/e8cd8d0c930a790ce29373a8c3ee080dd746b9eb/src/tests/posix.rs#L74"} {"record":"unit","id":"subprocess:src/tests/posix.rs:83:1:registration","source":"subprocess","unit":"registration","path":"src/tests/posix.rs","line":83,"column":1,"label":"waitpid_echild","disposition":{"kind":"missing","id":"try-wait-and-shared-child-handle"},"url":"https://github.com/hniksic/rust-subprocess/blob/e8cd8d0c930a790ce29373a8c3ee080dd746b9eb/src/tests/posix.rs#L83"} {"record":"unit","id":"subprocess:src/tests/posix.rs:98:1:registration","source":"subprocess","unit":"registration","path":"src/tests/posix.rs","line":98,"column":1,"label":"send_signal","disposition":{"kind":"missing","id":"native-exit-status-and-signal-metadata"},"url":"https://github.com/hniksic/rust-subprocess/blob/e8cd8d0c930a790ce29373a8c3ee080dd746b9eb/src/tests/posix.rs#L98"} -{"record":"unit","id":"subprocess:src/tests/posix.rs:106:1:registration","source":"subprocess","unit":"registration","path":"src/tests/posix.rs","line":106,"column":1,"label":"env_set_all_1","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/hniksic/rust-subprocess/blob/e8cd8d0c930a790ce29373a8c3ee080dd746b9eb/src/tests/posix.rs#L106"} -{"record":"unit","id":"subprocess:src/tests/posix.rs:118:1:registration","source":"subprocess","unit":"registration","path":"src/tests/posix.rs","line":118,"column":1,"label":"env_set_all_2","disposition":{"kind":"missing","id":"environment-clear-and-remove"},"url":"https://github.com/hniksic/rust-subprocess/blob/e8cd8d0c930a790ce29373a8c3ee080dd746b9eb/src/tests/posix.rs#L118"} +{"record":"unit","id":"subprocess:src/tests/posix.rs:106:1:registration","source":"subprocess","unit":"registration","path":"src/tests/posix.rs","line":106,"column":1,"label":"env_set_all_1","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/hniksic/rust-subprocess/blob/e8cd8d0c930a790ce29373a8c3ee080dd746b9eb/src/tests/posix.rs#L106"} +{"record":"unit","id":"subprocess:src/tests/posix.rs:118:1:registration","source":"subprocess","unit":"registration","path":"src/tests/posix.rs","line":118,"column":1,"label":"env_set_all_2","disposition":{"kind":"ported","id":"environment-clear-and-remove"},"url":"https://github.com/hniksic/rust-subprocess/blob/e8cd8d0c930a790ce29373a8c3ee080dd746b9eb/src/tests/posix.rs#L118"} {"record":"unit","id":"subprocess:src/tests/posix.rs:132:1:registration","source":"subprocess","unit":"registration","path":"src/tests/posix.rs","line":132,"column":1,"label":"exec_setpgid","disposition":{"kind":"missing","id":"custom-stdio-and-file-handles"},"url":"https://github.com/hniksic/rust-subprocess/blob/e8cd8d0c930a790ce29373a8c3ee080dd746b9eb/src/tests/posix.rs#L132"} {"record":"unit","id":"subprocess:src/tests/posix.rs:147:1:registration","source":"subprocess","unit":"registration","path":"src/tests/posix.rs","line":147,"column":1,"label":"send_signal_group","disposition":{"kind":"missing","id":"native-exit-status-and-signal-metadata"},"url":"https://github.com/hniksic/rust-subprocess/blob/e8cd8d0c930a790ce29373a8c3ee080dd746b9eb/src/tests/posix.rs#L147"} {"record":"unit","id":"subprocess:src/tests/posix.rs:162:1:registration","source":"subprocess","unit":"registration","path":"src/tests/posix.rs","line":162,"column":1,"label":"send_signal_group_after_finish","disposition":{"kind":"missing","id":"native-exit-status-and-signal-metadata"},"url":"https://github.com/hniksic/rust-subprocess/blob/e8cd8d0c930a790ce29373a8c3ee080dd746b9eb/src/tests/posix.rs#L162"}

; + pipe( + a: (builder: CommandBuilder) => CommandBuilder, + b: (builder: CommandBuilder) => CommandBuilder, + c: (builder: CommandBuilder) => CommandBuilder, + d: (builder: CommandBuilder) => CommandBuilder, + e: (builder: CommandBuilder) => CommandBuilder, + f: (builder: CommandBuilder) => CommandBuilder, + g: (builder: CommandBuilder) => CommandBuilder, + h: (builder: CommandBuilder) => CommandBuilder, + i: (builder: CommandBuilder) => CommandBuilder, + j: (builder: CommandBuilder) => CommandBuilder, + k: (builder: CommandBuilder) => CommandBuilder, + l: (builder: CommandBuilder) => CommandBuilder, + m: (builder: CommandBuilder) => CommandBuilder, + n: (builder: CommandBuilder) => CommandBuilder, + o: (builder: CommandBuilder) => CommandBuilder, + p: (builder: CommandBuilder) => CommandBuilder