Skip to content

Documentation: Array.join() pitfall causes arguments to merge into single string #153

@konard

Description

@konard

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:

  1. The array becomes a string: "file.txt --public --verbose"
  2. Template receives a string, not an array
  3. The string gets quoted as a single shell argument
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions