From db48fb1ba2719d2a914e8ef7a20d60993738df2e Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 19:55:00 +0300 Subject: [PATCH 1/5] Initial commit with task details for issue #47 Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/link-foundation/command-stream/issues/47 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a2f38730 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/link-foundation/command-stream/issues/47 +Your prepared branch: issue-47-b31e0547 +Your prepared working directory: /tmp/gh-issue-solver-1757436896609 + +Proceed. \ No newline at end of file From 884d4facd11de88d7bcb63c373b4f84f36674002 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 19:55:17 +0300 Subject: [PATCH 2/5] Remove CLAUDE.md - PR created successfully --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a2f38730..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/link-foundation/command-stream/issues/47 -Your prepared branch: issue-47-b31e0547 -Your prepared working directory: /tmp/gh-issue-solver-1757436896609 - -Proceed. \ No newline at end of file From 8d3689fdae1be57716569e25638707dcc801a26c Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 20:04:50 +0300 Subject: [PATCH 3/5] Fix stderr redirection handling for commands like gh pr create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add needsRealShell() check to virtual command bypass logic - Ensure commands with stderr redirection (>&2, 2>&1) use real shell - Add comprehensive test suite for stderr redirection scenarios - Fixes issue where gh pr create output wasn't captured properly - Update version to 0.7.2 This resolves GitHub issue #47 where gh pr create output was not properly captured because the virtual echo command was being used instead of falling back to the real shell for stderr redirection. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- examples/comprehensive-stderr-test.mjs | 75 ++++++++++++++++++++++++++ examples/debug-2to1.mjs | 34 ++++++++++++ examples/debug-stderr-redirection.mjs | 54 +++++++++++++++++++ examples/simple-stderr-test.mjs | 25 +++++++++ examples/test-gh-pr-create-fix.mjs | 67 +++++++++++++++++++++++ examples/test-gh-pr-create-issue.mjs | 74 +++++++++++++++++++++++++ package.json | 2 +- src/$.mjs | 2 +- src/shell-parser.mjs | 1 + tests/stderr-redirection.test.mjs | 61 +++++++++++++++++++++ 10 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 examples/comprehensive-stderr-test.mjs create mode 100644 examples/debug-2to1.mjs create mode 100644 examples/debug-stderr-redirection.mjs create mode 100644 examples/simple-stderr-test.mjs create mode 100644 examples/test-gh-pr-create-fix.mjs create mode 100755 examples/test-gh-pr-create-issue.mjs create mode 100644 tests/stderr-redirection.test.mjs diff --git a/examples/comprehensive-stderr-test.mjs b/examples/comprehensive-stderr-test.mjs new file mode 100644 index 00000000..69228eab --- /dev/null +++ b/examples/comprehensive-stderr-test.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// Comprehensive test for stderr handling fix + +import { $ } from '../src/$.mjs'; + +console.log('=== Comprehensive stderr handling test ===\n'); + +const tests = [ + { + name: 'Basic stderr redirection', + cmd: 'echo "error message" >&2', + expectedStdout: '', + expectedStderr: 'error message\n' + }, + { + name: 'Mixed stdout and stderr', + cmd: 'echo "stdout" && echo "stderr" >&2', + expectedStdout: 'stdout\n', + expectedStderr: 'stderr\n' + }, + { + name: '2>&1 redirection', + cmd: 'sh -c "echo \\"to stderr\\" >&2" 2>&1', + expectedStdout: 'to stderr\n', + expectedStderr: '' + }, + { + name: 'Normal stdout (should still work)', + cmd: 'echo "normal output"', + expectedStdout: 'normal output\n', + expectedStderr: '' + } +]; + +let passed = 0; +let total = tests.length; + +for (const test of tests) { + console.log(`Testing: ${test.name}`); + console.log(` Command: ${test.cmd}`); + + try { + const result = await $`${test.cmd}`; + + const stdoutMatch = result.stdout === test.expectedStdout; + const stderrMatch = result.stderr === test.expectedStderr; + + console.log(` stdout: ${JSON.stringify(result.stdout)} ${stdoutMatch ? '✓' : '✗'}`); + console.log(` stderr: ${JSON.stringify(result.stderr)} ${stderrMatch ? '✓' : '✗'}`); + + if (stdoutMatch && stderrMatch) { + console.log(` Result: PASS ✓`); + passed++; + } else { + console.log(` Result: FAIL ✗`); + console.log(` Expected stdout: ${JSON.stringify(test.expectedStdout)}`); + console.log(` Expected stderr: ${JSON.stringify(test.expectedStderr)}`); + } + } catch (error) { + console.log(` Error: ${error.message}`); + console.log(` Result: FAIL ✗`); + } + + console.log(''); +} + +console.log(`=== Summary: ${passed}/${total} tests passed ===`); + +if (passed === total) { + console.log('🎉 All tests passed!'); + process.exit(0); +} else { + console.log('❌ Some tests failed.'); + process.exit(1); +} \ No newline at end of file diff --git a/examples/debug-2to1.mjs b/examples/debug-2to1.mjs new file mode 100644 index 00000000..3e08099e --- /dev/null +++ b/examples/debug-2to1.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Debug 2>&1 redirection + +import { $ } from '../src/$.mjs'; +import { needsRealShell } from '../src/shell-parser.mjs'; + +const cmd = 'echo "to stderr" >&2 2>&1'; +console.log(`Command: ${cmd}`); +console.log(`needsRealShell: ${needsRealShell(cmd)}`); + +// Compare with command that works (pure shell execution) +console.log('\nTesting with pure shell execution (bash -c):'); + +import { execSync } from 'child_process'; + +try { + const result = execSync('bash -c \'echo "to stderr" >&2 2>&1\'', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + console.log('Pure shell stdout:', JSON.stringify(result)); +} catch (error) { + console.log('Pure shell stderr via error:', JSON.stringify(error.stderr)); + console.log('Pure shell stdout via error:', JSON.stringify(error.stdout)); +} + +console.log('\nTesting with command-stream:'); +try { + const result = await $`echo "to stderr" >&2 2>&1`; + console.log('command-stream stdout:', JSON.stringify(result.stdout)); + console.log('command-stream stderr:', JSON.stringify(result.stderr)); +} catch (error) { + console.log('Error:', error.message); +} \ No newline at end of file diff --git a/examples/debug-stderr-redirection.mjs b/examples/debug-stderr-redirection.mjs new file mode 100644 index 00000000..bd137ba9 --- /dev/null +++ b/examples/debug-stderr-redirection.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Debug stderr redirection handling + +import { $ } from '../src/$.mjs'; +import { parseShellCommand, needsRealShell } from '../src/shell-parser.mjs'; + +console.log('=== Debug stderr redirection handling ===\n'); + +// Test different stderr redirection patterns +const testCommands = [ + 'echo "test" >&2', + 'echo "test" 2>&1', + 'echo "test" >&2 2>&1', + 'echo "test" 2>test.log', + 'gh pr create --title "test"', +]; + +for (const cmd of testCommands) { + console.log(`\nTesting: ${cmd}`); + console.log(` needsRealShell: ${needsRealShell(cmd)}`); + + try { + const parsed = parseShellCommand(cmd); + console.log(` parsed: ${parsed ? 'success' : 'null (fallback to shell)'}`); + if (parsed) { + console.log(` parsed type: ${parsed.type}`); + } + } catch (error) { + console.log(` parsing error: ${error.message}`); + } +} + +console.log('\n=== Testing actual execution ===\n'); + +// Test with verbose mode to see what path is taken +process.env.COMMAND_STREAM_VERBOSE = 'true'; + +console.log('1. Simple stderr redirection:'); +try { + const result = await $`echo "Hello stderr" >&2`; + console.log(' stdout:', JSON.stringify(result.stdout)); + console.log(' stderr:', JSON.stringify(result.stderr)); +} catch (error) { + console.log(' Error:', error.message); +} + +console.log('\n2. Testing 2>&1 redirection:'); +try { + const result = await $`echo "Hello stderr" >&2 2>&1`; + console.log(' stdout:', JSON.stringify(result.stdout)); + console.log(' stderr:', JSON.stringify(result.stderr)); +} catch (error) { + console.log(' Error:', error.message); +} \ No newline at end of file diff --git a/examples/simple-stderr-test.mjs b/examples/simple-stderr-test.mjs new file mode 100644 index 00000000..dc54ac83 --- /dev/null +++ b/examples/simple-stderr-test.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +// Simple stderr test + +import { $ } from '../src/$.mjs'; +import { needsRealShell } from '../src/shell-parser.mjs'; + +const cmd = 'echo "test" >&2'; +console.log(`Command: ${cmd}`); +console.log(`needsRealShell: ${needsRealShell(cmd)}`); + +// Test with verbose logging to see execution path +process.env.COMMAND_STREAM_VERBOSE = 'true'; + +try { + console.log('\nExecuting...'); + const result = await $`echo "test" >&2`; + console.log('Result:', { + stdout: JSON.stringify(result.stdout), + stderr: JSON.stringify(result.stderr) + }); +} catch (error) { + console.log('Error:', error.message); + console.log('Error stdout:', error.stdout); + console.log('Error stderr:', error.stderr); +} \ No newline at end of file diff --git a/examples/test-gh-pr-create-fix.mjs b/examples/test-gh-pr-create-fix.mjs new file mode 100644 index 00000000..fc34c6d3 --- /dev/null +++ b/examples/test-gh-pr-create-fix.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Test the fix with gh pr create command simulation + +import { $ } from '../src/$.mjs'; +import { needsRealShell } from '../src/shell-parser.mjs'; + +console.log('=== Testing gh pr create output capture fix ===\n'); + +// Since we can't actually create PRs, let's simulate gh pr create behavior +// gh pr create outputs the PR URL to stderr, not stdout + +console.log('1. Checking needsRealShell behavior:'); +const ghCommand = 'gh pr create --title "test" --body "test"'; +console.log(` Command: ${ghCommand}`); +console.log(` needsRealShell: ${needsRealShell(ghCommand)}`); + +console.log('\n2. Simulating gh pr create stderr output:'); +try { + // This simulates how gh pr create actually behaves - it outputs URLs to stderr + const result = await $`echo "https://github.com/link-foundation/command-stream/pull/123" >&2`; + console.log(' stdout:', JSON.stringify(result.stdout)); + console.log(' stderr:', JSON.stringify(result.stderr)); + + if (result.stderr.includes('https://github.com')) { + console.log(' ✅ SUCCESS: stderr correctly captured PR URL'); + } else { + console.log(' ❌ FAIL: stderr did not capture PR URL'); + } +} catch (error) { + console.log(' Error:', error.message); +} + +console.log('\n3. Testing with both stdout and stderr:'); +try { + // Simulate a command that outputs to both streams (like gh pr create might) + const result = await $`echo "Creating pull request..." && echo "https://github.com/link-foundation/command-stream/pull/456" >&2`; + console.log(' stdout:', JSON.stringify(result.stdout)); + console.log(' stderr:', JSON.stringify(result.stderr)); + + const hasProgressMessage = result.stdout.includes('Creating pull request'); + const hasPrUrl = result.stderr.includes('https://github.com'); + + if (hasProgressMessage && hasPrUrl) { + console.log(' ✅ SUCCESS: Both stdout progress and stderr URL captured'); + } else { + console.log(' ❌ FAIL: Missing expected output'); + } +} catch (error) { + console.log(' Error:', error.message); +} + +console.log('\n4. Testing workaround still works (2>&1):'); +try { + const result = await $`echo "https://github.com/link-foundation/command-stream/pull/789" >&2 2>&1`; + console.log(' stdout with 2>&1:', JSON.stringify(result.stdout)); + console.log(' stderr with 2>&1:', JSON.stringify(result.stderr)); + + if (result.stdout.includes('https://github.com') || result.stderr.includes('https://github.com')) { + console.log(' ✅ SUCCESS: Workaround still works'); + } else { + console.log(' ❌ FAIL: Workaround not working'); + } +} catch (error) { + console.log(' Error:', error.message); +} + +console.log('\n=== Fix validation complete ==='); \ No newline at end of file diff --git a/examples/test-gh-pr-create-issue.mjs b/examples/test-gh-pr-create-issue.mjs new file mode 100755 index 00000000..6e820b58 --- /dev/null +++ b/examples/test-gh-pr-create-issue.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Test case to reproduce gh pr create output capture issue + +import { $ } from '../src/$.mjs'; +import { execSync } from 'child_process'; + +console.log('=== Testing gh pr create output capture issue ===\n'); + +// First test with a simple command that outputs to stderr +console.log('1. Testing with a simple stderr command:'); +try { + const result = await $`echo "stdout message" && echo "stderr message" >&2`; + console.log(' stdout:', JSON.stringify(result.stdout)); + console.log(' stderr:', JSON.stringify(result.stderr)); + console.log(' Combined output should show both streams captured'); +} catch (error) { + console.log(' Error:', error.message); +} + +console.log('\n2. Testing gh command availability:'); +try { + const result = await $`gh --version`; + console.log(' gh version:', result.stdout.split('\n')[0]); +} catch (error) { + console.log(' gh CLI not available:', error.message); + console.log(' Skipping gh pr create test'); + process.exit(1); +} + +console.log('\n3. Simulating gh pr create behavior:'); +// gh pr create actually outputs to stderr, let's simulate this +try { + // This command mimics how gh pr create behaves - outputting URL to stderr + const result = await $`echo "https://github.com/test/repo/pull/123" >&2`; + console.log(' stdout:', JSON.stringify(result.stdout)); + console.log(' stderr:', JSON.stringify(result.stderr)); + + if (result.stderr.includes('https://github.com')) { + console.log(' ✓ PASS: stderr captured PR URL correctly'); + } else { + console.log(' ✗ FAIL: stderr did not capture PR URL'); + } +} catch (error) { + console.log(' Error:', error.message); +} + +console.log('\n4. Comparison with execSync:'); +try { + const execResult = execSync('echo "https://github.com/test/repo/pull/456" >&2', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + console.log(' execSync stdout:', JSON.stringify(execResult)); +} catch (error) { + // execSync captures stderr in error.stderr + console.log(' execSync stderr via error:', JSON.stringify(error.stderr)); +} + +console.log('\n5. Testing with 2>&1 redirection:'); +try { + const result = await $`echo "https://github.com/test/repo/pull/789" >&2 2>&1`; + console.log(' stdout with 2>&1:', JSON.stringify(result.stdout)); + console.log(' stderr with 2>&1:', JSON.stringify(result.stderr)); + + if (result.stdout.includes('https://github.com')) { + console.log(' ✓ PASS: 2>&1 redirection works as workaround'); + } else { + console.log(' ✗ FAIL: 2>&1 redirection did not work'); + } +} catch (error) { + console.log(' Error:', error.message); +} + +console.log('\n=== Test completed ==='); \ No newline at end of file diff --git a/package.json b/package.json index 6723c5b9..6ac902de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "command-stream", - "version": "0.7.1", + "version": "0.7.2", "description": "Modern $ shell utility library with streaming, async iteration, and EventEmitter support, optimized for Bun runtime", "type": "module", "main": "src/$.mjs", diff --git a/src/$.mjs b/src/$.mjs index 46c72588..0597dfd9 100755 --- a/src/$.mjs +++ b/src/$.mjs @@ -1700,7 +1700,7 @@ class ProcessRunner extends StreamEmitter { commandCount: parsed.commands?.length }, null, 2)}`); return await this._runPipeline(parsed.commands); - } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd) && !this.options._bypassVirtual) { + } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd) && !this.options._bypassVirtual && !needsRealShell(this.spec.command)) { // For built-in virtual commands that have real counterparts (like sleep), // skip the virtual version when custom stdin is provided to ensure proper process handling const hasCustomStdin = this.options.stdin && diff --git a/src/shell-parser.mjs b/src/shell-parser.mjs index edbf0119..af0134c7 100644 --- a/src/shell-parser.mjs +++ b/src/shell-parser.mjs @@ -356,6 +356,7 @@ export function needsRealShell(command) { '*', // Glob patterns '?', // Glob patterns '[', // Glob patterns + '2>&1', // stderr to stdout redirection (check before >&) '2>', // stderr redirection '&>', // Combined redirection '>&', // File descriptor duplication diff --git a/tests/stderr-redirection.test.mjs b/tests/stderr-redirection.test.mjs new file mode 100644 index 00000000..7b9010e7 --- /dev/null +++ b/tests/stderr-redirection.test.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +import { test, expect, describe } from 'bun:test'; +import { $ } from '../src/$.mjs'; + +describe('stderr redirection handling', () => { + test('should capture stderr when using >&2 redirection', async () => { + const result = await $`echo "error message" >&2`; + + expect(result.stdout).toBe(''); + expect(result.stderr).toBe('error message\n'); + expect(result.code).toBe(0); + }); + + test('should handle mixed stdout and stderr', async () => { + const result = await $`echo "stdout message" && echo "stderr message" >&2`; + + expect(result.stdout).toBe('stdout message\n'); + expect(result.stderr).toBe('stderr message\n'); + expect(result.code).toBe(0); + }); + + test('should handle 2>&1 redirection correctly', async () => { + const result = await $`sh -c "echo \\"stderr to stdout\\" >&2" 2>&1`; + + expect(result.stdout).toBe('stderr to stdout\n'); + expect(result.stderr).toBe(''); + expect(result.code).toBe(0); + }); + + test('should bypass virtual commands when stderr redirection is needed', async () => { + // This test ensures that virtual echo command is bypassed when >&2 is used + // and the real shell handles the redirection properly + const result = await $`echo "virtual bypass test" >&2`; + + expect(result.stdout).toBe(''); + expect(result.stderr).toBe('virtual bypass test\n'); + expect(result.code).toBe(0); + }); + + test('should work with commands that actually output to stderr (simulating gh pr create)', async () => { + // Simulate behavior similar to gh pr create which outputs URLs to stderr + const result = await $`echo "https://github.com/test/repo/pull/123" >&2`; + + expect(result.stdout).toBe(''); + expect(result.stderr).toBe('https://github.com/test/repo/pull/123\n'); + expect(result.code).toBe(0); + + // Verify we can extract the URL from stderr + const prUrl = result.stderr.trim(); + expect(prUrl).toMatch(/^https:\/\/github\.com\/.*\/pull\/\d+$/); + }); + + test('normal stdout should still work (regression test)', async () => { + const result = await $`echo "normal output"`; + + expect(result.stdout).toBe('normal output\n'); + expect(result.stderr).toBe(''); + expect(result.code).toBe(0); + }); +}); \ No newline at end of file From bb463d3ae1dd217d34fc7c14fc58c11d948e8d65 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 15 Sep 2026 12:41:50 +0000 Subject: [PATCH 4/5] test: cover stderr-only CLI output across languages --- experiments/issue-46-redirection-parity.mjs | 21 ++++++++++----- js/tests/competitor-compatibility.test.mjs | 26 +++++++++++++++---- js/tests/redirection-silent-failure.test.mjs | 18 +++++++++++-- .../competitor_compatibility/behavior.rs | 16 +++++++++--- rust/tests/redirection_silent_failure.rs | 16 +++++++++--- 5 files changed, 77 insertions(+), 20 deletions(-) diff --git a/experiments/issue-46-redirection-parity.mjs b/experiments/issue-46-redirection-parity.mjs index 007267d3..2566d359 100644 --- a/experiments/issue-46-redirection-parity.mjs +++ b/experiments/issue-46-redirection-parity.mjs @@ -6,7 +6,8 @@ // (virtual) command is dispatched to that built-in with the shell operators // left in place as literal arguments, so the redirection silently does // nothing. This script diffs command-stream against /bin/sh so any divergence -// in exit code, stdout, or files written is visible. +// in exit code, stdout, stderr, or files written is visible. Issue #47 exposed +// the stderr part of that contract with a CLI success URL. import { execSync, spawnSync } from 'child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; @@ -28,6 +29,8 @@ const CASES = [ 'seq 1 3 > out.txt', 'basename /a/b > out.txt', // stderr redirection on a built-in. + 'echo https://github.com/octo/example/pull/123 >&2', + "sh -c 'echo https://github.com/octo/example/pull/123 >&2' 2>&1", 'echo hello 2> err.txt', 'ls /definitely/missing/path 2>/dev/null', 'ls /definitely/missing/path 2>&1', @@ -57,14 +60,18 @@ for (const cmd of CASES) { cwd: dirSh, encoding: 'utf8', }); - const expected = { code: sh.status, stdout: sh.stdout }; + const expected = { + code: sh.status, + stdout: sh.stdout, + stderr: sh.stderr, + }; let actual; try { const r = await $({ cwd: dirCs, mirror: false })`${{ raw: cmd }}`; - actual = { code: r.code, stdout: r.stdout }; + actual = { code: r.code, stdout: r.stdout, stderr: r.stderr }; } catch (e) { - actual = { code: e.code, stdout: e.stdout }; + actual = { code: e.code, stdout: e.stdout, stderr: e.stderr }; } const shFiles = execSync('ls -1', { cwd: dirSh, encoding: 'utf8' }).trim(); @@ -78,6 +85,8 @@ for (const cmd of CASES) { expected.code === actual.code && expected.stdout.replaceAll(dirSh, '') === actual.stdout.replaceAll(dirCs, '') && + expected.stderr.replaceAll(dirSh, '') === + actual.stderr.replaceAll(dirCs, '') && shFiles === csFiles && shOut === csOut; if (!same) { @@ -86,10 +95,10 @@ for (const cmd of CASES) { console.log(`${same ? 'OK ' : 'DIFF'} ${JSON.stringify(cmd)}`); if (!same) { console.log( - ` sh: code=${expected.code} stdout=${JSON.stringify(expected.stdout)} files=${JSON.stringify(shFiles)} contents=${JSON.stringify(shOut)}` + ` sh: code=${expected.code} stdout=${JSON.stringify(expected.stdout)} stderr=${JSON.stringify(expected.stderr)} files=${JSON.stringify(shFiles)} contents=${JSON.stringify(shOut)}` ); console.log( - ` cs: code=${actual.code} stdout=${JSON.stringify(actual.stdout)} files=${JSON.stringify(csFiles)} contents=${JSON.stringify(csOut)}` + ` cs: code=${actual.code} stdout=${JSON.stringify(actual.stdout)} stderr=${JSON.stringify(actual.stderr)} files=${JSON.stringify(csFiles)} contents=${JSON.stringify(csOut)}` ); } rmSync(dirSh, { recursive: true, force: true }); diff --git a/js/tests/competitor-compatibility.test.mjs b/js/tests/competitor-compatibility.test.mjs index b8ddae17..2adf96a2 100644 --- a/js/tests/competitor-compatibility.test.mjs +++ b/js/tests/competitor-compatibility.test.mjs @@ -451,13 +451,29 @@ describe('ported public process behavior', () => { port( 'stdout-stderr-separation', - 'captures stdout and stderr independently', + 'captures stdout-only, stderr-only, and mixed output independently', async () => { - const result = await runFixture('stdio', ['out\n', 'err\n']); + const cases = [ + { stdout: 'out\n', stderr: 'err\n' }, + { stdout: 'out-only\n', stderr: '' }, + // A successful CLI may use stderr for machine-readable output. gh pr + // create was reported to do this for its URL in issue #47. + { + stdout: '', + stderr: 'https://github.com/octo/example/pull/123\n', + }, + ]; - expect(result.code).toBe(0); - expect(result.stdout).toBe('out\n'); - expect(result.stderr).toBe('err\n'); + for (const expected of cases) { + const result = await runFixture('stdio', [ + expected.stdout, + expected.stderr, + ]); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(expected.stdout); + expect(result.stderr).toBe(expected.stderr); + } } ); diff --git a/js/tests/redirection-silent-failure.test.mjs b/js/tests/redirection-silent-failure.test.mjs index 4113a51d..49f3ac89 100644 --- a/js/tests/redirection-silent-failure.test.mjs +++ b/js/tests/redirection-silent-failure.test.mjs @@ -74,6 +74,11 @@ describe('Redirection is never handed to a built-in as an argument (issue #46)', 'cat /definitely/missing/path 2>/dev/null', 'ls /definitely/missing/path 2>/dev/null', 'ls /definitely/missing/path 2>&1', + // gh pr create was reported to emit its success URL on stderr (issue #47). + // Preserve the stream selected by the child/shell instead of losing or + // silently relabelling it. + 'echo https://github.com/octo/example/pull/123 >&2', + "sh -c 'echo https://github.com/octo/example/pull/123 >&2' 2>&1", 'exit 3 2>&1', 'echo a > out.txt && echo b >> out.txt', 'false > out.txt || echo fallback > out.txt', @@ -93,12 +98,21 @@ describe('Redirection is never handed to a built-in as an argument (issue #46)', cwd: csDir, mirror: false, })`${{ raw: command }}`; - actual = { code: result.code, stdout: result.stdout }; + actual = { + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + }; } catch (error) { - actual = { code: error.code, stdout: error.stdout }; + actual = { + code: error.code, + stdout: error.stdout, + stderr: error.stderr, + }; } expect(actual.stdout).toBe(expected.stdout); + expect(actual.stderr).toBe(expected.stderr); expect(actual.code).toBe(expected.code); expect(await snapshot(csDir)).toEqual(await snapshot(shDir)); }); diff --git a/rust/tests/competitor_compatibility/behavior.rs b/rust/tests/competitor_compatibility/behavior.rs index e3c94027..ad7667d6 100644 --- a/rust/tests/competitor_compatibility/behavior.rs +++ b/rust/tests/competitor_compatibility/behavior.rs @@ -133,11 +133,19 @@ async fn environment_passes_explicit_values_to_the_child() { #[tokio::test] async fn stdout_and_stderr_are_captured_separately() { - let result = run_fixture("output", &["out-value", "err-value"]).await; + for (stdout, stderr) in [ + ("out-value", "err-value"), + ("out-only", ""), + // A successful CLI may use stderr for machine-readable output. gh pr + // create was reported to do this for its URL in issue #47. + ("", "https://github.com/octo/example/pull/123\n"), + ] { + let result = run_fixture("output", &[stdout, stderr]).await; - assert_eq!(result.code, 0); - assert_eq!(result.stdout, "out-value"); - assert_eq!(result.stderr, "err-value"); + assert_eq!(result.code, 0); + assert_eq!(result.stdout, stdout); + assert_eq!(result.stderr, stderr); + } } #[tokio::test] diff --git a/rust/tests/redirection_silent_failure.rs b/rust/tests/redirection_silent_failure.rs index 102e168b..7ca93994 100644 --- a/rust/tests/redirection_silent_failure.rs +++ b/rust/tests/redirection_silent_failure.rs @@ -14,8 +14,8 @@ use std::path::Path; use std::process::Command; use tempfile::TempDir; -/// Run `command` in `dir` through /bin/sh and return (exit code, stdout). -fn run_in_sh(command: &str, dir: &Path) -> (i32, String) { +/// Run `command` in `dir` through /bin/sh and return its captured result. +fn run_in_sh(command: &str, dir: &Path) -> (i32, String, String) { let output = Command::new("/bin/sh") .arg("-c") .arg(command) @@ -25,6 +25,7 @@ fn run_in_sh(command: &str, dir: &Path) -> (i32, String) { ( output.status.code().unwrap_or(-1), String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), ) } @@ -54,7 +55,7 @@ async fn assert_matches_sh(command: &str) { let sh_dir = scratch(); let cs_dir = scratch(); - let (expected_code, expected_stdout) = run_in_sh(command, sh_dir.path()); + let (expected_code, expected_stdout, expected_stderr) = run_in_sh(command, sh_dir.path()); let mut runner = ProcessRunner::new( command, @@ -71,6 +72,11 @@ async fn assert_matches_sh(command: &str) { "stdout mismatch for {:?}", command ); + assert_eq!( + result.stderr, expected_stderr, + "stderr mismatch for {:?}", + command + ); assert_eq!( result.code, expected_code, "exit code mismatch for {:?}", @@ -101,6 +107,10 @@ async fn redirection_on_virtual_commands_matches_sh() { "cat 0< seed.txt", "cat /definitely/missing/path 2>/dev/null", "ls /definitely/missing/path 2>&1", + // gh pr create was reported to emit its success URL on stderr (issue + // #47). Preserve that stream instead of dropping or relabelling it. + "echo https://github.com/octo/example/pull/123 >&2", + "sh -c 'echo https://github.com/octo/example/pull/123 >&2' 2>&1", "echo a > out.txt && echo b >> out.txt", "false > out.txt || echo fallback > out.txt", // Quoted redirection characters are literal in sh, so they must stay From 728030cf3ae4503483233ca1931aa4112148a954 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 15 Sep 2026 12:41:59 +0000 Subject: [PATCH 5/5] docs: explain successful stderr-only command output --- js/.changeset/issue-47-stderr-only-output.md | 6 +++++ js/README.md | 23 +++++++++++++++++ rust/README.md | 25 +++++++++++++++++++ .../20260915_124000_stderr_only_output.md | 8 ++++++ 4 files changed, 62 insertions(+) create mode 100644 js/.changeset/issue-47-stderr-only-output.md create mode 100644 rust/changelog.d/20260915_124000_stderr_only_output.md diff --git a/js/.changeset/issue-47-stderr-only-output.md b/js/.changeset/issue-47-stderr-only-output.md new file mode 100644 index 00000000..553cfd9e --- /dev/null +++ b/js/.changeset/issue-47-stderr-only-output.md @@ -0,0 +1,6 @@ +--- +'command-stream': patch +--- + +Guarantee that successful stderr-only CLI output remains separately captured, +including pull request URLs, while `2>&1` retains normal shell merge behavior. diff --git a/js/README.md b/js/README.md index 5a5f19e2..e2bf3c63 100644 --- a/js/README.md +++ b/js/README.md @@ -1412,6 +1412,29 @@ console.log('Captured stderr:', result.stderr); // "Error!\n" console.log('Exit code:', result.code); // 0 ``` +### Successful CLI output on stderr + +Command-stream preserves the file descriptor chosen by the child process. A +successful command can therefore have an empty `stdout` and useful `stderr`. +For example, some CLI versions have printed a newly created pull request URL to +stderr: + +```javascript +const result = await $({ mirror: false })`gh pr create --fill`; +const prUrl = `${result.stdout}\n${result.stderr}`.match( + /^https:\/\/github\.com\/.*\/pull\/\d+$/m +)?.[0]; +``` + +When one combined stream is more convenient, use normal shell redirection. It +is opt-in because the shell-like default keeps stdout and stderr distinct: + +```javascript +const result = await $({ mirror: false })`gh pr create --fill 2>&1`; +console.log(result.stdout); // includes anything the command wrote to stderr +console.log(result.stderr); // empty after the redirection +``` + **Key Default Options:** - `mirror: true` - Live output to terminal (like shell) diff --git a/rust/README.md b/rust/README.md index 005461fe..31c8d048 100644 --- a/rust/README.md +++ b/rust/README.md @@ -36,6 +36,31 @@ async fn main() { } ``` +### Successful CLI output on stderr + +Command-stream preserves the file descriptor chosen by the child process. A +zero exit code can therefore accompany an empty `stdout` and useful `stderr`. +Check both when a CLI version may print a machine-readable result, such as a +new pull request URL, to stderr: + +```rust,no_run +use command_stream::run; + +# async fn example() -> Result<(), command_stream::Error> { +let result = run("gh pr create --fill").await?; +let pull_request_url = result + .stdout + .lines() + .chain(result.stderr.lines()) + .find(|line| line.starts_with("https://github.com/")); +# let _ = pull_request_url; +# Ok(()) +# } +``` + +Append `2>&1` to the command when normal shell stream merging is preferred. The +merged output is captured in `stdout`, while `stderr` is empty. + ## Streaming `StreamingRunner` streams output as it arrives and mirrors the JavaScript diff --git a/rust/changelog.d/20260915_124000_stderr_only_output.md b/rust/changelog.d/20260915_124000_stderr_only_output.md new file mode 100644 index 00000000..a4afaaac --- /dev/null +++ b/rust/changelog.d/20260915_124000_stderr_only_output.md @@ -0,0 +1,8 @@ +--- +bump: patch +--- + +### Fixed + +- Guarantee that successful stderr-only CLI output remains separately captured, + including pull request URLs, while `2>&1` retains normal shell merge behavior.