diff --git a/.projenrc.ts b/.projenrc.ts index b296a11ec..d599b69bc 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -775,6 +775,7 @@ const cdkAssetsLib = configureProject( ]), }), ); +cdkAssetsLib.with(tools.subprocess); cdkAssetsLib.with(tools.zip); cdkAssetsLib.with(tools['s3-path-style']); fixupTestTask(cdkAssetsLib); @@ -942,7 +943,6 @@ const toolkitLib = configureProject( 'picomatch', 'p-limit@^3', 'semver', - 'split2', 'wrap-ansi@^7', // Last non-ESM version 'yaml@^1', ], @@ -955,7 +955,6 @@ const toolkitLib = configureProject( '@smithy/util-stream', '@types/fs-extra@^11', '@types/picomatch', - '@types/split2', 'aws-cdk-lib', 'aws-sdk-client-mock', 'aws-sdk-client-mock-jest', @@ -1001,6 +1000,7 @@ const toolkitLib = configureProject( }), ); fixupTestTask(toolkitLib); +toolkitLib.with(tools.subprocess); toolkitLib.with(tools.zip); toolkitLib.with(tools['s3-path-style']); toolkitLib.tasks.tryFind('test')?.updateStep(0, { @@ -1370,6 +1370,7 @@ const cli = configureProject( releasableCommits: transitiveToolkitPackages('aws-cdk'), }), ); +cli.with(tools.subprocess); cli.with(tools.zip); new pj.javascript.UpgradeDependencies(cli, { diff --git a/packages/@aws-cdk/cdk-assets-lib/.eslintrc.json b/packages/@aws-cdk/cdk-assets-lib/.eslintrc.json index bf0384b1d..a7966375f 100644 --- a/packages/@aws-cdk/cdk-assets-lib/.eslintrc.json +++ b/packages/@aws-cdk/cdk-assets-lib/.eslintrc.json @@ -157,6 +157,10 @@ "name": "@aws-cdk/private-tools", "message": "Import shared tools from './private/tools' (the generated shim), not '@aws-cdk/private-tools' directly." }, + { + "name": "@aws-cdk/private-tools/lib/subprocess", + "message": "Import shared tools from './private/tools' (the generated shim), not '@aws-cdk/private-tools' directly." + }, { "name": "@aws-cdk/private-tools/lib/zip", "message": "Import shared tools from './private/tools' (the generated shim), not '@aws-cdk/private-tools' directly." diff --git a/packages/@aws-cdk/cdk-assets-lib/.projen/deps.json b/packages/@aws-cdk/cdk-assets-lib/.projen/deps.json index 356c294e1..937ae0eac 100644 --- a/packages/@aws-cdk/cdk-assets-lib/.projen/deps.json +++ b/packages/@aws-cdk/cdk-assets-lib/.projen/deps.json @@ -198,6 +198,11 @@ "version": "^4", "type": "runtime" }, + { + "name": "cross-spawn", + "version": "^7.0.6", + "type": "runtime" + }, { "name": "fast-glob", "version": "^3.3.3", diff --git a/packages/@aws-cdk/cdk-assets-lib/lib/private/shell.ts b/packages/@aws-cdk/cdk-assets-lib/lib/private/shell.ts index 5effc5e10..721ec1158 100644 --- a/packages/@aws-cdk/cdk-assets-lib/lib/private/shell.ts +++ b/packages/@aws-cdk/cdk-assets-lib/lib/private/shell.ts @@ -1,12 +1,14 @@ -import * as child_process from 'child_process'; import type { SubprocessOutputDestination } from './asset-handler'; +import { run, renderForDisplay, SubprocessError } from './tools'; export type ShellEventType = 'open' | 'data_stdout' | 'data_stderr' | 'close'; export type ShellEventPublisher = (event: ShellEventType, message: string) => void; -export interface ShellOptions extends child_process.SpawnOptions { +export interface ShellOptions { readonly shellEventPublisher: ShellEventPublisher; + readonly cwd?: string; + readonly env?: Record; readonly input?: string; readonly subprocessOutputDestination?: SubprocessOutputDestination; } @@ -14,58 +16,48 @@ export interface ShellOptions extends child_process.SpawnOptions { /** * OS helpers * - * Shell function which both prints to stdout and collects the output into a - * string. + * Executes the given command as an argv array (never through a shell) and + * returns its stdout, routing intermediate output to the configured + * destination. */ export async function shell(command: string[], options: ShellOptions): Promise { - handleShellOutput(renderCommandLine(command), options, 'open'); - const child = child_process.spawn(command[0], command.slice(1), { - ...options, - stdio: [options.input ? 'pipe' : 'ignore', 'pipe', 'pipe'], - }); - - return new Promise((resolve, reject) => { - if (options.input) { - child.stdin!.write(options.input); - child.stdin!.end(); - } - - const stdout = new Array(); - const stderr = new Array(); - - // Both emit event and collect output - child.stdout!.on('data', (chunk) => { - handleShellOutput(chunk, options, 'data_stdout'); - stdout.push(chunk); + const displayCommand = renderForDisplay(command); + handleShellOutput(displayCommand, options, 'open'); + + try { + const result = await run(command, { + cwd: options.cwd, + env: options.env, + input: options.input, + onOutput: (stream, data) => + handleShellOutput(data, options, stream === 'stdout' ? 'data_stdout' : 'data_stderr'), }); - - child.stderr!.on('data', (chunk) => { - handleShellOutput(chunk, options, 'data_stderr'); - stderr.push(chunk); - }); - - child.once('error', reject); - - child.once('close', (code, signal) => { - handleShellOutput(renderCommandLine(command), options, 'close'); - if (code === 0) { - resolve(Buffer.concat(stdout).toString('utf-8')); - } else { - const out = Buffer.concat(stderr).toString('utf-8').trim(); - reject( - new ProcessFailed( - code, - signal, - `${renderCommandLine(command)} exited with ${code != null ? 'error code' : 'signal'} ${code ?? signal}: ${out}`, - ), - ); + handleShellOutput(displayCommand, options, 'close'); + return result.stdout; + } catch (e: any) { + if (e instanceof SubprocessError) { + // A process that never started has no exit to report; rethrow the OS + // error (ENOENT, EACCES, …) as-is — callers key off its `code` (e.g. + // docker.ts turns ENOENT into "please install docker" guidance). + // No `instanceof Error` on the cause: errno errors come from the host + // realm and fail instanceof under test sandboxes. + if (e.kind === 'spawn-failed' && e.cause != null) { + throw e.cause; } - }); - }); + handleShellOutput(displayCommand, options, 'close'); + const stderr = e.stderr.trim(); + throw new ProcessFailed( + e.exitCode, + e.signal, + stderr ? `${e.message}: ${stderr}` : e.message, + ); + } + throw e; + } } function handleShellOutput( - chunk: Buffer | string, + chunk: string, options: ShellOptions, shellEventType: ShellEventType, ): void { @@ -73,7 +65,7 @@ function handleShellOutput( case 'ignore': return; case 'publish': - options.shellEventPublisher(shellEventType, chunk.toString('utf-8')); + options.shellEventPublisher(shellEventType, chunk); break; case 'stdio': default: @@ -85,7 +77,7 @@ function handleShellOutput( process.stderr.write(chunk); break; case 'open': - options.shellEventPublisher(shellEventType, chunk.toString('utf-8')); + options.shellEventPublisher(shellEventType, chunk); break; } break; @@ -104,65 +96,3 @@ class ProcessFailed extends Error { super(message); } } - -/** - * Render the given command line as a string - * - * Probably missing some cases but giving it a good effort. - */ -function renderCommandLine(cmd: string[]) { - if (process.platform !== 'win32') { - return doRender(cmd, hasAnyChars(' ', '\\', '!', '"', "'", '&', '$'), posixEscape); - } else { - return doRender(cmd, hasAnyChars(' ', '"', '&', '^', '%'), windowsEscape); - } -} - -/** - * Render a UNIX command line - */ -function doRender( - cmd: string[], - needsEscaping: (x: string) => boolean, - doEscape: (x: string) => string, -): string { - return cmd.map((x) => (needsEscaping(x) ? doEscape(x) : x)).join(' '); -} - -/** - * Return a predicate that checks if a string has any of the indicated chars in it - */ -function hasAnyChars(...chars: string[]): (x: string) => boolean { - return (str: string) => { - return chars.some((c) => str.indexOf(c) !== -1); - }; -} - -/** - * Escape a shell argument for POSIX shells - * - * Wrapping in single quotes and escaping single quotes inside will do it for us. - */ -function posixEscape(x: string) { - // Turn ' -> '"'"' - x = x.replace(/'/g, "'\"'\"'"); - return `'${x}'`; -} - -/** - * Escape a shell argument for cmd.exe - * - * This is how to do it right, but I'm not following everything: - * - * https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ - */ -function windowsEscape(x: string): string { - // First surround by double quotes, ignore the part about backslashes - x = `"${x}"`; - // Now escape all special characters - const shellMeta = new Set(['"', '&', '^', '%']); - return x - .split('') - .map((c) => (shellMeta.has(x) ? '^' + c : c)) - .join(''); -} diff --git a/packages/@aws-cdk/cdk-assets-lib/lib/private/tools.ts b/packages/@aws-cdk/cdk-assets-lib/lib/private/tools.ts index c65403196..da6f87654 100644 --- a/packages/@aws-cdk/cdk-assets-lib/lib/private/tools.ts +++ b/packages/@aws-cdk/cdk-assets-lib/lib/private/tools.ts @@ -2,6 +2,8 @@ /* eslint-disable import/no-extraneous-dependencies -- re-exports the build-time-only @aws-cdk/private-tools package */ /* eslint-disable no-restricted-imports -- this shim is the single sanctioned entry point to @aws-cdk/private-tools */ +export * from '@aws-cdk/private-tools/lib/subprocess'; + export * from '@aws-cdk/private-tools/lib/zip'; export * from '@aws-cdk/private-tools/lib/s3-path-style'; diff --git a/packages/@aws-cdk/cdk-assets-lib/package.json b/packages/@aws-cdk/cdk-assets-lib/package.json index fac8e7ff1..7e49ac280 100644 --- a/packages/@aws-cdk/cdk-assets-lib/package.json +++ b/packages/@aws-cdk/cdk-assets-lib/package.json @@ -79,6 +79,7 @@ "@aws-sdk/lib-storage": "^3", "@smithy/config-resolver": "^4", "@smithy/node-config-provider": "^4", + "cross-spawn": "^7.0.6", "fast-glob": "^3.3.3", "mime": "^2", "picomatch": "^4.0.5", diff --git a/packages/@aws-cdk/cdk-assets-lib/test/shell.test.ts b/packages/@aws-cdk/cdk-assets-lib/test/shell.test.ts new file mode 100644 index 000000000..b0b700423 --- /dev/null +++ b/packages/@aws-cdk/cdk-assets-lib/test/shell.test.ts @@ -0,0 +1,38 @@ +import { shell } from '../lib/private/shell'; + +describe('shell', () => { + test('spawn failures propagate the OS error so callers can key off e.code', async () => { + // docker.ts turns ENOENT into "please install docker" guidance; the + // wrapper must not swallow the errno code into a generic ProcessFailed. + await expect( + shell(['this-binary-does-not-exist-xyz'], { + shellEventPublisher: () => { + }, + subprocessOutputDestination: 'ignore', + }), + ).rejects.toThrow(expect.objectContaining({ code: 'ENOENT' })); + }); + + test('non-zero exits throw ProcessFailed with the exit code and stderr', async () => { + await expect( + shell([process.execPath, '-e', 'process.stderr.write("boom"); process.exit(3);'], { + shellEventPublisher: () => { + }, + subprocessOutputDestination: 'ignore', + }), + ).rejects.toThrow(expect.objectContaining({ + code: 'PROCESS_FAILED', + exitCode: 3, + })); + }); + + test('returns stdout on success', async () => { + const output = await shell([process.execPath, '-e', 'process.stdout.write("hello")'], { + shellEventPublisher: () => { + }, + subprocessOutputDestination: 'ignore', + }); + + expect(output).toEqual('hello'); + }); +}); diff --git a/packages/@aws-cdk/integ-runner/THIRD_PARTY_LICENSES b/packages/@aws-cdk/integ-runner/THIRD_PARTY_LICENSES index c60384558..8762810a1 100644 --- a/packages/@aws-cdk/integ-runner/THIRD_PARTY_LICENSES +++ b/packages/@aws-cdk/integ-runner/THIRD_PARTY_LICENSES @@ -10674,6 +10674,32 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** cross-spawn@7.0.6 - https://www.npmjs.com/package/cross-spawn/v/7.0.6 | MIT +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + ---------------- ** data-uri-to-buffer@6.0.2 - https://www.npmjs.com/package/data-uri-to-buffer/v/6.0.2 | MIT @@ -11237,6 +11263,26 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** isexe@2.0.0 - https://www.npmjs.com/package/isexe/v/2.0.0 | ISC +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------- ** json-source-map@0.6.1 - https://www.npmjs.com/package/json-source-map/v/0.6.1 | MIT @@ -11534,6 +11580,20 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** path-key@3.1.1 - https://www.npmjs.com/package/path-key/v/3.1.1 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ---------------- ** picomatch@2.3.2 - https://www.npmjs.com/package/picomatch/v/2.3.2 | MIT @@ -11761,6 +11821,34 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +---------------- + +** shebang-command@2.0.0 - https://www.npmjs.com/package/shebang-command/v/2.0.0 | MIT +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** shebang-regex@3.0.0 - https://www.npmjs.com/package/shebang-regex/v/3.0.0 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ---------------- ** slice-ansi@4.0.0 - https://www.npmjs.com/package/slice-ansi/v/4.0.0 | MIT @@ -11885,24 +11973,6 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------- - -** split2@4.2.0 - https://www.npmjs.com/package/split2/v/4.2.0 | ISC -Copyright (c) 2014-2018, Matteo Collina - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------- ** string-width@4.2.3 - https://www.npmjs.com/package/string-width/v/4.2.3 | MIT @@ -12041,6 +12111,26 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** which@2.0.2 - https://www.npmjs.com/package/which/v/2.0.2 | ISC +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------- ** workerpool@6.5.1 - https://www.npmjs.com/package/workerpool/v/6.5.1 | Apache-2.0 diff --git a/packages/@aws-cdk/toolkit-lib/.eslintrc.json b/packages/@aws-cdk/toolkit-lib/.eslintrc.json index 8823d723b..6a8e078ea 100644 --- a/packages/@aws-cdk/toolkit-lib/.eslintrc.json +++ b/packages/@aws-cdk/toolkit-lib/.eslintrc.json @@ -154,6 +154,10 @@ "name": "@aws-cdk/private-tools", "message": "Import shared tools from './private/tools' (the generated shim), not '@aws-cdk/private-tools' directly." }, + { + "name": "@aws-cdk/private-tools/lib/subprocess", + "message": "Import shared tools from './private/tools' (the generated shim), not '@aws-cdk/private-tools' directly." + }, { "name": "@aws-cdk/private-tools/lib/zip", "message": "Import shared tools from './private/tools' (the generated shim), not '@aws-cdk/private-tools' directly." diff --git a/packages/@aws-cdk/toolkit-lib/.projen/deps.json b/packages/@aws-cdk/toolkit-lib/.projen/deps.json index b34300fbe..ac6548c23 100644 --- a/packages/@aws-cdk/toolkit-lib/.projen/deps.json +++ b/packages/@aws-cdk/toolkit-lib/.projen/deps.json @@ -59,10 +59,6 @@ "name": "@types/picomatch", "type": "build" }, - { - "name": "@types/split2", - "type": "build" - }, { "name": "@typescript-eslint/eslint-plugin", "version": "^8", @@ -363,6 +359,11 @@ "version": "^4", "type": "runtime" }, + { + "name": "cross-spawn", + "version": "^7.0.6", + "type": "runtime" + }, { "name": "fast-deep-equal", "type": "runtime" @@ -390,10 +391,6 @@ "name": "semver", "type": "runtime" }, - { - "name": "split2", - "type": "runtime" - }, { "name": "wrap-ansi", "version": "^7", diff --git a/packages/@aws-cdk/toolkit-lib/.projen/tasks.json b/packages/@aws-cdk/toolkit-lib/.projen/tasks.json index 544dc90cd..5cfdf8f77 100644 --- a/packages/@aws-cdk/toolkit-lib/.projen/tasks.json +++ b/packages/@aws-cdk/toolkit-lib/.projen/tasks.json @@ -106,7 +106,7 @@ "--peer", "--no-deprecated", "--dep=dev,prod,peer,optional", - "--filter=@aws-cdk/aws-service-spec,@cdklabs/eslint-plugin,@jest/environment,@jest/globals,@jest/types,@microsoft/api-extractor,@smithy/util-stream,@types/jest,@types/jest-when,@types/picomatch,@types/split2,aws-cdk-lib,aws-sdk-client-mock,aws-sdk-client-mock-jest,esbuild,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,jest-environment-node,jest-when,license-checker,nx,projen,ts-jest,tsx,cdk-from-cfn,fast-deep-equal,picomatch,semver,split2" + "--filter=@aws-cdk/aws-service-spec,@cdklabs/eslint-plugin,@jest/environment,@jest/globals,@jest/types,@microsoft/api-extractor,@smithy/util-stream,@types/jest,@types/jest-when,@types/picomatch,aws-cdk-lib,aws-sdk-client-mock,aws-sdk-client-mock-jest,esbuild,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,jest-environment-node,jest-when,license-checker,nx,projen,ts-jest,tsx,cdk-from-cfn,fast-deep-equal,picomatch,semver" ] } ] diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/ec2-detection.ts b/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/ec2-detection.ts index 32d339e49..2cabc4860 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/ec2-detection.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/ec2-detection.ts @@ -1,6 +1,6 @@ -import { execSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { platform } from 'node:os'; +import { runSync } from '../../private/tools'; /** * Detect whether we are running on an EC2 instance by inspecting local system @@ -68,10 +68,12 @@ function detectEc2Linux(): boolean { function detectEc2Windows(): boolean { // On Windows EC2 instances the board asset tag is an instance ID, readable - // from the registry without elevated privileges. - const tag = execSync( - 'reg query "HKLM\\SYSTEM\\HardwareConfig\\Current" /v BaseBoardAssetTag 2>nul', - { encoding: 'utf-8', timeout: 500 }, + // from the registry without elevated privileges. stderr is discarded (the + // key does not exist off EC2); a non-zero exit throws, which detectEc2() + // treats as "assume EC2". + const tag = runSync( + ['reg', 'query', 'HKLM\\SYSTEM\\HardwareConfig\\Current', '/v', 'BaseBoardAssetTag'], + { timeoutMs: 500 }, ).trim(); // Output contains "BaseBoardAssetTag REG_SZ i-0abc..." if (/i-[0-9a-f]+/i.test(tag)) { diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index 8c973e990..54e5010b2 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -181,7 +181,7 @@ function guessInterpreter(file: FileInfo): string { return handler(file.fileName); } - return quoteSpaces(file.fileName); + return quoteShellPart(file.fileName); } /** @@ -197,7 +197,7 @@ type CommandGenerator = (file: string) => string; * Execute the given file with the same 'node' process as is running the current process */ function executeNode(scriptFile: string): string { - return `${quoteSpaces(process.execPath)} ${quoteSpaces(scriptFile)}`; + return `${quoteShellPart(process.execPath)} ${quoteShellPart(scriptFile)}`; } /** @@ -238,13 +238,29 @@ interface FileInfo { } /** - * Quote a shell part if it contains spaces + * Quote a file path for inclusion in the shell command line we synthesize + * around the user's `app` setting. * - * We're only interested in spaces, nothing else. + * This is quoting FOR EXECUTION (the result is run through the shell by + * `runUserCommandLine`), which is why it lives here at the command-line + * assembly boundary and not in the subprocess module, whose rendering is + * display-only. Only the file paths we discover ourselves pass through this; + * the rest of the user's command line is theirs verbatim. + * + * Parts made purely of safe characters pass through unquoted. Anything else + * is double-quoted — not just spaces: `&`, `(`, `;`, … in an unquoted path + * would be interpreted by the shell. Inside POSIX double quotes, `\`, `"`, + * `$` and backtick stay special and are escaped; on Windows, `"` is not a + * legal filename character, so plain wrapping suffices for paths. */ -function quoteSpaces(part: string) { - if (part.includes(' ')) { +function quoteShellPart(part: string) { + const isWindows = process.platform === 'win32'; + const safe = isWindows ? /^[A-Za-z0-9_+=:,.@\\/-]+$/ : /^[A-Za-z0-9_+=:,.@/-]+$/; + if (safe.test(part)) { + return part; + } + if (isWindows) { return `"${part}"`; } - return part; + return `"${part.replace(/([\\"$`])/g, '\\$1')}"`; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index 8c349253b..3b713d288 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -1,7 +1,5 @@ -import * as child_process from 'child_process'; import { readFileSync } from 'fs'; -// eslint-disable-next-line @typescript-eslint/no-require-imports -import split = require('split2'); +import { runUserCommandLine, SubprocessError } from '../../../private/tools'; import { AssemblyError } from '../../../toolkit/toolkit-error'; type EventPublisher = (event: 'open' | 'data_stdout' | 'data_stderr' | 'close', line: string) => void; @@ -28,94 +26,88 @@ interface ExecOptions { export async function execInChildProcess(commandAndArgs: string, options: ExecOptions = {}) { const captureOutput = options.captureOutput ?? true; - return new Promise((ok, fail) => { - // We use a slightly lower-level interface to: - // - // - Pass arguments in an array instead of a string, to get around a - // number of quoting issues introduced by the intermediate shell layer - // (which would be different between Linux and Windows). + const eventPublisher: EventPublisher = options.eventPublisher ?? ((type, line) => { + switch (type) { + case 'data_stdout': + process.stdout.write(line + '\n'); + return; + case 'data_stderr': + process.stderr.write(line + '\n'); + return; + case 'open': + case 'close': + return; + } + }); + + const stderr = new Array(); + + try { + // The command line is the user's own `app`/`build` setting. This runs + // through the shell verbatim; on Windows the shell is also what resolves + // .bat and .cmd files. Code scanning tools will flag this as a risk, but + // the input comes from the user's own configuration. // - // - We have to capture any output to stdout and stderr sp we can pass it on to the IoHost - // To ensure messages get to the user fast, we will emit every full line we receive. - const proc = child_process.spawn(commandAndArgs, { - stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'], - detached: false, + // Output is captured and re-emitted per full line, so messages get to the + // user fast and the IoHost receives whole lines. A synth can produce a lot + // of output; it is consumed via onOutput only, so don't also retain it. + await runUserCommandLine(commandAndArgs, { + stdio: captureOutput ? 'capture' : 'inherit', + buffering: 'lines', + collect: false, + ...(captureOutput ? { + onOutput: (stream: 'stdout' | 'stderr', line: string) => { + if (stream === 'stderr') { + stderr.push(line); + } + eventPublisher(stream === 'stdout' ? 'data_stdout' : 'data_stderr', line); + }, + } : {}), cwd: options.cwd, env: { - // On Windwows, Python will default to cp1252 when not connected to a terminal, but we + // On Windows, Python will default to cp1252 when not connected to a terminal, but we // expect it to be UTF-8 below (to be able to split on lines). PYTHONIOENCODING: 'utf-8', ...options.env, }, - - // We are using 'shell: true' on purprose. Traditionally we have allowed shell features in - // this string, so we have to continue to do so into the future. On Windows, this is simply - // necessary to run .bat and .cmd files properly. - // Code scanning tools will flag this as a risk. The input comes from a trusted source, - // so it does not represent a security risk. - shell: true, - }); - - const eventPublisher: EventPublisher = options.eventPublisher ?? ((type, line) => { - switch (type) { - case 'data_stdout': - process.stdout.write(line + '\n'); - return; - case 'data_stderr': - process.stderr.write(line + '\n'); - return; - case 'open': - case 'close': - return; - } }); - - const stderr = new Array(); - - if (captureOutput) { - proc.stdout!.pipe(split()).on('data', (line) => eventPublisher('data_stdout', line)); - proc.stderr!.pipe(split()).on('data', (line) => { - stderr.push(line); - return eventPublisher('data_stderr', line); - }); + } catch (e: any) { + if (!(e instanceof SubprocessError)) { + throw e; } - proc.on('error', (e) => { - fail(AssemblyError.withCause(`Failed to execute CDK app: ${commandAndArgs}`, e)); - }); + // The process never spawned (e.g. the shell itself could not start) + if (e.kind === 'spawn-failed') { + throw AssemblyError.withCause(`Failed to execute CDK app: ${commandAndArgs}`, e.cause ?? e); + } - proc.on('exit', code => { - if (code === 0) { - return ok(); - } else { - const stdErrString = stderr.join('\n'); + const stdErrString = stderr.join('\n'); - let cause: Error | undefined; - if (stderr.length) { - cause = new Error(stdErrString); - cause.name = 'ExecutionError'; - } + let cause: Error | undefined; + if (stderr.length) { + cause = new Error(stdErrString); + cause.name = 'ExecutionError'; + } - let error = AssemblyError.withCause(`${commandAndArgs}: Subprocess exited with error ${code}`, cause); + const failure = e.kind === 'killed' ? `was killed with signal ${e.signal}` : `exited with error ${e.exitCode}`; + const error = AssemblyError.withCause(`${commandAndArgs}: Subprocess ${failure}`, cause); - // Search for an error code, and throw that if we have it - if (options.errorCodeFile) { - const contents = tryReadFile(options.errorCodeFile); - if (contents) { - const errorInStdErr = contents.split('\n')[0]; + // Search for an error code, and throw that if we have it + if (options.errorCodeFile) { + const contents = tryReadFile(options.errorCodeFile); + if (contents) { + const errorInStdErr = contents.split('\n')[0]; - if (errorInStdErr) { - // Attach the synth error code. We don't need to change the message; the underlying error will already have been - // printed to stderr. - error.attachSynthesisErrorCode(errorInStdErr); - } - } + if (errorInStdErr) { + // Attach the synth error code. We don't need to change the message; the underlying error will already have been + // printed to stderr. + error.attachSynthesisErrorCode(errorInStdErr); } - - return fail(error); } - }); - }); + } + + throw error; + } } function tryReadFile(name: string): string | undefined { diff --git a/packages/@aws-cdk/toolkit-lib/lib/private/tools.ts b/packages/@aws-cdk/toolkit-lib/lib/private/tools.ts index c65403196..da6f87654 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/private/tools.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/private/tools.ts @@ -2,6 +2,8 @@ /* eslint-disable import/no-extraneous-dependencies -- re-exports the build-time-only @aws-cdk/private-tools package */ /* eslint-disable no-restricted-imports -- this shim is the single sanctioned entry point to @aws-cdk/private-tools */ +export * from '@aws-cdk/private-tools/lib/subprocess'; + export * from '@aws-cdk/private-tools/lib/zip'; export * from '@aws-cdk/private-tools/lib/s3-path-style'; diff --git a/packages/@aws-cdk/toolkit-lib/package.json b/packages/@aws-cdk/toolkit-lib/package.json index 4a4ef0078..c0bc59f9b 100644 --- a/packages/@aws-cdk/toolkit-lib/package.json +++ b/packages/@aws-cdk/toolkit-lib/package.json @@ -52,7 +52,6 @@ "@types/jest-when": "^3.5.5", "@types/node": "^20", "@types/picomatch": "^4", - "@types/split2": "^4.2.3", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "aws-cdk-lib": "2.265.0", @@ -122,13 +121,13 @@ "cdk-from-cfn": "^0.324.0", "chalk": "^4", "chokidar": "^4", + "cross-spawn": "^7.0.6", "fast-deep-equal": "^3.1.3", "fast-glob": "^3.3.3", "fs-extra": "^11", "p-limit": "^3", "picomatch": "^4", "semver": "^7.8.5", - "split2": "^4.2.0", "wrap-ansi": "^7", "yaml": "^1", "yazl": "^3.3.1" diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts index 6260b03e9..f007c3725 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts @@ -27,6 +27,11 @@ test.each([ // If the path is quoted with spaces that also works ...explodeBoth(['"command with spaces" arg1 arg2', BOTH, 'command with spaces', BOTH, DONTCARE, '"command with spaces" arg1 arg2']), ...explodeBoth(['"command with spaces.js" arg1 arg2', true, 'command with spaces.js', false, '/node', '/node "command with spaces.js" arg1 arg2']), + // Shell metacharacters other than spaces in a discovered file path also get quoted + ...explodeBoth(['/path/app(1)&x', BOTH, '/path/app(1)&x', BOTH, DONTCARE, '"/path/app(1)&x"']), + // On POSIX, $ ` " \ stay special inside double quotes and are escaped; on Windows they are not + ['/path/$app.js', false, '/path/$app.js', false, '/node', '/node "/path/\\$app.js"'], + ['/path/$app.js', true, '/path/$app.js', false, '/node', '/node "/path/$app.js"'], ])('cmd=%p win=%p (stat=%p) exe=%p node=%p => %p', async (commandLine: string, isWindows: boolean, statFile: string, isExecutable: boolean | undefined, nodePath: string, expected: string) => { // GIVEN process.execPath = nodePath; diff --git a/packages/@aws-cdk/toolkit-lib/tsconfig.json b/packages/@aws-cdk/toolkit-lib/tsconfig.json index 18715427a..dbe015ab6 100644 --- a/packages/@aws-cdk/toolkit-lib/tsconfig.json +++ b/packages/@aws-cdk/toolkit-lib/tsconfig.json @@ -32,8 +32,7 @@ "jest", "jest-when", "node", - "picomatch", - "split2" + "picomatch" ], "incremental": true, "skipLibCheck": true, diff --git a/packages/aws-cdk/.eslintrc.json b/packages/aws-cdk/.eslintrc.json index 1388c9a2a..b28fc0ca0 100644 --- a/packages/aws-cdk/.eslintrc.json +++ b/packages/aws-cdk/.eslintrc.json @@ -154,6 +154,10 @@ "name": "@aws-cdk/private-tools", "message": "Import shared tools from './private/tools' (the generated shim), not '@aws-cdk/private-tools' directly." }, + { + "name": "@aws-cdk/private-tools/lib/subprocess", + "message": "Import shared tools from './private/tools' (the generated shim), not '@aws-cdk/private-tools' directly." + }, { "name": "@aws-cdk/private-tools/lib/zip", "message": "Import shared tools from './private/tools' (the generated shim), not '@aws-cdk/private-tools' directly." diff --git a/packages/aws-cdk/.projen/deps.json b/packages/aws-cdk/.projen/deps.json index 47829d700..1782e6b43 100644 --- a/packages/aws-cdk/.projen/deps.json +++ b/packages/aws-cdk/.projen/deps.json @@ -390,6 +390,11 @@ "version": "^4", "type": "runtime" }, + { + "name": "cross-spawn", + "version": "^7.0.6", + "type": "runtime" + }, { "name": "decamelize", "version": "^5", diff --git a/packages/aws-cdk/THIRD_PARTY_LICENSES b/packages/aws-cdk/THIRD_PARTY_LICENSES index 08c31ccfe..e7d40985c 100644 --- a/packages/aws-cdk/THIRD_PARTY_LICENSES +++ b/packages/aws-cdk/THIRD_PARTY_LICENSES @@ -11037,6 +11037,32 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** cross-spawn@7.0.6 - https://www.npmjs.com/package/cross-spawn/v/7.0.6 | MIT +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + ---------------- ** data-uri-to-buffer@6.0.2 - https://www.npmjs.com/package/data-uri-to-buffer/v/6.0.2 | MIT @@ -11692,6 +11718,26 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** isexe@2.0.0 - https://www.npmjs.com/package/isexe/v/2.0.0 | ISC +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------- ** json-source-map@0.6.1 - https://www.npmjs.com/package/json-source-map/v/0.6.1 | MIT @@ -12133,6 +12179,20 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** path-key@3.1.1 - https://www.npmjs.com/package/path-key/v/3.1.1 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ---------------- ** picomatch@2.3.2 - https://www.npmjs.com/package/picomatch/v/2.3.2 | MIT @@ -12470,6 +12530,34 @@ WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +---------------- + +** shebang-command@2.0.0 - https://www.npmjs.com/package/shebang-command/v/2.0.0 | MIT +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** shebang-regex@3.0.0 - https://www.npmjs.com/package/shebang-regex/v/3.0.0 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ---------------- ** slice-ansi@4.0.0 - https://www.npmjs.com/package/slice-ansi/v/4.0.0 | MIT @@ -12594,24 +12682,6 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------- - -** split2@4.2.0 - https://www.npmjs.com/package/split2/v/4.2.0 | ISC -Copyright (c) 2014-2018, Matteo Collina - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------- ** string-width@4.2.3 - https://www.npmjs.com/package/string-width/v/4.2.3 | MIT @@ -12848,6 +12918,26 @@ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +---------------- + +** which@2.0.2 - https://www.npmjs.com/package/which/v/2.0.2 | ISC +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------- ** wrap-ansi@6.2.0 - https://www.npmjs.com/package/wrap-ansi/v/6.2.0 | MIT diff --git a/packages/aws-cdk/lib/cli/telemetry/library-version.ts b/packages/aws-cdk/lib/cli/telemetry/library-version.ts index 47c9ddee5..8372bde63 100644 --- a/packages/aws-cdk/lib/cli/telemetry/library-version.ts +++ b/packages/aws-cdk/lib/cli/telemetry/library-version.ts @@ -1,13 +1,11 @@ -import { exec } from 'child_process'; import * as path from 'path'; -import { promisify } from 'util'; import * as fs from 'fs-extra'; import type { IoHelper } from '../../api-private'; +import { run } from '../../private/tools'; export async function getLibraryVersion(ioHelper: IoHelper): Promise { try { - const command = "node -e 'process.stdout.write(require.resolve(\"aws-cdk-lib\"))'"; - const { stdout } = await promisify(exec)(command); + const { stdout } = await run([process.execPath, '-e', 'process.stdout.write(require.resolve("aws-cdk-lib"))']); // stdout should be a file path but lets double check if (!fs.existsSync(stdout)) { diff --git a/packages/aws-cdk/lib/commands/docs.ts b/packages/aws-cdk/lib/commands/docs.ts index fb1ec9479..493cec8d1 100644 --- a/packages/aws-cdk/lib/commands/docs.ts +++ b/packages/aws-cdk/lib/commands/docs.ts @@ -1,7 +1,6 @@ -import * as childProcess from 'child_process'; -import { promisify } from 'node:util'; import chalk from 'chalk'; import type { IoHelper } from '../api-private'; +import { runUserCommandLine } from '../private/tools'; export const command = 'docs'; export const describe = 'Opens the reference documentation in a browser'; @@ -26,13 +25,14 @@ export async function docs(options: DocsOptions): Promise { const ioHelper = options.ioHelper; const url = 'https://docs.aws.amazon.com/cdk/api/v2/'; await ioHelper.defaults.info(chalk.green(url)); + // The browser command is the user's own `--browser` option (with %u replaced + // by the constant docs URL) and may rely on shell features, so it runs + // through the shell verbatim. const browserCommand = (options.browser).replace(/%u/g, url); await ioHelper.defaults.debug(`Opening documentation ${chalk.green(browserCommand)}`); - const exec = promisify(childProcess.exec); - try { - const { stdout, stderr } = await exec(browserCommand); + const { stdout, stderr } = await runUserCommandLine(browserCommand); if (stdout) { await ioHelper.defaults.debug(stdout); } @@ -40,7 +40,7 @@ export async function docs(options: DocsOptions): Promise { await ioHelper.defaults.warn(stderr); } } catch (err: unknown) { - const e = err as childProcess.ExecException; + const e = err as Error; await ioHelper.defaults.debug(`An error occurred when trying to open a browser: ${e.stack || e.message}`); } diff --git a/packages/aws-cdk/lib/commands/init/init.ts b/packages/aws-cdk/lib/commands/init/init.ts index a1e0acf6e..f40b68199 100644 --- a/packages/aws-cdk/lib/commands/init/init.ts +++ b/packages/aws-cdk/lib/commands/init/init.ts @@ -1,16 +1,16 @@ -import * as childProcess from 'child_process'; import * as path from 'path'; import { ToolkitError } from '@aws-cdk/toolkit-lib'; import chalk from 'chalk'; import * as fs from 'fs-extra'; import { invokeBuiltinHooks } from './init-hooks'; +import { getPmCmdPrefix, type JsPackageManager } from './package-manager'; import type { IoHelper } from '../../api-private'; import { cliRootDir } from '../../cli/root-dir'; import { versionNumber } from '../../cli/version'; +import { run, SubprocessError } from '../../private/tools'; import { cdkHomeDir, formatErrorMessage, rangeFromSemver, stripCaret } from '../../util'; import type { LanguageInfo } from '../language'; import { getLanguageAlias, getLanguageExtensions, SUPPORTED_LANGUAGES } from '../language'; -import { getPmCmdPrefix, type JsPackageManager } from './package-manager'; /* eslint-disable @typescript-eslint/no-var-requires */ // Packages don't have @types module // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -782,7 +782,7 @@ async function initializeGitRepository(ioHelper: IoHelper, workDir: string) { try { await execute(ioHelper, 'git', ['init'], { cwd: workDir }); await execute(ioHelper, 'git', ['add', '.'], { cwd: workDir }); - await execute(ioHelper, 'git', ['commit', '--message="Initial commit"', '--no-gpg-sign'], { cwd: workDir }); + await execute(ioHelper, 'git', ['commit', '--message=Initial commit', '--no-gpg-sign'], { cwd: workDir }); } catch { await ioHelper.defaults.warn('Unable to initialize git repository for your project.'); } @@ -962,26 +962,20 @@ function isRoot(dir: string) { * @returns STDOUT (if successful). */ async function execute(ioHelper: IoHelper, cmd: string, args: string[], { cwd }: { cwd: string }) { - const child = childProcess.spawn(cmd, args, { - cwd, - shell: true, - stdio: ['ignore', 'pipe', 'inherit'], - }); - let stdout = ''; - child.stdout.on('data', (chunk) => (stdout += chunk.toString())); - return new Promise((ok, fail) => { - child.once('error', (err) => fail(err)); - child.once('exit', (status) => { - if (status === 0) { - return ok(stdout); - } else { - return fail(new ToolkitError('CommandFailed', `${cmd} exited with status ${status}`)); + try { + // stderr stays attached to the terminal so tools like npm keep their + // progress rendering; stdout is collected and returned. + const result = await run([cmd, ...args], { cwd, stdio: 'inherit-stderr' }); + return result.stdout; + } catch (err: any) { + if (err instanceof SubprocessError) { + await ioHelper.defaults.error(err.stdout); + if (err.kind === 'exited') { + throw new ToolkitError('CommandFailed', `${cmd} exited with status ${err.exitCode}`); } - }); - }).catch(async (err) => { - await ioHelper.defaults.error(stdout); + } throw err; - }); + } } interface Versions { diff --git a/packages/aws-cdk/lib/commands/init/os.ts b/packages/aws-cdk/lib/commands/init/os.ts index 99157468c..b6deef7b6 100644 --- a/packages/aws-cdk/lib/commands/init/os.ts +++ b/packages/aws-cdk/lib/commands/init/os.ts @@ -1,97 +1,30 @@ -import * as child_process from 'child_process'; import { ToolkitError } from '@aws-cdk/toolkit-lib'; import chalk from 'chalk'; import type { IoHelper } from '../../api-private'; +import { run, renderForDisplay, SubprocessError } from '../../private/tools'; /** * OS helpers * - * Shell function which both prints to stdout and collects the output into a - * string. + * Executes the given command (argv array, never through a shell) while both + * printing its stdout in real-time and collecting it into the returned + * string. stderr goes straight to the terminal. */ export async function shell(ioHelper: IoHelper, command: string[]): Promise { - const commandLine = renderCommandLine(command); - await ioHelper.defaults.debug(`Executing ${chalk.blue(commandLine)}`); - const child = child_process.spawn(command[0], renderArguments(command.slice(1)), { - // Need this for Windows where we want .cmd and .bat to be found as well. - shell: true, - stdio: ['ignore', 'pipe', 'inherit'], - }); - - return new Promise((resolve, reject) => { - const stdout = new Array(); - - // Both write to stdout and collect - child.stdout.on('data', chunk => { - process.stdout.write(chunk); - stdout.push(chunk); - }); - - child.once('error', reject); - - child.once('exit', code => { - if (code === 0) { - resolve(Buffer.from(stdout).toString('utf-8')); - } else { - reject(new ToolkitError('CommandFailed', `${commandLine} exited with error code ${code}`)); - } + await ioHelper.defaults.debug(`Executing ${chalk.blue(renderForDisplay(command))}`); + + try { + // stderr stays attached to the terminal so tools like npm keep their + // progress rendering; stdout is echoed in real time and also collected. + const result = await run(command, { + stdio: 'inherit-stderr', + onOutput: (_stream, data) => process.stdout.write(data), }); - }); -} - -function renderCommandLine(cmd: string[]) { - return renderArguments(cmd).join(' '); -} - -/** - * Render the arguments to include escape characters for each platform. - */ -function renderArguments(cmd: string[]) { - if (process.platform !== 'win32') { - return doRender(cmd, hasAnyChars(' ', '\\', '!', '"', "'", '&', '$'), posixEscape); - } else { - return doRender(cmd, hasAnyChars(' ', '"', '&', '^', '%'), windowsEscape); + return result.stdout; + } catch (e: any) { + if (e instanceof SubprocessError && e.kind === 'exited') { + throw new ToolkitError('CommandFailed', e.message); + } + throw e; } } - -/** - * Render a UNIX command line - */ -function doRender(cmd: string[], needsEscaping: (x: string) => boolean, doEscape: (x: string) => string): string[] { - return cmd.map(x => needsEscaping(x) ? doEscape(x) : x); -} - -/** - * Return a predicate that checks if a string has any of the indicated chars in it - */ -function hasAnyChars(...chars: string[]): (x: string) => boolean { - return (str: string) => { - return chars.some(c => str.indexOf(c) !== -1); - }; -} - -/** - * Escape a shell argument for POSIX shells - * - * Wrapping in single quotes and escaping single quotes inside will do it for us. - */ -function posixEscape(x: string) { - // Turn ' -> '"'"' - x = x.replace(/'/g, "'\"'\"'"); - return `'${x}'`; -} - -/** - * Escape a shell argument for cmd.exe - * - * This is how to do it right, but I'm not following everything: - * - * https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ - */ -function windowsEscape(x: string): string { - // First surround by double quotes, ignore the part about backslashes - x = `"${x}"`; - // Now escape all special characters - const shellMeta = new Set(['"', '&', '^', '%']); - return x.split('').map(c => shellMeta.has(x) ? '^' + c : c).join(''); -} diff --git a/packages/aws-cdk/lib/private/tools.ts b/packages/aws-cdk/lib/private/tools.ts index 013d2f4a9..8560c607b 100644 --- a/packages/aws-cdk/lib/private/tools.ts +++ b/packages/aws-cdk/lib/private/tools.ts @@ -2,4 +2,6 @@ /* eslint-disable import/no-extraneous-dependencies -- re-exports the build-time-only @aws-cdk/private-tools package */ /* eslint-disable no-restricted-imports -- this shim is the single sanctioned entry point to @aws-cdk/private-tools */ +export * from '@aws-cdk/private-tools/lib/subprocess'; + export * from '@aws-cdk/private-tools/lib/zip'; diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index bc8bb87cc..987c62459 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -126,6 +126,7 @@ "cdk-from-cfn": "^0.324.0", "chalk": "^4", "chokidar": "^4", + "cross-spawn": "^7.0.6", "decamelize": "^5", "enquirer": "^2.4.1", "fast-glob": "^3.3.3", diff --git a/packages/aws-cdk/test/cli/telemetry/library-version.test.ts b/packages/aws-cdk/test/cli/telemetry/library-version.test.ts index 7de577459..08555a9bb 100644 --- a/packages/aws-cdk/test/cli/telemetry/library-version.test.ts +++ b/packages/aws-cdk/test/cli/telemetry/library-version.test.ts @@ -1,12 +1,11 @@ -import { exec } from 'child_process'; -import { promisify } from 'util'; import * as fs from 'fs-extra'; import type { IoHelper } from '../../../lib/api-private'; import { getLibraryVersion } from '../../../lib/cli/telemetry/library-version'; +import { run } from '../../../lib/private/tools'; -// Mock child_process exec -jest.mock('child_process', () => ({ - exec: jest.fn(), +// Mock the subprocess tool's run() +jest.mock('@aws-cdk/private-tools/lib/subprocess', () => ({ + run: jest.fn(), })); // Mock fs-extra @@ -15,23 +14,15 @@ jest.mock('fs-extra', () => ({ readJSONSync: jest.fn(), })); -// Mock util promisify -jest.mock('util', () => ({ - promisify: jest.fn(), -})); - -const mockExec = exec as jest.MockedFunction; -const mockPromisify = promisify as jest.MockedFunction; +const mockRun = run as jest.MockedFunction; const mockExistsSync = fs.existsSync as jest.MockedFunction; const mockReadJSONSync = fs.readJSONSync as jest.MockedFunction; describe('getLibraryVersion', () => { let mockIoHelper: IoHelper; let traceSpy: jest.Mock; - let mockPromisifiedExec: jest.Mock; beforeEach(() => { - // Create mock IoHelper traceSpy = jest.fn(); mockIoHelper = { defaults: { @@ -39,46 +30,34 @@ describe('getLibraryVersion', () => { }, } as any; - // Create mock promisified exec function - mockPromisifiedExec = jest.fn(); - mockPromisify.mockReturnValue(mockPromisifiedExec); - - // Reset all mocks jest.clearAllMocks(); }); test('returns version when aws-cdk-lib is found and package.json is valid', async () => { - // GIVEN const mockLibPath = '/path/to/node_modules/aws-cdk-lib/index.js'; const mockPackageJsonPath = '/path/to/node_modules/aws-cdk-lib/package.json'; const expectedVersion = '2.100.0'; - mockPromisifiedExec.mockResolvedValue({ stdout: mockLibPath }); + mockRun.mockResolvedValue({ stdout: mockLibPath, stderr: '' }); mockExistsSync.mockReturnValue(true); mockReadJSONSync.mockReturnValue({ version: expectedVersion }); - // WHEN const result = await getLibraryVersion(mockIoHelper); - // THEN expect(result).toBe(expectedVersion); - expect(mockPromisify).toHaveBeenCalledWith(mockExec); - expect(mockPromisifiedExec).toHaveBeenCalledWith("node -e 'process.stdout.write(require.resolve(\"aws-cdk-lib\"))'"); + expect(mockRun).toHaveBeenCalledWith([process.execPath, '-e', 'process.stdout.write(require.resolve("aws-cdk-lib"))']); expect(mockExistsSync).toHaveBeenCalledWith(mockLibPath); expect(mockReadJSONSync).toHaveBeenCalledWith(mockPackageJsonPath); expect(traceSpy).not.toHaveBeenCalled(); }); test('returns undefined and logs trace when resolved path does not exist', async () => { - // GIVEN const mockLibPath = '/nonexistent/path/to/aws-cdk-lib/index.js'; - mockPromisifiedExec.mockResolvedValue({ stdout: mockLibPath }); + mockRun.mockResolvedValue({ stdout: mockLibPath, stderr: '' }); mockExistsSync.mockReturnValue(false); - // WHEN const result = await getLibraryVersion(mockIoHelper); - // THEN expect(result).toBeUndefined(); expect(mockExistsSync).toHaveBeenCalledWith(mockLibPath); expect(mockReadJSONSync).not.toHaveBeenCalled(); @@ -87,32 +66,26 @@ describe('getLibraryVersion', () => { ); }); - test('returns undefined and logs trace when exec command fails', async () => { - // GIVEN - const execError = new Error('Command failed: node -e ...'); - mockPromisifiedExec.mockRejectedValue(execError); + test('returns undefined and logs trace when run() throws', async () => { + const runError = new Error('spawn ENOENT'); + mockRun.mockRejectedValue(runError); - // WHEN const result = await getLibraryVersion(mockIoHelper); - // THEN expect(result).toBeUndefined(); expect(mockExistsSync).not.toHaveBeenCalled(); expect(mockReadJSONSync).not.toHaveBeenCalled(); - expect(traceSpy).toHaveBeenCalledWith(`Could not get CDK Library Version: ${execError}`); + expect(traceSpy).toHaveBeenCalledWith(`Could not get CDK Library Version: ${runError}`); }); test('handles package.json without version field', async () => { - // GIVEN const mockLibPath = '/path/to/node_modules/aws-cdk-lib/index.js'; - mockPromisifiedExec.mockResolvedValue({ stdout: mockLibPath }); + mockRun.mockResolvedValue({ stdout: mockLibPath, stderr: '' }); mockExistsSync.mockReturnValue(true); - mockReadJSONSync.mockReturnValue({ name: 'aws-cdk-lib' }); // No version field + mockReadJSONSync.mockReturnValue({ name: 'aws-cdk-lib' }); - // WHEN const result = await getLibraryVersion(mockIoHelper); - // THEN expect(result).toBeUndefined(); expect(traceSpy).toHaveBeenCalledWith('Could not get CDK Library Version: package.json does not have version field'); }); diff --git a/packages/aws-cdk/test/commands/cdk-docs.test.ts b/packages/aws-cdk/test/commands/cdk-docs.test.ts index 6224a1264..31b36ba22 100644 --- a/packages/aws-cdk/test/commands/cdk-docs.test.ts +++ b/packages/aws-cdk/test/commands/cdk-docs.test.ts @@ -1,33 +1,41 @@ -import * as child_process from 'child_process'; -import { mocked } from 'jest-mock'; import { docs } from '../../lib/commands/docs'; +import { runUserCommandLine } from '../../lib/private/tools'; import { TestIoHost } from '../_helpers/io-host'; const ioHost = new TestIoHost(); const ioHelper = ioHost.asHelper('docs'); -jest.mock('child_process'); +jest.mock('@aws-cdk/private-tools/lib/subprocess', () => ({ + runUserCommandLine: jest.fn(), +})); + +const mockRunUserCommandLine = runUserCommandLine as jest.MockedFunction; describe('`cdk docs`', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('exits with 0 when everything is OK', async () => { - const mockChildProcessExec: any = (_: string, cb: (err?: Error, stdout?: string, stderr?: string) => void) => cb(); - mocked(child_process.exec).mockImplementation(mockChildProcessExec); + mockRunUserCommandLine.mockResolvedValue({ stdout: '', stderr: '' }); const result = await docs({ ioHelper, browser: 'echo %u', }); + expect(result).toBe(0); + expect(mockRunUserCommandLine).toHaveBeenCalledWith('echo https://docs.aws.amazon.com/cdk/api/v2/'); }); test('exits with 0 when opening the browser fails', async () => { - const mockChildProcessExec: any = (_: string, cb: (err: Error, stdout?: string, stderr?: string) => void) => cb(new Error('TEST')); - mocked(child_process.exec).mockImplementation(mockChildProcessExec); + mockRunUserCommandLine.mockRejectedValue(new Error('TEST')); const result = await docs({ ioHelper, browser: 'echo %u', }); + expect(result).toBe(0); }); }); diff --git a/packages/aws-cdk/test/commands/init.test.ts b/packages/aws-cdk/test/commands/init.test.ts index 2ae61d58a..8598f8a1b 100644 --- a/packages/aws-cdk/test/commands/init.test.ts +++ b/packages/aws-cdk/test/commands/init.test.ts @@ -1,5 +1,4 @@ import child_process from 'child_process'; -import type { ChildProcess } from 'child_process'; import * as os from 'os'; import * as path from 'path'; import { promisify } from 'util'; @@ -8,12 +7,20 @@ import * as fs from 'fs-extra'; import { makeConfig } from '../../lib/cli/cli-config'; import { availableInitLanguages, availableInitTemplates, cliInit, currentlyRecommendedAwsCdkLibFlags, expandPlaceholders, printAvailableTemplates } from '../../lib/commands/init'; import { type JsPackageManager } from '../../lib/commands/init/package-manager'; +import type * as tools from '../../lib/private/tools'; import { createSingleLanguageTemplate, createMultiLanguageTemplate, createMultiTemplateRepository } from '../_fixtures/init-templates/template-helpers'; import { TestIoHost } from '../_helpers/io-host'; const ioHost = new TestIoHost(); const ioHelper = ioHost.asHelper('init'); +// jest.spyOn cannot attach to the shim's read-only re-export bindings, so replace the +// underlying module with a spy-able plain-object copy of itself and spy on that. +jest.mock('@aws-cdk/private-tools/lib/subprocess', () => ({ + ...jest.requireActual('@aws-cdk/private-tools/lib/subprocess'), +})); +const subprocess: typeof tools = jest.requireMock('@aws-cdk/private-tools/lib/subprocess'); + describe('constructs version', () => { cliTest('shows available templates when no parameters provided', async (workDir) => { // Test that calling cdk init without any parameters shows available templates @@ -1180,12 +1187,7 @@ describe('constructs version', () => { }); cliTest('C# post-install runs dotnet commands in src directory', async (workDir) => { - const spawnSpy = jest.spyOn(child_process, 'spawn').mockImplementation(() => ({ - stdout: { on: jest.fn() }, - once: jest.fn((event, cb) => { - if (event === 'exit') cb(0); - }), - }) as unknown as ChildProcess); + const runSpy = jest.spyOn(subprocess, 'run').mockResolvedValue({ stdout: '', stderr: '' }); try { const templateDir = path.join(workDir, 'csharp-template'); @@ -1208,14 +1210,14 @@ describe('constructs version', () => { workDir: projectDir, }); - const dotnetCalls = spawnSpy.mock.calls.filter(([cmd]) => cmd === 'dotnet'); + const dotnetCalls = runSpy.mock.calls.filter(([argv]: any[]) => argv[0] === 'dotnet'); const expectedCwd = path.join(projectDir, 'src'); expect(dotnetCalls).toEqual([ - ['dotnet', ['restore'], expect.objectContaining({ cwd: expectedCwd })], - ['dotnet', ['build'], expect.objectContaining({ cwd: expectedCwd })], + [['dotnet', 'restore'], expect.objectContaining({ cwd: expectedCwd })], + [['dotnet', 'build'], expect.objectContaining({ cwd: expectedCwd })], ]); } finally { - spawnSpy.mockRestore(); + runSpy.mockRestore(); } }); @@ -1427,17 +1429,14 @@ describe('constructs version', () => { }); describe('package-manager option', () => { - let spawnSpy: jest.SpyInstance; + let runSpy: jest.SpyInstance; beforeEach(async () => { - // Mock child_process.spawn to track which package manager is called - spawnSpy = jest.spyOn(child_process, 'spawn').mockImplementation(() => ({ - stdout: { on: jest.fn() }, - }) as unknown as ChildProcess); + runSpy = jest.spyOn(subprocess, 'run').mockResolvedValue({ stdout: '', stderr: '' }); }); afterEach(() => { - spawnSpy.mockRestore(); + runSpy.mockRestore(); }); test.each([ @@ -1460,8 +1459,8 @@ describe('constructs version', () => { }); const readme = await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'); - const installCalls = spawnSpy.mock.calls.filter( - ([cmd, args]) => cmd === packageManager && args.includes('install'), + const installCalls = runSpy.mock.calls.filter( + ([argv]) => argv[0] === packageManager && argv.includes('install'), ); expect(installCalls.length).toBeGreaterThan(0); @@ -1483,8 +1482,8 @@ describe('constructs version', () => { }); const readme = await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'); - const installCalls = spawnSpy.mock.calls.filter( - ([cmd, args]) => cmd === packageManager && args.includes('install'), + const installCalls = runSpy.mock.calls.filter( + ([argv]) => argv[0] === packageManager && argv.includes('install'), ); expect(installCalls.length).toBeGreaterThan(0); @@ -1506,8 +1505,8 @@ describe('constructs version', () => { }); const readme = await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'); - const installCalls = spawnSpy.mock.calls.filter( - ([cmd, args]) => cmd === packageManager && args.includes('install'), + const installCalls = runSpy.mock.calls.filter( + ([argv]) => argv[0] === packageManager && argv.includes('install'), ); expect(installCalls.length).toBeGreaterThan(0); @@ -1527,8 +1526,8 @@ describe('constructs version', () => { }); const readme = await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'); - const installCalls = spawnSpy.mock.calls.filter( - ([cmd, args]) => cmd === defaultPackageManager && args.includes('install'), + const installCalls = runSpy.mock.calls.filter( + ([argv]) => argv[0] === defaultPackageManager && argv.includes('install'), ); expect(installCalls.length).toBeGreaterThan(0); diff --git a/packages/cdk-assets/THIRD_PARTY_LICENSES b/packages/cdk-assets/THIRD_PARTY_LICENSES index 4a8617658..738938181 100644 --- a/packages/cdk-assets/THIRD_PARTY_LICENSES +++ b/packages/cdk-assets/THIRD_PARTY_LICENSES @@ -5787,6 +5787,32 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** cross-spawn@7.0.6 - https://www.npmjs.com/package/cross-spawn/v/7.0.6 | MIT +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + ---------------- ** emoji-regex@8.0.0 - https://www.npmjs.com/package/emoji-regex/v/8.0.0 | MIT @@ -6012,6 +6038,26 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** isexe@2.0.0 - https://www.npmjs.com/package/isexe/v/2.0.0 | ISC +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------- ** json-source-map@0.6.1 - https://www.npmjs.com/package/json-source-map/v/0.6.1 | MIT @@ -6142,6 +6188,20 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** path-key@3.1.1 - https://www.npmjs.com/package/path-key/v/3.1.1 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ---------------- ** picomatch@2.3.2 - https://www.npmjs.com/package/picomatch/v/2.3.2 | MIT @@ -6292,6 +6352,34 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +---------------- + +** shebang-command@2.0.0 - https://www.npmjs.com/package/shebang-command/v/2.0.0 | MIT +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** shebang-regex@3.0.0 - https://www.npmjs.com/package/shebang-regex/v/3.0.0 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ---------------- ** string-width@4.2.3 - https://www.npmjs.com/package/string-width/v/4.2.3 | MIT @@ -6346,6 +6434,26 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** which@2.0.2 - https://www.npmjs.com/package/which/v/2.0.2 | ISC +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------- ** wrap-ansi@7.0.0 - https://www.npmjs.com/package/wrap-ansi/v/7.0.0 | MIT diff --git a/yarn.lock b/yarn.lock index 7c778607d..5fc977336 100644 --- a/yarn.lock +++ b/yarn.lock @@ -132,6 +132,7 @@ __metadata: aws-sdk-client-mock-jest: "npm:^4.1.0" commit-and-tag-version: "npm:^12" constructs: "npm:^10.0.0" + cross-spawn: "npm:^7.0.6" esbuild: "npm:^0.28.2" eslint: "npm:^9" eslint-config-prettier: "npm:^10.1.8" @@ -553,7 +554,6 @@ __metadata: "@types/jest-when": "npm:^3.5.5" "@types/node": "npm:^20" "@types/picomatch": "npm:^4" - "@types/split2": "npm:^4.2.3" "@typescript-eslint/eslint-plugin": "npm:^8" "@typescript-eslint/parser": "npm:^8" aws-cdk-lib: "npm:2.265.0" @@ -564,6 +564,7 @@ __metadata: chokidar: "npm:^4" commit-and-tag-version: "npm:^12" constructs: "npm:^10.0.0" + cross-spawn: "npm:^7.0.6" esbuild: "npm:^0.28.2" eslint: "npm:^9" eslint-config-prettier: "npm:^10.1.8" @@ -588,7 +589,6 @@ __metadata: prettier: "npm:^2.8" projen: "npm:^0.101.31" semver: "npm:^7.8.5" - split2: "npm:^4.2.0" ts-jest: "npm:^29.4.12" tsx: "npm:^4.23.12" typescript: "npm:5.9" @@ -4432,15 +4432,6 @@ __metadata: languageName: node linkType: hard -"@types/split2@npm:^4.2.3": - version: 4.2.3 - resolution: "@types/split2@npm:4.2.3" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/92326872b1f6f2e5a0808682a3f0630efb98bed51b1e40e2133e740f664bbde7a21af54e2a1f1a1f6eac218a70d7a9bae22d3e6d614950c06ddab391b7a1dfca - languageName: node - linkType: hard - "@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" @@ -5686,6 +5677,7 @@ __metadata: chokidar: "npm:^4" commit-and-tag-version: "npm:^12" constructs: "npm:^10.0.0" + cross-spawn: "npm:^7.0.6" decamelize: "npm:^5" enquirer: "npm:^2.4.1" esbuild: "npm:^0.28.2" @@ -14564,13 +14556,6 @@ __metadata: languageName: node linkType: hard -"split2@npm:^4.2.0": - version: 4.2.0 - resolution: "split2@npm:4.2.0" - checksum: 10c0/b292beb8ce9215f8c642bb68be6249c5a4c7f332fc8ecadae7be5cbdf1ea95addc95f0459ef2e7ad9d45fd1064698a097e4eb211c83e772b49bc0ee423e91534 - languageName: node - linkType: hard - "split@npm:^1.0.1": version: 1.0.1 resolution: "split@npm:1.0.1"