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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions