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
126 changes: 126 additions & 0 deletions experiments/issue-39-json-competitors.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Compare JSON interpolation with the literal "$VALUE" contract used by sh.
// Bun, zx, and Execa escape interpolated strings by default; optional packages
// are reported as unavailable instead of being required by this repository.
//
// References:
// https://bun.com/docs/runtime/shell
// https://google.github.io/zx/quotes
// https://github.com/sindresorhus/execa
//
// Run: bun experiments/issue-39-json-competitors.mjs

import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { $ } from '../js/src/$.mjs';

const ARGV_PRINTER = fileURLToPath(
new URL('../js/tests/fixtures/argv-json.mjs', import.meta.url)
);
const VALUES = [
JSON.stringify({ compact: true, number: 42 }),
JSON.stringify(
{
quotes: '"double" and \'single\'',
special: '$HOME `date` $(echo injected); | &',
path: 'C:\\Program Files\\app',
controls: 'line 1\nline 2\tcolumn',
unicode: '雪 🚀',
},
null,
2
),
];

const parse = (stdout) => JSON.parse(String(stdout));

function shReference(value) {
const result = spawnSync(
'/bin/sh',
['-c', 'node "$ARGV_PRINTER" "$JSON_VALUE"'],
{
env: {
...process.env,
ARGV_PRINTER,
JSON_VALUE: value,
},
encoding: 'utf8',
}
);
if (result.status !== 0) {
throw new Error(result.stderr);
}
return parse(result.stdout);
}

async function commandStream(value) {
return parse(
(await $({ mirror: false })`node ${ARGV_PRINTER} ${value}`).stdout
);
}

async function bunShell(value) {
if (typeof Bun === 'undefined') {
return null;
}
const { $: bun$ } = await import('bun');
return parse((await bun$`node ${ARGV_PRINTER} ${value}`.quiet()).stdout);
}

async function optionalImport(name) {
try {
return await import(name);
} catch (error) {
if (error?.code === 'ERR_MODULE_NOT_FOUND') {
return null;
}
throw error;
}
}

async function zx(value) {
const module = await optionalImport('zx');
if (!module) {
return null;
}
const result = await module.$({ quiet: true })`node ${ARGV_PRINTER} ${value}`;
return parse(result.stdout);
}

async function execa(value) {
const module = await optionalImport('execa');
if (!module) {
return null;
}
const result = await module.execa`node ${ARGV_PRINTER} ${value}`;
return parse(result.stdout);
}

const implementations = [
['command-stream', commandStream],
['Bun shell', bunShell],
['zx', zx],
['Execa', execa],
];

let failures = 0;
for (const value of VALUES) {
const expected = shReference(value);
console.log(`\nvalue: ${JSON.stringify(value)}`);

for (const [name, run] of implementations) {
const actual = await run(value);
if (actual === null) {
console.log(` ${name.padEnd(14)} unavailable`);
continue;
}
const matches = JSON.stringify(actual) === JSON.stringify(expected);
if (name === 'command-stream' && !matches) {
failures += 1;
}
console.log(
` ${name.padEnd(14)} ${matches ? 'same as sh' : 'DIFFERS'} ${JSON.stringify(actual)}`
);
}
}

process.exitCode = failures === 0 ? 0 : 1;
6 changes: 6 additions & 0 deletions js/.changeset/issue-39-json-interpolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'command-stream': patch
---

Document and lock in literal JSON interpolation across command arguments,
author-written quote contexts, pipelines, and shell redirection.
27 changes: 27 additions & 0 deletions js/BEST-PRACTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This document covers best practices, common patterns, and pitfalls to avoid when

