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
13 changes: 13 additions & 0 deletions js/.changeset/issue-172-go-template-args.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'command-stream': patch
---

Clarify and diagnose Go/Docker template arguments that contain an internal space (issue #172).

A literal template token such as `--format {{json .Config.Env}}` contains an unquoted space, so `command-stream` splits it into separate argv words — exactly as a POSIX shell (`bash`) would. This made Docker report `template parsing error: ... unclosed action`, while a space-free `{{.Id}}` worked, which was surprising and hard to diagnose.

This is shell-faithful behavior, not a bug: quoting (`'{{json .Config.Env}}'`), double-quoting, or interpolating the whole token as a single `${value}` all pass it through untouched. To make the failing case easy to spot:

- **Diagnostics**: when a built command contains an unquoted `{{ … }}` token with an internal space, `command-stream` now prints a one-line warning to stderr pointing at the gotcha (fired once per unique token, silenced via `COMMAND_STREAM_NO_TEMPLATE_WARNING=1`).
- **Docs**: a new "Go templates & `{{ }}` arguments" section in the README documents the works/breaks/workaround patterns.
- A runnable `examples/go-template-arguments.mjs` demonstrates each pattern.
47 changes: 47 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,53 @@ const doubleQuoted = '"/path with spaces/file"';
await $`cat ${doubleQuoted}`; // → cat '"/path with spaces/file"' (preserves intent)
```

### Go templates & `{{ }}` arguments

`command-stream` gives you a real shell's word-splitting, including for tokens
you type **literally** in the template. That means a Go/Docker template flag
like `--format {{json .Config.Env}}` behaves exactly as it would in `bash`: the
**unquoted space** inside `{{ }}` splits the token into separate words, so the
child process receives a broken `--format` value (e.g. Docker reports
`template parsing error: ... unclosed action`).

The rule is simple — **quote template tokens that contain spaces**, just like
you would in a shell script:

```javascript
import { $ } from 'command-stream';

// ❌ BREAKS — the unquoted space splits the token into 3 argv words:
// `--format`, `{{json`, `.Config.Env}}`
await $`docker image inspect alpine --format {{json .Config.Env}}`;

// ✅ WORKS — a token with no space stays a single word
await $`docker image inspect alpine --format {{.Id}}`;

// ✅ WORKS — single-quote the template (recommended, mirrors shell scripts)
await $`docker image inspect alpine --format '{{json .Config.Env}}'`;

// ✅ WORKS — double quotes work too
await $`docker image inspect alpine --format "{{json .Config.Env}}"`;

// ✅ WORKS — interpolate the whole template as one ${value}; it is
// auto-quoted and always reaches the child as a single argument
const format = '{{json .Config.Env}}';
await $`docker image inspect alpine --format ${format}`;

// ✅ WORKAROUND — drop --format and parse the full JSON in JS
const all = await $`docker image inspect alpine`;
const env = JSON.parse(all.stdout)[0].Config.Env;
```

To help diagnose the broken case, `command-stream` prints a one-line warning to
stderr when a built command contains an **unquoted** `{{ … }}` token with an
internal space (the warning fires once per unique token). Silence it by setting
the `COMMAND_STREAM_NO_TEMPLATE_WARNING=1` environment variable.

> Note: a token whose space is already **quoted** (`'{{json .Config.Env}}'`) or
> supplied via interpolation (`${format}`) is passed through untouched — no
> splitting, no warning.

### Shell Injection Protection

All interpolated values are automatically secured:
Expand Down
58 changes: 58 additions & 0 deletions js/examples/go-template-arguments.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env node

// Demonstrates how Go/Docker template flags (`--format {{ ... }}`) behave with
// command-stream's shell-faithful word splitting, and the safe patterns for
// passing a template token that contains an internal space. See issue #172.
//
// Run: node examples/go-template-arguments.mjs
//
// command-stream mirrors a POSIX shell: an UNQUOTED space inside `{{ }}` splits
// the token into multiple arguments (just like bash), so quote it — exactly as
// you would in a shell script.

import { $ } from '../src/$.mjs';

// Print each received argument on its own line so we can see the word splitting.
const PRINTER = new URL('../tests/fixtures/argprint.mjs', import.meta.url)
.pathname;

async function show(title, run) {
const result = await run();
const args = result.stdout
.split('\n')
.filter((l) => l.startsWith('ARG['))
.map((l) => l.slice(4, -1));
console.log(`${title}\n -> ${JSON.stringify(args)}\n`);
}

console.log('=== Go template arguments with command-stream ===\n');

// ❌ Unquoted token with a space — split into 3 args (and a one-line warning
// is printed to stderr explaining why). This is what bash does too.
await show(
'1. UNQUOTED --format {{json .Config.Env}} (BREAKS, splits like bash)',
() => $({ mirror: false })`node ${PRINTER} --format {{json .Config.Env}}`
);

// ✅ Space-free token stays a single word.
await show(
'2. NO SPACE --format {{.Id}} (works)',
() => $({ mirror: false })`node ${PRINTER} --format {{.Id}}`
);

// ✅ Single-quote the template — recommended, mirrors shell scripts.
await show(
"3. SINGLE-QUOTED --format '{{json .Config.Env}}' (works)",
() => $({ mirror: false })`node ${PRINTER} --format '{{json .Config.Env}}'`
);

// ✅ Interpolate the whole template as one ${value}; it is auto-quoted.
const format = '{{json .Config.Env}}';
await show(
'4. INTERPOLATED --format ${format} (works)',
() => $({ mirror: false })`node ${PRINTER} --format ${format}`
);

console.log(
'Tip: set COMMAND_STREAM_NO_TEMPLATE_WARNING=1 to silence the diagnostic.'
);
122 changes: 122 additions & 0 deletions js/src/$.quote.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,126 @@ export function quote(value) {
return `'${value.replace(/'/g, "'\\''")}'`;
}

