From 3d2c68ce6fd31a3136593e04de172926c1d34570 Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:21:27 +0000 Subject: [PATCH 01/11] refactor: filter stacks before getting their templates (#960) The `refactor` command only operates on the stacks that are relevant for the target CDK application. However, it gets all the templates of the deployed stacks before filtering them, which is wasteful. Invert the order, so that the filtering happens before getting the templates. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --- .../cli-integ/resources/cdk-apps/app/app.js | 4 + ...cdk-destroy-nonexistent-stack.integtest.ts | 21 ++ .../cdk-destroy-stage-only.integtest.ts | 28 ++ packages/aws-cdk/lib/cli/cdk-toolkit.ts | 57 ++++ packages/aws-cdk/test/cli/cdk-toolkit.test.ts | 270 +++++++++++++++++- 5 files changed, 371 insertions(+), 9 deletions(-) create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index fe20f0b63..52e635ae6 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -1184,6 +1184,10 @@ switch (stackSet) { case 'stage-with-no-stacks': break; + case 'stage-only': + new SomeStage(app, `${stackPrefix}-stage`); + break; + default: throw new Error(`Unrecognized INTEG_STACK_SET: '${stackSet}'`); } diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts new file mode 100644 index 000000000..429e6c5be --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts @@ -0,0 +1,21 @@ +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest('cdk destroy does not fail even if the stacks do not exist', withDefaultFixture(async (fixture) => { + const nonExistingStackName1 = 'non-existing-stack-1'; + const nonExistingStackName2 = 'non-existing-stack-2'; + + await expect(fixture.cdkDestroy([nonExistingStackName1, nonExistingStackName2])).resolves.not.toThrow(); +})); + +integTest('cdk destroy with no force option exits without prompt if the stacks do not exist', withDefaultFixture(async (fixture) => { + const nonExistingStackName1 = 'non-existing-stack-1'; + const nonExistingStackName2 = 'non-existing-stack-2'; + + await expect(fixture.cdkDestroy([nonExistingStackName1, nonExistingStackName2], { + force: false, + })).resolves.not.toThrow(); +})); + +integTest('cdk destroy does not fail even if the stages do not exist', withDefaultFixture(async (fixture) => { + await expect(fixture.cdkDestroy('NonExistent/*')).resolves.not.toThrow(); +})); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts new file mode 100644 index 000000000..95a7d1c50 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts @@ -0,0 +1,28 @@ +import { DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest('cdk destroy can destroy stacks in stage-only configuration', withDefaultFixture(async (fixture) => { + const integStackSet = 'stage-only'; + + const stageNameSuffix = 'stage'; + const specifiedStackName = `${stageNameSuffix}/*`; + + await fixture.cdkDeploy(specifiedStackName, { + modEnv: { + INTEG_STACK_SET: integStackSet, + }, + }); + + const stackName = `${fixture.fullStackName(stageNameSuffix)}-StackInStage`; + const stack = await fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: stackName })); + expect(stack.Stacks?.length ?? 0).toEqual(1); + + await fixture.cdkDestroy(specifiedStackName, { + modEnv: { + INTEG_STACK_SET: integStackSet, + }, + }); + + await expect(fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: stackName }))) + .rejects.toThrow(/does not exist/); +})); diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 8f35662f3..4d2487000 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -10,6 +10,7 @@ import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; import { type EventName, EVENTS } from 'chokidar/handler.js'; import * as fs from 'fs-extra'; +import * as picomatch from 'picomatch'; import { CliIoHost } from './io-host'; import type { Configuration } from './user-configuration'; import { PROJECT_CONFIG } from './user-configuration'; @@ -917,6 +918,17 @@ export class CdkToolkit { const stacks = await this.selectStacksForDestroy(options.selector, options.exclusively); + await this.suggestStacks({ + selector: options.selector, + stacks, + exclusively: options.exclusively, + }); + + if (stacks.stackArtifacts.length === 0) { + await this.ioHost.asIoHelper().defaults.warn(`No stacks match the name(s): ${chalk.red(options.selector.patterns.join(', '))}`); + return; + } + if (!options.force) { const motivation = 'Destroying stacks is an irreversible action'; const question = `Are you sure you want to delete: ${chalk.blue(stacks.stackArtifacts.map((s) => s.hierarchicalId).join(', '))}`; @@ -1319,6 +1331,51 @@ export class CdkToolkit { return stacks; } + private async suggestStacks(props: { + selector: StackSelector; + stacks: StackCollection; + exclusively: boolean; + }) { + if (props.selector.patterns.length === 0) { + return; + } + + const assembly = await this.assembly(); + const selectorWithoutPatterns: StackSelector = { + patterns: [], + }; + const stacksWithoutPatterns = await assembly.selectStacks(selectorWithoutPatterns, { + extend: props.exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Downstream, + defaultBehavior: DefaultSelection.AllStacks, + }); + + const patterns = props.selector.patterns.map(pattern => { + const notExist = !props.stacks.stackArtifacts.find(stack => + picomatch.isMatch(stack.hierarchicalId, pattern), + ); + + const closelyMatched = notExist ? stacksWithoutPatterns.stackArtifacts.map(stack => { + if (picomatch.isMatch(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase())) { + return stack.hierarchicalId; + } + return; + }).filter((stack): stack is string => stack !== undefined) : []; + + return { + pattern, + notExist, + closelyMatched, + }; + }); + + for (const pattern of patterns) { + if (pattern.notExist) { + const closelyMatched = pattern.closelyMatched.length > 0 ? ` Do you mean ${chalk.blue(pattern.closelyMatched.join(', '))}?` : ''; + await this.ioHost.asIoHelper().defaults.warn(`${chalk.red(pattern.pattern)} does not exist.${closelyMatched}`); + } + } + } + /** * Validate the stacks for errors and warnings according to the CLI's current settings */ diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index ecf816ece..b47cc8f80 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -72,6 +72,7 @@ import type { CloudFormationClientResolvedConfig, CreateChangeSetInput, CreateCh import { CreateChangeSetCommand, DeleteChangeSetCommand, DescribeChangeSetCommand, DescribeStacksCommand, GetTemplateCommand, StackStatus } from '@aws-sdk/client-cloudformation'; import { GetParameterCommand } from '@aws-sdk/client-ssm'; import type { AwsStub } from 'aws-sdk-client-mock'; +import * as chalk from 'chalk'; import * as fs from 'fs-extra'; import { type Template, type SdkProvider, WorkGraphBuilder } from '../../lib/api'; import { Bootstrapper, type BootstrapSource } from '../../lib/api/bootstrap'; @@ -163,6 +164,42 @@ function defaultToolkitSetup() { }); } +async function singleStackToolkitSetup() { + const singleStackExecutable = await MockCloudExecutable.create({ + stacks: [MockStack.MOCK_STACK_B], + }); + + return new CdkToolkit({ + ioHost, + cloudExecutable: singleStackExecutable, + configuration: singleStackExecutable.configuration, + sdkProvider: singleStackExecutable.sdkProvider, + deployments: new FakeCloudFormation({ + 'Test-Stack-B': { Foo: 'Bar' }, + }), + }); +} + +// only stacks within stages (no top-level stacks) +async function stageOnlyToolkitSetup() { + const stageOnlyExecutable = await MockCloudExecutable.create({ + stacks: [], + nestedAssemblies: [{ + stacks: [MockStack.MOCK_STACK_C], + }], + }); + + return new CdkToolkit({ + ioHost, + cloudExecutable: stageOnlyExecutable, + configuration: stageOnlyExecutable.configuration, + sdkProvider: stageOnlyExecutable.sdkProvider, + deployments: new FakeCloudFormation({ + 'Test-Stack-C': { Baz: 'Zinga!' }, + }), + }); +} + const mockSdk = new MockSdk(); describe('bootstrap', () => { @@ -1738,17 +1775,232 @@ describe('deploy', () => { }); describe('destroy', () => { - test('destroy correct stack', async () => { + test('destroys correct stack', async () => { const toolkit = defaultToolkitSetup(); - expect(() => { - return toolkit.destroy({ - selector: { patterns: ['Test-Stack-A/Test-Stack-C'] }, - exclusively: true, - force: true, - fromDeploy: true, - }); - }).resolves; + await expect(toolkit.destroy({ + selector: { patterns: ['Test-Stack-A/Test-Stack-C'] }, + exclusively: true, + force: true, + fromDeploy: true, + })).resolves.not.toThrow(); + }); + + test('destroys with --all flag', async () => { + const toolkit = defaultToolkitSetup(); + + await expect(toolkit.destroy({ + selector: { allTopLevel: true, patterns: [] }, // --all flag sets allTopLevel: true + exclusively: true, + force: true, + fromDeploy: true, + })).resolves.not.toThrow(); + }); + + test('destroys stack within stage with wildcard pattern', async () => { + const toolkit = defaultToolkitSetup(); + + await expect(toolkit.destroy({ + selector: { patterns: ['Test*/*'] }, + exclusively: true, + force: true, + fromDeploy: true, + })).resolves.not.toThrow(); + }); + + test('destroys stack in single-stack configuration', async () => { + const toolkit = await singleStackToolkitSetup(); + + await expect(toolkit.destroy({ + selector: { patterns: [] }, + exclusively: true, + force: true, + fromDeploy: true, + })).resolves.not.toThrow(); + }); + + test('destroys stack with pattern in single-stack configuration', async () => { + const toolkit = await singleStackToolkitSetup(); + + await expect(toolkit.destroy({ + selector: { patterns: ['Test-Stack-B'] }, + exclusively: true, + force: true, + fromDeploy: true, + })).resolves.not.toThrow(); + }); + + test('destroys stack within stage in stage-only configuration', async () => { + const toolkit = await stageOnlyToolkitSetup(); + + await expect(toolkit.destroy({ + selector: { patterns: ['Test-Stack-A/Test-Stack-C'] }, + exclusively: true, + force: true, + fromDeploy: true, + })).resolves.not.toThrow(); + }); + + test('destroys stack within stage with wildcard pattern in stage-only configuration', async () => { + const toolkit = await stageOnlyToolkitSetup(); + + await expect(toolkit.destroy({ + selector: { patterns: ['Test*/*'] }, + exclusively: true, + force: true, + fromDeploy: true, + })).resolves.not.toThrow(); + }); + + test('warns if there are only non-existent stacks', async () => { + const toolkit = defaultToolkitSetup(); + + await toolkit.destroy({ + selector: { patterns: ['Test-Stack-X', 'Test-Stack-Y'] }, + exclusively: true, + force: true, + fromDeploy: true, + }); + + expect(flatten(notifySpy.mock.calls)).toEqual([ + expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-X')} does not exist.`), 'warn'), + expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-Y')} does not exist.`), 'warn'), + expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('Test-Stack-X, Test-Stack-Y')}`), 'warn'), + ]); + }); + + test('warns if there are only non-existent stacks even when exclusively is false', async () => { + const toolkit = defaultToolkitSetup(); + + await toolkit.destroy({ + selector: { patterns: ['Test-Stack-X', 'Test-Stack-Y'] }, + exclusively: false, + force: true, + fromDeploy: true, + }); + + expect(flatten(notifySpy.mock.calls)).toEqual([ + expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-X')} does not exist.`), 'warn'), + expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-Y')} does not exist.`), 'warn'), + expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('Test-Stack-X, Test-Stack-Y')}`), 'warn'), + ]); + }); + + test('warns if there is a non-existent stack and the other exists', async () => { + const toolkit = defaultToolkitSetup(); + + await toolkit.destroy({ + selector: { patterns: ['Test-Stack-X', 'Test-Stack-B'] }, + exclusively: true, + force: true, + fromDeploy: true, + }); + + expect(flatten(notifySpy.mock.calls)).toEqual( + expect.arrayContaining([ + expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-X')} does not exist.`), 'warn'), + ]), + ); + expect(flatten(notifySpy.mock.calls)).not.toEqual( + expect.arrayContaining([ + expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-B')} does not exist.`), 'warn'), + ]), + ); + expect(flatten(notifySpy.mock.calls)).not.toEqual( + expect.arrayContaining([ + expectIoMsg(expect.stringMatching(/No stacks match the name\(s\)/), 'warn'), + ]), + ); + }); + + test('warns when wildcard pattern does not match any stacks', async () => { + const toolkit = defaultToolkitSetup(); + + await toolkit.destroy({ + selector: { patterns: ['Foo*/*'] }, + exclusively: true, + force: true, + fromDeploy: true, + }); + + expect(flatten(notifySpy.mock.calls)).toEqual( + expect.arrayContaining([ + expectIoMsg(expect.stringContaining(`${chalk.red('Foo*/*')} does not exist.`), 'warn'), + expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('Foo*/*')}`), 'warn'), + ]), + ); + }); + + test('warns when destroying non-existent stack in stage-only configuration', async () => { + const toolkit = await stageOnlyToolkitSetup(); + + await toolkit.destroy({ + selector: { patterns: ['Foo*/*'] }, + exclusively: true, + force: true, + fromDeploy: true, + }); + + expect(flatten(notifySpy.mock.calls)).toEqual( + expect.arrayContaining([ + expectIoMsg(expect.stringContaining(`${chalk.red('Foo*/*')} does not exist.`), 'warn'), + expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('Foo*/*')}`), 'warn'), + ]), + ); + }); + + test('suggests valid names if there is a non-existent but closely matching stack', async () => { + const toolkit = defaultToolkitSetup(); + + await toolkit.destroy({ + selector: { patterns: ['test-stack-b'] }, + exclusively: true, + force: true, + fromDeploy: true, + }); + + expect(flatten(notifySpy.mock.calls)).toEqual( + expect.arrayContaining([ + expectIoMsg(expect.stringContaining(`${chalk.red('test-stack-b')} does not exist. Do you mean ${chalk.blue('Test-Stack-B')}?`), 'warn'), + expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('test-stack-b')}`), 'warn'), + ]), + ); + }); + + test('suggests stack names within stages if there is a non-existent but closely matching stack', async () => { + const toolkit = defaultToolkitSetup(); + + await toolkit.destroy({ + selector: { patterns: ['test-stack-a/test-stack-c'] }, + exclusively: true, + force: true, + fromDeploy: true, + }); + + expect(flatten(notifySpy.mock.calls)).toEqual( + expect.arrayContaining([ + expectIoMsg(expect.stringContaining(`${chalk.red('test-stack-a/test-stack-c')} does not exist. Do you mean ${chalk.blue('Test-Stack-A/Test-Stack-C')}?`), 'warn'), + expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('test-stack-a/test-stack-c')}`), 'warn'), + ]), + ); + }); + + test('suggests stack with wildcard pattern when only case differs', async () => { + const toolkit = defaultToolkitSetup(); + + await toolkit.destroy({ + selector: { patterns: ['test*/*'] }, + exclusively: true, + force: true, + fromDeploy: true, + }); + + expect(flatten(notifySpy.mock.calls)).toEqual( + expect.arrayContaining([ + expectIoMsg(expect.stringContaining(`${chalk.red('test*/*')} does not exist. Do you mean ${chalk.blue('Test-Stack-A/Test-Stack-C')}?`), 'warn'), + expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('test*/*')}`), 'warn'), + ]), + ); }); test('destroy with concurrency', async () => { From 6041851be038b64773efe9a572c119d2e828052d Mon Sep 17 00:00:00 2001 From: go-to-k <24818752+go-to-k@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:35:21 +0900 Subject: [PATCH 02/11] refactor: route cdk destroy through toolkit-lib toolkit-lib: add `force` to DestroyOptions; add W7010/W7011 warnings; move the stack-name suggestion + no-match warning + force-skip into `_destroy`; expose `destroyForAction` as protected so the CLI can keep `deploy` message attribution. aws-cdk: thin destroy delegation with selector -> StackSelector mapping; add `InternalToolkit.destroyFromDeploy`; remove `fromDeploy`/legacy loop; preserve graceful abort on a declined confirmation. --- .../toolkit-lib/lib/actions/destroy/index.ts | 7 + .../lib/api/io/private/messages.ts | 9 + .../toolkit-lib/lib/toolkit/toolkit.ts | 68 +++++++- packages/aws-cdk/lib/cli/cdk-toolkit.ts | 165 ++++++------------ 4 files changed, 127 insertions(+), 122 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/destroy/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/destroy/index.ts index c838aa1be..071689505 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/destroy/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/destroy/index.ts @@ -19,4 +19,11 @@ export interface DestroyOptions { * @default 1 */ readonly concurrency?: number; + + /** + * Do not ask for confirmation before destroying the stacks + * + * @default false + */ + readonly force?: boolean; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index d13911828..3159bca7f 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -386,6 +386,15 @@ export const IO = { interface: 'StackDestroy', }), + CDK_TOOLKIT_W7010: make.warn({ + code: 'CDK_TOOLKIT_W7010', + description: 'A provided stack name does not match any stack', + }), + CDK_TOOLKIT_W7011: make.warn({ + code: 'CDK_TOOLKIT_W7011', + description: 'No stacks match the provided names, nothing to destroy', + }), + CDK_TOOLKIT_I7900: make.result({ code: 'CDK_TOOLKIT_I7900', description: 'Stack deletion succeeded', diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 3a430a74a..1eb1098d8 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -8,6 +8,7 @@ import type { TemplateDiff } from '@aws-cdk/cloudformation-diff'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; import { type EventName, EVENTS } from 'chokidar/handler.js'; +import * as picomatch from 'picomatch'; /** * File events that we care about from chokidar. @@ -1580,9 +1581,19 @@ export class Toolkit extends CloudAssemblySourceBuilder { * Destroys the selected Stacks. */ public async destroy(cx: ICloudAssemblySource, options: DestroyOptions = {}): Promise { - const ioHelper = asIoHelper(this.ioHost, 'destroy'); + return this.destroyForAction(cx, 'destroy', options); + } + + /** + * Synthesize and destroy the selected stacks, attributing emitted messages to + * the given action. Exposed as `protected` so that callers within this repo + * (e.g. the CLI) can perform a destroy as part of a `deploy` while keeping the + * message attribution, without widening the public API. + */ + protected async destroyForAction(cx: ICloudAssemblySource, action: 'deploy' | 'destroy', options: DestroyOptions): Promise { + const ioHelper = asIoHelper(this.ioHost, action); await using assembly = await synthAndMeasure(ioHelper, cx, stacksOpt(options)); - return await this._destroy(assembly, 'destroy', options); + return await this._destroy(assembly, action, options); } /** @@ -1593,18 +1604,29 @@ export class Toolkit extends CloudAssemblySourceBuilder { const ioHelper = asIoHelper(this.ioHost, action); const stacks = await assembly.selectStacksV2(selectStacks); + await this.suggestStacks(ioHelper, assembly, selectStacks, stacks); + const ret: DestroyResult = { stacks: [], }; - const motivation = 'Destroying stacks is an irreversible action'; - const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`; - const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation })); - if (!confirmed) { - await ioHelper.notify(IO.CDK_TOOLKIT_E7010.msg('Aborted by user')); + if (stacks.stackCount === 0) { + await ioHelper.notify(IO.CDK_TOOLKIT_W7011.msg( + `No stacks match the name(s): ${chalk.red((selectStacks.patterns ?? []).join(', '))}`, + )); return ret; } + if (!options.force) { + const motivation = 'Destroying stacks is an irreversible action'; + const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`; + const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation })); + if (!confirmed) { + await ioHelper.notify(IO.CDK_TOOLKIT_E7010.msg('Aborted by user')); + return ret; + } + } + const concurrency = options.concurrency || 1; let destroyCount = 0; @@ -1656,6 +1678,38 @@ export class Toolkit extends CloudAssemblySourceBuilder { } } + /** + * Warn about stack name patterns that did not match any stack, suggesting a + * close match where one exists (e.g. when only the casing differs). + */ + private async suggestStacks( + ioHelper: IoHelper, + assembly: StackAssembly, + selector: StackSelector, + selected: StackCollection, + ): Promise { + const patterns = selector.patterns ?? []; + if (patterns.length === 0) { + return; + } + + const allStacks = await assembly.selectStacksV2(ALL_STACKS); + + for (const pattern of patterns) { + const matched = selected.stackArtifacts.some((stack) => picomatch.isMatch(stack.hierarchicalId, pattern)); + if (matched) { + continue; + } + + const closeMatches = allStacks.stackArtifacts + .filter((stack) => picomatch.isMatch(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase())) + .map((stack) => stack.hierarchicalId); + + const suggestion = closeMatches.length > 0 ? ` Do you mean ${chalk.blue(closeMatches.join(', '))}?` : ''; + await ioHelper.notify(IO.CDK_TOOLKIT_W7010.msg(`${chalk.red(pattern)} does not exist.${suggestion}`)); + } + } + /** * Create a deployments class */ diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 4d2487000..5b607fb06 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -4,13 +4,12 @@ import { format } from 'node:util'; import type { IManifestEntry } from '@aws-cdk/cdk-assets-lib'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; import { RequireApproval } from '@aws-cdk/cloud-assembly-schema'; -import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib'; +import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, DestroyOptions as DestroyToolkitOptions, DestroyResult, ICloudAssemblySource, PublishAssetsOptions, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib'; import { PermissionChangeType, Toolkit, ToolkitError } from '@aws-cdk/toolkit-lib'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; import { type EventName, EVENTS } from 'chokidar/handler.js'; import * as fs from 'fs-extra'; -import * as picomatch from 'picomatch'; import { CliIoHost } from './io-host'; import type { Configuration } from './user-configuration'; import { PROJECT_CONFIG } from './user-configuration'; @@ -18,7 +17,6 @@ import type { ActionLessRequest, IMessageSpan, IoHelper } from '../../lib/api-pr import { asIoHelper, cfnApi, createIgnoreMatcher, IO, tagsForStack, throwIfValidationFailures } from '../../lib/api-private'; import type { AssetBuildNode, AssetPublishNode, Concurrency, MarkerNode, StackNode, WorkGraph, WorkGraphActions } from '../api'; import { - buildDestroyWorkGraph, CloudWatchLogEventMonitor, DEFAULT_TOOLKIT_STACK_NAME, DiffFormatter, @@ -187,6 +185,14 @@ class InternalToolkit extends Toolkit { protected async sdkProvider(_action: ToolkitAction): Promise { return this._sdkProvider; } + + /** + * Destroy stacks as part of a deploy (e.g. removing a stack that synthesized + * to no resources), keeping emitted messages attributed to the deploy action. + */ + public async destroyFromDeploy(cx: ICloudAssemblySource, options: DestroyToolkitOptions = {}): Promise { + return this.destroyForAction(cx, 'deploy', options); + } } /** @@ -914,62 +920,54 @@ export class CdkToolkit { } public async destroy(options: DestroyOptions) { - const ioHelper = this.ioHost.asIoHelper(); - - const stacks = await this.selectStacksForDestroy(options.selector, options.exclusively); - - await this.suggestStacks({ - selector: options.selector, - stacks, - exclusively: options.exclusively, - }); - - if (stacks.stackArtifacts.length === 0) { - await this.ioHost.asIoHelper().defaults.warn(`No stacks match the name(s): ${chalk.red(options.selector.patterns.join(', '))}`); - return; + if ((options.concurrency ?? 1) > 1) { + this.ioHost.stackProgress = StackActivityProgress.EVENTS; } - - if (!options.force) { - const motivation = 'Destroying stacks is an irreversible action'; - const question = `Are you sure you want to delete: ${chalk.blue(stacks.stackArtifacts.map((s) => s.hierarchicalId).join(', '))}`; - try { - await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation })); - } catch (err: unknown) { - if (!ToolkitError.isToolkitError(err) || err.message != 'Aborted by user') { - throw err; // unexpected error - } - await ioHelper.notify(IO.CDK_TOOLKIT_E7010.msg(err.message)); + try { + await this.toolkit.destroy(this.props.cloudExecutable, this.buildToolkitDestroyOptions(options)); + } catch (err: unknown) { + // Preserve the CLI's historical behavior: declining the confirmation + // prompt aborts the command gracefully rather than surfacing as an error. + if (ToolkitError.isToolkitError(err) && err.message === 'Aborted by user') { + await this.ioHost.asIoHelper().notify(IO.CDK_TOOLKIT_E7010.msg(err.message)); return; } + throw err; } + } - const concurrency = options.concurrency || 1; - const action = options.fromDeploy ? 'deploy' : 'destroy'; - let destroyCount = 0; - - if (concurrency > 1) { + /** + * Destroy stacks as part of a deploy (e.g. removing a stack that synthesized + * to no resources). Keeps emitted messages attributed to the deploy action. + */ + public async destroyFromDeploy(options: DestroyOptions) { + if ((options.concurrency ?? 1) > 1) { this.ioHost.stackProgress = StackActivityProgress.EVENTS; } + await this.toolkit.destroyFromDeploy(this.props.cloudExecutable, this.buildToolkitDestroyOptions(options)); + } - const destroyStack = async (stackNode: StackNode) => { - const stack = stackNode.stack; - destroyCount++; - await ioHelper.defaults.info(chalk.green('%s: destroying... [%s/%s]'), chalk.blue(stack.displayName), destroyCount, stacks.stackCount); - try { - await this.props.deployments.destroyStack({ - stack, - deployName: stack.stackName, - roleArn: options.roleArn, - }); - await ioHelper.defaults.info(chalk.green(`\n ✅ %s: ${action}ed`), chalk.blue(stack.displayName)); - } catch (e) { - await ioHelper.defaults.error(`\n ❌ %s: ${action} failed`, chalk.blue(stack.displayName), e); - throw e; - } + private buildToolkitDestroyOptions(options: DestroyOptions) { + const patterns = options.selector.patterns; + let strategy: StackSelectionStrategy; + if (patterns.length > 0) { + strategy = StackSelectionStrategy.PATTERN_MATCH; + } else if (options.selector.allTopLevel) { + // `--all`: every stack in the top-level (main) assembly. + strategy = StackSelectionStrategy.MAIN_ASSEMBLY; + } else { + strategy = StackSelectionStrategy.ONLY_SINGLE; + } + return { + stacks: { + patterns, + strategy, + expand: options.exclusively ? ExpandStackSelection.NONE : ExpandStackSelection.DOWNSTREAM, + }, + force: options.force, + roleArn: options.roleArn, + concurrency: options.concurrency, }; - - const workGraph = buildDestroyWorkGraph(stacks.stackArtifacts, ioHelper); - await workGraph.processStacks(concurrency, destroyStack); } public async list( @@ -1319,63 +1317,6 @@ export class CdkToolkit { return selectedForDiff; } - private async selectStacksForDestroy(selector: StackSelector, exclusively?: boolean) { - const assembly = await this.assembly(); - const stacks = await assembly.selectStacks(selector, { - extend: exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Downstream, - defaultBehavior: DefaultSelection.OnlySingle, - }); - - // No validation - - return stacks; - } - - private async suggestStacks(props: { - selector: StackSelector; - stacks: StackCollection; - exclusively: boolean; - }) { - if (props.selector.patterns.length === 0) { - return; - } - - const assembly = await this.assembly(); - const selectorWithoutPatterns: StackSelector = { - patterns: [], - }; - const stacksWithoutPatterns = await assembly.selectStacks(selectorWithoutPatterns, { - extend: props.exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Downstream, - defaultBehavior: DefaultSelection.AllStacks, - }); - - const patterns = props.selector.patterns.map(pattern => { - const notExist = !props.stacks.stackArtifacts.find(stack => - picomatch.isMatch(stack.hierarchicalId, pattern), - ); - - const closelyMatched = notExist ? stacksWithoutPatterns.stackArtifacts.map(stack => { - if (picomatch.isMatch(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase())) { - return stack.hierarchicalId; - } - return; - }).filter((stack): stack is string => stack !== undefined) : []; - - return { - pattern, - notExist, - closelyMatched, - }; - }); - - for (const pattern of patterns) { - if (pattern.notExist) { - const closelyMatched = pattern.closelyMatched.length > 0 ? ` Do you mean ${chalk.blue(pattern.closelyMatched.join(', '))}?` : ''; - await this.ioHost.asIoHelper().defaults.warn(`${chalk.red(pattern.pattern)} does not exist.${closelyMatched}`); - } - } - } - /** * Validate the stacks for errors and warnings according to the CLI's current settings */ @@ -1881,11 +1822,6 @@ export interface DestroyOptions { */ roleArn?: string; - /** - * Whether the destroy request came from a deploy. - */ - fromDeploy?: boolean; - /** * Maximum number of simultaneous destroys (dependency permitting) to execute. */ @@ -2186,7 +2122,7 @@ class WorkGraphDeploymentActions implements WorkGraphActions { constructor( private readonly deployments: Deployments, private readonly ioHost: CliIoHost, - private readonly stackOperations: Pick, + private readonly stackOperations: Pick, private readonly options: WorkGraphDeploymentActionsOptions, ) { } @@ -2282,12 +2218,11 @@ class WorkGraphDeploymentActions implements WorkGraphActions { await this.ioHost.asIoHelper().defaults.warn('%s: stack has no resources, skipping deployment.', chalk.bold(stack.displayName)); } else { await this.ioHost.asIoHelper().defaults.warn('%s: stack has no resources, deleting existing stack.', chalk.bold(stack.displayName)); - await this.stackOperations.destroy({ + await this.stackOperations.destroyFromDeploy({ selector: { patterns: [stack.hierarchicalId] }, exclusively: true, force: true, roleArn: this.options.roleArn, - fromDeploy: true, }); } return; From ec87712f0977ca170aa3223fd66d7472e2e18ef5 Mon Sep 17 00:00:00 2001 From: go-to-k <24818752+go-to-k@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:35:21 +0900 Subject: [PATCH 03/11] chore: regenerate message registry and destroy IoHost snapshots Generated output of routing destroy through toolkit-lib: message-registry.md is regenerated from messages.ts, and the destroy command's __io_snapshots__ reflect the messages now emitted by toolkit-lib. No hand edits. --- .../toolkit-lib/docs/message-registry.md | 2 ++ ...and_rethrows_when_destroyStack_fails.ndjson | 12 ++++++++---- ...troys_nothing_when_the_user_declines.ndjson | 10 ++++++---- ..._and_proceeds_when_the_user_confirms.ndjson | 15 ++++++++++----- ...d_error_from_the_confirmation_prompt.ndjson | 8 +++++--- ...ack_fromDeploy_makes_it_say_deployed.ndjson | 13 +++++++++---- ...ll_top-level_stacks_with_concurrency.ndjson | 18 ++++++++++++------ ...forwards_the_roleArn_to_destroyStack.ndjson | 13 +++++++++---- ...ts_dependency_order_with_concurrency.ndjson | 18 ++++++++++++------ 9 files changed, 73 insertions(+), 36 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index d40e7dc66..22924652e 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -126,6 +126,8 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `CDK_TOOLKIT_I7010` | Confirm destroy stacks | `info` | {@link ConfirmationRequest} | | `CDK_TOOLKIT_I7100` | Stack destroy progress | `info` | {@link StackDestroyProgress} | | `CDK_TOOLKIT_I7101` | Start stack destroying | `trace` | {@link StackDestroy} | +| `CDK_TOOLKIT_W7010` | A provided stack name does not match any stack | `warn` | n/a | +| `CDK_TOOLKIT_W7011` | No stacks match the provided names, nothing to destroy | `warn` | n/a | | `CDK_TOOLKIT_I7900` | Stack deletion succeeded | `result` | [cxapi.CloudFormationStackArtifact](https://docs.aws.amazon.com/cdk/api/v2/docs/@aws-cdk_cx-api.CloudFormationStackArtifact.html) | | `CDK_TOOLKIT_E7010` | Action was aborted due to negative confirmation of request | `error` | n/a | | `CDK_TOOLKIT_E7900` | Stack deletion failed | `error` | {@link ErrorPayload} | diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/destroy_failure_emits_a_failure_message_and_rethrows_when_destroyStack_fails.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/destroy_failure_emits_a_failure_message_and_rethrows_when_destroyStack_fails.ndjson index 1949ffabf..fef5340dd 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/destroy_failure_emits_a_failure_message_and_rethrows_when_destroyStack_fails.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/destroy_failure_emits_a_failure_message_and_rethrows_when_destroyStack_fails.ndjson @@ -1,4 +1,8 @@ -{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} -{"seq":2,"type":"notify","action":"none","level":"info","code":null,"message":"Test-Stack-B: destroying... [1/1]"} -{"seq":3,"type":"notify","action":"none","level":"error","code":null,"message":"\n ❌ Test-Stack-B: destroy failed Error: Deletion failed"} +{"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} +{"seq":4,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} +{"seq":5,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-B: destroying... [1/1]"} +{"seq":6,"type":"notify","action":"destroy","level":"error","code":"CDK_TOOLKIT_E7900","message":"❌ Test-Stack-B: destroy failed Error: Deletion failed"} +{"seq":7,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7000","message":"✨ Destroy time: "} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_aborts_CDK_TOOLKIT_E7010_and_destroys_nothing_when_the_user_declines.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_aborts_CDK_TOOLKIT_E7010_and_destroys_nothing_when_the_user_declines.ndjson index 005692230..06fcf9b8c 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_aborts_CDK_TOOLKIT_E7010_and_destroys_nothing_when_the_user_declines.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_aborts_CDK_TOOLKIT_E7010_and_destroys_nothing_when_the_user_declines.ndjson @@ -1,4 +1,6 @@ -{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} -{"seq":2,"type":"request","action":"none","level":"info","code":"CDK_TOOLKIT_I7010","message":"Are you sure you want to delete: Test-Stack-B","response":{"error":"AbortedByUser"}} -{"seq":3,"type":"notify","action":"none","level":"error","code":"CDK_TOOLKIT_E7010","message":"Aborted by user"} +{"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} +{"seq":4,"type":"request","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7010","message":"Are you sure you want to delete: Test-Stack-B","response":{"error":"AbortedByUser"}} +{"seq":5,"type":"notify","action":"none","level":"error","code":"CDK_TOOLKIT_E7010","message":"Aborted by user"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_asks_for_confirmation_and_proceeds_when_the_user_confirms.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_asks_for_confirmation_and_proceeds_when_the_user_confirms.ndjson index cc3fcee8e..048362fb9 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_asks_for_confirmation_and_proceeds_when_the_user_confirms.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_asks_for_confirmation_and_proceeds_when_the_user_confirms.ndjson @@ -1,5 +1,10 @@ -{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} -{"seq":2,"type":"request","action":"none","level":"info","code":"CDK_TOOLKIT_I7010","message":"Are you sure you want to delete: Test-Stack-B","response":true} -{"seq":3,"type":"notify","action":"none","level":"info","code":null,"message":"Test-Stack-B: destroying... [1/1]"} -{"seq":4,"type":"notify","action":"none","level":"info","code":null,"message":"\n ✅ Test-Stack-B: destroyed"} +{"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} +{"seq":4,"type":"request","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7010","message":"Are you sure you want to delete: Test-Stack-B","response":true} +{"seq":5,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} +{"seq":6,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-B: destroying... [1/1]"} +{"seq":7,"type":"notify","action":"destroy","level":"result","code":"CDK_TOOLKIT_I7900","message":"✅ Test-Stack-B: destroyed"} +{"seq":8,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7001","message":"✨ Destroy time: "} +{"seq":9,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7000","message":"✨ Destroy time: "} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_rethrows_an_unexpected_error_from_the_confirmation_prompt.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_rethrows_an_unexpected_error_from_the_confirmation_prompt.ndjson index 556173a33..abacb086a 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_rethrows_an_unexpected_error_from_the_confirmation_prompt.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_rethrows_an_unexpected_error_from_the_confirmation_prompt.ndjson @@ -1,3 +1,5 @@ -{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} -{"seq":2,"type":"request","action":"none","level":"info","code":"CDK_TOOLKIT_I7010","message":"Are you sure you want to delete: Test-Stack-B","response":{"error":"SomethingElse"}} +{"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} +{"seq":4,"type":"request","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7010","message":"Are you sure you want to delete: Test-Stack-B","response":{"error":"SomethingElse"}} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_a_single_nested_stack_fromDeploy_makes_it_say_deployed.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_a_single_nested_stack_fromDeploy_makes_it_say_deployed.ndjson index 5b1101a87..dd8353634 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_a_single_nested_stack_fromDeploy_makes_it_say_deployed.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_a_single_nested_stack_fromDeploy_makes_it_say_deployed.ndjson @@ -1,4 +1,9 @@ -{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} -{"seq":2,"type":"notify","action":"none","level":"info","code":null,"message":"Test-Stack-A/Test-Stack-C: destroying... [1/1]"} -{"seq":3,"type":"notify","action":"none","level":"info","code":null,"message":"\n ✅ Test-Stack-A/Test-Stack-C: deployed"} +{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"deploy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} +{"seq":4,"type":"notify","action":"deploy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} +{"seq":5,"type":"notify","action":"deploy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-A/Test-Stack-C: destroying... [1/1]"} +{"seq":6,"type":"notify","action":"deploy","level":"result","code":"CDK_TOOLKIT_I7900","message":"✅ Test-Stack-A/Test-Stack-C: deployed"} +{"seq":7,"type":"notify","action":"deploy","level":"trace","code":"CDK_TOOLKIT_I7001","message":"✨ Destroy time: "} +{"seq":8,"type":"notify","action":"deploy","level":"info","code":"CDK_TOOLKIT_I7000","message":"✨ Destroy time: "} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_all_top-level_stacks_with_concurrency.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_all_top-level_stacks_with_concurrency.ndjson index 3e36a8f8b..3a9549158 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_all_top-level_stacks_with_concurrency.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_all_top-level_stacks_with_concurrency.ndjson @@ -1,6 +1,12 @@ -{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} -{"seq":2,"type":"notify","action":"none","level":"info","code":null,"message":"Test-Stack-A-Display-Name: destroying... [1/2]"} -{"seq":3,"type":"notify","action":"none","level":"info","code":null,"message":"Test-Stack-B: destroying... [2/2]"} -{"seq":4,"type":"notify","action":"none","level":"info","code":null,"message":"\n ✅ Test-Stack-A-Display-Name: destroyed"} -{"seq":5,"type":"notify","action":"none","level":"info","code":null,"message":"\n ✅ Test-Stack-B: destroyed"} +{"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} +{"seq":4,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} +{"seq":5,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-A-Display-Name: destroying... [1/2]"} +{"seq":6,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-B: destroying... [2/2]"} +{"seq":7,"type":"notify","action":"destroy","level":"result","code":"CDK_TOOLKIT_I7900","message":"✅ Test-Stack-A-Display-Name: destroyed"} +{"seq":8,"type":"notify","action":"destroy","level":"result","code":"CDK_TOOLKIT_I7900","message":"✅ Test-Stack-B: destroyed"} +{"seq":9,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7001","message":"✨ Destroy time: "} +{"seq":10,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7001","message":"✨ Destroy time: "} +{"seq":11,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7000","message":"✨ Destroy time: "} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_forwards_the_roleArn_to_destroyStack.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_forwards_the_roleArn_to_destroyStack.ndjson index 7091ac8c9..b5370bdb3 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_forwards_the_roleArn_to_destroyStack.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_forwards_the_roleArn_to_destroyStack.ndjson @@ -1,4 +1,9 @@ -{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} -{"seq":2,"type":"notify","action":"none","level":"info","code":null,"message":"Test-Stack-B: destroying... [1/1]"} -{"seq":3,"type":"notify","action":"none","level":"info","code":null,"message":"\n ✅ Test-Stack-B: destroyed"} +{"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} +{"seq":4,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} +{"seq":5,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-B: destroying... [1/1]"} +{"seq":6,"type":"notify","action":"destroy","level":"result","code":"CDK_TOOLKIT_I7900","message":"✅ Test-Stack-B: destroyed"} +{"seq":7,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7001","message":"✨ Destroy time: "} +{"seq":8,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7000","message":"✨ Destroy time: "} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_respects_dependency_order_with_concurrency.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_respects_dependency_order_with_concurrency.ndjson index cbc66fdb3..79a18476d 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_respects_dependency_order_with_concurrency.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_respects_dependency_order_with_concurrency.ndjson @@ -1,6 +1,12 @@ -{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} -{"seq":2,"type":"notify","action":"none","level":"info","code":null,"message":"Test-Stack-D: destroying... [1/2]"} -{"seq":3,"type":"notify","action":"none","level":"info","code":null,"message":"\n ✅ Test-Stack-D: destroyed"} -{"seq":4,"type":"notify","action":"none","level":"info","code":null,"message":"Test-Stack-C: destroying... [2/2]"} -{"seq":5,"type":"notify","action":"none","level":"info","code":null,"message":"\n ✅ Test-Stack-C: destroyed"} +{"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} +{"seq":4,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} +{"seq":5,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-D: destroying... [1/2]"} +{"seq":6,"type":"notify","action":"destroy","level":"result","code":"CDK_TOOLKIT_I7900","message":"✅ Test-Stack-D: destroyed"} +{"seq":7,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7001","message":"✨ Destroy time: "} +{"seq":8,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-C: destroying... [2/2]"} +{"seq":9,"type":"notify","action":"destroy","level":"result","code":"CDK_TOOLKIT_I7900","message":"✅ Test-Stack-C: destroyed"} +{"seq":10,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7001","message":"✨ Destroy time: "} +{"seq":11,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7000","message":"✨ Destroy time: "} From b2ff421e71343ab26bd0a2d01e293095a111f21c Mon Sep 17 00:00:00 2001 From: go-to-k <24818752+go-to-k@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:35:21 +0900 Subject: [PATCH 04/11] test: migrate destroy tests to toolkit-lib Move the destroy logic tests to toolkit-lib (including stage/nested-stage regression coverage and the force / suggestion / no-match cases) with new stage fixtures; reduce the CLI tests to delegation/mapping checks; rework the destroy command snapshot test to spy on Deployments.prototype. --- .../test/_fixtures/stack-with-stage/index.ts | 16 + .../test/_fixtures/stage-only/index.ts | 16 + .../toolkit-lib/test/actions/destroy.test.ts | 225 ++++++++++++ packages/aws-cdk/test/cli/cdk-toolkit.test.ts | 326 ++++-------------- .../aws-cdk/test/commands/destroy.test.ts | 24 +- 5 files changed, 337 insertions(+), 270 deletions(-) create mode 100644 packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts create mode 100644 packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts new file mode 100644 index 000000000..51219a694 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts @@ -0,0 +1,16 @@ +import * as core from 'aws-cdk-lib/core'; + +/** + * An app with a top-level stack plus a stack nested inside a Stage. + * + * Hierarchical ids: `TopLevelStack` and `Stage/StackInStage`. Used to exercise + * destroy/suggestion behavior across both top-level and nested-stage stacks. + */ +export default async () => { + const app = new core.App({ autoSynth: false }); + new core.Stack(app, 'TopLevelStack'); + const stage = new core.Stage(app, 'Stage'); + new core.Stack(stage, 'StackInStage'); + + return app.synth(); +}; diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts new file mode 100644 index 000000000..ed0835628 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts @@ -0,0 +1,16 @@ +import * as core from 'aws-cdk-lib/core'; + +/** + * An app whose only stack lives inside a Stage (no top-level stacks). + * + * This is the configuration that regressed the original `cdk destroy` warning + * feature: code that only looked at top-level stacks could not see (or suggest) + * stacks nested in a Stage. The stack's hierarchical id is `Stage/StackInStage`. + */ +export default async () => { + const app = new core.App({ autoSynth: false }); + const stage = new core.Stage(app, 'Stage'); + new core.Stack(stage, 'StackInStage'); + + return app.synth(); +}; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts index d638b1f95..de90edbad 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts @@ -107,6 +107,231 @@ describe('destroy', () => { expect(mockDispose).toHaveBeenCalled(); await realDispose(); }); + + test('skips the confirmation prompt when force is true', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + force: true, + }); + + // THEN + expect(ioHost.requestSpy).not.toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_I7010', + })); + expect(mockDestroyStack).toHaveBeenCalledTimes(2); + }); + + test('warns when no stacks match the given name(s)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['DoesNotExist'] }, + force: true, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'destroy', + level: 'warn', + code: 'CDK_TOOLKIT_W7011', + message: expect.stringContaining('No stacks match the name(s)'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a closely matching stack when the name does not exist', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stack1'] }, + force: true, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'destroy', + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining('does not exist. Do you mean'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('warns about a missing name but still destroys the matching stacks', async () => { + // WHEN: one name matches (Stack1), the other does not + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stack1', 'DoesNotExist'] }, + force: true, + }); + + // THEN: warn for the missing name, but no "no stacks match" and the match is destroyed + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('DoesNotExist')} does not exist.`), + })); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7011', + })); + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('destroys all top-level stacks with the MAIN_ASSEMBLY strategy (cdk destroy --all)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.MAIN_ASSEMBLY }, + force: true, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(2); + }); + + test('destroys the single stack with the ONLY_SINGLE strategy (cdk destroy with no patterns)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.ONLY_SINGLE }, + force: true, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('warns about every non-existent name when none of them match', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['NopeA', 'NopeB'] }, + force: true, + }); + + // THEN: a per-name warning for each, plus the overall no-match warning + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('NopeA')} does not exist`), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('NopeB')} does not exist`), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7011', + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + describe('stacks nested in a stage', () => { + test('destroys a stack inside a stage by its hierarchical id', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-stage'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/StackInStage'] }, + force: true, + }); + + // THEN: only the staged stack is destroyed, not the top-level one + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + expect(mockDestroyStack.mock.calls[0][0].stack.hierarchicalId).toEqual('Stage/StackInStage'); + }); + + test('destroys a staged stack via a wildcard pattern', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-stage'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/*'] }, + force: true, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + expect(mockDestroyStack.mock.calls[0][0].stack.hierarchicalId).toEqual('Stage/StackInStage'); + }); + + test('destroys a stack in a stage-only app (no top-level stacks)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/StackInStage'] }, + force: true, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('destroys a staged stack via wildcard in a stage-only app', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/*'] }, + force: true, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + // Regression for the original revert: a stage-only app must not break the + // warning path (the candidate lookup must see nested-stage stacks). + test('warns without failing for a non-existent name in a stage-only app', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['DoesNotExist/*'] }, + force: true, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7011', + message: expect.stringContaining('No stacks match the name(s)'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + // Regression core: a close match nested inside a stage must be suggested, + // which only works if the candidate lookup includes nested-stage stacks. + test('suggests a nested-stage stack when only the casing differs', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stage/stackinstage'] }, + force: true, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`does not exist. Do you mean ${chalk.blue('Stage/StackInStage')}?`), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a nested-stage stack for a wildcard pattern that differs only in case', async () => { + // WHEN: a lower-cased wildcard that matches nothing but resembles the staged stack + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stage/*'] }, + force: true, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`does not exist. Do you mean ${chalk.blue('Stage/StackInStage')}?`), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + }); }); function successfulDestroy() { diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index b47cc8f80..b8d66013e 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -67,12 +67,11 @@ import * as cxapi from '@aws-cdk/cloud-assembly-api'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import { Manifest, RequireApproval } from '@aws-cdk/cloud-assembly-schema'; import type { DeploymentMethod } from '@aws-cdk/toolkit-lib'; -import { Toolkit } from '@aws-cdk/toolkit-lib'; +import { ExpandStackSelection, StackSelectionStrategy, Toolkit } from '@aws-cdk/toolkit-lib'; import type { CloudFormationClientResolvedConfig, CreateChangeSetInput, CreateChangeSetOutput, DeleteChangeSetInput, DeleteChangeSetOutput, DescribeChangeSetInput, DescribeChangeSetOutput, ServiceInputTypes, ServiceOutputTypes } from '@aws-sdk/client-cloudformation'; import { CreateChangeSetCommand, DeleteChangeSetCommand, DescribeChangeSetCommand, DescribeStacksCommand, GetTemplateCommand, StackStatus } from '@aws-sdk/client-cloudformation'; import { GetParameterCommand } from '@aws-sdk/client-ssm'; import type { AwsStub } from 'aws-sdk-client-mock'; -import * as chalk from 'chalk'; import * as fs from 'fs-extra'; import { type Template, type SdkProvider, WorkGraphBuilder } from '../../lib/api'; import { Bootstrapper, type BootstrapSource } from '../../lib/api/bootstrap'; @@ -164,42 +163,6 @@ function defaultToolkitSetup() { }); } -async function singleStackToolkitSetup() { - const singleStackExecutable = await MockCloudExecutable.create({ - stacks: [MockStack.MOCK_STACK_B], - }); - - return new CdkToolkit({ - ioHost, - cloudExecutable: singleStackExecutable, - configuration: singleStackExecutable.configuration, - sdkProvider: singleStackExecutable.sdkProvider, - deployments: new FakeCloudFormation({ - 'Test-Stack-B': { Foo: 'Bar' }, - }), - }); -} - -// only stacks within stages (no top-level stacks) -async function stageOnlyToolkitSetup() { - const stageOnlyExecutable = await MockCloudExecutable.create({ - stacks: [], - nestedAssemblies: [{ - stacks: [MockStack.MOCK_STACK_C], - }], - }); - - return new CdkToolkit({ - ioHost, - cloudExecutable: stageOnlyExecutable, - configuration: stageOnlyExecutable.configuration, - sdkProvider: stageOnlyExecutable.sdkProvider, - deployments: new FakeCloudFormation({ - 'Test-Stack-C': { Baz: 'Zinga!' }, - }), - }); -} - const mockSdk = new MockSdk(); describe('bootstrap', () => { @@ -1775,289 +1738,132 @@ describe('deploy', () => { }); describe('destroy', () => { - test('destroys correct stack', async () => { - const toolkit = defaultToolkitSetup(); + let destroyForActionSpy: jest.SpyInstance; - await expect(toolkit.destroy({ - selector: { patterns: ['Test-Stack-A/Test-Stack-C'] }, - exclusively: true, - force: true, - fromDeploy: true, - })).resolves.not.toThrow(); + beforeEach(() => { + // `destroy` and `destroyFromDeploy` both funnel through Toolkit's protected + // `destroyForAction(cx, action, options)`, so spying there lets us assert the + // CLI -> toolkit-lib option mapping (and the action attribution) in one place. + destroyForActionSpy = jest.spyOn(Toolkit.prototype as any, 'destroyForAction').mockResolvedValue({ stacks: [] }); }); - test('destroys with --all flag', async () => { - const toolkit = defaultToolkitSetup(); - - await expect(toolkit.destroy({ - selector: { allTopLevel: true, patterns: [] }, // --all flag sets allTopLevel: true - exclusively: true, - force: true, - fromDeploy: true, - })).resolves.not.toThrow(); + afterEach(() => { + destroyForActionSpy.mockRestore(); }); - test('destroys stack within stage with wildcard pattern', async () => { - const toolkit = defaultToolkitSetup(); - - await expect(toolkit.destroy({ - selector: { patterns: ['Test*/*'] }, - exclusively: true, - force: true, - fromDeploy: true, - })).resolves.not.toThrow(); - }); - - test('destroys stack in single-stack configuration', async () => { - const toolkit = await singleStackToolkitSetup(); - - await expect(toolkit.destroy({ - selector: { patterns: [] }, - exclusively: true, - force: true, - fromDeploy: true, - })).resolves.not.toThrow(); - }); - - test('destroys stack with pattern in single-stack configuration', async () => { - const toolkit = await singleStackToolkitSetup(); - - await expect(toolkit.destroy({ - selector: { patterns: ['Test-Stack-B'] }, - exclusively: true, - force: true, - fromDeploy: true, - })).resolves.not.toThrow(); - }); - - test('destroys stack within stage in stage-only configuration', async () => { - const toolkit = await stageOnlyToolkitSetup(); - - await expect(toolkit.destroy({ - selector: { patterns: ['Test-Stack-A/Test-Stack-C'] }, - exclusively: true, - force: true, - fromDeploy: true, - })).resolves.not.toThrow(); - }); - - test('destroys stack within stage with wildcard pattern in stage-only configuration', async () => { - const toolkit = await stageOnlyToolkitSetup(); - - await expect(toolkit.destroy({ - selector: { patterns: ['Test*/*'] }, - exclusively: true, - force: true, - fromDeploy: true, - })).resolves.not.toThrow(); - }); - - test('warns if there are only non-existent stacks', async () => { + test('delegates with PATTERN_MATCH and no expansion when patterns are given and exclusively is true', async () => { const toolkit = defaultToolkitSetup(); await toolkit.destroy({ - selector: { patterns: ['Test-Stack-X', 'Test-Stack-Y'] }, + selector: { patterns: ['Test-Stack-A'] }, exclusively: true, force: true, - fromDeploy: true, }); - expect(flatten(notifySpy.mock.calls)).toEqual([ - expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-X')} does not exist.`), 'warn'), - expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-Y')} does not exist.`), 'warn'), - expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('Test-Stack-X, Test-Stack-Y')}`), 'warn'), - ]); + expect(destroyForActionSpy).toHaveBeenCalledWith( + cloudExecutable, + 'destroy', + expect.objectContaining({ + stacks: { + patterns: ['Test-Stack-A'], + strategy: StackSelectionStrategy.PATTERN_MATCH, + expand: ExpandStackSelection.NONE, + }, + force: true, + }), + ); }); - test('warns if there are only non-existent stacks even when exclusively is false', async () => { + test('expands to downstream stacks when exclusively is false', async () => { const toolkit = defaultToolkitSetup(); await toolkit.destroy({ - selector: { patterns: ['Test-Stack-X', 'Test-Stack-Y'] }, + selector: { patterns: ['Test-Stack-A'] }, exclusively: false, force: true, - fromDeploy: true, }); - expect(flatten(notifySpy.mock.calls)).toEqual([ - expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-X')} does not exist.`), 'warn'), - expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-Y')} does not exist.`), 'warn'), - expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('Test-Stack-X, Test-Stack-Y')}`), 'warn'), - ]); - }); - - test('warns if there is a non-existent stack and the other exists', async () => { - const toolkit = defaultToolkitSetup(); - - await toolkit.destroy({ - selector: { patterns: ['Test-Stack-X', 'Test-Stack-B'] }, - exclusively: true, - force: true, - fromDeploy: true, - }); - - expect(flatten(notifySpy.mock.calls)).toEqual( - expect.arrayContaining([ - expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-X')} does not exist.`), 'warn'), - ]), - ); - expect(flatten(notifySpy.mock.calls)).not.toEqual( - expect.arrayContaining([ - expectIoMsg(expect.stringContaining(`${chalk.red('Test-Stack-B')} does not exist.`), 'warn'), - ]), - ); - expect(flatten(notifySpy.mock.calls)).not.toEqual( - expect.arrayContaining([ - expectIoMsg(expect.stringMatching(/No stacks match the name\(s\)/), 'warn'), - ]), + expect(destroyForActionSpy).toHaveBeenCalledWith( + cloudExecutable, + 'destroy', + expect.objectContaining({ + stacks: expect.objectContaining({ expand: ExpandStackSelection.DOWNSTREAM }), + }), ); }); - test('warns when wildcard pattern does not match any stacks', async () => { + test('selects a single stack when no patterns are given', async () => { const toolkit = defaultToolkitSetup(); await toolkit.destroy({ - selector: { patterns: ['Foo*/*'] }, - exclusively: true, - force: true, - fromDeploy: true, - }); - - expect(flatten(notifySpy.mock.calls)).toEqual( - expect.arrayContaining([ - expectIoMsg(expect.stringContaining(`${chalk.red('Foo*/*')} does not exist.`), 'warn'), - expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('Foo*/*')}`), 'warn'), - ]), - ); - }); - - test('warns when destroying non-existent stack in stage-only configuration', async () => { - const toolkit = await stageOnlyToolkitSetup(); - - await toolkit.destroy({ - selector: { patterns: ['Foo*/*'] }, + selector: { patterns: [] }, exclusively: true, force: true, - fromDeploy: true, }); - expect(flatten(notifySpy.mock.calls)).toEqual( - expect.arrayContaining([ - expectIoMsg(expect.stringContaining(`${chalk.red('Foo*/*')} does not exist.`), 'warn'), - expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('Foo*/*')}`), 'warn'), - ]), + expect(destroyForActionSpy).toHaveBeenCalledWith( + cloudExecutable, + 'destroy', + expect.objectContaining({ + stacks: expect.objectContaining({ strategy: StackSelectionStrategy.ONLY_SINGLE }), + }), ); }); - test('suggests valid names if there is a non-existent but closely matching stack', async () => { + test('selects all top-level stacks for the --all flag (allTopLevel)', async () => { const toolkit = defaultToolkitSetup(); await toolkit.destroy({ - selector: { patterns: ['test-stack-b'] }, + selector: { allTopLevel: true, patterns: [] }, exclusively: true, force: true, - fromDeploy: true, }); - expect(flatten(notifySpy.mock.calls)).toEqual( - expect.arrayContaining([ - expectIoMsg(expect.stringContaining(`${chalk.red('test-stack-b')} does not exist. Do you mean ${chalk.blue('Test-Stack-B')}?`), 'warn'), - expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('test-stack-b')}`), 'warn'), - ]), + expect(destroyForActionSpy).toHaveBeenCalledWith( + cloudExecutable, + 'destroy', + expect.objectContaining({ + stacks: expect.objectContaining({ strategy: StackSelectionStrategy.MAIN_ASSEMBLY }), + }), ); }); - test('suggests stack names within stages if there is a non-existent but closely matching stack', async () => { + test('passes force, roleArn and concurrency through to toolkit-lib', async () => { const toolkit = defaultToolkitSetup(); await toolkit.destroy({ - selector: { patterns: ['test-stack-a/test-stack-c'] }, + selector: { patterns: ['Test-Stack-A'] }, exclusively: true, force: true, - fromDeploy: true, + roleArn: 'arn:aws:iam::123456789012:role/destroy', + concurrency: 5, }); - expect(flatten(notifySpy.mock.calls)).toEqual( - expect.arrayContaining([ - expectIoMsg(expect.stringContaining(`${chalk.red('test-stack-a/test-stack-c')} does not exist. Do you mean ${chalk.blue('Test-Stack-A/Test-Stack-C')}?`), 'warn'), - expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('test-stack-a/test-stack-c')}`), 'warn'), - ]), + expect(destroyForActionSpy).toHaveBeenCalledWith( + cloudExecutable, + 'destroy', + expect.objectContaining({ + force: true, + roleArn: 'arn:aws:iam::123456789012:role/destroy', + concurrency: 5, + }), ); }); - test('suggests stack with wildcard pattern when only case differs', async () => { + test('destroyFromDeploy delegates with the deploy action for message attribution', async () => { const toolkit = defaultToolkitSetup(); - await toolkit.destroy({ - selector: { patterns: ['test*/*'] }, + await toolkit.destroyFromDeploy({ + selector: { patterns: ['Test-Stack-A'] }, exclusively: true, force: true, - fromDeploy: true, - }); - - expect(flatten(notifySpy.mock.calls)).toEqual( - expect.arrayContaining([ - expectIoMsg(expect.stringContaining(`${chalk.red('test*/*')} does not exist. Do you mean ${chalk.blue('Test-Stack-A/Test-Stack-C')}?`), 'warn'), - expectIoMsg(expect.stringContaining(`No stacks match the name(s): ${chalk.red('test*/*')}`), 'warn'), - ]), - ); - }); - - test('destroy with concurrency', async () => { - const toolkit = defaultToolkitSetup(); - - await toolkit.destroy({ - selector: { patterns: ['*'] }, - exclusively: false, - force: true, - concurrency: 5, - }); - }); - - test('destroy respects dependency order with concurrency', async () => { - const stackC: TestStackArtifact = { - stackName: 'Test-Stack-C', - template: { Resources: { TemplateName: 'Test-Stack-C' } }, - env: 'aws://123456789012/bermuda-triangle-1', - }; - const stackD: TestStackArtifact = { - stackName: 'Test-Stack-D', - template: { Resources: { TemplateName: 'Test-Stack-D' } }, - env: 'aws://123456789012/bermuda-triangle-1', - depends: [stackC.stackName], - }; - cloudExecutable = await MockCloudExecutable.create({ - stacks: [stackC, stackD], - }); - - const destroyOrder: string[] = []; - const fakeDeployments = new FakeCloudFormation({ - 'Test-Stack-C': { Baz: 'Zinga!' }, - 'Test-Stack-D': { Baz: 'Zinga!' }, }); - const originalDestroyStack = fakeDeployments.destroyStack.bind(fakeDeployments); - fakeDeployments.destroyStack = async (options: DestroyStackOptions) => { - destroyOrder.push(options.stack.stackName); - return originalDestroyStack(options); - }; - const toolkit = new CdkToolkit({ - ioHost, + expect(destroyForActionSpy).toHaveBeenCalledWith( cloudExecutable, - configuration: cloudExecutable.configuration, - sdkProvider: cloudExecutable.sdkProvider, - deployments: fakeDeployments, - }); - - await toolkit.destroy({ - selector: { allTopLevel: true, patterns: [] }, - exclusively: false, - force: true, - concurrency: 10, - }); - - // stackD depends on stackC, so D must be destroyed before C - expect(destroyOrder.indexOf('Test-Stack-D')).toBeLessThan(destroyOrder.indexOf('Test-Stack-C')); + 'deploy', + expect.objectContaining({ force: true }), + ); }); }); diff --git a/packages/aws-cdk/test/commands/destroy.test.ts b/packages/aws-cdk/test/commands/destroy.test.ts index 28227c1fa..7a2aec485 100644 --- a/packages/aws-cdk/test/commands/destroy.test.ts +++ b/packages/aws-cdk/test/commands/destroy.test.ts @@ -33,6 +33,7 @@ const STACK_C_NESTED: TestStackArtifact = { let cloudExecutable: MockCloudExecutable; let cloudFormation: jest.Mocked; +let destroyStackSpy: jest.SpyInstance; let toolkit: CdkToolkit; let ioHost = CliIoHost.instance(); let recorder: IoHostRecorder; @@ -51,6 +52,10 @@ beforeEach(async () => { cloudFormation = instanceMockFrom(Deployments); + // `cdk destroy` delegates to toolkit-lib, which constructs its own + // `Deployments` instance, so intercept the actual deletion at the prototype. + destroyStackSpy = jest.spyOn(Deployments.prototype, 'destroyStack').mockResolvedValue({ stackArn: 'arn' } as any); + toolkit = new CdkToolkit({ ioHost, cloudExecutable, @@ -72,14 +77,13 @@ afterEach(async () => { describe('force: true (no confirmation prompt)', () => { test('destroys a single (nested) stack; "fromDeploy" makes it say "deployed"', async () => { - await toolkit.destroy({ + await toolkit.destroyFromDeploy({ selector: { patterns: ['Test-Stack-A/Test-Stack-C'] }, exclusively: true, force: true, - fromDeploy: true, }); - expect(cloudFormation.destroyStack).toHaveBeenCalledTimes(1); + expect(destroyStackSpy).toHaveBeenCalledTimes(1); }); test('destroys all top-level stacks with concurrency', async () => { @@ -90,7 +94,7 @@ describe('force: true (no confirmation prompt)', () => { concurrency: 5, }); - expect(cloudFormation.destroyStack).toHaveBeenCalledTimes(2); + expect(destroyStackSpy).toHaveBeenCalledTimes(2); }); test('respects dependency order with concurrency', async () => { @@ -108,7 +112,7 @@ describe('force: true (no confirmation prompt)', () => { cloudExecutable = await MockCloudExecutable.create({ stacks: [stackC, stackD] }, undefined, ioHost); const destroyOrder: string[] = []; - cloudFormation.destroyStack.mockImplementation(async (options: DestroyStackOptions) => { + destroyStackSpy.mockImplementation(async (options: DestroyStackOptions) => { destroyOrder.push(options.stack.stackName); return { stackArn: 'arn' }; }); @@ -140,7 +144,7 @@ describe('force: true (no confirmation prompt)', () => { roleArn: 'arn:aws:iam::123456789012:role/DestroyRole', }); - expect(cloudFormation.destroyStack).toHaveBeenCalledWith( + expect(destroyStackSpy).toHaveBeenCalledWith( expect.objectContaining({ roleArn: 'arn:aws:iam::123456789012:role/DestroyRole' }), ); }); @@ -157,7 +161,7 @@ describe('force: false (confirmation prompt)', () => { }); // Confirmed -> the stack is actually destroyed. - expect(cloudFormation.destroyStack).toHaveBeenCalledTimes(1); + expect(destroyStackSpy).toHaveBeenCalledTimes(1); }); test('aborts (CDK_TOOLKIT_E7010) and destroys nothing when the user declines', async () => { @@ -173,7 +177,7 @@ describe('force: false (confirmation prompt)', () => { }); // Aborted before any destroy happened. - expect(cloudFormation.destroyStack).not.toHaveBeenCalled(); + expect(destroyStackSpy).not.toHaveBeenCalled(); }); test('rethrows an unexpected error from the confirmation prompt', async () => { @@ -187,13 +191,13 @@ describe('force: false (confirmation prompt)', () => { force: false, })).rejects.toThrow('tty exploded'); - expect(cloudFormation.destroyStack).not.toHaveBeenCalled(); + expect(destroyStackSpy).not.toHaveBeenCalled(); }); }); describe('destroy failure', () => { test('emits a failure message and rethrows when destroyStack fails', async () => { - cloudFormation.destroyStack.mockRejectedValue(new Error('Deletion failed')); + destroyStackSpy.mockRejectedValue(new Error('Deletion failed')); await expect(toolkit.destroy({ selector: { patterns: ['Test-Stack-B'] }, From 102d3575b12bf3bff2d5f38d2653c1f60298980c Mon Sep 17 00:00:00 2001 From: go-to-k <24818752+go-to-k@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:12:25 +0900 Subject: [PATCH 05/11] chore(toolkit-lib): fix picomatch import order (eslint import/order) --- packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 1eb1098d8..39ba42eb2 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -8,7 +8,6 @@ import type { TemplateDiff } from '@aws-cdk/cloudformation-diff'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; import { type EventName, EVENTS } from 'chokidar/handler.js'; -import * as picomatch from 'picomatch'; /** * File events that we care about from chokidar. @@ -25,6 +24,7 @@ function isFileEvent(event: EventName): event is FileEvent { return (FILE_EVENTS as readonly string[]).includes(event); } import * as fs from 'fs-extra'; +import * as picomatch from 'picomatch'; import { NonInteractiveIoHost } from './non-interactive-io-host'; import type { ToolkitServices } from './private'; import { assemblyFromSource } from './private'; From ac99b781f3fc54d2c4fdac414e72323d2fe4aba1 Mon Sep 17 00:00:00 2001 From: go-to-k <24818752+go-to-k@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:07:58 +0900 Subject: [PATCH 06/11] fix(cli): make destroy IoHost snapshots color-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destroy command snapshot test was non-deterministic across color modes: toolkit-lib wraps a leading newline inside a chalk color escape (`\n ✅ ...`), so `withTrimmedWhitespace`'s `.trim()` cannot remove it when colors are on (the string starts with the ANSI escape, not whitespace). The recorder stripped ANSI only after receiving the already-(un)trimmed message, so the leading newline survived with colors on and the snapshot differed between a TTY and CI. Trim in the recorder's `normalize()` after stripping ANSI so the recorded stream is the same regardless of whether colors were enabled, and regenerate the affected snapshots. --- packages/aws-cdk/test/_helpers/io-recorder.ts | 8 +++++++- ...re_message_and_rethrows_when_destroyStack_fails.ndjson | 2 +- ...010_and_destroys_nothing_when_the_user_declines.ndjson | 2 +- ...onfirmation_and_proceeds_when_the_user_confirms.ndjson | 2 +- ...n_unexpected_error_from_the_confirmation_prompt.ndjson | 2 +- ...e_nested_stack_fromDeploy_makes_it_say_deployed.ndjson | 2 +- ..._destroys_all_top-level_stacks_with_concurrency.ndjson | 2 +- ...ion_prompt_forwards_the_roleArn_to_destroyStack.ndjson | 2 +- ...ompt_respects_dependency_order_with_concurrency.ndjson | 2 +- 9 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/aws-cdk/test/_helpers/io-recorder.ts b/packages/aws-cdk/test/_helpers/io-recorder.ts index 5fbb74135..e64d1fdf1 100644 --- a/packages/aws-cdk/test/_helpers/io-recorder.ts +++ b/packages/aws-cdk/test/_helpers/io-recorder.ts @@ -294,7 +294,13 @@ export class IoHostRecorder { for (const s of this.scrubbers) { out = out.replace(s.pattern, s.replacement); } - return out; + // Trim only after stripping ANSI, so the snapshot is independent of the + // active color mode (TTY vs not). toolkit-lib trims its messages, but a + // leading newline that is wrapped inside a chalk color escape survives that + // trim (the string starts with the escape, not whitespace); once we strip + // the ANSI here the newline is re-exposed, so we trim again to keep the + // recorded stream deterministic regardless of whether colors were enabled. + return out.trim(); } private scrubValue(value: unknown): unknown { diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/destroy_failure_emits_a_failure_message_and_rethrows_when_destroyStack_fails.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/destroy_failure_emits_a_failure_message_and_rethrows_when_destroyStack_fails.ndjson index fef5340dd..d9e89cf3b 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/destroy_failure_emits_a_failure_message_and_rethrows_when_destroyStack_fails.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/destroy_failure_emits_a_failure_message_and_rethrows_when_destroyStack_fails.ndjson @@ -1,6 +1,6 @@ {"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} {"seq":4,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} {"seq":5,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-B: destroying... [1/1]"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_aborts_CDK_TOOLKIT_E7010_and_destroys_nothing_when_the_user_declines.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_aborts_CDK_TOOLKIT_E7010_and_destroys_nothing_when_the_user_declines.ndjson index 06fcf9b8c..1ecbee466 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_aborts_CDK_TOOLKIT_E7010_and_destroys_nothing_when_the_user_declines.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_aborts_CDK_TOOLKIT_E7010_and_destroys_nothing_when_the_user_declines.ndjson @@ -1,6 +1,6 @@ {"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} {"seq":4,"type":"request","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7010","message":"Are you sure you want to delete: Test-Stack-B","response":{"error":"AbortedByUser"}} {"seq":5,"type":"notify","action":"none","level":"error","code":"CDK_TOOLKIT_E7010","message":"Aborted by user"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_asks_for_confirmation_and_proceeds_when_the_user_confirms.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_asks_for_confirmation_and_proceeds_when_the_user_confirms.ndjson index 048362fb9..c68678c5a 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_asks_for_confirmation_and_proceeds_when_the_user_confirms.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_asks_for_confirmation_and_proceeds_when_the_user_confirms.ndjson @@ -1,6 +1,6 @@ {"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} {"seq":4,"type":"request","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7010","message":"Are you sure you want to delete: Test-Stack-B","response":true} {"seq":5,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_rethrows_an_unexpected_error_from_the_confirmation_prompt.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_rethrows_an_unexpected_error_from_the_confirmation_prompt.ndjson index abacb086a..382617e19 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_rethrows_an_unexpected_error_from_the_confirmation_prompt.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_false_confirmation_prompt_rethrows_an_unexpected_error_from_the_confirmation_prompt.ndjson @@ -1,5 +1,5 @@ {"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} {"seq":4,"type":"request","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7010","message":"Are you sure you want to delete: Test-Stack-B","response":{"error":"SomethingElse"}} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_a_single_nested_stack_fromDeploy_makes_it_say_deployed.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_a_single_nested_stack_fromDeploy_makes_it_say_deployed.ndjson index dd8353634..50fbfa90e 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_a_single_nested_stack_fromDeploy_makes_it_say_deployed.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_a_single_nested_stack_fromDeploy_makes_it_say_deployed.ndjson @@ -1,6 +1,6 @@ {"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"deploy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} {"seq":4,"type":"notify","action":"deploy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} {"seq":5,"type":"notify","action":"deploy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-A/Test-Stack-C: destroying... [1/1]"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_all_top-level_stacks_with_concurrency.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_all_top-level_stacks_with_concurrency.ndjson index 3a9549158..3bc34120b 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_all_top-level_stacks_with_concurrency.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_destroys_all_top-level_stacks_with_concurrency.ndjson @@ -1,6 +1,6 @@ {"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} {"seq":4,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} {"seq":5,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-A-Display-Name: destroying... [1/2]"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_forwards_the_roleArn_to_destroyStack.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_forwards_the_roleArn_to_destroyStack.ndjson index b5370bdb3..39944ad03 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_forwards_the_roleArn_to_destroyStack.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_forwards_the_roleArn_to_destroyStack.ndjson @@ -1,6 +1,6 @@ {"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} {"seq":4,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} {"seq":5,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-B: destroying... [1/1]"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_respects_dependency_order_with_concurrency.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_respects_dependency_order_with_concurrency.ndjson index 79a18476d..ac4e1df24 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_respects_dependency_order_with_concurrency.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/destroy/force_true_no_confirmation_prompt_respects_dependency_order_with_concurrency.ndjson @@ -1,6 +1,6 @@ {"seq":0,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} {"seq":4,"type":"notify","action":"destroy","level":"trace","code":"CDK_TOOLKIT_I7101","message":"Starting Destroy ..."} {"seq":5,"type":"notify","action":"destroy","level":"info","code":"CDK_TOOLKIT_I7100","message":"Test-Stack-D: destroying... [1/2]"} From 46261305c9b427d08ec1aeffc8f1d97e3cf7b254 Mon Sep 17 00:00:00 2001 From: go-to-k <24818752+go-to-k@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:58:00 +0900 Subject: [PATCH 07/11] fix(toolkit-lib): keep destroy confirmation stack names blue The destroy confirmation prompt colored the stack names red. Use blue to match the color the CLI used before destroy was routed through toolkit-lib. --- packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 39ba42eb2..9a0374cb4 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -1619,7 +1619,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { if (!options.force) { const motivation = 'Destroying stacks is an irreversible action'; - const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`; + const question = `Are you sure you want to delete: ${chalk.blue(stacks.hierarchicalIds.join(', '))}`; const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation })); if (!confirmed) { await ioHelper.notify(IO.CDK_TOOLKIT_E7010.msg('Aborted by user')); From 9c1083cbbdeed933836ff1f6eb4ac9f5e6213cff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 05:02:54 +0000 Subject: [PATCH 08/11] chore: self mutation Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- ...k_list_fails_when_stacks_have_a_circular_dependency.ndjson | 2 +- ..._list_lists_a_multi-level_dependency_chain_in_order.ndjson | 4 ++-- .../list/cdk_list_lists_full_stack_details_with_--long.ndjson | 4 ++-- .../list/cdk_list_lists_the_selected_stacks.ndjson | 2 +- ...dk_list_orders_cross-stack_references_by_dependency.ndjson | 4 ++-- ...chine-readable_JSON_without_the_synthesis-time_line.ndjson | 2 +- ...shows_nested_dependencies_addressed_by_display_name.ndjson | 4 ++-- .../cdk_list_shows_the_dependencies_between_stacks.ndjson | 4 ++-- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_fails_when_stacks_have_a_circular_dependency.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_fails_when_stacks_have_a_circular_dependency.ndjson index daba78b91..7a8152c91 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_fails_when_stacks_have_a_circular_dependency.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_fails_when_stacks_have_a_circular_dependency.ndjson @@ -1,4 +1,4 @@ {"seq":0,"type":"notify","action":"list","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"list","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_a_multi-level_dependency_chain_in_order.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_a_multi-level_dependency_chain_in_order.ndjson index d783f4352..1c4c0be83 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_a_multi-level_dependency_chain_in_order.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_a_multi-level_dependency_chain_in_order.ndjson @@ -1,5 +1,5 @@ {"seq":0,"type":"notify","action":"list","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"list","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} -{"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"- id: Test-Stack-A\n dependencies: []\n- id: Test-Stack-B\n dependencies:\n - id: Test-Stack-A\n dependencies: []\n- id: Test-Stack-C\n dependencies:\n - id: Test-Stack-B\n dependencies:\n - id: Test-Stack-A\n dependencies: []\n"} +{"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"- id: Test-Stack-A\n dependencies: []\n- id: Test-Stack-B\n dependencies:\n - id: Test-Stack-A\n dependencies: []\n- id: Test-Stack-C\n dependencies:\n - id: Test-Stack-B\n dependencies:\n - id: Test-Stack-A\n dependencies: []"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_full_stack_details_with_--long.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_full_stack_details_with_--long.ndjson index 1926bb2c2..3b565cac3 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_full_stack_details_with_--long.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_full_stack_details_with_--long.ndjson @@ -1,5 +1,5 @@ {"seq":0,"type":"notify","action":"list","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"list","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} -{"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"- id: Test-Stack-A\n name: Test-Stack-A\n environment:\n account: \"123456789012\"\n region: bermuda-triangle-1\n name: aws://123456789012/bermuda-triangle-1\n- id: Test-Stack-B\n name: Test-Stack-B\n environment:\n account: \"123456789012\"\n region: bermuda-triangle-1\n name: aws://123456789012/bermuda-triangle-1\n"} +{"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"- id: Test-Stack-A\n name: Test-Stack-A\n environment:\n account: \"123456789012\"\n region: bermuda-triangle-1\n name: aws://123456789012/bermuda-triangle-1\n- id: Test-Stack-B\n name: Test-Stack-B\n environment:\n account: \"123456789012\"\n region: bermuda-triangle-1\n name: aws://123456789012/bermuda-triangle-1"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_the_selected_stacks.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_the_selected_stacks.ndjson index f900792e5..ad94ce724 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_the_selected_stacks.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_lists_the_selected_stacks.ndjson @@ -1,5 +1,5 @@ {"seq":0,"type":"notify","action":"list","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"list","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} {"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"Test-Stack-A\nTest-Stack-B"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_orders_cross-stack_references_by_dependency.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_orders_cross-stack_references_by_dependency.ndjson index b9692af48..7aaa05598 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_orders_cross-stack_references_by_dependency.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_orders_cross-stack_references_by_dependency.ndjson @@ -1,5 +1,5 @@ {"seq":0,"type":"notify","action":"list","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"list","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} -{"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"- id: Test-Stack-C\n dependencies: []\n- id: Test-Stack-A\n dependencies:\n - id: Test-Stack-C\n dependencies: []\n"} +{"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"- id: Test-Stack-C\n dependencies: []\n- id: Test-Stack-A\n dependencies:\n - id: Test-Stack-C\n dependencies: []"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_prints_machine-readable_JSON_without_the_synthesis-time_line.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_prints_machine-readable_JSON_without_the_synthesis-time_line.ndjson index 7dee225d9..b350133c3 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_prints_machine-readable_JSON_without_the_synthesis-time_line.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_prints_machine-readable_JSON_without_the_synthesis-time_line.ndjson @@ -1,5 +1,5 @@ {"seq":0,"type":"notify","action":"list","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"list","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: ","dropped":true} {"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"Test-Stack-A\nTest-Stack-B"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_shows_nested_dependencies_addressed_by_display_name.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_shows_nested_dependencies_addressed_by_display_name.ndjson index fdfcf8650..466177f6f 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_shows_nested_dependencies_addressed_by_display_name.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_shows_nested_dependencies_addressed_by_display_name.ndjson @@ -1,5 +1,5 @@ {"seq":0,"type":"notify","action":"list","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"list","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} -{"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"- id: Test-Stack-A\n dependencies: []\n- id: Test-Stack-A/Test-Stack-B\n dependencies:\n - id: Test-Stack-A\n dependencies: []\n- id: Test-Stack-A/Test-Stack-B/Test-Stack-C\n dependencies:\n - id: Test-Stack-A/Test-Stack-B\n dependencies:\n - id: Test-Stack-A\n dependencies: []\n"} +{"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"- id: Test-Stack-A\n dependencies: []\n- id: Test-Stack-A/Test-Stack-B\n dependencies:\n - id: Test-Stack-A\n dependencies: []\n- id: Test-Stack-A/Test-Stack-B/Test-Stack-C\n dependencies:\n - id: Test-Stack-A/Test-Stack-B\n dependencies:\n - id: Test-Stack-A\n dependencies: []"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_shows_the_dependencies_between_stacks.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_shows_the_dependencies_between_stacks.ndjson index 29061097a..52d459aef 100644 --- a/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_shows_the_dependencies_between_stacks.ndjson +++ b/packages/aws-cdk/test/commands/__io_snapshots__/list/cdk_list_shows_the_dependencies_between_stacks.ndjson @@ -1,5 +1,5 @@ {"seq":0,"type":"notify","action":"list","level":"trace","code":"CDK_TOOLKIT_I1001","message":"Starting Synthesis ..."} {"seq":1,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} -{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"list","level":"trace","code":"CDK_CLI_I1001","message":"✨ Synthesis time: "} {"seq":3,"type":"notify","action":"list","level":"info","code":"CDK_TOOLKIT_I1000","message":"✨ Synthesis time: "} -{"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"- id: Test-Stack-A\n dependencies: []\n- id: Test-Stack-B\n dependencies:\n - id: Test-Stack-A\n dependencies: []\n"} +{"seq":4,"type":"notify","action":"list","level":"result","code":"CDK_TOOLKIT_I2901","message":"- id: Test-Stack-A\n dependencies: []\n- id: Test-Stack-B\n dependencies:\n - id: Test-Stack-A\n dependencies: []"} From 0747ba5eec384aa1ab6cd1c1b44424d0e92c9b50 Mon Sep 17 00:00:00 2001 From: go-to-k <24818752+go-to-k@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:13:30 +0900 Subject: [PATCH 09/11] feat(toolkit-lib): destroy warns and suggests when stack names do not match Re-applies the destroy warning feature on top of main's toolkit-lib destroy migration (#1686): - W7011: emit a warning and exit without prompting when no stacks match - W7010: warn about each non-existent name (suggesting a close match when one exists, including stacks nested in a stage) while still destroying matches - adds suggestStacks() to Toolkit and the supporting message codes/registry - restores toolkit-lib unit tests, stage fixtures, and the destroy integ tests The toolkit-lib migration plumbing from this branch is dropped in favor of main's implementation; only the warning behavior is preserved. --- .../cli-integ/resources/cdk-apps/app/app.js | 4 + ...cdk-destroy-nonexistent-stack.integtest.ts | 21 ++ .../cdk-destroy-stage-only.integtest.ts | 28 +++ .../toolkit-lib/docs/message-registry.md | 2 + .../lib/api/io/private/messages.ts | 9 + .../toolkit-lib/lib/toolkit/toolkit.ts | 43 ++++ .../test/_fixtures/stack-with-stage/index.ts | 16 ++ .../test/_fixtures/stage-only/index.ts | 16 ++ .../toolkit-lib/test/actions/destroy.test.ts | 193 ++++++++++++++++++ 9 files changed, 332 insertions(+) create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts create mode 100644 packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts create mode 100644 packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index c0c7642e4..a6f916dfa 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -1207,6 +1207,10 @@ switch (stackSet) { case 'stage-with-no-stacks': break; + case 'stage-only': + new SomeStage(app, `${stackPrefix}-stage`); + break; + default: throw new Error(`Unrecognized INTEG_STACK_SET: '${stackSet}'`); } diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts new file mode 100644 index 000000000..429e6c5be --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts @@ -0,0 +1,21 @@ +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest('cdk destroy does not fail even if the stacks do not exist', withDefaultFixture(async (fixture) => { + const nonExistingStackName1 = 'non-existing-stack-1'; + const nonExistingStackName2 = 'non-existing-stack-2'; + + await expect(fixture.cdkDestroy([nonExistingStackName1, nonExistingStackName2])).resolves.not.toThrow(); +})); + +integTest('cdk destroy with no force option exits without prompt if the stacks do not exist', withDefaultFixture(async (fixture) => { + const nonExistingStackName1 = 'non-existing-stack-1'; + const nonExistingStackName2 = 'non-existing-stack-2'; + + await expect(fixture.cdkDestroy([nonExistingStackName1, nonExistingStackName2], { + force: false, + })).resolves.not.toThrow(); +})); + +integTest('cdk destroy does not fail even if the stages do not exist', withDefaultFixture(async (fixture) => { + await expect(fixture.cdkDestroy('NonExistent/*')).resolves.not.toThrow(); +})); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts new file mode 100644 index 000000000..95a7d1c50 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts @@ -0,0 +1,28 @@ +import { DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest('cdk destroy can destroy stacks in stage-only configuration', withDefaultFixture(async (fixture) => { + const integStackSet = 'stage-only'; + + const stageNameSuffix = 'stage'; + const specifiedStackName = `${stageNameSuffix}/*`; + + await fixture.cdkDeploy(specifiedStackName, { + modEnv: { + INTEG_STACK_SET: integStackSet, + }, + }); + + const stackName = `${fixture.fullStackName(stageNameSuffix)}-StackInStage`; + const stack = await fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: stackName })); + expect(stack.Stacks?.length ?? 0).toEqual(1); + + await fixture.cdkDestroy(specifiedStackName, { + modEnv: { + INTEG_STACK_SET: integStackSet, + }, + }); + + await expect(fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: stackName }))) + .rejects.toThrow(/does not exist/); +})); diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index d22bd90f6..87ac30ecf 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -128,6 +128,8 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `CDK_TOOLKIT_I7010` | Confirm destroy stacks | `info` | {@link ConfirmationRequest} | | `CDK_TOOLKIT_I7100` | Stack destroy progress | `info` | {@link StackDestroyProgress} | | `CDK_TOOLKIT_I7101` | Start stack destroying | `trace` | {@link StackDestroy} | +| `CDK_TOOLKIT_W7010` | A provided stack name does not match any stack | `warn` | n/a | +| `CDK_TOOLKIT_W7011` | No stacks match the provided names, nothing to destroy | `warn` | n/a | | `CDK_TOOLKIT_I7900` | Stack deletion succeeded | `result` | [cxapi.CloudFormationStackArtifact](https://docs.aws.amazon.com/cdk/api/v2/docs/@aws-cdk_cx-api.CloudFormationStackArtifact.html) | | `CDK_TOOLKIT_E7010` | Action was aborted due to negative confirmation of request | `error` | n/a | | `CDK_TOOLKIT_E7900` | Stack deletion failed | `error` | {@link ErrorPayload} | diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index 08ab78f13..7ca18f05a 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -394,6 +394,15 @@ export const IO = { interface: 'StackDestroy', }), + CDK_TOOLKIT_W7010: make.warn({ + code: 'CDK_TOOLKIT_W7010', + description: 'A provided stack name does not match any stack', + }), + CDK_TOOLKIT_W7011: make.warn({ + code: 'CDK_TOOLKIT_W7011', + description: 'No stacks match the provided names, nothing to destroy', + }), + CDK_TOOLKIT_I7900: make.result({ code: 'CDK_TOOLKIT_I7900', description: 'Stack deletion succeeded', diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index b7c1e17e7..2f9f90a96 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -24,6 +24,7 @@ function isFileEvent(event: EventName): event is FileEvent { return (FILE_EVENTS as readonly string[]).includes(event); } import * as fs from 'fs-extra'; +import * as picomatch from 'picomatch'; import { NonInteractiveIoHost } from './non-interactive-io-host'; import type { ToolkitServices } from './private'; import { assemblyFromSource } from './private'; @@ -1604,10 +1605,19 @@ export class Toolkit extends CloudAssemblySourceBuilder { const ioHelper = asIoHelper(this.ioHost, action); const stacks = await assembly.selectStacksV2(selectStacks); + await this.suggestStacks(ioHelper, assembly, selectStacks, stacks); + const ret: DestroyResult = { stacks: [], }; + if (stacks.stackCount === 0) { + await ioHelper.notify(IO.CDK_TOOLKIT_W7011.msg( + `No stacks match the name(s): ${chalk.red((selectStacks.patterns ?? []).join(', '))}`, + )); + return ret; + } + const motivation = 'Destroying stacks is an irreversible action'; const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`; const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation })); @@ -1667,6 +1677,39 @@ export class Toolkit extends CloudAssemblySourceBuilder { } } + /** + * Warn about destroy patterns that matched no stack, suggesting a close match + * when one exists (e.g. only the casing differs). Stacks nested inside a stage + * are considered, so a staged stack can be suggested too. + */ + private async suggestStacks( + ioHelper: IoHelper, + assembly: StackAssembly, + selector: StackSelector, + selected: StackCollection, + ): Promise { + const patterns = selector.patterns ?? []; + if (patterns.length === 0) { + return; + } + + const allStacks = await assembly.selectStacksV2(ALL_STACKS); + + for (const pattern of patterns) { + const matched = selected.stackArtifacts.some((stack) => picomatch.isMatch(stack.hierarchicalId, pattern)); + if (matched) { + continue; + } + + const closeMatches = allStacks.stackArtifacts + .filter((stack) => picomatch.isMatch(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase())) + .map((stack) => stack.hierarchicalId); + + const suggestion = closeMatches.length > 0 ? ` Do you mean ${chalk.blue(closeMatches.join(', '))}?` : ''; + await ioHelper.notify(IO.CDK_TOOLKIT_W7010.msg(`${chalk.red(pattern)} does not exist.${suggestion}`)); + } + } + /** * Create a deployments class */ diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts new file mode 100644 index 000000000..51219a694 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts @@ -0,0 +1,16 @@ +import * as core from 'aws-cdk-lib/core'; + +/** + * An app with a top-level stack plus a stack nested inside a Stage. + * + * Hierarchical ids: `TopLevelStack` and `Stage/StackInStage`. Used to exercise + * destroy/suggestion behavior across both top-level and nested-stage stacks. + */ +export default async () => { + const app = new core.App({ autoSynth: false }); + new core.Stack(app, 'TopLevelStack'); + const stage = new core.Stage(app, 'Stage'); + new core.Stack(stage, 'StackInStage'); + + return app.synth(); +}; diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts new file mode 100644 index 000000000..ed0835628 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts @@ -0,0 +1,16 @@ +import * as core from 'aws-cdk-lib/core'; + +/** + * An app whose only stack lives inside a Stage (no top-level stacks). + * + * This is the configuration that regressed the original `cdk destroy` warning + * feature: code that only looked at top-level stacks could not see (or suggest) + * stacks nested in a Stage. The stack's hierarchical id is `Stage/StackInStage`. + */ +export default async () => { + const app = new core.App({ autoSynth: false }); + const stage = new core.Stage(app, 'Stage'); + new core.Stack(stage, 'StackInStage'); + + return app.synth(); +}; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts index 26c786c9e..2d631d93a 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts @@ -107,6 +107,199 @@ describe('destroy', () => { expect(mockDispose).toHaveBeenCalled(); await realDispose(); }); + + test('warns when no stacks match the given name(s)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['DoesNotExist'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'destroy', + level: 'warn', + code: 'CDK_TOOLKIT_W7011', + message: expect.stringContaining('No stacks match the name(s)'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a closely matching stack when the name does not exist', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stack1'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'destroy', + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining('does not exist. Do you mean'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('warns about a missing name but still destroys the matching stacks', async () => { + // WHEN: one name matches (Stack1), the other does not + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stack1', 'DoesNotExist'] }, + }); + + // THEN: warn for the missing name, but no "no stacks match" and the match is destroyed + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('DoesNotExist')} does not exist.`), + })); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7011', + })); + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('destroys all top-level stacks with the MAIN_ASSEMBLY strategy (cdk destroy --all)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.MAIN_ASSEMBLY }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(2); + }); + + test('destroys the single stack with the ONLY_SINGLE strategy (cdk destroy with no patterns)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.ONLY_SINGLE }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('warns about every non-existent name when none of them match', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['NopeA', 'NopeB'] }, + }); + + // THEN: a per-name warning for each, plus the overall no-match warning + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('NopeA')} does not exist`), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('NopeB')} does not exist`), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7011', + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + describe('stacks nested in a stage', () => { + test('destroys a stack inside a stage by its hierarchical id', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-stage'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/StackInStage'] }, + }); + + // THEN: only the staged stack is destroyed, not the top-level one + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + expect(mockDestroyStack.mock.calls[0][0].stack.hierarchicalId).toEqual('Stage/StackInStage'); + }); + + test('destroys a staged stack via a wildcard pattern', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-stage'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/*'] }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + expect(mockDestroyStack.mock.calls[0][0].stack.hierarchicalId).toEqual('Stage/StackInStage'); + }); + + test('destroys a stack in a stage-only app (no top-level stacks)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/StackInStage'] }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('destroys a staged stack via wildcard in a stage-only app', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/*'] }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('warns without failing for a non-existent name in a stage-only app', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['DoesNotExist/*'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7011', + message: expect.stringContaining('No stacks match the name(s)'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a nested-stage stack when only the casing differs', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stage/stackinstage'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`does not exist. Do you mean ${chalk.blue('Stage/StackInStage')}?`), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a nested-stage stack for a wildcard pattern that differs only in case', async () => { + // WHEN: a lower-cased wildcard that matches nothing but resembles the staged stack + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stage/*'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`does not exist. Do you mean ${chalk.blue('Stage/StackInStage')}?`), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + }); }); function successfulDestroy() { From d6661874ae9b099b84f3aa29442662096e2e4291 Mon Sep 17 00:00:00 2001 From: go-to-k <24818752+go-to-k@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:02:54 +0900 Subject: [PATCH 10/11] fix(toolkit-lib): keep destroy confirmation stack names blue The destroy confirmation prompt colored the stack names red (inherited from main's _destroy). Use blue to match the color the CLI used before destroy was routed through toolkit-lib, and assert it so the merge does not silently re-introduce red. Ref: https://github.com/aws/aws-cdk-cli/pull/984#issuecomment-4787129749 --- packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts | 2 +- packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 2f9f90a96..ffcd322f0 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -1619,7 +1619,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { } const motivation = 'Destroying stacks is an irreversible action'; - const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`; + const question = `Are you sure you want to delete: ${chalk.blue(stacks.hierarchicalIds.join(', '))}`; const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation })); if (!confirmed) { await ioHelper.notify(IO.CDK_TOOLKIT_E7010.msg('Aborted by user')); diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts index 2d631d93a..bba850461 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts @@ -40,7 +40,9 @@ describe('destroy', () => { action: 'destroy', level: 'info', code: 'CDK_TOOLKIT_I7010', - message: expect.stringContaining('Are you sure you want to delete'), + // Stack names are colored blue, matching the color the CLI used before + // destroy was routed through toolkit-lib (not red). + message: expect.stringContaining(`Are you sure you want to delete: ${chalk.blue('Stack1')}`), })); }); From 484c5ca643daf32bc2ce7e25dbc340be5719a160 Mon Sep 17 00:00:00 2001 From: go-to-k <24818752+go-to-k@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:33:18 +0900 Subject: [PATCH 11/11] test(toolkit-lib): drop redundant comment in destroy confirmation color test --- packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts index bba850461..4224bf345 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts @@ -40,8 +40,6 @@ describe('destroy', () => { action: 'destroy', level: 'info', code: 'CDK_TOOLKIT_I7010', - // Stack names are colored blue, matching the color the CLI used before - // destroy was routed through toolkit-lib (not red). message: expect.stringContaining(`Are you sure you want to delete: ${chalk.blue('Stack1')}`), })); });