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.
- package/README.md +63 -0
- package/js/BEST-PRACTICES.md +376 -0
- package/js/docs/IMPLEMENTATION_NOTES.md +129 -0
- package/js/docs/SHELL_OPERATORS_IMPLEMENTATION.md +101 -0
- package/js/docs/case-studies/issue-144/README.md +215 -0
- package/js/docs/case-studies/issue-144/failures-summary.md +161 -0
- package/js/docs/case-studies/issue-146/README.md +244 -0
- package/js/docs/case-studies/issue-153/README.md +167 -0
- package/js/docs/shell-operators-implementation.md +97 -0
- package/js/tests/array-interpolation.test.mjs +329 -0
- package/package.json +1 -1
- package/rust/BEST-PRACTICES.md +382 -0
|
@@ -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