Skip to content

Commit 3c4e927

Browse files
authored
fix(cli-test): invoke commands without shell intermediate (#2582)
1 parent 1f91b1f commit 3c4e927

4 files changed

Lines changed: 55 additions & 94 deletions

File tree

.changeset/itchy-buttons-begin.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@slack/cli-test": patch
3+
---
4+
5+
fix: invoke commands without shell intermediate
6+
7+
Behind the scenes commands are now spawned direct to avoid unexpected input and output redirection or odd argument parsings. This is what happens and what changed:
8+
9+
Linux:
10+
11+
```diff
12+
- /bin/sh -c "slack trigger run --workflow #/workflows/give_kudos_workflow"
13+
+ execvp("slack", ["trigger", "run", "--workflow", "#/workflows/give_kudos_workflow"])
14+
```
15+
16+
Windows:
17+
18+
```diff
19+
- cmd.exe /s /c "slack trigger run --workflow #/workflows/give_kudos_workflow"
20+
+ CreateProcessW("slack", ["trigger", "run", "--workflow", "#/workflows/give_kudos_workflow"])
21+
```

packages/cli-test/src/cli/commands/datastore.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,6 @@ export interface DatastoreCommandArguments {
1414
queryExpressionValues: object;
1515
}
1616

17-
/**
18-
* Used to escape double quotes in JSON strings; this is needed when JSON is passed as a command line argument, which for the datastore commands, it is.
19-
*/
20-
function escapeJSON(obj: Record<string, unknown>): string {
21-
return `"${JSON.stringify(obj).replace(/"/g, '\\"')}"`;
22-
}
23-
2417
/**
2518
* `slack datastore get`
2619
* @returns command output
@@ -32,7 +25,7 @@ export const datastoreGet = async function datastoreGet(
3225
datastore: args.datastoreName,
3326
id: args.primaryKeyValue,
3427
};
35-
const cmd = new SlackCLIProcess(['datastore', 'get', escapeJSON(getQueryObj)], args);
28+
const cmd = new SlackCLIProcess(['datastore', 'get', JSON.stringify(getQueryObj)], args);
3629
const proc = await cmd.execAsync({
3730
cwd: args.appPath,
3831
});
@@ -50,7 +43,7 @@ export const datastoreDelete = async function datastoreDelete(
5043
datastore: args.datastoreName,
5144
id: args.primaryKeyValue,
5245
};
53-
const cmd = new SlackCLIProcess(['datastore', 'delete', escapeJSON(deleteQueryObj)], args);
46+
const cmd = new SlackCLIProcess(['datastore', 'delete', JSON.stringify(deleteQueryObj)], args);
5447
const proc = await cmd.execAsync({
5548
cwd: args.appPath,
5649
});
@@ -68,7 +61,7 @@ export const datastorePut = async function datastorePut(
6861
datastore: args.datastoreName,
6962
item: args.putItem,
7063
};
71-
const cmd = new SlackCLIProcess(['datastore', 'put', escapeJSON(putQueryObj)], args);
64+
const cmd = new SlackCLIProcess(['datastore', 'put', JSON.stringify(putQueryObj)], args);
7265
const proc = await cmd.execAsync({
7366
cwd: args.appPath,
7467
});
@@ -88,7 +81,7 @@ export const datastoreQuery = async function datastoreQuery(
8881
expression: args.queryExpression,
8982
expression_values: args.queryExpressionValues,
9083
};
91-
const cmd = new SlackCLIProcess(['datastore', 'query', escapeJSON(queryObj)], args);
84+
const cmd = new SlackCLIProcess(['datastore', 'query', JSON.stringify(queryObj)], args);
9285
const proc = await cmd.execAsync({
9386
cwd: args.appPath,
9487
});

packages/cli-test/src/cli/shell.test.ts

Lines changed: 28 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('shell module', () => {
6969
runSpy,
7070
sinon.match.string,
7171
sinon.match.array,
72-
sinon.match({ shell: true, env: fakeEnv }),
72+
sinon.match({ shell: false, env: fakeEnv }),
7373
);
7474
});
7575
it('should return the command outputs unchanged', () => {
@@ -85,35 +85,19 @@ describe('shell module', () => {
8585
shell.runCommandSync('about to explode', []);
8686
}, /this is bat country/);
8787
});
88-
if (process.platform === 'win32') {
89-
it('on Windows, should wrap command to shell out in a `cmd /s /c` wrapper process', () => {
90-
const fakeEnv = { HEY: 'yo' };
91-
sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv);
92-
const fakeCmd = 'echo';
93-
const fakeArgs = ['"hi there"'];
94-
shell.runCommandSync(fakeCmd, fakeArgs);
95-
sandbox.assert.calledWithMatch(
96-
runSpy,
97-
'cmd',
98-
sinon.match.array.contains(['/s', '/c', fakeCmd, ...fakeArgs]),
99-
sinon.match({ shell: true, env: fakeEnv }),
100-
);
101-
});
102-
} else {
103-
it('on non-Windows, should shell out to provided command directly', () => {
104-
const fakeEnv = { HEY: 'yo' };
105-
sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv);
106-
const fakeCmd = 'echo';
107-
const fakeArgs = ['"hi there"'];
108-
shell.runCommandSync(fakeCmd, fakeArgs);
109-
sandbox.assert.calledWithMatch(
110-
runSpy,
111-
fakeCmd,
112-
sinon.match.array.contains(fakeArgs),
113-
sinon.match({ shell: true, env: fakeEnv }),
114-
);
115-
});
116-
}
88+
it('should spawn without a shell', () => {
89+
const fakeEnv = { HEY: 'yo' };
90+
sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv);
91+
const fakeCmd = 'echo';
92+
const fakeArgs = ['"hi there"'];
93+
shell.runCommandSync(fakeCmd, fakeArgs);
94+
sandbox.assert.calledWithMatch(
95+
runSpy,
96+
fakeCmd,
97+
sinon.match.array.contains(fakeArgs),
98+
sinon.match({ shell: false, env: fakeEnv }),
99+
);
100+
});
117101
});
118102

119103
describe('spawnProcess method', () => {
@@ -128,7 +112,7 @@ describe('shell module', () => {
128112
spawnSpy,
129113
sinon.match.string,
130114
sinon.match.array,
131-
sinon.match({ shell: true, env: fakeEnv }),
115+
sinon.match({ shell: false, env: fakeEnv }),
132116
);
133117
});
134118
it('should return the command outputs unchanged', () => {
@@ -146,35 +130,19 @@ describe('shell module', () => {
146130
shell.spawnProcess('about to explode', []);
147131
}, /this is bat country/);
148132
});
149-
if (process.platform === 'win32') {
150-
it('on Windows, should wrap command to shell out in a `cmd /s /c` wrapper process', () => {
151-
const fakeEnv = { HEY: 'yo' };
152-
sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv);
153-
const fakeCmd = 'echo';
154-
const fakeArgs = ['"hi there"'];
155-
shell.spawnProcess(fakeCmd, fakeArgs);
156-
sandbox.assert.calledWithMatch(
157-
spawnSpy,
158-
'cmd',
159-
sinon.match.array.contains(['/s', '/c', fakeCmd, ...fakeArgs]),
160-
sinon.match({ shell: true, env: fakeEnv }),
161-
);
162-
});
163-
} else {
164-
it('on non-Windows, should shell out to provided command directly', () => {
165-
const fakeEnv = { HEY: 'yo' };
166-
sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv);
167-
const fakeCmd = 'echo';
168-
const fakeArgs = ['"hi there"'];
169-
shell.spawnProcess(fakeCmd, fakeArgs);
170-
sandbox.assert.calledWithMatch(
171-
spawnSpy,
172-
fakeCmd,
173-
sinon.match.array.contains(fakeArgs),
174-
sinon.match({ shell: true, env: fakeEnv }),
175-
);
176-
});
177-
}
133+
it('should spawn without a shell', () => {
134+
const fakeEnv = { HEY: 'yo' };
135+
sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv);
136+
const fakeCmd = 'echo';
137+
const fakeArgs = ['"hi there"'];
138+
shell.spawnProcess(fakeCmd, fakeArgs);
139+
sandbox.assert.calledWithMatch(
140+
spawnSpy,
141+
fakeCmd,
142+
sinon.match.array.contains(fakeArgs),
143+
sinon.match({ shell: false, env: fakeEnv }),
144+
);
145+
});
178146
});
179147

180148
describe('waitForOutput method', () => {

packages/cli-test/src/cli/shell.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -251,40 +251,19 @@ export const shell = {
251251
};
252252

253253
/**
254-
* @description Returns arguments used to pass into child_process.spawn or spawnSync. Handles Windows-specifics hacks.
254+
* @description Returns arguments used to pass into child_process.spawn or spawnSync.
255255
*/
256256
function getSpawnArguments(
257257
command: string,
258258
args: string[],
259259
env: ReturnType<typeof shell.assembleShellEnv>,
260260
shellOpts?: Partial<child.SpawnOptionsWithoutStdio>,
261261
): [string, string[], child.SpawnOptionsWithoutStdio] {
262-
if (process.platform === 'win32') {
263-
// In windows, we actually spawn a command prompt and tell it to invoke the CLI command.
264-
// The combination of windows and node's child_process spawning is complicated: on windows, child_process strips quotes from arguments. This makes passing JSON difficult.
265-
// As a workaround, we:
266-
// 1. Wrap the CLI command with a Windows Command Prompt (cmd.exe) process, and
267-
// 2. Execute the command to completion (via the /c option), and
268-
// 3. Leave spaces intact (via the /s option), and
269-
// 4. Feed the arguments as an argument array into `child_process.spawn`.
270-
// End-result is a process that looks like:
271-
// cmd.exe "/s" "/c" "slack" "app" "list"
272-
const windowsArgs = ['/s', '/c'].concat([command]).concat(args);
273-
return [
274-
'cmd',
275-
windowsArgs,
276-
{
277-
shell: true,
278-
env,
279-
...shellOpts,
280-
},
281-
];
282-
}
283262
return [
284263
command,
285264
args,
286265
{
287-
shell: true,
266+
shell: false,
288267
env,
289268
...shellOpts,
290269
},

0 commit comments

Comments
 (0)