diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 5a6db6273..173b2e193 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -38,6 +38,7 @@ import { getLanguageFromAlias } from '../commands/language'; import { getMigrateScanType } from '../commands/migrate'; import { execProgram, CloudExecutable } from '../cxapp'; import type { StackSelector, Synthesizer } from '../cxapp'; +import { findUnknownOptions } from './util/check-unknown-options'; import { isCI } from './util/ci'; import { guessAgent } from './util/guess-agent'; @@ -99,6 +100,12 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise 0) { + const formatted = unknownOptions.map((o) => `--${o}`).join(', '); + await ioHost.defaults.warn(`Unknown option(s): ${formatted}. These will be ignored. Run 'cdk --help' to see available options.`); + } + const configuration = await Configuration.fromArgsAndFiles(ioHelper, { commandLineArguments: { diff --git a/packages/aws-cdk/lib/cli/util/check-unknown-options.ts b/packages/aws-cdk/lib/cli/util/check-unknown-options.ts new file mode 100644 index 000000000..dc86eb1f8 --- /dev/null +++ b/packages/aws-cdk/lib/cli/util/check-unknown-options.ts @@ -0,0 +1,90 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const config = require('../cli-type-registry.json'); + +/** + * Build a set of all known option names for a given option definitions object. + * Includes the kebab-case name, its camelCase equivalent, all aliases, and negativeAliases. + * + * @param optionDefs - Option definitions keyed by their kebab-case CLI names + * (as defined in cli-type-registry.json). Each key is converted to camelCase + * and both forms are included in the returned set. + */ +function collectKnownOptions(optionDefs: Record): Set { + const known = new Set(); + for (const [name, def] of Object.entries(optionDefs)) { + known.add(name); + known.add(kebabToCamel(name)); + if (def.alias) { + const aliases = Array.isArray(def.alias) ? def.alias : [def.alias]; + for (const a of aliases) { + known.add(a); + } + } + if (def.negativeAlias) { + known.add(def.negativeAlias); + } + } + return known; +} + +/** Pre-computed set of known global options (static, doesn't depend on argv) */ +const globalKnownOptions = collectKnownOptions(config.globalOptions); + +/** yargs internal keys that are always present in argv */ +const yargsInternals = new Set(['_', '$0', 'help', 'h', 'version']); + +/** + * Detect unrecognized CLI options. + * + * Yargs does not enable strict option checking by default, so unknown flags + * are silently swallowed. This function compares the parsed argv keys against + * the known global and command options from the CLI type registry and returns + * any that don't match. + */ +export function findUnknownOptions(argv: any): string[] { + const command = argv._[0]; + + const commandDef = config.commands[command]; + const commandKnownOptions = commandDef?.options + ? collectKnownOptions(commandDef.options) + : new Set(); + + const positionalArg = commandDef?.arg?.name; + + const unknown: string[] = []; + for (const key of Object.keys(argv)) { + if (argv[key] === undefined) { + continue; + } + if (yargsInternals.has(key) || key === positionalArg) { + continue; + } + if (globalKnownOptions.has(key) || commandKnownOptions.has(key)) { + continue; + } + // yargs .env('CDK') injects CDK_* environment variables as camelCase argv + // keys (e.g. CDK_INTEG_ATMOSPHERE_POOL -> integAtmospherePool). These are + // intentional configuration from the environment, not user typos. + if (isFromEnvPrefix(key, 'CDK')) { + continue; + } + + unknown.push(key); + } + + return unknown; +} + +function kebabToCamel(str: string): string { + return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); +} + +/** + * Checks whether a camelCase argv key was injected by yargs' .env(PREFIX) + * feature. yargs converts PREFIX_FOO_BAR env vars into camelCase keys + * (fooBar). We reverse the mapping and check if the env var exists. + */ +function isFromEnvPrefix(key: string, prefix: string): boolean { + const screamingSnake = key.replace(/[A-Z]/g, (m) => `_${m}`).toUpperCase(); + return process.env[`${prefix}_${screamingSnake}`] !== undefined; +} diff --git a/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts b/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts new file mode 100644 index 000000000..b00c01c66 --- /dev/null +++ b/packages/aws-cdk/test/cli/util/check-unknown-options.test.ts @@ -0,0 +1,111 @@ +import { findUnknownOptions } from '../../../lib/cli/util/check-unknown-options'; + +describe('findUnknownOptions', () => { + test('returns empty array for known global options', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + profile: 'my-profile', + verbose: 1, + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('returns empty array for known command options', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + force: true, + all: false, + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('detects unknown options', () => { + const argv = { + _: ['bootstrap'], + $0: 'cdk', + profile: 'my-profile', + fakeOption: 'value', + }; + const unknown = findUnknownOptions(argv); + expect(unknown).toEqual(['fakeOption']); + }); + + test('does not report camelCase variants of known kebab-case options', () => { + const argv = { + '_': ['deploy'], + '$0': 'cdk', + 'ca-bundle-path': '/tmp/ca.pem', + 'caBundlePath': '/tmp/ca.pem', + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('does not report yargs internal keys', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + help: false, + h: false, + version: false, + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('does not report aliases', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + v: 1, + j: false, + a: 'node bin/app.js', + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + test('does not report negativeAlias keys', () => { + const argv = { + _: ['deploy'], + $0: 'cdk', + R: true, + rollback: false, + }; + expect(findUnknownOptions(argv)).toEqual([]); + }); + + // yargs .env('CDK') injects CDK_* env vars as camelCase keys in argv. + // This also covers "noFoo" patterns (e.g. CDK_NO_ROLLBACK -> noRollback). + test('does not report keys injected by yargs .env("CDK") from environment variables', () => { + process.env.CDK_INTEG_ATMOSPHERE_POOL = 'test-pool'; + process.env.CDK_MAJOR_VERSION = '2'; + try { + const argv = { + _: ['deploy'], + $0: 'cdk', + integAtmospherePool: 'test-pool', + majorVersion: '2', + }; + expect(findUnknownOptions(argv)).toEqual([]); + } finally { + delete process.env.CDK_INTEG_ATMOSPHERE_POOL; + delete process.env.CDK_MAJOR_VERSION; + } + }); + + test('still reports truly unknown options even when CDK_ env vars exist', () => { + process.env.CDK_INTEG_ATMOSPHERE_POOL = 'test-pool'; + try { + const argv = { + _: ['deploy'], + $0: 'cdk', + integAtmospherePool: 'test-pool', + totallyFakeOption: 'value', + }; + const unknown = findUnknownOptions(argv); + expect(unknown).toEqual(['totallyFakeOption']); + } finally { + delete process.env.CDK_INTEG_ATMOSPHERE_POOL; + } + }); +});