Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions experiments/issue-40-github-markdown-competitors.mjs
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions js/.changeset/github-markdown-body.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions js/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 42 additions & 0 deletions js/examples/github-cli-markdown-body.mjs
Original file line number Diff line number Diff line change
@@ -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());
}
24 changes: 16 additions & 8 deletions js/tests/competitor-compatibility.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');
Expand Down Expand Up @@ -366,6 +367,7 @@ describe('ported public process behavior', () => {
';',
'*',
'?',
COMPLEX_MARKDOWN_BODY,
];
const result = await runFixture('argv', expected);

Expand All @@ -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]);
}
}
);

Expand Down
19 changes: 19 additions & 0 deletions js/tests/fixtures/complex-markdown-body.mjs
Original file line number Diff line number Diff line change
@@ -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.`;
Loading
Loading