diff --git a/.projenrc.ts b/.projenrc.ts index 6e8c625da..f601c54a0 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1161,6 +1161,7 @@ const cli = configureProject( 'glob', 'minimatch', 'p-limit@^3', + 'p-queue@^6', 'promptly', 'proxy-agent', 'semver', diff --git a/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES b/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES index dd5f22d5c..214213a61 100644 --- a/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES +++ b/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES @@ -22090,6 +22090,32 @@ SOFTWARE. +---------------- + +** eventemitter3@4.0.7 - https://www.npmjs.com/package/eventemitter3/v/4.0.7 | MIT +The MIT License (MIT) + +Copyright (c) 2014 Arnout Kazemier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ---------------- ** fast-deep-equal@3.1.3 - https://www.npmjs.com/package/fast-deep-equal/v/3.1.3 | MIT @@ -22846,6 +22872,32 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** p-finally@1.0.0 - https://www.npmjs.com/package/p-finally/v/1.0.0 | MIT +The MIT License (MIT) + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + ---------------- ** p-limit@2.3.0 - https://www.npmjs.com/package/p-limit/v/2.3.0 | MIT @@ -22888,6 +22940,34 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** p-queue@6.6.2 - https://www.npmjs.com/package/p-queue/v/6.6.2 | MIT +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** p-timeout@3.2.0 - https://www.npmjs.com/package/p-timeout/v/3.2.0 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ---------------- ** p-try@2.2.0 - https://www.npmjs.com/package/p-try/v/2.2.0 | MIT diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 27bce6055..858f81529 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -1393,3 +1393,4 @@ export class Toolkit extends CloudAssemblySourceBuilder { } } } + diff --git a/packages/aws-cdk/.projen/deps.json b/packages/aws-cdk/.projen/deps.json index cd2092fc6..1a27f2b67 100644 --- a/packages/aws-cdk/.projen/deps.json +++ b/packages/aws-cdk/.projen/deps.json @@ -363,6 +363,11 @@ "version": "^3", "type": "runtime" }, + { + "name": "p-queue", + "version": "^6", + "type": "runtime" + }, { "name": "promptly", "type": "runtime" diff --git a/packages/aws-cdk/THIRD_PARTY_LICENSES b/packages/aws-cdk/THIRD_PARTY_LICENSES index 0db84348c..916a4b6e8 100644 --- a/packages/aws-cdk/THIRD_PARTY_LICENSES +++ b/packages/aws-cdk/THIRD_PARTY_LICENSES @@ -21883,6 +21883,32 @@ SOFTWARE. +---------------- + +** eventemitter3@4.0.7 - https://www.npmjs.com/package/eventemitter3/v/4.0.7 | MIT +The MIT License (MIT) + +Copyright (c) 2014 Arnout Kazemier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ---------------- ** fast-deep-equal@3.1.3 - https://www.npmjs.com/package/fast-deep-equal/v/3.1.3 | MIT @@ -22639,6 +22665,32 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** p-finally@1.0.0 - https://www.npmjs.com/package/p-finally/v/1.0.0 | MIT +The MIT License (MIT) + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + ---------------- ** p-limit@2.3.0 - https://www.npmjs.com/package/p-limit/v/2.3.0 | MIT @@ -22681,6 +22733,34 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** p-queue@6.6.2 - https://www.npmjs.com/package/p-queue/v/6.6.2 | MIT +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** p-timeout@3.2.0 - https://www.npmjs.com/package/p-timeout/v/3.2.0 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ---------------- ** p-try@2.2.0 - https://www.npmjs.com/package/p-try/v/2.2.0 | MIT diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index e81387f6a..fbe1aefcb 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -129,7 +129,9 @@ export async function makeConfig(): Promise { unconfigured: { type: 'boolean', desc: 'Modify unconfigured feature flags', requiresArg: false }, recommended: { type: 'boolean', desc: 'Change flags to recommended states', requiresArg: false }, default: { type: 'boolean', desc: 'Change flags to default state', requiresArg: false }, - interactive: { type: 'boolean', alias: ['i'], desc: 'Interactive option for the flags command', requiresArg: false }, + interactive: { type: 'boolean', alias: ['i'], desc: 'Interactive option for the flags command' }, + safe: { type: 'boolean', desc: 'Enable all feature flags that do not impact the user\'s application', requiresArg: false }, + concurrency: { type: 'number', alias: ['n'], desc: 'Maximum number of simultaneous synths to execute.', default: 4, requiresArg: true }, }, }, 'deploy': { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index b6fa46b55..3d7cbb488 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -383,8 +383,21 @@ "alias": [ "i" ], - "desc": "Interactive option for the flags command", + "desc": "Interactive option for the flags command" + }, + "safe": { + "type": "boolean", + "desc": "Enable all feature flags that do not impact the user's application", "requiresArg": false + }, + "concurrency": { + "type": "number", + "alias": [ + "n" + ], + "desc": "Maximum number of simultaneous synths to execute.", + "default": 4, + "requiresArg": true } } }, diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index c3342e835..040212bc7 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -26,7 +26,7 @@ import type { Settings } from '../api/settings'; import { contextHandler as context } from '../commands/context'; import { docs } from '../commands/docs'; import { doctor } from '../commands/doctor'; -import { handleFlags } from '../commands/flag-operations'; +import { FlagCommandHandler } from '../commands/flags/flags'; import { cliInit, printAvailableTemplates } from '../commands/init'; import { getMigrateScanType } from '../commands/migrate'; import { execProgram, CloudExecutable } from '../cxapp'; @@ -501,7 +501,8 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { type: 'boolean', alias: ['i'], desc: 'Interactive option for the flags command', + }) + .option('safe', { + default: undefined, + type: 'boolean', + desc: "Enable all feature flags that do not impact the user's application", requiresArg: false, + }) + .option('concurrency', { + default: 4, + type: 'number', + alias: ['n'], + desc: 'Maximum number of simultaneous synths to execute.', + requiresArg: true, }), ) .command('deploy [STACKS..]', 'Deploys the stack(s) named STACKS into your AWS account', (yargs: Argv) => diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 151edbfd9..6a18b5873 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -681,6 +681,22 @@ export interface FlagsOptions { */ readonly interactive?: boolean; + /** + * Enable all feature flags that do not impact the user's application + * + * @default - undefined + */ + readonly safe?: boolean; + + /** + * Maximum number of simultaneous synths to execute. + * + * aliases: n + * + * @default - 4 + */ + readonly concurrency?: number; + /** * Positional argument for flags */ diff --git a/packages/aws-cdk/lib/commands/flags/flags.ts b/packages/aws-cdk/lib/commands/flags/flags.ts new file mode 100644 index 000000000..bfdbae1c1 --- /dev/null +++ b/packages/aws-cdk/lib/commands/flags/flags.ts @@ -0,0 +1,38 @@ +import type { FeatureFlag, Toolkit } from '@aws-cdk/toolkit-lib'; +import { InteractiveHandler } from './interactive-handler'; +import { FlagOperations } from './operations'; +import { FlagOperationRouter } from './router'; +import type { FlagOperationsParams } from './types'; +import { FlagValidator } from './validator'; +import type { IoHelper } from '../../api-private'; +import { OBSOLETE_FLAGS } from '../../obsolete-flags'; + +export class FlagCommandHandler { + private readonly flags: FeatureFlag[]; + private readonly router: FlagOperationRouter; + private readonly options: FlagOperationsParams; + private readonly ioHelper: IoHelper; + + /** Main component that sets up all flag operation components */ + constructor(flagData: FeatureFlag[], ioHelper: IoHelper, options: FlagOperationsParams, toolkit: Toolkit) { + this.flags = flagData.filter(flag => !OBSOLETE_FLAGS.includes(flag.name)); + this.options = { ...options, concurrency: options.concurrency ?? 4 }; + this.ioHelper = ioHelper; + + const validator = new FlagValidator(ioHelper); + const flagOperations = new FlagOperations(this.flags, toolkit, ioHelper); + const interactiveHandler = new InteractiveHandler(this.flags, flagOperations); + + this.router = new FlagOperationRouter(validator, interactiveHandler, flagOperations); + } + + /** Main entry point that processes the flags command */ + async processFlagsCommand(): Promise { + if (this.flags.length === 0) { + await this.ioHelper.defaults.error('The \'cdk flags\' command is not compatible with the AWS CDK library used by your application. Please upgrade to 2.212.0 or above.'); + return; + } + + await this.router.route(this.options); + } +} diff --git a/packages/aws-cdk/lib/commands/flags/interactive-handler.ts b/packages/aws-cdk/lib/commands/flags/interactive-handler.ts new file mode 100644 index 000000000..f8ad2b134 --- /dev/null +++ b/packages/aws-cdk/lib/commands/flags/interactive-handler.ts @@ -0,0 +1,84 @@ +import type { FeatureFlag } from '@aws-cdk/toolkit-lib'; +// @ts-ignore +import { Select } from 'enquirer'; +import type { FlagOperations } from './operations'; +import { FlagsMenuOptions, type FlagOperationsParams } from './types'; + +export class InteractiveHandler { + constructor( + private readonly flags: FeatureFlag[], + private readonly flagOperations: FlagOperations, + ) { + } + + /** Displays flags that have differences between user and recommended values */ + private async displayFlagsWithDifferences(): Promise { + const flagsWithDifferences = this.flags.filter(flag => + flag.userValue === undefined || !this.isUserValueEqualToRecommended(flag)); + + if (flagsWithDifferences.length > 0) { + await this.flagOperations.displayFlagTable(flagsWithDifferences); + } + } + + /** Checks if user value matches recommended value */ + private isUserValueEqualToRecommended(flag: FeatureFlag): boolean { + return String(flag.userValue) === String(flag.recommendedValue); + } + + /** Main interactive mode handler that shows menu and processes user selection */ + async handleInteractiveMode(): Promise { + await this.displayFlagsWithDifferences(); + + const prompt = new Select({ + name: 'option', + message: 'Menu', + choices: Object.values(FlagsMenuOptions), + }); + + const answer = await prompt.run(); + + switch (answer) { + case FlagsMenuOptions.ALL_TO_RECOMMENDED: + return { recommended: true, all: true, set: true }; + case FlagsMenuOptions.UNCONFIGURED_TO_RECOMMENDED: + return { recommended: true, unconfigured: true, set: true }; + case FlagsMenuOptions.UNCONFIGURED_TO_DEFAULT: + return { default: true, unconfigured: true, set: true }; + case FlagsMenuOptions.MODIFY_SPECIFIC_FLAG: + return this.handleSpecificFlagSelection(); + case FlagsMenuOptions.EXIT: + return null; + default: + return null; + } + } + + /** Handles the specific flag selection flow with flag and value prompts */ + private async handleSpecificFlagSelection(): Promise { + const booleanFlags = this.flags.filter(flag => this.flagOperations.isBooleanFlag(flag)); + + const flagPrompt = new Select({ + name: 'flag', + message: 'Select which flag you would like to modify:', + limit: 100, + choices: booleanFlags.map(flag => flag.name), + }); + + const selectedFlagName = await flagPrompt.run(); + + const valuePrompt = new Select({ + name: 'value', + message: 'Select a value:', + choices: ['true', 'false'], + }); + + const value = await valuePrompt.run(); + + return { + FLAGNAME: [selectedFlagName], + value, + set: true, + }; + } +} diff --git a/packages/aws-cdk/lib/commands/flags/operations.ts b/packages/aws-cdk/lib/commands/flags/operations.ts new file mode 100644 index 000000000..38ca2586b --- /dev/null +++ b/packages/aws-cdk/lib/commands/flags/operations.ts @@ -0,0 +1,487 @@ +import * as os from 'os'; +import * as path from 'path'; +import { formatTable } from '@aws-cdk/cloudformation-diff'; +import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; +import type { FeatureFlag, Toolkit } from '@aws-cdk/toolkit-lib'; +import { CdkAppMultiContext, MemoryContext, DiffMethod } from '@aws-cdk/toolkit-lib'; +import * as chalk from 'chalk'; +import * as fs from 'fs-extra'; +import PQueue from 'p-queue'; +import type { FlagOperationsParams } from './types'; +import { StackSelectionStrategy } from '../../api'; +import type { IoHelper } from '../../api-private'; + +export class FlagOperations { + private app: string; + private baseContextValues: Record; + private allStacks: CloudFormationStackArtifact[]; + private queue: PQueue; + private baselineTempDir?: string; + + constructor( + private readonly flags: FeatureFlag[], + private readonly toolkit: Toolkit, + private readonly ioHelper: IoHelper, + ) { + this.app = ''; + this.baseContextValues = {}; + this.allStacks = []; + this.queue = new PQueue({ concurrency: 4 }); + } + + /** Main entry point that routes to either flag setting or display operations */ + async execute(params: FlagOperationsParams): Promise { + if (params.set) { + if (params.FLAGNAME && params.value) { + await this.setFlag(params); + } else { + await this.setMultipleFlags(params); + } + } else { + await this.displayFlags(params); + } + } + + /** Sets a single specific flag with validation and user confirmation */ + async setFlag(params: FlagOperationsParams): Promise { + const flagName = params.FLAGNAME![0]; + const flag = this.flags.find(f => f.name === flagName); + + if (!flag) { + await this.ioHelper.defaults.error('Flag not found.'); + return; + } + + if (!this.isBooleanFlag(flag)) { + await this.ioHelper.defaults.error(`Flag '${flagName}' is not a boolean flag. Only boolean flags are currently supported.`); + return; + } + + const prototypeSuccess = await this.prototypeChanges([flagName], params); + if (prototypeSuccess) { + await this.handleUserResponse([flagName], params); + } + } + + /** Sets multiple flags (all or unconfigured) with validation and user confirmation */ + async setMultipleFlags(params: FlagOperationsParams): Promise { + if (params.default && !this.flags.some(f => f.unconfiguredBehavesLike)) { + await this.ioHelper.defaults.error('The --default options are not compatible with the AWS CDK library used by your application. Please upgrade to 2.212.0 or above.'); + return; + } + + const flagsToSet = this.getFlagsToSet(params); + const prototypeSuccess = await this.prototypeChanges(flagsToSet, params); + + if (prototypeSuccess) { + await this.handleUserResponse(flagsToSet, params); + } + } + + /** Determines which flags should be set based on the provided parameters */ + private getFlagsToSet(params: FlagOperationsParams): string[] { + if (params.all && params.default) { + return this.flags + .filter(flag => this.isBooleanFlag(flag)) + .map(flag => flag.name); + } else if (params.all) { + return this.flags + .filter(flag => flag.userValue === undefined || !this.isUserValueEqualToRecommended(flag)) + .filter(flag => this.isBooleanFlag(flag)) + .map(flag => flag.name); + } else { + return this.flags + .filter(flag => flag.userValue === undefined) + .filter(flag => this.isBooleanFlag(flag)) + .map(flag => flag.name); + } + } + + /** Sets flags that don't cause template changes */ + async setSafeFlags(params: FlagOperationsParams): Promise { + const cdkJson = await JSON.parse(await fs.readFile(path.join(process.cwd(), 'cdk.json'), 'utf-8')); + this.app = params.app || cdkJson.app; + + const isUsingTsNode = this.app.includes('ts-node'); + if (isUsingTsNode && !this.app.includes('-T') && !this.app.includes('--transpileOnly')) { + await this.ioHelper.defaults.info('Repeated synths with ts-node will type-check the application on every synth. Add --transpileOnly to cdk.json\'s "app" command to make this operation faster.'); + } + + const unconfiguredFlags = this.flags.filter(flag => + flag.userValue === undefined && this.isBooleanFlag(flag)); + + if (unconfiguredFlags.length === 0) { + await this.ioHelper.defaults.info('All feature flags are configured.'); + return; + } + + await this.initializeSafetyCheck(); + const safeFlags = await this.batchTestFlags(unconfiguredFlags); + await this.cleanupSafetyCheck(); + + if (safeFlags.length > 0) { + await this.ioHelper.defaults.info('Flags that can be set without template changes:'); + for (const flag of safeFlags) { + await this.ioHelper.defaults.info(`- ${flag.name} -> ${flag.recommendedValue}`); + } + await this.handleUserResponse(safeFlags.map(flag => flag.name), { ...params, recommended: true }); + } else { + await this.ioHelper.defaults.info('No more flags can be set without causing template changes.'); + } + } + + /** Initializes the safety check by reading context and synthesizing baseline templates */ + private async initializeSafetyCheck(): Promise { + const baseContext = new CdkAppMultiContext(process.cwd()); + this.baseContextValues = await baseContext.read(); + + this.baselineTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-baseline-')); + const baseSource = await this.toolkit.fromCdkApp(this.app, { + contextStore: baseContext, + outdir: this.baselineTempDir, + }); + + const baseCx = await this.toolkit.synth(baseSource); + const baseAssembly = baseCx.cloudAssembly; + this.allStacks = baseAssembly.stacksRecursively; + this.queue = new PQueue({ concurrency: 4 }); + } + + /** Cleans up temporary directories created during safety checks */ + private async cleanupSafetyCheck(): Promise { + if (this.baselineTempDir) { + await fs.remove(this.baselineTempDir); + this.baselineTempDir = undefined; + } + } + + /** Tests multiple flags together and isolates unsafe ones using binary search */ + private async batchTestFlags(flags: FeatureFlag[]): Promise { + if (flags.length === 0) return []; + + const allFlagsContext = { ...this.baseContextValues }; + flags.forEach(flag => { + allFlagsContext[flag.name] = flag.recommendedValue; + }); + + const allSafe = await this.testBatch(allFlagsContext); + if (allSafe) return flags; + + return this.isolateUnsafeFlags(flags); + } + + /** Tests if a set of context values causes template changes by synthesizing and diffing */ + private async testBatch(contextValues: Record): Promise { + const testContext = new MemoryContext(contextValues); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-test-')); + const testSource = await this.toolkit.fromCdkApp(this.app, { + contextStore: testContext, + outdir: tempDir, + }); + + const testCx = await this.toolkit.synth(testSource); + + try { + for (const stack of this.allStacks) { + const templatePath = stack.templateFullPath; + const diff = await this.toolkit.diff(testCx, { + method: DiffMethod.LocalFile(templatePath), + stacks: { + strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE, + patterns: [stack.hierarchicalId], + }, + }); + + for (const stackDiff of Object.values(diff)) { + if (stackDiff.differenceCount > 0) { + return false; + } + } + } + return true; + } finally { + await fs.remove(tempDir); + } + } + + /** Uses binary search to isolate which flags are safe to set without template changes */ + private async isolateUnsafeFlags(flags: FeatureFlag[]): Promise { + const safeFlags: FeatureFlag[] = []; + + const processBatch = async (batch: FeatureFlag[], contextValues: Record): Promise => { + if (batch.length === 1) { + const isSafe = await this.testBatch( + { ...contextValues, [batch[0].name]: batch[0].recommendedValue }, + ); + if (isSafe) safeFlags.push(batch[0]); + return; + } + + const batchContext = { ...contextValues }; + batch.forEach(flag => { + batchContext[flag.name] = flag.recommendedValue; + }); + + const isSafeBatch = await this.testBatch(batchContext); + if (isSafeBatch) { + safeFlags.push(...batch); + return; + } + + const mid = Math.floor(batch.length / 2); + const left = batch.slice(0, mid); + const right = batch.slice(mid); + + void this.queue.add(() => processBatch(left, contextValues)); + void this.queue.add(() => processBatch(right, contextValues)); + }; + + void this.queue.add(() => processBatch(flags, this.baseContextValues)); + await this.queue.onIdle(); + return safeFlags; + } + + /** Prototypes flag changes by synthesizing templates and showing diffs to the user */ + private async prototypeChanges(flagNames: string[], params: FlagOperationsParams): Promise { + const baseContext = new CdkAppMultiContext(process.cwd()); + const baseContextValues = await baseContext.read(); + const memoryContext = new MemoryContext(baseContextValues); + + const cdkJson = await JSON.parse(await fs.readFile(path.join(process.cwd(), 'cdk.json'), 'utf-8')); + const app = cdkJson.app; + + const source = await this.toolkit.fromCdkApp(app, { + contextStore: baseContext, + outdir: fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-original-')), + }); + + const updateObj = await this.buildUpdateObject(flagNames, params, baseContextValues); + if (!updateObj) return false; + + await memoryContext.update(updateObj); + const cx = await this.toolkit.synth(source); + const assembly = cx.cloudAssembly; + + const modifiedSource = await this.toolkit.fromCdkApp(app, { + contextStore: memoryContext, + outdir: fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-temp-')), + }); + + const modifiedCx = await this.toolkit.synth(modifiedSource); + const allStacks = assembly.stacksRecursively; + + for (const stack of allStacks) { + const templatePath = stack.templateFullPath; + await this.toolkit.diff(modifiedCx, { + method: DiffMethod.LocalFile(templatePath), + stacks: { + strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE, + patterns: [stack.hierarchicalId], + }, + }); + } + + await this.displayFlagChanges(updateObj, baseContextValues); + return true; + } + + /** Displays a summary of flag changes showing old and new values */ + private async displayFlagChanges(updateObj: Record, baseContextValues: Record): Promise { + await this.ioHelper.defaults.info('\nFlag changes:'); + for (const [flagName, newValue] of Object.entries(updateObj)) { + const currentValue = baseContextValues[flagName]; + const currentDisplay = currentValue === undefined ? '' : String(currentValue); + await this.ioHelper.defaults.info(` ${flagName}: ${currentDisplay} → ${newValue}`); + } + } + + /** Builds the update object with new flag values based on parameters and current context */ + private async buildUpdateObject(flagNames: string[], params: FlagOperationsParams, baseContextValues: Record) + : Promise | null> { + const updateObj: Record = {}; + + if (flagNames.length === 1 && params.value !== undefined) { + const flagName = flagNames[0]; + const boolValue = params.value === 'true'; + if (baseContextValues[flagName] === boolValue) { + await this.ioHelper.defaults.info('Flag is already set to the specified value. No changes needed.'); + return null; + } + updateObj[flagName] = boolValue; + } else { + for (const flagName of flagNames) { + const flag = this.flags.find(f => f.name === flagName); + if (!flag) { + await this.ioHelper.defaults.error(`Flag ${flagName} not found.`); + return null; + } + const newValue = params.recommended + ? flag.recommendedValue as boolean + : String(flag.unconfiguredBehavesLike?.v2) === 'true'; + updateObj[flagName] = newValue; + } + } + return updateObj; + } + + /** Prompts user for confirmation and applies changes if accepted */ + private async handleUserResponse(flagNames: string[], params: FlagOperationsParams): Promise { + const userAccepted = await this.ioHelper.requestResponse({ + time: new Date(), + level: 'info', + code: 'CDK_TOOLKIT_I9300', + message: 'Do you want to accept these changes?', + data: { + flagNames, + responseDescription: 'Enter "y" to apply changes or "n" to cancel', + }, + defaultResponse: false, + }); + + if (userAccepted) { + await this.modifyValues(flagNames, params); + await this.ioHelper.defaults.info('Flag value(s) updated successfully.'); + } else { + await this.ioHelper.defaults.info('Operation cancelled'); + } + + await this.cleanupTempDirectories(); + } + + /** Removes temporary directories created during flag operations */ + private async cleanupTempDirectories(): Promise { + const originalDir = path.join(process.cwd(), 'original'); + const tempDir = path.join(process.cwd(), 'temp'); + await fs.remove(originalDir); + await fs.remove(tempDir); + } + + /** Actually modifies the cdk.json file with the new flag values */ + private async modifyValues(flagNames: string[], params: FlagOperationsParams): Promise { + const cdkJsonPath = path.join(process.cwd(), 'cdk.json'); + const cdkJsonContent = await fs.readFile(cdkJsonPath, 'utf-8'); + const cdkJson = JSON.parse(cdkJsonContent); + + if (flagNames.length === 1 && !params.safe) { + const boolValue = params.value === 'true'; + cdkJson.context[String(flagNames[0])] = boolValue; + await this.ioHelper.defaults.info(`Setting flag '${flagNames}' to: ${boolValue}`); + } else { + for (const flagName of flagNames) { + const flag = this.flags.find(f => f.name === flagName); + const newValue = params.recommended || params.safe + ? flag!.recommendedValue as boolean + : String(flag!.unconfiguredBehavesLike?.v2) === 'true'; + cdkJson.context[flagName] = newValue; + } + } + await fs.writeFile(cdkJsonPath, JSON.stringify(cdkJson, null, 2), 'utf-8'); + } + + /** Displays flags in a table format, either specific flags or filtered by criteria */ + async displayFlags(params: FlagOperationsParams): Promise { + const { FLAGNAME, all } = params; + + if (FLAGNAME && FLAGNAME.length > 0) { + await this.displaySpecificFlags(FLAGNAME); + return; + } + + const flagsToDisplay = all ? this.flags : this.flags.filter(flag => + flag.userValue === undefined || !this.isUserValueEqualToRecommended(flag)); + + await this.displayFlagTable(flagsToDisplay); + + // Add helpful message after empty table when not using --all + if (!all && flagsToDisplay.length === 0) { + await this.ioHelper.defaults.info(''); + await this.ioHelper.defaults.info('✅ All feature flags are already set to their recommended values.'); + await this.ioHelper.defaults.info('Use \'cdk flags --all --unstable=flags\' to see all flags and their current values.'); + } + } + + /** Displays detailed information for specific flags matching the given names */ + private async displaySpecificFlags(flagNames: string[]): Promise { + const matchingFlags = this.flags.filter(f => + flagNames.some(searchTerm => f.name.toLowerCase().includes(searchTerm.toLowerCase()))); + + if (matchingFlags.length === 0) { + await this.ioHelper.defaults.error(`Flag matching "${flagNames.join(', ')}" not found.`); + return; + } + + if (matchingFlags.length === 1) { + const flag = matchingFlags[0]; + await this.ioHelper.defaults.info(`Flag name: ${flag.name}`); + await this.ioHelper.defaults.info(`Description: ${flag.explanation}`); + await this.ioHelper.defaults.info(`Recommended value: ${flag.recommendedValue}`); + await this.ioHelper.defaults.info(`User value: ${flag.userValue}`); + return; + } + + await this.ioHelper.defaults.info(`Found ${matchingFlags.length} flags matching "${flagNames.join(', ')}":`); + await this.displayFlagTable(matchingFlags); + } + + /** Returns sort order for flags */ + private getFlagSortOrder(flag: FeatureFlag): number { + if (flag.userValue === undefined) return 3; + if (this.isUserValueEqualToRecommended(flag)) return 1; + return 2; + } + + /** Displays flags in a formatted table grouped by module and sorted */ + async displayFlagTable(flags: FeatureFlag[]): Promise { + const sortedFlags = [...flags].sort((a, b) => { + const orderA = this.getFlagSortOrder(a); + const orderB = this.getFlagSortOrder(b); + + if (orderA !== orderB) return orderA - orderB; + if (a.module !== b.module) return a.module.localeCompare(b.module); + return a.name.localeCompare(b.name); + }); + + const rows: string[][] = [['Feature Flag Name', 'Recommended Value', 'User Value']]; + let currentModule = ''; + + sortedFlags.forEach((flag) => { + if (flag.module !== currentModule) { + rows.push([chalk.bold(`Module: ${flag.module}`), '', '']); + currentModule = flag.module; + } + rows.push([ + ` ${flag.name}`, + String(flag.recommendedValue), + flag.userValue === undefined ? '' : String(flag.userValue), + ]); + }); + + const formattedTable = formatTable(rows, undefined, true); + await this.ioHelper.defaults.info(formattedTable); + } + + /** Checks if a flag has a boolean recommended value */ + isBooleanFlag(flag: FeatureFlag): boolean { + const recommended = flag.recommendedValue; + return typeof recommended === 'boolean' || + recommended === 'true' || + recommended === 'false'; + } + + /** Checks if the user's current value matches the recommended value */ + private isUserValueEqualToRecommended(flag: FeatureFlag): boolean { + return String(flag.userValue) === String(flag.recommendedValue); + } + + /** Shows helpful usage examples and available command options */ + async displayHelpMessage(): Promise { + await this.ioHelper.defaults.info('\n' + chalk.bold('Available options:')); + await this.ioHelper.defaults.info(' cdk flags --interactive # Interactive menu to manage flags'); + await this.ioHelper.defaults.info(' cdk flags --all # Show all flags (including configured ones)'); + await this.ioHelper.defaults.info(' cdk flags --set --all --recommended # Set all flags to recommended values'); + await this.ioHelper.defaults.info(' cdk flags --set --all --default # Set all flags to default values'); + await this.ioHelper.defaults.info(' cdk flags --set --unconfigured --recommended # Set unconfigured flags to recommended'); + await this.ioHelper.defaults.info(' cdk flags --set --value # Set specific flag'); + await this.ioHelper.defaults.info(' cdk flags --safe # Safely set flags that don\'t change templates'); + } +} diff --git a/packages/aws-cdk/lib/commands/flags/router.ts b/packages/aws-cdk/lib/commands/flags/router.ts new file mode 100644 index 000000000..d97719c1a --- /dev/null +++ b/packages/aws-cdk/lib/commands/flags/router.ts @@ -0,0 +1,66 @@ +import type { InteractiveHandler } from './interactive-handler'; +import type { FlagOperations } from './operations.ts'; +import type { FlagOperationsParams } from './types'; +import type { FlagValidator } from './validator'; + +export class FlagOperationRouter { + constructor( + private readonly validator: FlagValidator, + private readonly interactiveHandler: InteractiveHandler, + private readonly flagOperations: FlagOperations, + ) { + } + + /** Routes flag operations to appropriate handlers based on parameters */ + async route(params: FlagOperationsParams): Promise { + if (params.interactive) { + await this.handleInteractiveMode(); + return; + } + + if (params.safe) { + await this.flagOperations.setSafeFlags(params); + return; + } + + const isValid = await this.validator.validateParams(params); + if (!isValid) return; + + if (params.set) { + await this.handleSetOperations(params); + } else { + await this.flagOperations.displayFlags(params); + await this.showHelpMessage(params); + } + } + + /** Handles flag setting operations, routing to single or multiple flag methods */ + private async handleSetOperations(params: FlagOperationsParams): Promise { + if (params.FLAGNAME && params.value) { + await this.flagOperations.setFlag(params); + } else if (params.all || params.unconfigured) { + await this.flagOperations.setMultipleFlags(params); + } + } + + /** Manages interactive mode */ + private async handleInteractiveMode(): Promise { + while (true) { + const interactiveParams = await this.interactiveHandler.handleInteractiveMode(); + if (!interactiveParams) return; + + await this.flagOperations.execute(interactiveParams); + + if (!interactiveParams.FLAGNAME) { + return; + } + } + } + + /** Shows help message when no specific options are provided */ + private async showHelpMessage(params: FlagOperationsParams): Promise { + if (!params.all && !params.FLAGNAME) { + await this.flagOperations.displayHelpMessage(); + } + } +} diff --git a/packages/aws-cdk/lib/commands/flags/types.ts b/packages/aws-cdk/lib/commands/flags/types.ts new file mode 100644 index 000000000..040ded024 --- /dev/null +++ b/packages/aws-cdk/lib/commands/flags/types.ts @@ -0,0 +1,14 @@ +import type { FlagsOptions } from '../../cli/user-input'; + +export enum FlagsMenuOptions { + ALL_TO_RECOMMENDED = 'Set all flags to recommended values', + UNCONFIGURED_TO_RECOMMENDED = 'Set unconfigured flags to recommended values', + UNCONFIGURED_TO_DEFAULT = 'Set unconfigured flags to their implied configuration (record current behavior)', + MODIFY_SPECIFIC_FLAG = 'Modify a specific flag', + EXIT = 'Exit', +} + +export interface FlagOperationsParams extends FlagsOptions { + /** User provided --app option */ + app?: string; +} diff --git a/packages/aws-cdk/lib/commands/flags/validator.ts b/packages/aws-cdk/lib/commands/flags/validator.ts new file mode 100644 index 000000000..dfa3e72aa --- /dev/null +++ b/packages/aws-cdk/lib/commands/flags/validator.ts @@ -0,0 +1,100 @@ +import type { FlagOperationsParams } from './types'; +import type { IoHelper } from '../../api-private'; + +export class FlagValidator { + constructor(private readonly ioHelper: IoHelper) { + } + + /** Shows error message when CDK version is incompatible with flags command */ + async showIncompatibleVersionError(): Promise { + await this.ioHelper.defaults.error('The \'cdk flags\' command is not compatible with the AWS CDK library used by your application. Please upgrade to 2.212.0 or above.'); + } + + /** Validates all parameters and returns true if valid, false if any validation fails */ + async validateParams(params: FlagOperationsParams): Promise { + const validations = [ + () => this.validateFlagNameAndAll(params), + () => this.validateSetRequirement(params), + () => this.validateValueRequirement(params), + () => this.validateMutuallyExclusive(params), + () => this.validateUnconfiguredUsage(params), + () => this.validateSetWithFlags(params), + ]; + + for (const validation of validations) { + const isValid = await validation(); + if (!isValid) return false; + } + return true; + } + + /** Validates that --all and specific flag names are not used together */ + private async validateFlagNameAndAll(params: FlagOperationsParams): Promise { + if (params.FLAGNAME && params.all) { + await this.ioHelper.defaults.error('Error: Cannot use both --all and a specific flag name. Please use either --all to show all flags or specify a single flag name.'); + return false; + } + return true; + } + + /** Validates that modification options require --set flag */ + private async validateSetRequirement(params: FlagOperationsParams): Promise { + if ((params.value || params.recommended || params.default || params.unconfigured) && !params.set) { + await this.ioHelper.defaults.error('Error: This option can only be used with --set.'); + return false; + } + return true; + } + + /** Validates that --value requires a specific flag name */ + private async validateValueRequirement(params: FlagOperationsParams): Promise { + if (params.value && !params.FLAGNAME) { + await this.ioHelper.defaults.error('Error: --value requires a specific flag name. Please specify a flag name when providing a value.'); + return false; + } + return true; + } + + /** Validates that mutually exclusive options are not used together */ + private async validateMutuallyExclusive(params: FlagOperationsParams): Promise { + if (params.recommended && params.default) { + await this.ioHelper.defaults.error('Error: Cannot use both --recommended and --default. Please choose one option.'); + return false; + } + if (params.unconfigured && params.all) { + await this.ioHelper.defaults.error('Error: Cannot use both --unconfigured and --all. Please choose one option.'); + return false; + } + return true; + } + + /** Validates that --unconfigured is not used with specific flag names */ + private async validateUnconfiguredUsage(params: FlagOperationsParams): Promise { + if (params.unconfigured && params.FLAGNAME) { + await this.ioHelper.defaults.error('Error: Cannot use --unconfigured with a specific flag name. --unconfigured works with multiple flags.'); + return false; + } + return true; + } + + /** Validates that --set operations have required accompanying options */ + private async validateSetWithFlags(params: FlagOperationsParams): Promise { + if (params.set && params.FLAGNAME && !params.value) { + await this.ioHelper.defaults.error('Error: When setting a specific flag, you must provide a --value.'); + return false; + } + if (params.set && params.all && !params.recommended && !params.default) { + await this.ioHelper.defaults.error('Error: When using --set with --all, you must specify either --recommended or --default.'); + return false; + } + if (params.set && params.unconfigured && !params.recommended && !params.default) { + await this.ioHelper.defaults.error('Error: When using --set with --unconfigured, you must specify either --recommended or --default.'); + return false; + } + if (params.set && !params.all && !params.unconfigured && !params.FLAGNAME) { + await this.ioHelper.defaults.error('Error: When using --set, you must specify either --all, --unconfigured, or provide a specific flag name.'); + return false; + } + return true; + } +} diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index 7e680df96..b383be7a4 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -122,6 +122,7 @@ "glob": "^11.0.3", "minimatch": "10.0.3", "p-limit": "^3", + "p-queue": "^6", "promptly": "^3.2.0", "proxy-agent": "^6.5.0", "semver": "^7.7.2", diff --git a/packages/aws-cdk/test/commands/flag-operations.test.ts b/packages/aws-cdk/test/commands/flag-operations.test.ts index 94c8dab34..e49fe01e3 100644 --- a/packages/aws-cdk/test/commands/flag-operations.test.ts +++ b/packages/aws-cdk/test/commands/flag-operations.test.ts @@ -8,6 +8,7 @@ import { asIoHelper } from '../../lib/api-private'; import { CliIoHost } from '../../lib/cli/io-host'; import type { FlagsOptions } from '../../lib/cli/user-input'; import { displayFlags, handleFlags } from '../../lib/commands/flag-operations'; +import { FlagCommandHandler } from '../../lib/commands/flags/flags'; jest.mock('enquirer', () => ({ Select: jest.fn(), @@ -24,32 +25,24 @@ const mockFlagsData: FeatureFlag[] = [ { module: 'aws-cdk-lib', name: '@aws-cdk/core:testFlag', - recommendedValue: 'true', - userValue: 'false', + recommendedValue: true, + userValue: false, explanation: 'Test flag for unit tests', }, { module: 'aws-cdk-lib', name: '@aws-cdk/s3:anotherFlag', - recommendedValue: 'false', + recommendedValue: false, userValue: undefined, explanation: 'Another test flag', }, { module: 'different-module', name: '@aws-cdk/core:matchingFlag', - recommendedValue: 'true', - userValue: 'true', + recommendedValue: true, + userValue: true, explanation: 'Flag that matches recommendation', }, - { - module: 'different-module', - name: '@aws-cdk/core:anotherMatchingFlag', - recommendedValue: 'true', - userValue: 'true', - explanation: 'Flag that matches recommendation', - unconfiguredBehavesLike: { v2: 'true' }, - }, ]; function createMockToolkit(): jest.Mocked { @@ -120,40 +113,28 @@ function output() { describe('displayFlags', () => { test('displays multiple feature flags', async () => { - const params = { - flagData: mockFlagsData, - toolkit: mockToolkit, - ioHelper, - all: true, - }; - await displayFlags(params); + const options = { all: true }; + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); - expect(plainTextOutput).toContain(' @aws-cdk/core:testFlag'); - expect(plainTextOutput).toContain(' @aws-cdk/s3:anotherFlag'); + expect(plainTextOutput).toContain('@aws-cdk/core:testFlag'); + expect(plainTextOutput).toContain('@aws-cdk/s3:anotherFlag'); }); test('handles null user values correctly', async () => { - const params = { - flagData: mockFlagsData, - toolkit: mockToolkit, - ioHelper, - all: true, - }; - await displayFlags(params); + const options = { all: true }; + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain(''); }); test('handles mixed data types in flag values', async () => { - const params = { - flagData: mockFlagsData, - toolkit: mockToolkit, - ioHelper, - all: true, - }; - await displayFlags(params); + const options = { all: true }; + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain('true'); @@ -161,13 +142,9 @@ describe('displayFlags', () => { }); test('displays single flag by name', async () => { - const params = { - flagData: mockFlagsData, - toolkit: mockToolkit, - ioHelper, - flagName: ['@aws-cdk/core:testFlag'], - }; - await displayFlags(params); + const options = { FLAGNAME: ['@aws-cdk/core:testFlag'] }; + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain('Description: Test flag for unit tests'); @@ -176,13 +153,9 @@ describe('displayFlags', () => { }); test('groups flags by module', async () => { - const params = { - flagData: mockFlagsData, - toolkit: mockToolkit, - ioHelper, - all: true, - }; - await displayFlags(params); + const options = { all: true }; + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain('aws-cdk-lib'); @@ -265,13 +238,9 @@ describe('displayFlags', () => { }); test('displays single flag details when only one substring match is found', async () => { - const params = { - flagData: mockFlagsData, - toolkit: createMockToolkit(), - ioHelper, - flagName: ['s3'], - }; - await displayFlags(params); + const options = { FLAGNAME: ['s3'] }; + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, createMockToolkit()); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain('Description: Another test flag'); @@ -282,48 +251,34 @@ describe('displayFlags', () => { }); test('returns "Flag not found" if user enters non-matching substring', async () => { - const params = { - flagData: mockFlagsData, - toolkit: createMockToolkit(), - ioHelper, - flagName: ['qwerty'], - }; - await displayFlags(params); + const options = { FLAGNAME: ['qwerty'] }; + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, createMockToolkit()); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain('Flag matching \"qwerty\" not found.'); }); test('returns all matching flags if user enters common substring', async () => { - const params = { - flagData: mockFlagsData, - toolkit: createMockToolkit(), - ioHelper, - flagName: ['flag'], - }; - await displayFlags(params); + const options = { FLAGNAME: ['flag'] }; + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, createMockToolkit()); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); - expect(plainTextOutput).toContain(' @aws-cdk/core:testFlag'); - expect(plainTextOutput).toContain(' @aws-cdk/s3:anotherFlag'); - expect(plainTextOutput).toContain(' @aws-cdk/core:matchingFlag'); - expect(plainTextOutput).not.toContain(' @aws-cdk/core:anothermatchingFlag'); + expect(plainTextOutput).toContain('@aws-cdk/core:testFlag'); + expect(plainTextOutput).toContain('@aws-cdk/s3:anotherFlag'); + expect(plainTextOutput).toContain('@aws-cdk/core:matchingFlag'); }); test('returns all matching flags if user enters multiple substrings', async () => { - const params = { - flagData: mockFlagsData, - toolkit: createMockToolkit(), - ioHelper, - flagName: ['matching', 'test'], - }; - await displayFlags(params); + const options = { FLAGNAME: ['matching', 'test'] }; + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, createMockToolkit()); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); - expect(plainTextOutput).toContain(' @aws-cdk/core:testFlag'); - expect(plainTextOutput).toContain(' @aws-cdk/core:matchingFlag'); - expect(plainTextOutput).not.toContain(' @aws-cdk/s3:anotherFlag'); - expect(plainTextOutput).not.toContain(' @aws-cdk/core:anothermatchingFlag'); + expect(plainTextOutput).toContain('@aws-cdk/core:testFlag'); + expect(plainTextOutput).toContain('@aws-cdk/core:matchingFlag'); + expect(plainTextOutput).not.toContain('@aws-cdk/s3:anotherFlag'); }); test('displays empty table message when all flags are set to recommended values', async () => { @@ -389,13 +344,14 @@ describe('displayFlags', () => { }); }); -describe('handleFlags', () => { +describe('processFlagsCommand', () => { test('displays specific flag when FLAGNAME is provided without set option', async () => { const options: FlagsOptions = { FLAGNAME: ['@aws-cdk/core:testFlag'], }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain('Description: Test flag for unit tests'); @@ -408,7 +364,8 @@ describe('handleFlags', () => { all: true, }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain(' @aws-cdk/core:testFlag'); @@ -419,7 +376,8 @@ describe('handleFlags', () => { const options: FlagsOptions = { }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain(' @aws-cdk/core:testFlag'); @@ -432,7 +390,8 @@ describe('handleFlags', () => { FLAGNAME: ['@aws-cdk/core:nonExistentFlag'], }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain('Flag matching \"@aws-cdk/core:nonExistentFlag\" not found.'); @@ -452,7 +411,8 @@ describe('handleFlags', () => { value: 'true', }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); expect(mockToolkit.fromCdkApp).toHaveBeenCalledTimes(2); expect(mockToolkit.synth).toHaveBeenCalledTimes(2); @@ -473,17 +433,15 @@ describe('handleFlags', () => { setupMockToolkitForPrototyping(mockToolkit); - setupMockToolkitForPrototyping(mockToolkit); - const options: FlagsOptions = { FLAGNAME: ['@aws-cdk/core:testFlag'], set: true, value: 'true', }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); - expect(mockToolkit.fromCdkApp).toHaveBeenCalledTimes(1); expect(mockToolkit.fromCdkApp).toHaveBeenCalledTimes(1); expect(mockToolkit.synth).not.toHaveBeenCalled(); expect(mockToolkit.diff).not.toHaveBeenCalled(); @@ -508,7 +466,8 @@ describe('handleFlags', () => { value: 'true', }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const finalContent = await fs.promises.readFile(cdkJsonPath, 'utf-8'); const finalJson = JSON.parse(finalContent); @@ -545,7 +504,8 @@ describe('handleFlags', () => { value: 'true', }; - await handleFlags(nonBooleanFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(nonBooleanFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); expect(mockToolkit.fromCdkApp).not.toHaveBeenCalled(); expect(mockToolkit.synth).not.toHaveBeenCalled(); @@ -592,7 +552,8 @@ describe('handleFlags', () => { default: true, }; - await handleFlags(flagsWithUnconfiguredBehavior, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(flagsWithUnconfiguredBehavior, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const updatedContent = await fs.promises.readFile(cdkJsonPath, 'utf-8'); const updatedJson = JSON.parse(updatedContent); @@ -610,7 +571,8 @@ describe('handleFlags', () => { const options: FlagsOptions = {}; - await handleFlags(mockNoFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockNoFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain('The \'cdk flags\' command is not compatible with the AWS CDK library used by your application. Please upgrade to 2.212.0 or above.'); @@ -923,7 +885,8 @@ describe('modifyValues', () => { value: 'true', }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const updatedContent = await fs.promises.readFile(cdkJsonPath, 'utf-8'); const updatedJson = JSON.parse(updatedContent); @@ -952,7 +915,8 @@ describe('modifyValues', () => { recommended: true, }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const updatedContent = await fs.promises.readFile(cdkJsonPath, 'utf-8'); const updatedJson = JSON.parse(updatedContent); @@ -982,7 +946,8 @@ describe('modifyValues', () => { recommended: true, }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const updatedContent = await fs.promises.readFile(cdkJsonPath, 'utf-8'); const updatedJson = JSON.parse(updatedContent); @@ -1021,7 +986,8 @@ describe('checkDefaultBehavior', () => { default: true, }; - await handleFlags(flagsWithUnconfiguredBehavior, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(flagsWithUnconfiguredBehavior, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); expect(mockToolkit.fromCdkApp).toHaveBeenCalled(); expect(mockToolkit.synth).toHaveBeenCalled(); @@ -1047,7 +1013,8 @@ describe('checkDefaultBehavior', () => { default: true, }; - await handleFlags(flagsWithoutUnconfiguredBehavior, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(flagsWithoutUnconfiguredBehavior, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); const plainTextOutput = output(); expect(plainTextOutput).toContain('The --default options are not compatible with the AWS CDK library used by your application.'); @@ -1077,7 +1044,8 @@ describe('interactive prompts lead to the correct function calls', () => { interactive: true, }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); expect(mockToolkit.fromCdkApp).toHaveBeenCalledTimes(2); expect(mockToolkit.synth).toHaveBeenCalledTimes(2); @@ -1110,7 +1078,8 @@ describe('interactive prompts lead to the correct function calls', () => { interactive: true, }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); expect(mockToolkit.fromCdkApp).toHaveBeenCalledTimes(2); expect(mockToolkit.synth).toHaveBeenCalledTimes(2); @@ -1160,7 +1129,8 @@ describe('interactive prompts lead to the correct function calls', () => { interactive: true, }; - await handleFlags(flagsWithUnconfiguredBehavior, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(flagsWithUnconfiguredBehavior, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); expect(mockToolkit.fromCdkApp).toHaveBeenCalledTimes(2); expect(mockToolkit.synth).toHaveBeenCalledTimes(2); @@ -1193,7 +1163,8 @@ describe('interactive prompts lead to the correct function calls', () => { interactive: true, }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); expect(mockToolkit.fromCdkApp).toHaveBeenCalledTimes(2); expect(mockToolkit.synth).toHaveBeenCalledTimes(2); @@ -1220,7 +1191,8 @@ describe('interactive prompts lead to the correct function calls', () => { interactive: true, }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); expect(mockToolkit.fromCdkApp).not.toHaveBeenCalled(); expect(mockToolkit.synth).not.toHaveBeenCalled(); @@ -1244,7 +1216,8 @@ describe('interactive prompts lead to the correct function calls', () => { interactive: true, }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); expect(Select).toHaveBeenCalledWith({ name: 'option', @@ -1261,3 +1234,167 @@ describe('interactive prompts lead to the correct function calls', () => { await cleanupCdkJsonFile(cdkJsonPath); }); }); + +describe('setSafeFlags', () => { + beforeEach(() => { + setupMockToolkitForPrototyping(mockToolkit); + jest.clearAllMocks(); + }); + + test('shows ts-node performance tip when ts-node is detected in cdk.json app command', async () => { + const cdkJsonPath = await createCdkJsonFile({}); + await fs.promises.writeFile(cdkJsonPath, JSON.stringify({ + app: 'npx ts-node --prefer-ts-exts bin/app.ts', + context: {}, + }, null, 2)); + + mockToolkit.diff.mockResolvedValue({ + TestStack: { differenceCount: 0 } as any, + }); + + const requestResponseSpy = jest.spyOn(ioHelper, 'requestResponse'); + requestResponseSpy.mockResolvedValue(false); + + const options: FlagsOptions = { + safe: true, + concurrency: 4, + }; + + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); + + const plainTextOutput = output(); + expect(plainTextOutput).toContain('Repeated synths with ts-node will type-check the application on every synth. Add --transpileOnly to cdk.json\'s "app" command to make this operation faster.'); + + await cleanupCdkJsonFile(cdkJsonPath); + requestResponseSpy.mockRestore(); + }); + + test('shows ts-node performance tip when user supplies --app option with ts-node', async () => { + const cdkJsonPath = await createCdkJsonFile({}); + + mockToolkit.diff.mockResolvedValue({ + TestStack: { differenceCount: 0 } as any, + }); + + const requestResponseSpy = jest.spyOn(ioHelper, 'requestResponse'); + requestResponseSpy.mockResolvedValue(false); + + const options: FlagsOptions & { app?: string } = { + safe: true, + concurrency: 4, + app: 'npx ts-node bin/app.ts', + }; + + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); + + const plainTextOutput = output(); + expect(plainTextOutput).toContain('Repeated synths with ts-node will type-check the application on every synth. Add --transpileOnly to cdk.json\'s "app" command to make this operation faster.'); + + await cleanupCdkJsonFile(cdkJsonPath); + requestResponseSpy.mockRestore(); + }); + + test('returns early when no unconfigured flags exist', async () => { + const configuredFlags: FeatureFlag[] = [ + { + module: 'aws-cdk-lib', + name: '@aws-cdk/core:configuredFlag', + recommendedValue: 'true', + userValue: 'true', + explanation: 'Already configured flag', + }, + ]; + + const cdkJsonPath = await createCdkJsonFile({}); + + const options: FlagsOptions = { + safe: true, + concurrency: 4, + }; + + const flagOperations = new FlagCommandHandler(configuredFlags, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); + + const plainTextOutput = output(); + expect(plainTextOutput).toContain('All feature flags are configured.'); + expect(mockToolkit.fromCdkApp).not.toHaveBeenCalled(); + + await cleanupCdkJsonFile(cdkJsonPath); + }); + + test('identifies safe flags that can be set without template changes', async () => { + const cdkJsonPath = await createCdkJsonFile({}); + + mockToolkit.diff.mockResolvedValue({ + TestStack: { differenceCount: 0 } as any, + }); + + const requestResponseSpy = jest.spyOn(ioHelper, 'requestResponse'); + requestResponseSpy.mockResolvedValue(true); + + const options: FlagsOptions = { + safe: true, + concurrency: 4, + }; + + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); + + const plainTextOutput = output(); + expect(plainTextOutput).toContain('Flags that can be set without template changes:'); + expect(plainTextOutput).toContain('@aws-cdk/s3:anotherFlag -> false'); + + await cleanupCdkJsonFile(cdkJsonPath); + requestResponseSpy.mockRestore(); + }); + + test('handles case where no flags are safe to set', async () => { + const cdkJsonPath = await createCdkJsonFile({}); + + mockToolkit.diff.mockResolvedValue({ + TestStack: { differenceCount: 1 } as any, + }); + + const options: FlagsOptions = { + safe: true, + concurrency: 4, + }; + + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); + + const plainTextOutput = output(); + expect(plainTextOutput).toContain('No more flags can be set without causing template changes.'); + + await cleanupCdkJsonFile(cdkJsonPath); + }); + + test('applies safe flags when user confirms', async () => { + const cdkJsonPath = await createCdkJsonFile({}); + + mockToolkit.diff.mockResolvedValue({ + TestStack: { differenceCount: 0 } as any, + }); + + const requestResponseSpy = jest.spyOn(ioHelper, 'requestResponse'); + requestResponseSpy.mockResolvedValue(true); + + const options: FlagsOptions = { + safe: true, + concurrency: 4, + }; + + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit); + await flagOperations.processFlagsCommand(); + + const updatedContent = await fs.promises.readFile(cdkJsonPath, 'utf-8'); + const updatedJson = JSON.parse(updatedContent); + expect(updatedJson.context['@aws-cdk/s3:anotherFlag']).toBe(false); + expect(requestResponseSpy).toHaveBeenCalled(); + + await cleanupCdkJsonFile(cdkJsonPath); + requestResponseSpy.mockRestore(); + }); +});