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 .gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# .gitkeep file auto-generated at 2026-09-06T21:34:11.215Z for PR creation at branch issue-41-448ca60fbc42 for issue https://github.com/link-foundation/command-stream/issues/41
18 changes: 18 additions & 0 deletions experiments/issue-41-broken-quoting.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { $ } from '../js/src/$.mjs';
for (const v of [`"it's"`, `''`, `'; touch /tmp/pwned-41; '`]) {
const c = $({ mirror: false })`printf ${{ raw: '"[%s]\\n"' }} ${v}`;
console.log(JSON.stringify(v), 'built=', JSON.stringify(c.spec.command));
try {
const r = await c;
console.log(
' out=',
JSON.stringify(r.stdout),
'code=',
r.code,
'err=',
JSON.stringify(r.stderr.slice(0, 80))
);
} catch (e) {
console.log(' THREW', e.message);
}
}
74 changes: 74 additions & 0 deletions experiments/issue-41-competitors.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Competitor comparison for issue #41: how does an interpolated path with
// spaces (or quotes) reach the child process in each library?
//
// Reference: `prog "$V"` in /bin/sh - the value is always one argument.
// Run with: bun experiments/issue-41-competitors.mjs
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { $ } from '../js/src/$.mjs';

const PRINTER = fileURLToPath(
new URL('../js/tests/fixtures/argprint.mjs', import.meta.url)
);

const VALUES = [
'/Users/john/My Documents/report.txt',
"'/tmp/pre single quoted/f.txt'",
'"/tmp/pre double quoted/f.txt"',
"/tmp/it's a dir/f.txt",
'/tmp/$HOME dir/f.txt',
];

const parse = (stdout) =>
[...stdout.matchAll(/^ARG\[([\s\S]*?)\]$/gm)].map((m) => m[1]);

const shReference = (value) =>
parse(
execFileSync('/bin/sh', ['-c', `node ${PRINTER} "$V"`], {
env: { ...process.env, V: value },
encoding: 'utf8',
})
);

const commandStream = async (value) =>
parse((await $({ mirror: false })`node ${PRINTER} ${value}`).stdout);

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

async function execaRun(value) {
try {
const { execa } = await import('execa');
return parse((await execa`node ${PRINTER} ${value}`).stdout + '\n');
} catch {
return null; // not installed
}
}

for (const value of VALUES) {
const expected = shReference(value);
const rows = {
'sh "$V"': expected,
'command-stream': await commandStream(value),
'bun $': await bunShell(value),
execa: await execaRun(value),
};
console.log(`\nvalue ${JSON.stringify(value)}`);
for (const [name, args] of Object.entries(rows)) {
if (args === null) {
console.log(` ${name.padEnd(14)} (not available here)`);
continue;
}
const same = JSON.stringify(args) === JSON.stringify(expected);
console.log(
` ${name.padEnd(14)} ${same ? 'same as sh' : 'DIFFERS '} ${JSON.stringify(args)}`
);
}
}
51 changes: 51 additions & 0 deletions experiments/issue-41-diff-sh.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Differential test: command-stream interpolation vs POSIX sh "$var"
import { $ } from '../js/src/$.mjs';
import { execFileSync } from 'child_process';

const values = [
'/Users/john/My Documents/report.txt',
"/tmp/it's a dir/file.txt",
'/tmp/quoted "name"/f.txt',
"'/already/single quoted/path'",
'"/already/double quoted/path"',
'/tmp/back\\slash dir/f.txt',
'/tmp/$HOME dir/f.txt',
'/tmp/tab\there/f.txt',
'/tmp/new\nline/f.txt',
' leading and trailing ',
'',
'/tmp/glob*dir/f.txt',
'/tmp/~tilde dir/f.txt',
'/tmp/semi;colon dir/f.txt',
'/tmp/(paren) dir/f.txt',
'/tmp/emoji 🚀 dir/f.txt',
'C:\\Program Files\\App\\app.exe',
];

