diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 556decc5..21fdecbe 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -268,6 +268,7 @@ jobs: node --test js/tests/node-terminal-artifacts.mjs node --test js/tests/node-commonjs-entry.mjs node --test js/tests/node-process-regressions.mjs + node --test js/tests/github-cli-body.test.mjs release: name: Release JavaScript package diff --git a/experiments/issue-40-github-markdown-competitors.mjs b/experiments/issue-40-github-markdown-competitors.mjs new file mode 100644 index 00000000..cdf4b64a --- /dev/null +++ b/experiments/issue-40-github-markdown-competitors.mjs @@ -0,0 +1,109 @@ +// Compare a complex GitHub Markdown body with sh, Bun, zx, and Execa. +// Optional packages are reported as unavailable rather than required. +// +// Run installed implementations: +// bun experiments/issue-40-github-markdown-competitors.mjs +// Run all competitors through zx's package environment: +// bunx --bun zx experiments/issue-40-github-markdown-competitors.mjs + +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { $ as commandStream$ } from '../js/src/$.mjs'; +import { COMPLEX_MARKDOWN_BODY } from '../js/tests/fixtures/complex-markdown-body.mjs'; + +const ARGV_PRINTER = fileURLToPath( + new URL('../js/tests/fixtures/argv-json.mjs', import.meta.url) +); +const expected = [COMPLEX_MARKDOWN_BODY]; +const parse = (stdout) => JSON.parse(String(stdout)); + +function shReference() { + return parse( + execFileSync('/bin/sh', ['-c', 'node "$ARGV_PRINTER" "$BODY"'], { + encoding: 'utf8', + env: { + ...process.env, + ARGV_PRINTER, + BODY: COMPLEX_MARKDOWN_BODY, + }, + }) + ); +} + +async function optionalImport(name) { + try { + return await import(name); + } catch (error) { + if ( + error?.code === 'ERR_MODULE_NOT_FOUND' || + error?.code === 'MODULE_NOT_FOUND' + ) { + return null; + } + throw error; + } +} + +const zx = await optionalImport('zx'); +const execaModule = await optionalImport('execa'); +const runners = { + 'command-stream': async () => + parse( + ( + await commandStream$({ + mirror: false, + })`node ${ARGV_PRINTER} ${COMPLEX_MARKDOWN_BODY}` + ).stdout + ), + 'command-stream "${body}"': async () => + parse( + ( + await commandStream$({ + mirror: false, + })`node ${ARGV_PRINTER} "${COMPLEX_MARKDOWN_BODY}"` + ).stdout + ), + 'Bun $': + typeof Bun === 'undefined' + ? null + : async () => + parse( + (await Bun.$`node ${ARGV_PRINTER} ${COMPLEX_MARKDOWN_BODY}`.quiet()) + .stdout + ), + 'zx $': zx?.$ + ? async () => + parse( + ( + await zx.$({ + quiet: true, + })`node ${ARGV_PRINTER} ${COMPLEX_MARKDOWN_BODY}` + ).stdout + ) + : null, + Execa: execaModule?.execa + ? async () => + parse( + ( + await execaModule.execa`node ${ARGV_PRINTER} ${COMPLEX_MARKDOWN_BODY}` + ).stdout + ) + : null, +}; + +console.log(`sh "$BODY": ${JSON.stringify(shReference())}`); + +let failures = 0; +for (const [name, run] of Object.entries(runners)) { + if (!run) { + console.log(`${name}: unavailable`); + continue; + } + + const actual = await run(); + const matches = JSON.stringify(actual) === JSON.stringify(expected); + failures += matches ? 0 : 1; + console.log(`${name}: ${matches ? 'same as sh' : 'DIFFERS'}`); +} + +process.exitCode = failures === 0 ? 0 : 1; diff --git a/js/.changeset/github-markdown-body.md b/js/.changeset/github-markdown-body.md new file mode 100644 index 00000000..91d2ac70 --- /dev/null +++ b/js/.changeset/github-markdown-body.md @@ -0,0 +1,6 @@ +--- +'command-stream': patch +--- + +Document and lock in exact GitHub CLI Markdown body interpolation, including +fenced code, quotes, shell-looking text, multiline whitespace, and Unicode. diff --git a/js/README.md b/js/README.md index 34c0dfd0..5a5f19e2 100644 --- a/js/README.md +++ b/js/README.md @@ -406,6 +406,44 @@ option. Use `fs.writeFile` for binary data. See [`examples/multiline-content.mjs`](examples/multiline-content.mjs) for both text-writing patterns. +### GitHub CLI Markdown Bodies + +Pass a generated issue body directly, without adding quotes or escaping the +Markdown yourself. Fenced code, inline backticks, `${...}` text, shell-looking +syntax, quotes, backslashes, newlines, and Unicode all stay in one literal +`--body` argument: + +```javascript +const title = 'Bug report'; +const body = `## Reproduction + +\`\`\`javascript +const message = \`literal \${value}\`; +\`\`\` + +$HOME and $(whoami) are documentation, not shell syntax.`; + +await $`gh issue create --repo ${repository} --title ${title} --body ${body}`; +``` + +Author-written quotes are also context-aware, so `--body "${body}"` has the +same one-argument result with the default configuration. The unquoted form is +simpler and remains safe if legacy code opts out of context-aware quoting with +`COMMAND_STREAM_QUOTE_CONTEXT=0`. + +When the body already comes from a file, GitHub CLI's native `--body-file` +option avoids loading it into an argument. `-` reads from standard input: + +```javascript +await $({ + stdin: body, +})`gh issue create --repo ${repository} --title ${title} --body-file -`; +``` + +Neither form requires a GitHub-specific escaping helper. See +[`examples/github-cli-markdown-body.mjs`](examples/github-cli-markdown-body.mjs) +for a runnable example of both modes. + ### Go templates & `{{ }}` arguments `command-stream` gives you a real shell's word-splitting, including for tokens diff --git a/js/examples/README.md b/js/examples/README.md index 0879a771..7cd5f5bb 100644 --- a/js/examples/README.md +++ b/js/examples/README.md @@ -164,6 +164,7 @@ The simplest examples to get started: - `paths-with-spaces.mjs` - File paths with spaces need no manual quoting (GitHub issue #41) - `quote-context-bash-c.mjs` - Interpolating inside your own quotes (GitHub issue #49) - `json-interpolation.mjs` - Pass JSON literally and redirect it without manual escaping (GitHub issue #39) +- `github-cli-markdown-body.mjs` - Create a GitHub issue from complex Markdown directly or through stdin (GitHub issue #40) ### 🔧 Syntax Comparisons diff --git a/js/examples/github-cli-markdown-body.mjs b/js/examples/github-cli-markdown-body.mjs new file mode 100644 index 00000000..97c9d053 --- /dev/null +++ b/js/examples/github-cli-markdown-body.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// Create one issue with a complex Markdown body (GitHub issue #40). +// +// Direct argument mode: +// COMMAND_STREAM_EXAMPLE_REPOSITORY=owner/repository \ +// bun js/examples/github-cli-markdown-body.mjs +// +// GitHub CLI stdin mode: +// COMMAND_STREAM_EXAMPLE_REPOSITORY=owner/repository \ +// bun js/examples/github-cli-markdown-body.mjs --body-file + +import { $ } from '../src/$.mjs'; + +const repository = process.env.COMMAND_STREAM_EXAMPLE_REPOSITORY; +const useBodyFile = process.argv.includes('--body-file'); +const title = 'command-stream complex Markdown example'; +const body = `## Reproduction + +\`\`\`javascript +const message = \`literal \${value}\`; +console.log("double", 'single', message); +\`\`\` + +- shell-looking text stays literal: $HOME \${USER} $(whoami) \`date\` +- paths stay intact: C:\\Program Files\\command-stream\\ +- Unicode stays intact: 雪 🚀 café`; + +if (!repository) { + console.error('Set COMMAND_STREAM_EXAMPLE_REPOSITORY=owner/repository.'); + process.exitCode = 1; +} else { + const result = useBodyFile + ? await $({ + mirror: false, + stdin: body, + })`gh issue create --repo ${repository} --title ${title} --body-file -` + : await $({ + mirror: false, + })`gh issue create --repo ${repository} --title ${title} --body ${body}`; + + console.log(result.stdout.trim()); +} diff --git a/js/tests/competitor-compatibility.test.mjs b/js/tests/competitor-compatibility.test.mjs index eb97366a..b8ddae17 100644 --- a/js/tests/competitor-compatibility.test.mjs +++ b/js/tests/competitor-compatibility.test.mjs @@ -20,6 +20,7 @@ import { portedCases, snapshotDate, } from './competitor-corpus.mjs'; +import { COMPLEX_MARKDOWN_BODY } from './fixtures/complex-markdown-body.mjs'; const testDirectory = dirname(fileURLToPath(import.meta.url)); const packageDirectory = join(testDirectory, '..'); @@ -366,6 +367,7 @@ describe('ported public process behavior', () => { ';', '*', '?', + COMPLEX_MARKDOWN_BODY, ]; const result = await runFixture('argv', expected); @@ -378,15 +380,21 @@ describe('ported public process behavior', () => { 'safe-template-interpolation', 'quotes untrusted template values as one literal argument', async () => { - const dangerous = "'; echo injected; echo '$HOME $(uname) *"; - const result = await $({ - capture: true, - mirror: false, - stdin: 'ignore', - })`${process.execPath} ${fixturePath} argv ${dangerous}`; + const values = [ + "'; echo injected; echo '$HOME $(uname) *", + COMPLEX_MARKDOWN_BODY, + ]; - expect(result.code).toBe(0); - expect(JSON.parse(result.stdout)).toEqual([dangerous]); + for (const value of values) { + const result = await $({ + capture: true, + mirror: false, + stdin: 'ignore', + })`${process.execPath} ${fixturePath} argv ${value}`; + + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual([value]); + } } ); diff --git a/js/tests/fixtures/complex-markdown-body.mjs b/js/tests/fixtures/complex-markdown-body.mjs new file mode 100644 index 00000000..0eb18d18 --- /dev/null +++ b/js/tests/fixtures/complex-markdown-body.mjs @@ -0,0 +1,19 @@ +// A single regression payload shared by issue #40 and the competitor corpus. +// The final two spaces on the whitespace line are assembled explicitly so +// editors and formatters cannot trim the data under test. +export const COMPLEX_MARKDOWN_BODY = `## Bug description + +Passing Markdown through \`gh issue create --body\` must preserve: + +- fenced code blocks: +\`\`\`javascript +const message = \`literal \${value}\`; +console.log("double", 'single', message); +\`\`\` +- shell-looking text: $HOME \${USER} $(whoami) \`date\` +- operators and globs: && || ; | > < * ? [abc] {one,two} +- whitespace: leading, repeated, and trailing${' '} +- backslashes and paths: C:\\Program Files\\command-stream\\README.md +- Unicode: snow 雪, rocket 🚀, and café + +Nothing above is shell syntax.`; diff --git a/js/tests/github-cli-body.test.mjs b/js/tests/github-cli-body.test.mjs new file mode 100644 index 00000000..2cf0dfa3 --- /dev/null +++ b/js/tests/github-cli-body.test.mjs @@ -0,0 +1,119 @@ +// GitHub CLI complex Markdown regression coverage (issue #40). +// +// A body interpolated into a command is one literal argv value. This file uses +// node:test so the same regression runs under Bun and every supported Node.js +// version in CI. + +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterEach, test } from 'node:test'; +import { COMPLEX_MARKDOWN_BODY } from './fixtures/complex-markdown-body.mjs'; + +const moduleUrl = process.env.COMMAND_STREAM_TEST_MODULE + ? pathToFileURL(process.env.COMMAND_STREAM_TEST_MODULE).href + : new URL('../src/$.mjs', import.meta.url).href; +const commandStream = await import(moduleUrl); +const { $ } = commandStream; +const resetQuoteContext = commandStream.setQuoteContextEnabled ?? (() => {}); + +const ARGV_PRINTER = fileURLToPath( + new URL('./fixtures/argv-json.mjs', import.meta.url) +); +const TITLE = 'Complex "Markdown" issue'; + +const expectedArgs = [ + 'issue', + 'create', + '--repo', + 'owner/repo', + '--title', + TITLE, + '--body', + COMPLEX_MARKDOWN_BODY, +]; + +afterEach(() => resetQuoteContext(null)); + +async function receivedArgs(command) { + const result = await command; + assert.equal(result.code, 0, result.stderr); + return JSON.parse(result.stdout); +} + +test('unquoted --body interpolation preserves complex Markdown exactly', async () => { + const actual = await receivedArgs( + $({ + mirror: false, + })`${process.execPath} ${ARGV_PRINTER} issue create --repo owner/repo --title ${TITLE} --body ${COMPLEX_MARKDOWN_BODY}` + ); + + assert.deepEqual(actual, expectedArgs); +}); + +test('unquoted --body preserves a backslash immediately before a newline', async () => { + const body = 'path ending in a backslash\\\nnext line'; + const actual = await receivedArgs( + $({ + mirror: false, + })`${process.execPath} ${ARGV_PRINTER} --body ${body}` + ); + + assert.deepEqual(actual, ['--body', body]); +}); + +test('double-quoted --body interpolation preserves complex Markdown exactly', async () => { + const actual = await receivedArgs( + $({ + mirror: false, + })`${process.execPath} ${ARGV_PRINTER} issue create --repo owner/repo --title "${TITLE}" --body "${COMPLEX_MARKDOWN_BODY}"` + ); + + assert.deepEqual(actual, expectedArgs); +}); + +test('single-quoted --body interpolation preserves complex Markdown exactly', async () => { + const actual = await receivedArgs( + $({ + mirror: false, + })`${process.execPath} ${ARGV_PRINTER} issue create --repo owner/repo --title '${TITLE}' --body '${COMPLEX_MARKDOWN_BODY}'` + ); + + assert.deepEqual(actual, expectedArgs); +}); + +test( + 'shell syntax in a quoted body remains data', + { skip: process.platform === 'win32' }, + async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'issue-40-body-')); + const marker = path.join(directory, 'injected'); + const body = `safe\n"; touch ${marker}; #\n$(touch ${marker})\n\`touch ${marker}\``; + + try { + const actual = await receivedArgs( + $({ + mirror: false, + })`${process.execPath} ${ARGV_PRINTER} --body "${body}"` + ); + assert.deepEqual(actual, ['--body', body]); + assert.equal(existsSync(marker), false); + } finally { + await rm(directory, { recursive: true, force: true }); + } + } +); + +test('legacy quote mode remains usable with shell-style unquoted interpolation', async () => { + resetQuoteContext(false); + + const actual = await receivedArgs( + $({ + mirror: false, + })`${process.execPath} ${ARGV_PRINTER} --body ${COMPLEX_MARKDOWN_BODY}` + ); + assert.deepEqual(actual, ['--body', COMPLEX_MARKDOWN_BODY]); +}); diff --git a/rust/README.md b/rust/README.md index 2348bf50..005461fe 100644 --- a/rust/README.md +++ b/rust/README.md @@ -124,6 +124,37 @@ assert_eq!(result.stdout, content); Use `printf '%s'` instead of `echo` when exact text matters; `echo` normally adds a trailing newline. If no command is involved, prefer `std::fs::write`. +## GitHub CLI Markdown Bodies + +The same literal-argument contract applies to complex issue bodies. No manual +escaping is needed for fenced code, `${...}` text, quotes, shell-looking +syntax, backslashes, newlines, or Unicode: + +````rust,no_run +use command_stream::s; + +# async fn example() -> Result<(), command_stream::Error> { +let repository = "owner/repository"; +let title = "Bug report"; +let body = "## Reproduction\n\n```rust\nlet message = \"literal ${value}\";\n```\n\n\ + $HOME and $(whoami) are documentation, not shell syntax."; + +let result = s!( + "gh issue create --repo {} --title {} --body {}", + repository, + title, + body, +) +.await?; +assert!(result.is_success()); +# Ok(()) +# } +```` + +If the text already lives in a file, use GitHub CLI's `--body-file` option. +For platform-native argument handling without a shell, pass the same values to +`StreamingRunner::from_argv`. + ## Command Line The crate also builds a `command-stream` binary: diff --git a/rust/changelog.d/20260914_000000_github_markdown_body.md b/rust/changelog.d/20260914_000000_github_markdown_body.md new file mode 100644 index 00000000..9b9f19ba --- /dev/null +++ b/rust/changelog.d/20260914_000000_github_markdown_body.md @@ -0,0 +1,8 @@ +--- +bump: patch +--- + +### Fixed + +- Lock in exact complex Markdown arguments across direct argv execution and + shell-safe macro interpolation. diff --git a/rust/tests/competitor_compatibility/behavior.rs b/rust/tests/competitor_compatibility/behavior.rs index f477dded..e3c94027 100644 --- a/rust/tests/competitor_compatibility/behavior.rs +++ b/rust/tests/competitor_compatibility/behavior.rs @@ -8,6 +8,22 @@ use std::collections::HashMap; use std::ffi::OsString; use std::time::Duration; +const COMPLEX_MARKDOWN_ARGUMENT: &str = r##"## Bug description + +Passing Markdown through `gh issue create --body` must preserve: + +- fenced code blocks: +```rust +let message = format!("literal ${value}"); +``` +- shell-looking text: $HOME ${USER} $(whoami) `date` +- quotes and operators: "double" 'single' && || ; | > < * ? [abc] {one,two} +- whitespace: leading, repeated, tabs\t, and newlines +- backslashes and paths: C:\Program Files\command-stream\ +- Unicode: snow 雪, rocket 🚀, and café + +Nothing above is shell syntax."##; + pub const BEHAVIOR_CASE_IDS: &[&str] = &[ "direct-exact-argv", "argument-edge-cases", @@ -54,6 +70,7 @@ async fn argument_edge_cases_reach_the_child_verbatim() { ";", "*", "?", + COMPLEX_MARKDOWN_ARGUMENT, ]; let result = run_fixture("argv", &expected).await; @@ -65,11 +82,17 @@ async fn argument_edge_cases_reach_the_child_verbatim() { #[tokio::test] async fn safe_template_interpolation_is_one_literal_argument() { let executable = fixture_path().display(); - let dangerous = "'; echo injected; echo '$HOME $(uname) *"; - let result = cmd!("{} argv {}", executable, dangerous).await.unwrap(); + let values = [ + "'; echo injected; echo '$HOME $(uname) *", + COMPLEX_MARKDOWN_ARGUMENT, + ]; - assert_eq!(result.code, 0); - assert_eq!(decode_hex_lines(&result.stdout), [dangerous]); + for value in values { + let result = cmd!("{} argv {}", executable, value).await.unwrap(); + + assert_eq!(result.code, 0); + assert_eq!(decode_hex_lines(&result.stdout), [value]); + } } #[tokio::test]