// Remember which split-template snippets we've already warned about so a hot
// loop doesn't spam stderr with the same diagnostic over and over.
const warnedTemplateSnippets = new Set();

/**
* Scan a fully-built command string for an unquoted Go/Handlebars-style
* template token (`{{ ... }}`) that contains an unquoted space.
*
* Such a token is split by the shell (and by command-stream, which mirrors
* shell word-splitting) into multiple argv words, so `--format {{json .X}}`
* reaches the child as `--format`, `{{json`, `.X}}` — exactly what a POSIX
* shell would do, but surprising for Go templates. We return the offending
* snippet so the caller can point the user at the gotcha.
*
* @param {string} command - The assembled command string
* @returns {string|null} The split template snippet, or null if none
*/
export function findSplitTemplateToken(command) {
if (typeof command !== 'string' || !command.includes('{{')) {
return null;
}

let inSingle = false;
let inDouble = false;
for (let i = 0; i < command.length; i++) {
const char = command[i];
if (inSingle) {
inSingle = char !== "'";
continue;
}
if (inDouble) {
inDouble = char !== '"';
continue;
}
if (char === "'") {
inSingle = true;
continue;
}
if (char === '"') {
inDouble = true;
continue;
}

// An unquoted `{{` — scan forward for its matching `}}`, reporting it when
// an unquoted space appears in between (which is what triggers splitting).
if (char === '{' && command[i + 1] === '{') {
const close = scanTemplateClose(command, i + 2);
if (close.splits) {
return command.slice(i, close.endIndex + 2);
}
i = close.endIndex;
}
}

return null;
}

/**
* Starting just after an unquoted `{{`, scan to the matching unquoted `}}`,
* tracking whether an unquoted space appears in between.
*
* @param {string} command - The command string
* @param {number} startIndex - Index just past the opening `{{`
* @returns {{ splits: boolean, endIndex: number }} `splits` is true when a
* closing `}}` was found with an intervening unquoted space; `endIndex`
* points at the closing `}}` (or the end of input).
*/
function scanTemplateClose(command, startIndex) {
let j = startIndex;
let hasUnquotedSpace = false;
let inSingle = false;
let inDouble = false;
while (j < command.length) {
const cj = command[j];
if (inSingle) {
inSingle = cj !== "'";
} else if (inDouble) {
inDouble = cj !== '"';
} else if (cj === "'") {
inSingle = true;
} else if (cj === '"') {
inDouble = true;
} else if (cj === '}' && command[j + 1] === '}') {
return { splits: hasUnquotedSpace, endIndex: j };
} else if (/\s/.test(cj)) {
hasUnquotedSpace = true;
}
j++;
}
return { splits: false, endIndex: j };
}

/**
* Emit a one-line diagnostic when a built command contains an unquoted Go
* template token with an internal space. This points users at the
* shell-splitting gotcha behind the cryptic downstream errors (e.g. Go's
* "unclosed action"). Silenced via COMMAND_STREAM_NO_TEMPLATE_WARNING=1, and
* each unique snippet is only reported once per process.
*
* @param {string} command - The assembled command string
*/
function warnOnSplitTemplate(command) {
if (process.env.COMMAND_STREAM_NO_TEMPLATE_WARNING) {
return;
}
const snippet = findSplitTemplateToken(command);
if (!snippet || warnedTemplateSnippets.has(snippet)) {
return;
}
warnedTemplateSnippets.add(snippet);
console.error(
`[command-stream] Warning: template token \`${snippet}\` contains an ` +
`unquoted space, so the shell splits it into multiple arguments (just ` +
`like bash would). Quote it ('${snippet}') or interpolate it as a ` +
`single \${value} to pass it as one argument. See README ` +
`"Go templates & {{ }} arguments". Set ` +
`COMMAND_STREAM_NO_TEMPLATE_WARNING=1 to silence.`
);
}

/**
* Build a shell command from template strings and values
* @param {string[]} strings - Template literal strings
Expand Down Expand Up @@ -92,6 +212,7 @@ export function buildShellCommand(strings, values) {
() =>
`BRANCH: buildShellCommand => COMPLETE_COMMAND | ${JSON.stringify({ command: commandStr }, null, 2)}`
);
warnOnSplitTemplate(commandStr);
return commandStr;
}
}
Expand All @@ -109,6 +230,7 @@ export function buildShellCommand(strings, values) {
() =>
`buildShellCommand EXIT | ${JSON.stringify({ command: out }, null, 2)}`
);
warnOnSplitTemplate(out);
return out;
}

Expand Down
5 changes: 5 additions & 0 deletions js/tests/fixtures/argprint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Test fixture: print each received argv argument on its own line, wrapped in
// ARG[...] markers so tests can assert exact word-splitting. See issue #172.
for (const a of process.argv.slice(2)) {
console.log(`ARG[${a}]`);
}
Loading