function shArgs(value) {
// What a POSIX shell gives argv when you write: prog "$var"
return execFileSync('/bin/sh', ['-c', 'printf "[%s]\\n" "$V"'], {
env: { ...process.env, V: value },
encoding: 'utf8',
});
}

let fails = 0;
for (const v of values) {
const expected = shArgs(v);
const built = $({ mirror: false })`printf ${{ raw: '"[%s]\\n"' }} ${v}`;
let actual;
try {
actual = (await built).stdout;
} catch (e) {
actual = 'THREW ' + e.message;
}
const ok = actual === expected;
if (!ok) {
fails++;
}
console.log(
`${ok ? 'OK ' : 'FAIL'} value=${JSON.stringify(v)}\n built=${JSON.stringify(built.spec.command)}\n sh =${JSON.stringify(expected)}\n cs =${JSON.stringify(actual)}`
);
}
console.log('failures:', fails, '/', values.length);
6 changes: 6 additions & 0 deletions experiments/issue-41-injection.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { $ } from '../js/src/$.mjs';
const evil = `"' ; touch /tmp/pwned-41b ; '"`;
const c = $({ mirror: false })`printf ${{ raw: '"[%s]\\n"' }} ${evil}`;
console.log('built=', JSON.stringify(c.spec.command));
const r = await c;
console.log('code', r.code, JSON.stringify(r.stdout), JSON.stringify(r.stderr));
61 changes: 61 additions & 0 deletions experiments/issue-41-matrix.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { $ } from '../js/src/$.mjs';
import fs from 'fs';

const dir = '/tmp/space test dir';
const filePath = `${dir}/report file.txt`;
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, 'hello content\n');

async function t(name, fn) {
try {
const r = await fn();
console.log(
`[${name}] code=${r.code} out=${JSON.stringify(r.stdout)} err=${JSON.stringify((r.stderr || '').slice(0, 120))}`
);
} catch (e) {
console.log(`[${name}] THREW ${e.message}`);
}
}

// redirection to a path with spaces
await t(
'redirect out',
() => $({ mirror: false })`echo hi > ${dir}/out file.txt`
);
await t(
'redirect out interp full path',
() => $({ mirror: false })`echo hi > ${dir + '/out2.txt'}`
);
await t('read back', () => $({ mirror: false })`cat ${dir + '/out2.txt'}`);
// virtual/builtin commands
await t('cd virtual', () => $({ mirror: false })`cd ${dir}`);
await t('pwd after cd', () => $({ mirror: false })`cd ${dir} && pwd`);
await t('echo builtin', () => $({ mirror: false })`echo ${'a b'} ${'c d'}`);
// pipeline with spaces
await t(
'pipe grep',
() => $({ mirror: false })`cat ${filePath} | grep ${'hello content'}`
);
// array interpolation
await t('array args', () => $({ mirror: false })`echo ${['a b', 'c d']}`);
// sh -c inner
await t('sh -c', () => $({ mirror: false })`sh -c "cat '${filePath}'"`);
await t(
'sh -c double',
() => $({ mirror: false })`sh -c "cat \"${filePath}\""`
);
// trailing/leading spaces value
await t(
'value with quotes literal',
() => $({ mirror: false })`echo ${"'quoted'"}`
);
await t('value with tab', () => $({ mirror: false })`echo ${'a\tb'} | cat -A`);
// backslash in path
await t(
'backslash path',
() => $({ mirror: false })`echo ${'/tmp/back\\slash'}`
);
// env var in path should not expand
await t('dollar path', () => $({ mirror: false })`echo ${'/tmp/$HOME/x'}`);
// glob dir with space
await t('ls glob', () => $({ mirror: false })`ls ${dir}/*.txt`);
6 changes: 6 additions & 0 deletions experiments/issue-41-nested.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { $ } from '../js/src/$.mjs';
const filePath = '/tmp/space test dir/report file.txt';
const cmd = $({ mirror: false })`sh -c "cat \"${filePath}\""`;
console.log('BUILT:', JSON.stringify(cmd.spec.command));
const r = await cmd;
console.log('code', r.code, JSON.stringify(r.stdout), JSON.stringify(r.stderr));
22 changes: 22 additions & 0 deletions experiments/issue-41-newline.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { $ } from '../js/src/$.mjs';
import { fileURLToPath } from 'node:url';
const P = fileURLToPath(
new URL('../js/tests/fixtures/argprint.mjs', import.meta.url)
);
const v = '/tmp/new\nline/f.txt';
for (const build of [
() => $({ mirror: false })`node ${P} ${v}`,
() => $({ mirror: false })`printf "[%s]\n" ${v}`,
]) {
const c = build();
console.log('BUILT', JSON.stringify(c.spec.command));
const r = await c;
console.log(
' code',
r.code,
'out',
JSON.stringify(r.stdout),
'err',
JSON.stringify(r.stderr)
);
}
31 changes: 31 additions & 0 deletions experiments/issue-41-repro.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { $ } from '../js/src/$.mjs';

