From 417e2638d2321ec635f1c378e1e56034d24036b7 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 6 May 2026 09:13:47 -0700 Subject: [PATCH 01/11] fix(cli-test): remove cmd.exe wrapping on Windows to fix Docker container hangs In Windows Server Docker containers, processes hang when they inherit Docker's entrypoint pipe handles. Using cmd.exe (via shell:true or the explicit /s /c wrapper) triggers this issue. This change spawns the CLI binary directly on Windows with shell:false. Also simplifies escapeJSON in datastore commands since outer quote wrapping is no longer needed without cmd.exe consuming them. Co-Authored-By: Claude --- .../cli-test/src/cli/commands/datastore.ts | 4 ++-- packages/cli-test/src/cli/shell.test.ts | 16 +++++++------- packages/cli-test/src/cli/shell.ts | 21 +++++++------------ 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/packages/cli-test/src/cli/commands/datastore.ts b/packages/cli-test/src/cli/commands/datastore.ts index e2def3bdd..18da09e0a 100644 --- a/packages/cli-test/src/cli/commands/datastore.ts +++ b/packages/cli-test/src/cli/commands/datastore.ts @@ -15,10 +15,10 @@ export interface DatastoreCommandArguments { } /** - * 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. + * Serializes an object to a JSON string for passing as a command line argument. */ function escapeJSON(obj: Record): string { - return `"${JSON.stringify(obj).replace(/"/g, '\\"')}"`; + return JSON.stringify(obj); } /** diff --git a/packages/cli-test/src/cli/shell.test.ts b/packages/cli-test/src/cli/shell.test.ts index 3f71b018a..02b4aae50 100644 --- a/packages/cli-test/src/cli/shell.test.ts +++ b/packages/cli-test/src/cli/shell.test.ts @@ -86,7 +86,7 @@ describe('shell module', () => { }, /this is bat country/); }); if (process.platform === 'win32') { - it('on Windows, should wrap command to shell out in a `cmd /s /c` wrapper process', () => { + it('on Windows, should spawn the command directly without cmd.exe', () => { const fakeEnv = { HEY: 'yo' }; sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); const fakeCmd = 'echo'; @@ -94,9 +94,9 @@ describe('shell module', () => { shell.runCommandSync(fakeCmd, fakeArgs); sandbox.assert.calledWithMatch( runSpy, - 'cmd', - sinon.match.array.contains(['/s', '/c', fakeCmd, ...fakeArgs]), - sinon.match({ shell: true, env: fakeEnv }), + fakeCmd, + sinon.match.array.contains(fakeArgs), + sinon.match({ shell: false, env: fakeEnv }), ); }); } else { @@ -147,7 +147,7 @@ describe('shell module', () => { }, /this is bat country/); }); if (process.platform === 'win32') { - it('on Windows, should wrap command to shell out in a `cmd /s /c` wrapper process', () => { + it('on Windows, should spawn the command directly without cmd.exe', () => { const fakeEnv = { HEY: 'yo' }; sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); const fakeCmd = 'echo'; @@ -155,9 +155,9 @@ describe('shell module', () => { shell.spawnProcess(fakeCmd, fakeArgs); sandbox.assert.calledWithMatch( spawnSpy, - 'cmd', - sinon.match.array.contains(['/s', '/c', fakeCmd, ...fakeArgs]), - sinon.match({ shell: true, env: fakeEnv }), + fakeCmd, + sinon.match.array.contains(fakeArgs), + sinon.match({ shell: false, env: fakeEnv }), ); }); } else { diff --git a/packages/cli-test/src/cli/shell.ts b/packages/cli-test/src/cli/shell.ts index a07abd077..22e96149c 100644 --- a/packages/cli-test/src/cli/shell.ts +++ b/packages/cli-test/src/cli/shell.ts @@ -251,7 +251,7 @@ export const shell = { }; /** - * @description Returns arguments used to pass into child_process.spawn or spawnSync. Handles Windows-specifics hacks. + * @description Returns arguments used to pass into child_process.spawn or spawnSync. Handles Windows-specifics. */ function getSpawnArguments( command: string, @@ -260,21 +260,14 @@ function getSpawnArguments( shellOpts?: Partial, ): [string, string[], child.SpawnOptionsWithoutStdio] { if (process.platform === 'win32') { - // In windows, we actually spawn a command prompt and tell it to invoke the CLI command. - // 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. - // As a workaround, we: - // 1. Wrap the CLI command with a Windows Command Prompt (cmd.exe) process, and - // 2. Execute the command to completion (via the /c option), and - // 3. Leave spaces intact (via the /s option), and - // 4. Feed the arguments as an argument array into `child_process.spawn`. - // End-result is a process that looks like: - // cmd.exe "/s" "/c" "slack" "app" "list" - const windowsArgs = ['/s', '/c'].concat([command]).concat(args); + // Spawn the CLI binary directly without cmd.exe. Using cmd.exe or shell:true + // causes processes to hang in Windows Docker containers due to pipe handle + // inheritance from the Docker entrypoint. return [ - 'cmd', - windowsArgs, + command, + args, { - shell: true, + shell: false, env, ...shellOpts, }, From 9bbce7ae91ac716706bf085149ebab3e19794398 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 6 May 2026 09:55:05 -0700 Subject: [PATCH 02/11] fix(cli-test): make shell tests platform-aware for Windows shell:false The generic env parameter tests hardcoded shell:true but on Windows it's now shell:false. Use process.platform to set the expected value. Co-Authored-By: Claude --- packages/cli-test/src/cli/shell.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli-test/src/cli/shell.test.ts b/packages/cli-test/src/cli/shell.test.ts index 02b4aae50..ff2596a85 100644 --- a/packages/cli-test/src/cli/shell.test.ts +++ b/packages/cli-test/src/cli/shell.test.ts @@ -65,11 +65,12 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.runCommandSync(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); + const expectedShell = process.platform !== 'win32'; sandbox.assert.calledWithMatch( runSpy, sinon.match.string, sinon.match.array, - sinon.match({ shell: true, env: fakeEnv }), + sinon.match({ shell: expectedShell, env: fakeEnv }), ); }); it('should return the command outputs unchanged', () => { @@ -124,11 +125,12 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.spawnProcess(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); + const expectedShell = process.platform !== 'win32'; sandbox.assert.calledWithMatch( spawnSpy, sinon.match.string, sinon.match.array, - sinon.match({ shell: true, env: fakeEnv }), + sinon.match({ shell: expectedShell, env: fakeEnv }), ); }); it('should return the command outputs unchanged', () => { From 7e95328b269a676645fb3c37851aa833d0795987 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 6 May 2026 11:18:14 -0700 Subject: [PATCH 03/11] fix(cli-test): use shell:false on all platforms Using shell:true causes issues with special characters like # being interpreted as comments. Spawning the CLI binary directly with shell:false avoids both the Windows Docker hang and the need for outer-quote hacks to protect shell metacharacters. Co-Authored-By: Claude --- packages/cli-test/src/cli/shell.test.ts | 88 ++++++++----------------- packages/cli-test/src/cli/shell.ts | 2 +- 2 files changed, 29 insertions(+), 61 deletions(-) diff --git a/packages/cli-test/src/cli/shell.test.ts b/packages/cli-test/src/cli/shell.test.ts index ff2596a85..05d6a68f7 100644 --- a/packages/cli-test/src/cli/shell.test.ts +++ b/packages/cli-test/src/cli/shell.test.ts @@ -65,7 +65,7 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.runCommandSync(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); - const expectedShell = process.platform !== 'win32'; + const expectedShell = false; sandbox.assert.calledWithMatch( runSpy, sinon.match.string, @@ -86,35 +86,19 @@ describe('shell module', () => { shell.runCommandSync('about to explode', []); }, /this is bat country/); }); - if (process.platform === 'win32') { - it('on Windows, should spawn the command directly without cmd.exe', () => { - const fakeEnv = { HEY: 'yo' }; - sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); - const fakeCmd = 'echo'; - const fakeArgs = ['"hi there"']; - shell.runCommandSync(fakeCmd, fakeArgs); - sandbox.assert.calledWithMatch( - runSpy, - fakeCmd, - sinon.match.array.contains(fakeArgs), - sinon.match({ shell: false, env: fakeEnv }), - ); - }); - } else { - it('on non-Windows, should shell out to provided command directly', () => { - const fakeEnv = { HEY: 'yo' }; - sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); - const fakeCmd = 'echo'; - const fakeArgs = ['"hi there"']; - shell.runCommandSync(fakeCmd, fakeArgs); - sandbox.assert.calledWithMatch( - runSpy, - fakeCmd, - sinon.match.array.contains(fakeArgs), - sinon.match({ shell: true, env: fakeEnv }), - ); - }); - } + it('should spawn the command directly without a shell', () => { + const fakeEnv = { HEY: 'yo' }; + sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); + const fakeCmd = 'echo'; + const fakeArgs = ['"hi there"']; + shell.runCommandSync(fakeCmd, fakeArgs); + sandbox.assert.calledWithMatch( + runSpy, + fakeCmd, + sinon.match.array.contains(fakeArgs), + sinon.match({ shell: false, env: fakeEnv }), + ); + }); }); describe('spawnProcess method', () => { @@ -125,7 +109,7 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.spawnProcess(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); - const expectedShell = process.platform !== 'win32'; + const expectedShell = false; sandbox.assert.calledWithMatch( spawnSpy, sinon.match.string, @@ -148,35 +132,19 @@ describe('shell module', () => { shell.spawnProcess('about to explode', []); }, /this is bat country/); }); - if (process.platform === 'win32') { - it('on Windows, should spawn the command directly without cmd.exe', () => { - const fakeEnv = { HEY: 'yo' }; - sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); - const fakeCmd = 'echo'; - const fakeArgs = ['"hi there"']; - shell.spawnProcess(fakeCmd, fakeArgs); - sandbox.assert.calledWithMatch( - spawnSpy, - fakeCmd, - sinon.match.array.contains(fakeArgs), - sinon.match({ shell: false, env: fakeEnv }), - ); - }); - } else { - it('on non-Windows, should shell out to provided command directly', () => { - const fakeEnv = { HEY: 'yo' }; - sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); - const fakeCmd = 'echo'; - const fakeArgs = ['"hi there"']; - shell.spawnProcess(fakeCmd, fakeArgs); - sandbox.assert.calledWithMatch( - spawnSpy, - fakeCmd, - sinon.match.array.contains(fakeArgs), - sinon.match({ shell: true, env: fakeEnv }), - ); - }); - } + it('should spawn the command directly without a shell', () => { + const fakeEnv = { HEY: 'yo' }; + sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); + const fakeCmd = 'echo'; + const fakeArgs = ['"hi there"']; + shell.spawnProcess(fakeCmd, fakeArgs); + sandbox.assert.calledWithMatch( + spawnSpy, + fakeCmd, + sinon.match.array.contains(fakeArgs), + sinon.match({ shell: false, env: fakeEnv }), + ); + }); }); describe('waitForOutput method', () => { diff --git a/packages/cli-test/src/cli/shell.ts b/packages/cli-test/src/cli/shell.ts index 22e96149c..ca3261866 100644 --- a/packages/cli-test/src/cli/shell.ts +++ b/packages/cli-test/src/cli/shell.ts @@ -277,7 +277,7 @@ function getSpawnArguments( command, args, { - shell: true, + shell: false, env, ...shellOpts, }, From 5c75ad8040a8d631c6243c6f4bbc88b3d4832f37 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 6 May 2026 13:12:11 -0700 Subject: [PATCH 04/11] refactor(cli-test): simplify getSpawnArguments, inline escapeJSON - Remove redundant platform if-branch in getSpawnArguments (both paths were identical after shell:false change) - Inline escapeJSON as JSON.stringify at call sites - Inline expectedShell constant directly in test assertions Co-Authored-By: Claude --- packages/cli-test/src/cli/commands/datastore.ts | 15 ++++----------- packages/cli-test/src/cli/shell.test.ts | 6 ++---- packages/cli-test/src/cli/shell.ts | 16 +--------------- 3 files changed, 7 insertions(+), 30 deletions(-) diff --git a/packages/cli-test/src/cli/commands/datastore.ts b/packages/cli-test/src/cli/commands/datastore.ts index 18da09e0a..1387a52da 100644 --- a/packages/cli-test/src/cli/commands/datastore.ts +++ b/packages/cli-test/src/cli/commands/datastore.ts @@ -14,13 +14,6 @@ export interface DatastoreCommandArguments { queryExpressionValues: object; } -/** - * Serializes an object to a JSON string for passing as a command line argument. - */ -function escapeJSON(obj: Record): string { - return JSON.stringify(obj); -} - /** * `slack datastore get` * @returns command output @@ -32,7 +25,7 @@ export const datastoreGet = async function datastoreGet( datastore: args.datastoreName, id: args.primaryKeyValue, }; - const cmd = new SlackCLIProcess(['datastore', 'get', escapeJSON(getQueryObj)], args); + const cmd = new SlackCLIProcess(['datastore', 'get', JSON.stringify(getQueryObj)], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -50,7 +43,7 @@ export const datastoreDelete = async function datastoreDelete( datastore: args.datastoreName, id: args.primaryKeyValue, }; - const cmd = new SlackCLIProcess(['datastore', 'delete', escapeJSON(deleteQueryObj)], args); + const cmd = new SlackCLIProcess(['datastore', 'delete', JSON.stringify(deleteQueryObj)], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -68,7 +61,7 @@ export const datastorePut = async function datastorePut( datastore: args.datastoreName, item: args.putItem, }; - const cmd = new SlackCLIProcess(['datastore', 'put', escapeJSON(putQueryObj)], args); + const cmd = new SlackCLIProcess(['datastore', 'put', JSON.stringify(putQueryObj)], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -88,7 +81,7 @@ export const datastoreQuery = async function datastoreQuery( expression: args.queryExpression, expression_values: args.queryExpressionValues, }; - const cmd = new SlackCLIProcess(['datastore', 'query', escapeJSON(queryObj)], args); + const cmd = new SlackCLIProcess(['datastore', 'query', JSON.stringify(queryObj)], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); diff --git a/packages/cli-test/src/cli/shell.test.ts b/packages/cli-test/src/cli/shell.test.ts index 05d6a68f7..67900ef15 100644 --- a/packages/cli-test/src/cli/shell.test.ts +++ b/packages/cli-test/src/cli/shell.test.ts @@ -65,12 +65,11 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.runCommandSync(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); - const expectedShell = false; sandbox.assert.calledWithMatch( runSpy, sinon.match.string, sinon.match.array, - sinon.match({ shell: expectedShell, env: fakeEnv }), + sinon.match({ shell: false, env: fakeEnv }), ); }); it('should return the command outputs unchanged', () => { @@ -109,12 +108,11 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.spawnProcess(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); - const expectedShell = false; sandbox.assert.calledWithMatch( spawnSpy, sinon.match.string, sinon.match.array, - sinon.match({ shell: expectedShell, env: fakeEnv }), + sinon.match({ shell: false, env: fakeEnv }), ); }); it('should return the command outputs unchanged', () => { diff --git a/packages/cli-test/src/cli/shell.ts b/packages/cli-test/src/cli/shell.ts index ca3261866..9e6858fc5 100644 --- a/packages/cli-test/src/cli/shell.ts +++ b/packages/cli-test/src/cli/shell.ts @@ -251,7 +251,7 @@ export const shell = { }; /** - * @description Returns arguments used to pass into child_process.spawn or spawnSync. Handles Windows-specifics. + * @description Returns arguments used to pass into child_process.spawn or spawnSync. */ function getSpawnArguments( command: string, @@ -259,20 +259,6 @@ function getSpawnArguments( env: ReturnType, shellOpts?: Partial, ): [string, string[], child.SpawnOptionsWithoutStdio] { - if (process.platform === 'win32') { - // Spawn the CLI binary directly without cmd.exe. Using cmd.exe or shell:true - // causes processes to hang in Windows Docker containers due to pipe handle - // inheritance from the Docker entrypoint. - return [ - command, - args, - { - shell: false, - env, - ...shellOpts, - }, - ]; - } return [ command, args, From a77f94df7692ac928f60c21513c6c5d9115f3fe5 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 6 May 2026 13:24:26 -0700 Subject: [PATCH 05/11] chore: release @slack/cli-test@3.0.2-rc.1 --- packages/cli-test/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-test/package.json b/packages/cli-test/package.json index ac23d0f5e..475eb7879 100644 --- a/packages/cli-test/package.json +++ b/packages/cli-test/package.json @@ -1,6 +1,6 @@ { "name": "@slack/cli-test", - "version": "3.0.1", + "version": "3.0.2-rc.1", "description": "Node.js bindings for the Slack CLI for use in automated testing", "author": "Salesforce, Inc.", "license": "MIT", From fa34c5b97ab86b9f4260f46ccaf8ec88a10261ce Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 6 May 2026 15:57:00 -0700 Subject: [PATCH 06/11] fix: use shell:true on Linux with proper quoting, shell:false on Windows The Go CLI flag parser fails to receive values starting with # (like #/workflows/give_kudos_workflow) when passed as direct argv entries via shell:false on Linux. Reverting to shell:true on non-Windows with single-quote escaping for each argument protects special characters. Windows retains shell:false to avoid Docker pipe handle inheritance hangs. Co-Authored-By: Claude --- packages/cli-test/package.json | 2 +- packages/cli-test/src/cli/shell.test.ts | 20 +++++++++++------- packages/cli-test/src/cli/shell.ts | 28 +++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/cli-test/package.json b/packages/cli-test/package.json index 475eb7879..9c2c21148 100644 --- a/packages/cli-test/package.json +++ b/packages/cli-test/package.json @@ -1,6 +1,6 @@ { "name": "@slack/cli-test", - "version": "3.0.2-rc.1", + "version": "3.0.2-rc.2", "description": "Node.js bindings for the Slack CLI for use in automated testing", "author": "Salesforce, Inc.", "license": "MIT", diff --git a/packages/cli-test/src/cli/shell.test.ts b/packages/cli-test/src/cli/shell.test.ts index 67900ef15..f9ecdae97 100644 --- a/packages/cli-test/src/cli/shell.test.ts +++ b/packages/cli-test/src/cli/shell.test.ts @@ -65,11 +65,12 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.runCommandSync(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); + const expectedShell = process.platform === 'win32' ? false : true; sandbox.assert.calledWithMatch( runSpy, sinon.match.string, sinon.match.array, - sinon.match({ shell: false, env: fakeEnv }), + sinon.match({ shell: expectedShell, env: fakeEnv }), ); }); it('should return the command outputs unchanged', () => { @@ -85,17 +86,18 @@ describe('shell module', () => { shell.runCommandSync('about to explode', []); }, /this is bat country/); }); - it('should spawn the command directly without a shell', () => { + it('should use shell:false on Windows and shell:true on other platforms', () => { const fakeEnv = { HEY: 'yo' }; sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); const fakeCmd = 'echo'; const fakeArgs = ['"hi there"']; shell.runCommandSync(fakeCmd, fakeArgs); + const expectedShell = process.platform === 'win32' ? false : true; sandbox.assert.calledWithMatch( runSpy, fakeCmd, - sinon.match.array.contains(fakeArgs), - sinon.match({ shell: false, env: fakeEnv }), + sinon.match.array, + sinon.match({ shell: expectedShell, env: fakeEnv }), ); }); }); @@ -108,11 +110,12 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.spawnProcess(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); + const expectedShell = process.platform === 'win32' ? false : true; sandbox.assert.calledWithMatch( spawnSpy, sinon.match.string, sinon.match.array, - sinon.match({ shell: false, env: fakeEnv }), + sinon.match({ shell: expectedShell, env: fakeEnv }), ); }); it('should return the command outputs unchanged', () => { @@ -130,17 +133,18 @@ describe('shell module', () => { shell.spawnProcess('about to explode', []); }, /this is bat country/); }); - it('should spawn the command directly without a shell', () => { + it('should use shell:false on Windows and shell:true on other platforms', () => { const fakeEnv = { HEY: 'yo' }; sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); const fakeCmd = 'echo'; const fakeArgs = ['"hi there"']; shell.spawnProcess(fakeCmd, fakeArgs); + const expectedShell = process.platform === 'win32' ? false : true; sandbox.assert.calledWithMatch( spawnSpy, fakeCmd, - sinon.match.array.contains(fakeArgs), - sinon.match({ shell: false, env: fakeEnv }), + sinon.match.array, + sinon.match({ shell: expectedShell, env: fakeEnv }), ); }); }); diff --git a/packages/cli-test/src/cli/shell.ts b/packages/cli-test/src/cli/shell.ts index 9e6858fc5..dc4ca688a 100644 --- a/packages/cli-test/src/cli/shell.ts +++ b/packages/cli-test/src/cli/shell.ts @@ -252,6 +252,8 @@ export const shell = { /** * @description Returns arguments used to pass into child_process.spawn or spawnSync. + * On Windows, shell is set to false to avoid Docker pipe handle inheritance hangs. + * On other platforms, shell is set to true for compatibility with Go CLI flag parsing. */ function getSpawnArguments( command: string, @@ -259,13 +261,35 @@ function getSpawnArguments( env: ReturnType, shellOpts?: Partial, ): [string, string[], child.SpawnOptionsWithoutStdio] { + if (process.platform === 'win32') { + return [ + command, + args, + { + shell: false, + env, + ...shellOpts, + }, + ]; + } + // Shell-quote each argument to protect special characters (e.g. #) and spaces return [ command, - args, + args.map(shellQuote), { - shell: false, + shell: true, env, ...shellOpts, }, ]; } + +/** + * @description Wraps a string in single quotes for safe shell interpolation. + * Single quotes prevent all shell interpretation; embedded single quotes are + * handled by ending the quoted segment, adding an escaped single quote, and + * restarting the quoted segment. + */ +function shellQuote(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} From 75c939a85320a495e853eb762b86ee8df7993a9d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 6 May 2026 15:57:29 -0700 Subject: [PATCH 07/11] style: simplify ternary to !== comparison per biome lint Co-Authored-By: Claude --- packages/cli-test/src/cli/shell.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli-test/src/cli/shell.test.ts b/packages/cli-test/src/cli/shell.test.ts index f9ecdae97..eb889688a 100644 --- a/packages/cli-test/src/cli/shell.test.ts +++ b/packages/cli-test/src/cli/shell.test.ts @@ -65,7 +65,7 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.runCommandSync(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); - const expectedShell = process.platform === 'win32' ? false : true; + const expectedShell = process.platform !== 'win32'; sandbox.assert.calledWithMatch( runSpy, sinon.match.string, @@ -92,7 +92,7 @@ describe('shell module', () => { const fakeCmd = 'echo'; const fakeArgs = ['"hi there"']; shell.runCommandSync(fakeCmd, fakeArgs); - const expectedShell = process.platform === 'win32' ? false : true; + const expectedShell = process.platform !== 'win32'; sandbox.assert.calledWithMatch( runSpy, fakeCmd, @@ -110,7 +110,7 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.spawnProcess(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); - const expectedShell = process.platform === 'win32' ? false : true; + const expectedShell = process.platform !== 'win32'; sandbox.assert.calledWithMatch( spawnSpy, sinon.match.string, @@ -139,7 +139,7 @@ describe('shell module', () => { const fakeCmd = 'echo'; const fakeArgs = ['"hi there"']; shell.spawnProcess(fakeCmd, fakeArgs); - const expectedShell = process.platform === 'win32' ? false : true; + const expectedShell = process.platform !== 'win32'; sandbox.assert.calledWithMatch( spawnSpy, fakeCmd, From ddd22618b116972e36531517fbce8f318d3cf435 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 6 May 2026 18:11:55 -0700 Subject: [PATCH 08/11] fix: use shell:false on all platforms, remove shellQuote shell:false passes args directly to the process without any shell interpretation. This is simpler and avoids the need for quoting special characters like #. The original shell:true + cmd.exe workaround was only needed because child_process strips quotes when using a shell. Co-Authored-By: Claude --- packages/cli-test/package.json | 2 +- packages/cli-test/src/cli/shell.test.ts | 20 +++++++----------- packages/cli-test/src/cli/shell.ts | 28 ++----------------------- 3 files changed, 11 insertions(+), 39 deletions(-) diff --git a/packages/cli-test/package.json b/packages/cli-test/package.json index 9c2c21148..30bad15a8 100644 --- a/packages/cli-test/package.json +++ b/packages/cli-test/package.json @@ -1,6 +1,6 @@ { "name": "@slack/cli-test", - "version": "3.0.2-rc.2", + "version": "3.0.2-rc.3", "description": "Node.js bindings for the Slack CLI for use in automated testing", "author": "Salesforce, Inc.", "license": "MIT", diff --git a/packages/cli-test/src/cli/shell.test.ts b/packages/cli-test/src/cli/shell.test.ts index eb889688a..5282a90e1 100644 --- a/packages/cli-test/src/cli/shell.test.ts +++ b/packages/cli-test/src/cli/shell.test.ts @@ -65,12 +65,11 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.runCommandSync(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); - const expectedShell = process.platform !== 'win32'; sandbox.assert.calledWithMatch( runSpy, sinon.match.string, sinon.match.array, - sinon.match({ shell: expectedShell, env: fakeEnv }), + sinon.match({ shell: false, env: fakeEnv }), ); }); it('should return the command outputs unchanged', () => { @@ -86,18 +85,17 @@ describe('shell module', () => { shell.runCommandSync('about to explode', []); }, /this is bat country/); }); - it('should use shell:false on Windows and shell:true on other platforms', () => { + it('should spawn without a shell', () => { const fakeEnv = { HEY: 'yo' }; sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); const fakeCmd = 'echo'; const fakeArgs = ['"hi there"']; shell.runCommandSync(fakeCmd, fakeArgs); - const expectedShell = process.platform !== 'win32'; sandbox.assert.calledWithMatch( runSpy, fakeCmd, - sinon.match.array, - sinon.match({ shell: expectedShell, env: fakeEnv }), + sinon.match.array.contains(fakeArgs), + sinon.match({ shell: false, env: fakeEnv }), ); }); }); @@ -110,12 +108,11 @@ describe('shell module', () => { const fakeArgs = ['"hi there"']; shell.spawnProcess(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); - const expectedShell = process.platform !== 'win32'; sandbox.assert.calledWithMatch( spawnSpy, sinon.match.string, sinon.match.array, - sinon.match({ shell: expectedShell, env: fakeEnv }), + sinon.match({ shell: false, env: fakeEnv }), ); }); it('should return the command outputs unchanged', () => { @@ -133,18 +130,17 @@ describe('shell module', () => { shell.spawnProcess('about to explode', []); }, /this is bat country/); }); - it('should use shell:false on Windows and shell:true on other platforms', () => { + it('should spawn without a shell', () => { const fakeEnv = { HEY: 'yo' }; sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); const fakeCmd = 'echo'; const fakeArgs = ['"hi there"']; shell.spawnProcess(fakeCmd, fakeArgs); - const expectedShell = process.platform !== 'win32'; sandbox.assert.calledWithMatch( spawnSpy, fakeCmd, - sinon.match.array, - sinon.match({ shell: expectedShell, env: fakeEnv }), + sinon.match.array.contains(fakeArgs), + sinon.match({ shell: false, env: fakeEnv }), ); }); }); diff --git a/packages/cli-test/src/cli/shell.ts b/packages/cli-test/src/cli/shell.ts index dc4ca688a..9e6858fc5 100644 --- a/packages/cli-test/src/cli/shell.ts +++ b/packages/cli-test/src/cli/shell.ts @@ -252,8 +252,6 @@ export const shell = { /** * @description Returns arguments used to pass into child_process.spawn or spawnSync. - * On Windows, shell is set to false to avoid Docker pipe handle inheritance hangs. - * On other platforms, shell is set to true for compatibility with Go CLI flag parsing. */ function getSpawnArguments( command: string, @@ -261,35 +259,13 @@ function getSpawnArguments( env: ReturnType, shellOpts?: Partial, ): [string, string[], child.SpawnOptionsWithoutStdio] { - if (process.platform === 'win32') { - return [ - command, - args, - { - shell: false, - env, - ...shellOpts, - }, - ]; - } - // Shell-quote each argument to protect special characters (e.g. #) and spaces return [ command, - args.map(shellQuote), + args, { - shell: true, + shell: false, env, ...shellOpts, }, ]; } - -/** - * @description Wraps a string in single quotes for safe shell interpolation. - * Single quotes prevent all shell interpretation; embedded single quotes are - * handled by ending the quoted segment, adding an escaped single quote, and - * restarting the quoted segment. - */ -function shellQuote(arg: string): string { - return `'${arg.replace(/'/g, "'\\''")}'`; -} From 0278d8899a7cbec9984332e4106ffecef91f8f55 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 6 May 2026 19:13:49 -0700 Subject: [PATCH 09/11] chore: changeset --- .changeset/itchy-buttons-begin.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/itchy-buttons-begin.md diff --git a/.changeset/itchy-buttons-begin.md b/.changeset/itchy-buttons-begin.md new file mode 100644 index 000000000..b5cc8f17f --- /dev/null +++ b/.changeset/itchy-buttons-begin.md @@ -0,0 +1,21 @@ +--- +"@slack/cli-test": patch +--- + +fix(cli-test): invoke commands without shell intermediate + +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: + +Linux: + +```diff +- /bin/sh -c "slack trigger run --workflow #/workflows/give_kudos_workflow" ++ execvp("slack", ["trigger", "run", "--workflow", "#/workflows/give_kudos_workflow"]) +``` + +Windows: + +```diff +- cmd.exe /s /c "slack trigger run --workflow #/workflows/give_kudos_workflow" ++ CreateProcessW("slack", ["trigger", "run", "--workflow", "#/workflows/give_kudos_workflow"]) +``` From 4b9c56c8b1054b008ddd095664d8f4f36f815898 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 6 May 2026 19:18:20 -0700 Subject: [PATCH 10/11] docs: remove package prefix from changeset --- .changeset/itchy-buttons-begin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/itchy-buttons-begin.md b/.changeset/itchy-buttons-begin.md index b5cc8f17f..7fc86c306 100644 --- a/.changeset/itchy-buttons-begin.md +++ b/.changeset/itchy-buttons-begin.md @@ -2,7 +2,7 @@ "@slack/cli-test": patch --- -fix(cli-test): invoke commands without shell intermediate +fix: invoke commands without shell intermediate 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: From 67559abf6f6bc7faf5413d397f4a65d2d4173d92 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 7 May 2026 08:08:39 -0700 Subject: [PATCH 11/11] chore: remove prerelease tag Co-authored-by: Eden Zimbelman --- packages/cli-test/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-test/package.json b/packages/cli-test/package.json index 30bad15a8..ac23d0f5e 100644 --- a/packages/cli-test/package.json +++ b/packages/cli-test/package.json @@ -1,6 +1,6 @@ { "name": "@slack/cli-test", - "version": "3.0.2-rc.3", + "version": "3.0.1", "description": "Node.js bindings for the Slack CLI for use in automated testing", "author": "Salesforce, Inc.", "license": "MIT",