-
Notifications
You must be signed in to change notification settings - Fork 1
Description
Summary
When using the $ template tag with a joined array string (e.g., ${args.join(' ')}), the entire string is treated as a single argument rather than multiple separate arguments. This is a common pitfall that's not clearly documented.
Problem Description
The command-stream library correctly handles arrays passed directly to template interpolations - each element becomes a separate argument. However, if users call .join(' ') on an array before passing it, the result becomes a single string with spaces that gets quoted as one argument.
This is technically expected behavior (a string is a string), but the pitfall is subtle and the correct approach (pass the array directly) is not prominently documented.
Reproducible Example
❌ Incorrect Usage (Common Pitfall)
import { $ } from 'command-stream';
// User wants to run: command file.txt --public --verbose
const args = ['file.txt', '--public', '--verbose'];
// Calling .join(' ') converts array to string BEFORE template interpolation
const result = await $`command ${args.join(' ')}`;
// What gets executed: command 'file.txt --public --verbose'
// The shell receives this as: command [one-single-argument]
// NOT: command file.txt --public --verbose
This causes errors like:
Error: File does not exist: "file.txt --public --verbose"
The flags become part of the filename argument!
✅ Correct Usage
import { $ } from 'command-stream';
const args = ['file.txt', '--public', '--verbose'];
// Pass the array directly - command-stream handles it correctly
const result = await $`command ${args}`;
// What gets executed: command file.txt --public --verbose
// Each element becomes a separate argument ✓
Why This Happens
The buildShellCommand function in $.quote.mjs has special array handling:
if (Array.isArray(value)) {
return value.map(quote).join(' '); // Each element quoted separately
}
But when you call .join(' ') before passing to the template:
- The array becomes a string:
"file.txt --public --verbose" - Template receives a string, not an array
- The string gets quoted as a single shell argument
- Command sees one argument containing spaces, not multiple arguments
Workarounds
Option 1: Pass Array Directly (Recommended)
const args = ['file.txt', '--public', '--verbose'];
await $`command ${args}`;
Option 2: Use Separate Interpolations
const file = 'file.txt';
const flags = ['--public', '--verbose'];
await $`command ${file} ${flags}`;
Option 3: Use Spread with Multiple Interpolations
// If you need dynamic argument building
const baseArgs = ['file.txt'];
const conditionalArgs = isVerbose ? ['--verbose'] : [];
await $`command ${[...baseArgs, ...conditionalArgs]}`;
Suggestions for Improvement
1. Documentation Enhancement
Add a prominent "Common Pitfalls" section to the README:
## ⚠️ Common Pitfalls
### Array Argument Handling
**Do NOT use `.join(' ')` on arrays before interpolation:**
```javascript
// ❌ WRONG - entire string becomes one argument
const args = ['file.txt', '--flag'];
await $`cmd ${args.join(' ')}`; // cmd receives 1 argument
// ✅ CORRECT - each element is a separate argument
await $`cmd ${args}`; // cmd receives 2 arguments
### 2. Runtime Warning (Optional Enhancement)
Consider detecting and warning about strings that look like they were incorrectly joined arrays:
```javascript
// In $.quote.mjs, quote() function
function quote(value) {
if (typeof value === 'string') {
// Warn if string contains CLI flag patterns that suggest pre-joined array
if (/\s+--?\w+/.test(value) && !value.startsWith('-')) {
console.warn(
`[command-stream] Warning: Interpolated value "${value.substring(0, 50)}..." ` +
`contains what looks like CLI flags. If this was an array, pass it directly ` +
`instead of using .join(' '). See: https://github.com/link-foundation/command-stream#array-pitfall`
);
}
}
// ... rest of quote logic
}
3. TypeScript Overload Hints
If using TypeScript, add JSDoc/type hints that discourage string interpolation for multi-argument values:
/**
* Template tag for executing shell commands.
*
* @example
* // For multiple arguments, pass an array directly:
* const args = ['--flag1', '--flag2'];
* await $`command ${args}`; // ✓ Correct
*
* // Do NOT join arrays first:
* await $`command ${args.join(' ')}`; // ✗ Wrong - single argument
*/
Real-World Impact
This issue caused a production bug in hive-mind repository (link-assistant/hive-mind#1096) where log upload commands failed with:
Error: File does not exist: "/path/to/log.txt --public --verbose"
The fix was changing from:
const commandArgs = [`${logFile}`, publicFlag];
if (verbose) commandArgs.push('--verbose');
await $`gh-upload-log ${commandArgs.join(' ')}`; // ❌ Bug
To:
await $`gh-upload-log ${logFile} ${publicFlag} --verbose`; // ✅ Fix
Environment
- command-stream version: latest
- Runtime: Bun/Node.js
- OS: Linux
Labels
documentation, enhancement