const dir = '/tmp/space test dir';
const filePath = `${dir}/report file.txt`;

async function t(name, fn) {
try {
const r = await fn();
console.log(
`[${name}] code=${r.code} stdout=${JSON.stringify(r.stdout)} stderr=${JSON.stringify(r.stderr)}`
);
} catch (e) {
console.log(`[${name}] THREW ${e.message}`);
}
}

await t('cat unquoted interp', () => $({ mirror: false })`cat ${filePath}`);
await t('cat quoted interp', () => $({ mirror: false })`cat "${filePath}"`);
await t('ls dir', () => $({ mirror: false })`ls ${dir}`);
await t('echo path', () => $({ mirror: false })`echo ${filePath}`);
await t('cd + pwd', () => $({ mirror: false })`cd ${dir} && pwd`);
await t(
'builtin cat via virtual?',
() => $({ mirror: false })`cat ${filePath} | head -1`
);
await t(
'test -f',
() => $({ mirror: false })`test -f ${filePath} && echo EXISTS`
);
await t('cp', () => $({ mirror: false })`cp ${filePath} ${dir}/copy\ file.txt`);
console.log('cmd:', $({ mirror: false })`cat ${filePath}`.spec.command);
22 changes: 22 additions & 0 deletions js/.changeset/issue-41-paths-with-spaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'command-stream': minor
---

