command-stream 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ # Case Study: Array.join() Pitfall Causes Arguments to Merge (Issue #153)
2
+
3
+ ## Summary
4
+
5
+ This document provides a comprehensive analysis of the `Array.join()` pitfall in command-stream, where calling `.join(' ')` on an array before template interpolation causes all elements to be treated as a single argument instead of multiple separate arguments.
6
+
7
+ ## Timeline of Events
8
+
9
+ ### January 10, 2026
10
+
11
+ 1. **~00:10 UTC** - Production bug discovered in `hive-mind` repository (issue link-assistant/hive-mind#1096)
12
+ 2. **~00:10 UTC** - Log upload command failed with error: `File does not exist: "/tmp/solution-draft-log-pr-1768003849690.txt" --public --verbose`
13
+ 3. **~21:47 UTC** - Issue #153 created to document the pitfall and improve documentation
14
+
15
+ ## Real-World Impact
16
+
17
+ ### Production Bug in hive-mind#1096
18
+
19
+ The bug manifested in a log upload workflow where CLI arguments were incorrectly joined:
20
+
21
+ **Error Message:**
22
+
23
+ ```
24
+ Error: File does not exist: "/tmp/solution-draft-log-pr-1768003849690.txt" --public --verbose
25
+ ```
26
+
27
+ **Root Cause:**
28
+ The flags `--public` and `--verbose` were incorrectly merged into the file path as a single string argument. The gh-upload-log command received:
29
+
30
+ - Expected: 3 arguments: `"/tmp/solution-draft-log-pr-1768003849690.txt"`, `--public`, `--verbose`
31
+ - Actual: 1 argument: `"/tmp/solution-draft-log-pr-1768003849690.txt" --public --verbose`
32
+
33
+ **Original Buggy Code Pattern:**
34
+
35
+ ```javascript
36
+ const commandArgs = [`${logFile}`, publicFlag];
37
+ if (verbose) commandArgs.push('--verbose');
38
+ await $`gh-upload-log ${commandArgs.join(' ')}`; // BUG: Single string argument
39
+ ```
40
+
41
+ **Fixed Code Pattern:**
42
+
43
+ ```javascript
44
+ await $`gh-upload-log ${logFile} ${publicFlag} --verbose`; // Each value is a separate argument
45
+ ```
46
+
47
+ ## Technical Analysis
48
+
49
+ ### Why This Happens
50
+
51
+ The `buildShellCommand` function in `$.quote.mjs` handles arrays specially:
52
+
53
+ ```javascript
54
+ if (Array.isArray(value)) {
55
+ return value.map(quote).join(' '); // Each element quoted separately
56
+ }
57
+ ```
58
+
59
+ When an array is passed directly:
60
+
61
+ 1. Each element is individually quoted
62
+ 2. They are joined with spaces
63
+ 3. The shell receives multiple arguments
64
+
65
+ But when you call `.join(' ')` before passing to the template:
66
+
67
+ 1. The array becomes a string: `"file.txt --public --verbose"`
68
+ 2. Template receives a **string**, not an array
69
+ 3. The **entire string** gets quoted as one shell argument
70
+ 4. The command sees one argument containing spaces, not multiple arguments
71
+
72
+ ### Demonstration
73
+
74
+ ```javascript
75
+ // Direct array interpolation (CORRECT)
76
+ const args = ['file.txt', '--public', '--verbose'];
77
+ await $`command ${args}`;
78
+ // Executed: command file.txt --public --verbose
79
+ // Shell receives: ['command', 'file.txt', '--public', '--verbose']
80
+
81
+ // Pre-joined array (INCORRECT)
82
+ const args = ['file.txt', '--public', '--verbose'];
83
+ await $`command ${args.join(' ')}`;
84
+ // Executed: command 'file.txt --public --verbose'
85
+ // Shell receives: ['command', 'file.txt --public --verbose']
86
+ ```
87
+
88
+ ## Solutions
89
+
90
+ ### Correct Usage Patterns
91
+
92
+ #### 1. Pass Array Directly (Recommended)
93
+
94
+ ```javascript
95
+ const args = ['file.txt', '--public', '--verbose'];
96
+ await $`command ${args}`;
97
+ ```
98
+
99
+ #### 2. Use Separate Interpolations
100
+
101
+ ```javascript
102
+ const file = 'file.txt';
103
+ const flags = ['--public', '--verbose'];
104
+ await $`command ${file} ${flags}`;
105
+ ```
106
+
107
+ #### 3. Build Array Dynamically
108
+
109
+ ```javascript
110
+ const baseArgs = ['file.txt'];
111
+ const conditionalArgs = isVerbose ? ['--verbose'] : [];
112
+ const allArgs = [...baseArgs, ...conditionalArgs];
113
+ await $`command ${allArgs}`;
114
+ ```
115
+
116
+ ### Incorrect Usage (Anti-Patterns)
117
+
118
+ ```javascript
119
+ // DON'T DO THIS - array becomes single argument
120
+ await $`command ${args.join(' ')}`;
121
+
122
+ // DON'T DO THIS - template string becomes single argument
123
+ await $`command ${`${file} ${flag}`}`;
124
+
125
+ // DON'T DO THIS - manual string concatenation
126
+ await $`command ${file + ' ' + flag}`;
127
+ ```
128
+
129
+ ## Error Recognition
130
+
131
+ When you see errors like these, suspect the Array.join() pitfall:
132
+
133
+ 1. **File not found with flags in the path:**
134
+
135
+ ```
136
+ Error: File does not exist: "/path/to/file.txt --flag --option"
137
+ ```
138
+
139
+ 2. **Command received unexpected argument count:**
140
+
141
+ ```
142
+ Error: Expected 3 arguments, got 1
143
+ ```
144
+
145
+ 3. **Flags not recognized:**
146
+ ```
147
+ Error: Unknown option: "value --flag"
148
+ ```
149
+
150
+ ## Prevention Strategies
151
+
152
+ 1. **Never use `.join()` before template interpolation** - Pass arrays directly
153
+ 2. **Review string concatenation** - Ensure separate values stay separate
154
+ 3. **Test with special characters** - Include spaces and flags in test cases
155
+ 4. **Add debug logging** - Log the actual arguments being passed
156
+
157
+ ## Related Documentation
158
+
159
+ - [BEST-PRACTICES.md](../../BEST-PRACTICES.md) - Best practices for command-stream usage
160
+ - [README.md](../../../../README.md) - Common Pitfalls section
161
+ - [$.quote.mjs](../../../src/$.quote.mjs) - Quote function implementation
162
+
163
+ ## References
164
+
165
+ - Issue #153: https://github.com/link-foundation/command-stream/issues/153
166
+ - Production Bug: https://github.com/link-assistant/hive-mind/issues/1096
167
+ - Full Log: https://gist.github.com/konard/70a7c02ac0d1eee232dae2fbe5eeca7b
@@ -0,0 +1,97 @@
1
+ # Shell Operators Implementation Plan
2
+
3
+ ## Current State
4
+
5
+ - Commands with `&&`, `||`, `;`, `()` are passed to `sh -c` as a whole string
6
+ - This runs in a subprocess, so `cd` changes don't affect the parent process
7
+ - Virtual commands only work for simple commands and pipe chains
8
+
9
+ ## Required Changes
10
+
11
+ ### 1. Enhanced Command Parser
12
+
13
+ Need to parse these operators:
14
+
15
+ - `&&` - AND: execute next command only if previous succeeds (exit code 0)
16
+ - `||` - OR: execute next command only if previous fails (exit code != 0)
17
+ - `;` - SEMICOLON: execute next command regardless of previous result
18
+ - `()` - SUBSHELL: execute commands in a subshell (isolated environment)
19
+
20
+ ### 2. Command Execution Flow
21
+
22
+ ```javascript
23
+ // Current flow for "cd /tmp && ls"
24
+ 1. Detect && operator
25
+ 2. Pass entire string to sh -c "cd /tmp && ls"
26
+ 3. Subprocess executes, cd affects only subprocess
27
+
28
+ // New flow
29
+ 1. Parse: ["cd /tmp", "&&", "ls"]
30
+ 2. Execute "cd /tmp" via virtual command (changes process.cwd)
31
+ 3. If exit code == 0, execute "ls"
32
+ 4. Both commands see the changed directory
33
+ ```
34
+
35
+ ### 3. Subshell Handling
36
+
37
+ For `(cd /tmp && ls)`:
38
+
39
+ 1. Save current process.cwd()
40
+ 2. Execute commands inside ()
41
+ 3. Restore original cwd after subshell completes
42
+
43
+ ### 4. Parser Implementation
44
+
45
+ ```javascript
46
+ function parseShellCommand(command) {
47
+ // Parse operators while respecting:
48
+ // - Quoted strings "..." and '...'
49
+ // - Escaped characters
50
+ // - Nested parentheses
51
+
52
+ return {
53
+ type: 'sequence',
54
+ operators: ['&&', ';'],
55
+ commands: [
56
+ { type: 'simple', cmd: 'cd', args: ['/tmp'] },
57
+ { type: 'simple', cmd: 'ls', args: [] },
58
+ ],
59
+ };
60
+ }
61
+ ```
62
+
63
+ ### 5. Execution Engine
64
+
65
+ ```javascript
66
+ async function executeSequence(parsedCommand) {
67
+ let lastExitCode = 0;
68
+
69
+ for (let i = 0; i < parsedCommand.commands.length; i++) {
70
+ const command = parsedCommand.commands[i];
71
+ const operator = parsedCommand.operators[i - 1];
72
+
73
+ // Check operator conditions
74
+ if (operator === '&&' && lastExitCode !== 0) continue;
75
+ if (operator === '||' && lastExitCode === 0) continue;
76
+
77
+ // Execute command
78
+ const result = await executeCommand(command);
79
+ lastExitCode = result.code;
80
+ }
81
+ }
82
+ ```
83
+
84
+ ## Benefits
85
+
86
+ 1. Virtual commands work in all contexts
87
+ 2. `cd` behaves like real shell cd
88
+ 3. Consistent behavior across platforms
89
+ 4. Better control over execution flow
90
+
91
+ ## Testing Requirements
92
+
93
+ 1. Test all operators: `&&`, `||`, `;`
94
+ 2. Test subshells: `()`
95
+ 3. Test nested subshells: `(cd /tmp && (cd /usr && pwd))`
96
+ 4. Test with virtual and real commands mixed
97
+ 5. Test error handling and exit codes
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Tests for array interpolation in command-stream
3
+ *
4
+ * This test file covers the Array.join() pitfall documented in issue #153.
5
+ * The key insight: arrays passed directly to template interpolation are handled
6
+ * correctly (each element becomes a separate argument), but if you call .join(' ')
7
+ * before passing, the entire string becomes a single argument.
8
+ *
9
+ * @see https://github.com/link-foundation/command-stream/issues/153
10
+ * @see js/docs/case-studies/issue-153/README.md
11
+ * @see js/BEST-PRACTICES.md
12
+ */
13
+
14
+ import { $, quote } from '../src/$.mjs';
15
+ import { describe, test, expect } from 'bun:test';
16
+ import './test-helper.mjs'; // Automatically sets up beforeEach/afterEach cleanup
17
+
18
+ describe('Array Interpolation', () => {
19
+ describe('Direct Array Passing (Correct Usage)', () => {
20
+ test('should treat each array element as separate argument', () => {
21
+ const args = ['arg1', 'arg2', 'arg3'];
22
+ const cmd = $({ mirror: false })`echo ${args}`;
23
+
24
+ expect(cmd.spec.command).toBe('echo arg1 arg2 arg3');
25
+ });
26
+
27
+ test('should quote elements with spaces individually', () => {
28
+ const args = ['file with spaces.txt', '--verbose'];
29
+ const cmd = $({ mirror: false })`command ${args}`;
30
+
31
+ expect(cmd.spec.command).toBe("command 'file with spaces.txt' --verbose");
32
+ });
33
+
34
+ test('should handle empty array', () => {
35
+ const args = [];
36
+ const cmd = $({ mirror: false })`echo ${args}`;
37
+
38
+ expect(cmd.spec.command).toBe('echo ');
39
+ });
40
+
41
+ test('should handle single-element array', () => {
42
+ const args = ['single'];
43
+ const cmd = $({ mirror: false })`echo ${args}`;
44
+
45
+ expect(cmd.spec.command).toBe('echo single');
46
+ });
47
+
48
+ test('should handle array with special characters', () => {
49
+ const args = ['$var', '`command`', '$(sub)'];
50
+ const cmd = $({ mirror: false })`echo ${args}`;
51
+
52
+ // Each special character should be quoted
53
+ expect(cmd.spec.command).toBe("echo '$var' '`command`' '$(sub)'");
54
+ });
55
+
56
+ test('should handle array with flags correctly', () => {
57
+ const args = ['input.txt', '--public', '--verbose'];
58
+ const cmd = $({ mirror: false })`upload ${args}`;
59
+
60
+ expect(cmd.spec.command).toBe('upload input.txt --public --verbose');
61
+ });
62
+ });
63
+
64
+ describe('Pre-joined Array (Anti-pattern)', () => {
65
+ test('joined array becomes single argument with spaces', () => {
66
+ const args = ['file.txt', '--flag'];
67
+ // This is the anti-pattern - joining before interpolation
68
+ const joined = args.join(' ');
69
+ const cmd = $({ mirror: false })`command ${joined}`;
70
+
71
+ // The joined string gets quoted as ONE argument
72
+ expect(cmd.spec.command).toBe("command 'file.txt --flag'");
73
+ });
74
+
75
+ test('demonstrates the bug: flags become part of filename', () => {
76
+ // This reproduces the exact bug from hive-mind#1096
77
+ const args = ['/tmp/logfile.txt', '--public', '--verbose'];
78
+ const joined = args.join(' ');
79
+ const cmd = $({ mirror: false })`gh-upload-log ${joined}`;
80
+
81
+ // WRONG: The shell sees one argument containing spaces
82
+ expect(cmd.spec.command).toBe(
83
+ "gh-upload-log '/tmp/logfile.txt --public --verbose'"
84
+ );
85
+ // This would cause: Error: File does not exist: "/tmp/logfile.txt --public --verbose"
86
+ });
87
+
88
+ test('correct usage vs incorrect usage comparison', () => {
89
+ const args = ['file.txt', '--flag1', '--flag2'];
90
+
91
+ // CORRECT: Direct array interpolation
92
+ const correctCmd = $({ mirror: false })`cmd ${args}`;
93
+ expect(correctCmd.spec.command).toBe('cmd file.txt --flag1 --flag2');
94
+
95
+ // INCORRECT: Pre-joined array
96
+ const incorrectCmd = $({ mirror: false })`cmd ${args.join(' ')}`;
97
+ expect(incorrectCmd.spec.command).toBe("cmd 'file.txt --flag1 --flag2'");
98
+ });
99
+ });
100
+
101
+ describe('Mixed Interpolation Patterns', () => {
102
+ test('should handle multiple separate interpolations', () => {
103
+ const file = 'data.txt';
104
+ const flags = ['--verbose', '--force'];
105
+ const cmd = $({ mirror: false })`process ${file} ${flags}`;
106
+
107
+ expect(cmd.spec.command).toBe('process data.txt --verbose --force');
108
+ });
109
+
110
+ test('should handle array with conditional elements', () => {
111
+ const baseArgs = ['input.txt'];
112
+ const verbose = true;
113
+ const force = false;
114
+
115
+ if (verbose) {
116
+ baseArgs.push('--verbose');
117
+ }
118
+ if (force) {
119
+ baseArgs.push('--force');
120
+ }
121
+
122
+ const cmd = $({ mirror: false })`command ${baseArgs}`;
123
+ expect(cmd.spec.command).toBe('command input.txt --verbose');
124
+ });
125
+
126
+ test('should handle spread operator pattern', () => {
127
+ const files = ['file1.txt', 'file2.txt'];
128
+ const flags = ['--recursive'];
129
+ const allArgs = [...files, ...flags];
130
+
131
+ const cmd = $({ mirror: false })`copy ${allArgs}`;
132
+ expect(cmd.spec.command).toBe('copy file1.txt file2.txt --recursive');
133
+ });
134
+ });
135
+
136
+ describe('Real-World Use Cases', () => {
137
+ test('git command with multiple flags', () => {
138
+ const flags = ['--oneline', '--graph', '--all'];
139
+ const cmd = $({ mirror: false })`git log ${flags}`;
140
+
141
+ expect(cmd.spec.command).toBe('git log --oneline --graph --all');
142
+ });
143
+
144
+ test('npm install with packages', () => {
145
+ const packages = ['lodash', 'express', 'typescript'];
146
+ const cmd = $({ mirror: false })`npm install ${packages}`;
147
+
148
+ expect(cmd.spec.command).toBe('npm install lodash express typescript');
149
+ });
150
+
151
+ test('file operations with paths containing spaces', () => {
152
+ const files = ['My Documents/file1.txt', 'Other Folder/file2.txt'];
153
+ const cmd = $({ mirror: false })`cat ${files}`;
154
+
155
+ expect(cmd.spec.command).toBe(
156
+ "cat 'My Documents/file1.txt' 'Other Folder/file2.txt'"
157
+ );
158
+ });
159
+
160
+ test('docker command with environment variables', () => {
161
+ const envVars = ['-e', 'NODE_ENV=production', '-e', 'DEBUG=false'];
162
+ const cmd = $({ mirror: false })`docker run ${envVars} myimage`;
163
+
164
+ expect(cmd.spec.command).toBe(
165
+ 'docker run -e NODE_ENV=production -e DEBUG=false myimage'
166
+ );
167
+ });
168
+
169
+ test('rsync with exclude patterns', () => {
170
+ const excludes = ['--exclude', 'node_modules', '--exclude', '.git'];
171
+ const cmd = $({ mirror: false })`rsync -av ${excludes} src/ dest/`;
172
+
173
+ expect(cmd.spec.command).toBe(
174
+ 'rsync -av --exclude node_modules --exclude .git src/ dest/'
175
+ );
176
+ });
177
+ });
178
+
179
+ describe('Edge Cases', () => {
180
+ test('array with empty strings', () => {
181
+ const args = ['', 'arg', ''];
182
+ const cmd = $({ mirror: false })`echo ${args}`;
183
+
184
+ expect(cmd.spec.command).toBe("echo '' arg ''");
185
+ });
186
+
187
+ test('array with null-ish values coerced to strings', () => {
188
+ const args = [null, undefined, 'valid'];
189
+ const cmd = $({ mirror: false })`echo ${args}`;
190
+
191
+ // null and undefined become empty strings
192
+ expect(cmd.spec.command).toBe("echo '' '' valid");
193
+ });
194
+
195
+ test('nested arrays are flattened by the user (not automatic)', () => {
196
+ // Note: nested arrays are not automatically flattened
197
+ // Users should flatten them before passing
198
+ const nested = [['a', 'b'], 'c'];
199
+ const flattened = nested.flat();
200
+ const cmd = $({ mirror: false })`echo ${flattened}`;
201
+
202
+ expect(cmd.spec.command).toBe('echo a b c');
203
+ });
204
+
205
+ test('array with numbers', () => {
206
+ const args = [1, 2, 3];
207
+ const cmd = $({ mirror: false })`seq ${args}`;
208
+
209
+ expect(cmd.spec.command).toBe('seq 1 2 3');
210
+ });
211
+
212
+ test('array with boolean coercion', () => {
213
+ const args = [true, false];
214
+ const cmd = $({ mirror: false })`echo ${args}`;
215
+
216
+ expect(cmd.spec.command).toBe('echo true false');
217
+ });
218
+ });
219
+ });
220
+
221
+ describe('quote() Function Direct Tests', () => {
222
+ test('quote function handles arrays correctly', () => {
223
+ const args = ['file.txt', '--flag'];
224
+ const result = quote(args);
225
+
226
+ expect(result).toBe('file.txt --flag');
227
+ });
228
+
229
+ test('quote function handles nested arrays', () => {
230
+ const args = ['safe', 'has space'];
231
+ const result = quote(args);
232
+
233
+ expect(result).toBe("safe 'has space'");
234
+ });
235
+
236
+ test('quote function handles mixed safe and unsafe elements', () => {
237
+ const args = ['safe', '$dangerous', 'also-safe'];
238
+ const result = quote(args);
239
+
240
+ expect(result).toBe("safe '$dangerous' also-safe");
241
+ });
242
+ });
243
+
244
+ describe('Functional Tests (Command Execution)', () => {
245
+ test('array arguments work correctly with real command', async () => {
246
+ const args = ['hello', 'world'];
247
+ const result = await $({ mirror: false, capture: true })`echo ${args}`;
248
+
249
+ expect(result.code).toBe(0);
250
+ expect(result.stdout.trim()).toBe('hello world');
251
+ });
252
+
253
+ test('pre-joined array creates single argument (demonstrates bug)', async () => {
254
+ // This test shows that pre-joined arrays cause the bug
255
+ const args = ['hello', 'world'];
256
+ const joined = args.join(' ');
257
+ const result = await $({ mirror: false, capture: true })`echo ${joined}`;
258
+
259
+ expect(result.code).toBe(0);
260
+ // Output is the same in this case because echo just prints
261
+ // But the shell received it as a single quoted argument
262
+ expect(result.stdout.trim()).toBe('hello world');
263
+ });
264
+
265
+ test('array with spaces handled correctly', async () => {
266
+ // Create test files to demonstrate proper argument handling
267
+ const result = await $({ mirror: false, capture: true })`echo ${'one two'}`;
268
+
269
+ // 'one two' is passed as single argument (quoted)
270
+ expect(result.stdout.trim()).toBe('one two');
271
+ });
272
+
273
+ test('array elements become separate arguments for wc', async () => {
274
+ // wc -w counts words - this shows that arguments are properly separated
275
+ const args = ['a', 'b', 'c'];
276
+
277
+ // Create a test that shows echo receives separate args
278
+ const result = await $({
279
+ mirror: false,
280
+ capture: true,
281
+ })`/bin/sh -c 'echo $#'`;
282
+ expect(result.code).toBe(0);
283
+ // Shell received 0 extra args (just the -c and script)
284
+ });
285
+ });
286
+
287
+ describe('Documentation Examples Verification', () => {
288
+ test('README Common Pitfalls example - incorrect usage', () => {
289
+ const args = ['file.txt', '--public', '--verbose'];
290
+ const cmd = $({ mirror: false })`command ${args.join(' ')}`;
291
+
292
+ // This demonstrates the bug: one argument instead of three
293
+ expect(cmd.spec.command).toBe("command 'file.txt --public --verbose'");
294
+ });
295
+
296
+ test('README Common Pitfalls example - correct usage', () => {
297
+ const args = ['file.txt', '--public', '--verbose'];
298
+ const cmd = $({ mirror: false })`command ${args}`;
299
+
300
+ // Correct: three separate arguments
301
+ expect(cmd.spec.command).toBe('command file.txt --public --verbose');
302
+ });
303
+
304
+ test('BEST-PRACTICES.md Pattern 1: Direct array passing', () => {
305
+ const args = ['file.txt', '--verbose'];
306
+ const cmd = $({ mirror: false })`command ${args}`;
307
+
308
+ expect(cmd.spec.command).toBe('command file.txt --verbose');
309
+ });
310
+
311
+ test('BEST-PRACTICES.md Pattern 2: Separate interpolations', () => {
312
+ const file = 'file.txt';
313
+ const flags = ['--verbose', '--force'];
314
+ const cmd = $({ mirror: false })`command ${file} ${flags}`;
315
+
316
+ expect(cmd.spec.command).toBe('command file.txt --verbose --force');
317
+ });
318
+
319
+ test('BEST-PRACTICES.md Pattern 3: Build array dynamically', () => {
320
+ const verbose = true;
321
+ const allArgs = ['input.txt'];
322
+ if (verbose) {
323
+ allArgs.push('--verbose');
324
+ }
325
+ const cmd = $({ mirror: false })`command ${allArgs}`;
326
+
327
+ expect(cmd.spec.command).toBe('command input.txt --verbose');
328
+ });
329
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "command-stream",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Modern $ shell utility library with streaming, async iteration, and EventEmitter support, optimized for Bun runtime",
5
5
  "type": "module",
6
6
  "main": "js/src/$.mjs",