- [Array Argument Handling](#array-argument-handling)
- [String Interpolation](#string-interpolation)
- [JSON and Structured Data](#json-and-structured-data)
- [Security Best Practices](#security-best-practices)
- [Error Handling](#error-handling)
- [Real-time Streaming](#real-time-streaming)
Expand Down Expand Up @@ -115,6 +116,32 @@ await $`bash -c "${script}"`;
To restore the old always-quote behavior, call `shell.quoteContext(false)` or set
`COMMAND_STREAM_QUOTE_CONTEXT=0`.

### JSON and Structured Data

Interpolate `JSON.stringify()` output directly. Do not add shell quotes to the
value or manually escape its double quotes:

```javascript
const json = JSON.stringify(credentials);

// RIGHT: one literal argument
await $`some-cli --credentials ${json}`;

// WRONG: the added backslashes become part of the argument
await $`some-cli --credentials ${json.replaceAll('"', '\\"')}`;
```

When redirecting exact bytes, use `printf '%s'`; the fixed format string keeps
`%` sequences in the JSON from being interpreted:

```javascript
await $`printf '%s' ${json} > ${outputFile}`;
```

`echo` adds a newline and different shells do not handle its backslash escapes
identically. For a file-only operation, `fs.writeFile(outputFile, json)` is
simpler and avoids a shell.

### Paths With Spaces

Interpolate the path as-is. An interpolated value always becomes exactly one
Expand Down
39 changes: 39 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,45 @@ setQuoteContextEnabled(null); // follow the environment again
Or set `COMMAND_STREAM_QUOTE_CONTEXT=0` in the environment to disable it for a
whole process without touching code.

### JSON and Other Structured Arguments

Pass serialized data directly. Every interpolation is one literal argument, so
JSON quotes, apostrophes, dollar signs, backticks, backslashes, whitespace, and
Unicode remain data instead of becoming shell syntax:

```javascript
const json = JSON.stringify({
message: 'She said "hello"',
path: 'C:\\Program Files\\app',
template: '$HOME and `date`',
});

await $`some-cli --payload ${json}`;
```

Do not pre-quote the value or replace `"` with `\\"`. Those added quote or
backslash characters are caller data and are intentionally preserved, matching
the literal-argument behavior of `"$value"` in `sh`, Bun's `$`, zx, and
Execa.

For byte-exact redirection, use a constant `printf` format string and put the
JSON in a separate argument:

```javascript
const outputFile = 'config.json';
await $`printf '%s' ${json} > ${outputFile}`;
```

`echo` appends a newline and its backslash handling varies between shells, so
it is not a byte-preserving serialization primitive. If no external command is
needed, avoid a shell and use `fs.writeFile(outputFile, json)`.

No JSON-specific mode is needed: automatic interpolation already provides the
safe, unsurprising literal contract. The
`COMMAND_STREAM_PREQUOTED_PASSTHROUGH` and
`COMMAND_STREAM_QUOTE_CONTEXT` switches remain available only for legacy
general quoting compatibility.

### 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 @@ -162,6 +162,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)

### 🔧 Syntax Comparisons

Expand Down
36 changes: 36 additions & 0 deletions js/examples/json-interpolation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env node

import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { $ } from '../src/$.mjs';

const data = {
name: 'Test Project',
description: 'A project with "quotes" and apostrophes',
config: {
special: 'Value with `backticks`, $variables, and C:\\Program Files',
},
};
const json = JSON.stringify(data, null, 2);
const directory = await mkdtemp(path.join(os.tmpdir(), 'command-stream-json-'));

try {
const shellOutput = path.join(directory, 'from shell.json');

// Interpolate JSON directly. It becomes one literal argument, so no manual
// quote escaping is needed. printf is preferable to echo when bytes matter.
await $({ mirror: false })`printf '%s' ${json} > ${shellOutput}`;
const roundTrip = JSON.parse(await readFile(shellOutput, 'utf8'));
console.log('Shell command round trip:', roundTrip);

// If no command needs the data, skip the shell entirely.
const directOutput = path.join(directory, 'from JavaScript.json');
await writeFile(directOutput, json);
console.log(
'Direct write round trip:',
JSON.parse(await readFile(directOutput))
);
} finally {
await rm(directory, { recursive: true, force: true });
}
1 change: 1 addition & 0 deletions js/tests/fixtures/argv-json.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
process.stdout.write(JSON.stringify(process.argv.slice(2)));
Loading
Loading