Interpolate every value as exactly one literal argument, like `"$var"` in a
POSIX shell. `quote()` no longer treats a value that starts and ends with a
matching quote as ready-made shell syntax: those quote characters are part of
the value, so a path such as `/My Documents/report.txt` (or a pre-quoted one)
reaches the command intact (issue #41). This matches `sh`, Bun's `$`, zx and
execa, and it removes two defects of the old heuristic - `quote('"it\'s"')`
emitted the unterminated string `'"it\'s"'`, and a value like
`"' ; touch /tmp/pwned ; '"` was spliced in as shell syntax and executed. The
previous behavior is available for balanced values only, via
`shell.preQuotedPassthrough(true)`, `setPreQuotedPassthroughEnabled(true)`, or
`COMMAND_STREAM_PREQUOTED_PASSTHROUGH=1`.

Also ignore `EPIPE` when a pipeline stage closes the stdin of a process that
has already exited. Closing (or writing to) that pipe is a normal race in a
pipeline - a shell ignores it - but the streaming pipeline let the rejection
escape as an unhandled error, which could fail an otherwise successful
command. This matches the Rust implementation, which already discards those
write and shutdown errors.
28 changes: 28 additions & 0 deletions js/BEST-PRACTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,34 @@ await $`bash -c "${script}"`;
To restore the old always-quote behavior, call `shell.quoteContext(false)` or set
`COMMAND_STREAM_QUOTE_CONTEXT=0`.

### Paths With Spaces

Interpolate the path as-is. An interpolated value always becomes exactly one
argument, so spaces, apostrophes, and other special characters need no help
from you - the same guarantee as `"$path"` in a shell script:

```javascript
const file = '/Users/john/My Documents/report.txt';

await $`cat ${file}`; // one argument, spaces included
await $`cp ${file} ${'/tmp/My Backups/'}`;
```

Never pre-quote the value. Quote characters you add become part of the file
name, exactly as `sh` would treat them:

```javascript
// WRONG: looks for a file whose name starts and ends with a quote
await $`cat ${"'" + file + "'"}`;

// RIGHT
await $`cat ${file}`;
```

Before v0.21 a value that started and ended with a matching quote was spliced
in as shell syntax. Set `COMMAND_STREAM_PREQUOTED_PASSTHROUGH=1` or call
`shell.preQuotedPassthrough(true)` if you still depend on that.

### Using raw() for Trusted Commands

Only use `raw()` with trusted, hardcoded command strings:
Expand Down
49 changes: 46 additions & 3 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,14 +222,57 @@ await $`echo ${pathWithSpaces}`; // pathWithSpaces = "/my path/file" → echo '/
// Special characters that trigger auto-quoting:
// Spaces, $, ;, |, &, >, <, `, *, ?, [, ], {, }, (, ), !, #, and others

// User-provided quotes are preserved
// Quote characters inside a value are data, never shell syntax
const quotedPath = "'/path with spaces/file'";
await $`cat ${quotedPath}`; // → cat '/path with spaces/file' (no double-quoting!)
await $`cat ${quotedPath}`; // → cat with the argument: '/path with spaces/file'

const doubleQuoted = '"/path with spaces/file"';
await $`cat ${doubleQuoted}`; // → cat '"/path with spaces/file"' (preserves intent)
await $`cat ${doubleQuoted}`; // → cat with the argument: "/path with spaces/file"
```

### Paths With Spaces

An interpolated value always becomes **exactly one argument**, spaces and all —
the same guarantee you get from `"$path"` in a shell script, and the same
behavior as Bun's `$`, zx, and execa:

```javascript
const file = '/Users/john/My Documents/report.txt';

await $`cat ${file}`; // one argument: /Users/john/My Documents/report.txt
await $`cp ${file} ${'/tmp/My Backups/'}`; // both paths stay intact
await $`ls -la ${'/Applications/Visual Studio Code.app'}`;
```

Do **not** pre-quote the path yourself. Quote characters you put in the value
are literal characters of the file name, exactly as `sh` treats them:

```javascript
// ❌ looks for a file whose name literally starts and ends with a quote
await $`cat ${`'${file}'`}`;

// ✅ just interpolate the path
await $`cat ${file}`;
```

**Opting out.** Before v0.21 a value that started and ended with a matching
quote was spliced into the command as shell syntax instead of being quoted.
That diverged from `sh` and could produce unrunnable commands: the value
`"it's"` was emitted as `'"it's"'`, an unterminated string. If you depend on
the old behavior:

```javascript
import { shell, setPreQuotedPassthroughEnabled } from 'command-stream';

shell.preQuotedPassthrough(true); // or: setPreQuotedPassthroughEnabled(true)
shell.preQuotedPassthrough(false); // back to sh-like quoting
setPreQuotedPassthroughEnabled(null); // follow the environment again
```

Or set `COMMAND_STREAM_PREQUOTED_PASSTHROUGH=1` for a whole process. Even then
only _balanced_ values are passed through, so a value like `"a" ; rm -rf / ; "b"`
is still quoted rather than executed.

### Interpolating Inside Your Own Quotes

Quoting is context-aware: an interpolated value is quoted only where a quote is
Expand Down
Loading
Loading