diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/notices/filter.ts b/packages/@aws-cdk/toolkit-lib/lib/api/notices/filter.ts index 95e0aefd0..3c7239ea7 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/notices/filter.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/notices/filter.ts @@ -1,4 +1,5 @@ import * as semver from 'semver'; +import { languageDisplayName } from '../../util/guess-language'; import type { IoHelper } from '../io/private'; import type { ConstructTreeNode } from '../tree'; import { loadTreeFromDir } from '../tree'; @@ -11,8 +12,15 @@ function normalizeComponents(xs: Array): Component[][] return xs.map(x => Array.isArray(x) ? x : [x]); } +function renderComponent(c: Component): string { + if (c.name.startsWith('language:')) { + return `${languageDisplayName(c.name.slice('language:'.length))} apps`; + } + return `${c.name}: ${c.version}`; +} + function renderConjunction(xs: Component[]): string { - return xs.map(c => `${c.name}: ${c.version}`).join(' AND '); + return xs.map(renderComponent).join(' AND '); } interface ActualComponent { @@ -40,7 +48,7 @@ interface ActualComponent { readonly dynamicName?: string; /** - * If matched, what we should put in the set of dynamic values insstead of the version. + * If matched, what we should put in the set of dynamic values instead of the version. * * Only used if `dynamicName` is set; by default we will add the actual version * of the component. @@ -55,6 +63,13 @@ export interface NoticesFilterFilterOptions { readonly cliVersion: string; readonly outDir: string; readonly bootstrappedEnvironments: BootstrappedEnvironment[]; + + /** + * The detected CDK app language. + * + * @default - no language component is added + */ + readonly language?: string; } export class NoticesFilter { @@ -111,6 +126,14 @@ export class NoticesFilter { // Bootstrap environments ...bootstrappedEnvironments, + + // Language + ...(options.language ? [{ + name: `language:${options.language}`, + version: '0.0.0', + dynamicName: 'LANGUAGE', + dynamicValue: languageDisplayName(options.language), + }] : []), ]; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts b/packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts index 5afb83df6..fa5185321 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts @@ -40,6 +40,13 @@ export interface NoticesProps { */ readonly output?: string; + /** + * The detected CDK app language. + * + * @default - no language filtering + */ + readonly language?: string; + /** * Options for the HTTPS requests made by Notices */ @@ -113,6 +120,7 @@ export class Notices { private readonly context: Context; private readonly output: string; + private readonly language?: string; private readonly acknowledgedIssueNumbers: Set; private readonly httpOptions: NoticesHttpOptions; private readonly ioHelper: IoHelper; @@ -127,6 +135,7 @@ export class Notices { this.context = props.context; this.acknowledgedIssueNumbers = new Set(this.context.get('acknowledged-issue-numbers') ?? []); this.output = props.output ?? 'cdk.out'; + this.language = props.language; this.httpOptions = props.httpOptions ?? {}; this.ioHelper = asIoHelper(props.ioHost, 'notices' as any /* forcing a CliAction to a ToolkitAction */); this.cliVersion = props.cliVersion; @@ -164,12 +173,13 @@ export class Notices { /** * Filter the data source for relevant notices */ - public filter(options: NoticesDisplayOptions = {}): Promise { + public async filter(options: NoticesDisplayOptions = {}): Promise { return new NoticesFilter(this.ioHelper).filter({ data: this.noticesFromData(options.includeAcknowledged ?? false), cliVersion: this.cliVersion, outDir: this.output, bootstrappedEnvironments: Array.from(this.bootstrappedEnvironments.values()), + language: this.language, }); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/util/directories.ts b/packages/@aws-cdk/toolkit-lib/lib/util/directories.ts index 4b69432bf..9d0d8f379 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/util/directories.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/util/directories.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as fsExtra from 'fs-extra'; import { ToolkitError } from '../toolkit/toolkit-error'; /** @@ -63,3 +64,27 @@ export function bundledPackageRootDir(start: string, fail?: boolean) { return _rootDir(start); } + +/** + * Recursively lists all files in a directory up to the specified depth. + * + * @param dirName - The directory path to list files from + * @param depth - Maximum depth to traverse (1 = current directory only, 2 = one level deep, etc.) + * @returns Array of file names (not full paths) found within the depth limit + */ +export async function listFiles(dirName: string, depth: number, excludeDirs?: string[]): Promise { + const ret = await fsExtra.readdir(dirName, { encoding: 'utf-8', withFileTypes: true }); + + // unlikely to be unbound, it's a file system + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + return (await Promise.all(ret.map(async (f) => { + if (f.isDirectory()) { + if (depth <= 1 || excludeDirs?.includes(f.name)) { + return []; + } + return listFiles(path.join(dirName, f.name), depth - 1, excludeDirs); + } else { + return [f.name]; + } + }))).flatMap(xs => xs); +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/util/guess-language.ts b/packages/@aws-cdk/toolkit-lib/lib/util/guess-language.ts new file mode 100644 index 000000000..0757f0eaa --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/util/guess-language.ts @@ -0,0 +1,63 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { listFiles } from './directories'; + +const DISPLAY_NAMES: Record = { + typescript: 'TypeScript', + javascript: 'JavaScript', + python: 'Python', + java: 'Java', + dotnet: '.NET', + go: 'Go', +}; + +/** + * Return the display name for a language identifier. + */ +export function languageDisplayName(language: string): string { + return DISPLAY_NAMES[language] ?? language; +} + +/** + * Guess the CDK app language based on the files in the given directory. + * + * Returns `undefined` if our guess fails. + */ +export async function guessLanguage(dir: string): Promise { + try { + const files = new Set(await listFiles(dir, 2, ['node_modules'])); + + if (files.has('package.json')) { + const pjContents = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf-8')); + const deps = new Set([ + ...Object.keys(pjContents.dependencies ?? {}), + ...Object.keys(pjContents.devDependencies ?? {}), + ]); + if (deps.has('typescript') || deps.has('ts-node') || deps.has('tsx') || deps.has('swc')) { + return 'typescript'; + } else { + return 'javascript'; + } + } + + if (files.has('requirements.txt') || files.has('setup.py') || files.has('pyproject.toml')) { + return 'python'; + } + + if (files.has('pom.xml') || files.has('build.xml') || files.has('settings.gradle')) { + return 'java'; + } + + if (Array.from(files).some(n => n.endsWith('.sln') || n.endsWith('.csproj') || n.endsWith('.fsproj') || n.endsWith('.vbproj'))) { + return 'dotnet'; + } + + if (files.has('go.mod')) { + return 'go'; + } + } catch { + // Swallow failure + } + + return undefined; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/util/index.ts b/packages/@aws-cdk/toolkit-lib/lib/util/index.ts index 8af7c9ede..cb381791e 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/util/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/util/index.ts @@ -1,12 +1,13 @@ export * from './archive'; export * from './arrays'; -export * from './glob-matcher'; export * from './bool'; export * from './bytes'; export * from './cloudformation'; export * from './content-hash'; export * from './directories'; export * from './format-error'; +export * from './glob-matcher'; +export * from './guess-language'; export * from './json'; export * from './net'; export * from './objects'; diff --git a/packages/@aws-cdk/toolkit-lib/test/api/environment/environment-resources.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/environment/environment-resources.test.ts index 05b9e3d05..3e47bd08b 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/environment/environment-resources.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/environment/environment-resources.test.ts @@ -125,6 +125,7 @@ describe('validate version without bootstrap stack', () => { cliVersion: '1.0.0', data: [], outDir: 'cdk.out', + language: undefined, }); }); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/guess-language.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/guess-language.test.ts new file mode 100644 index 000000000..1e09b05e0 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/guess-language.test.ts @@ -0,0 +1,70 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { guessLanguage } from '../../lib/util/guess-language'; + +describe('guessLanguage', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(__dirname, 'guess-lang-')); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + test('returns typescript when package.json has typescript dependency', async () => { + await fs.writeJson(path.join(tmpDir, 'package.json'), { + dependencies: { typescript: '^5.0.0' }, + }); + expect(await guessLanguage(tmpDir)).toBe('typescript'); + }); + + test('returns typescript when package.json has ts-node devDependency', async () => { + await fs.writeJson(path.join(tmpDir, 'package.json'), { + devDependencies: { 'ts-node': '^10.0.0' }, + }); + expect(await guessLanguage(tmpDir)).toBe('typescript'); + }); + + test('returns javascript when package.json has no typescript indicators', async () => { + await fs.writeJson(path.join(tmpDir, 'package.json'), { + dependencies: { 'aws-cdk-lib': '^2.0.0' }, + }); + expect(await guessLanguage(tmpDir)).toBe('javascript'); + }); + + test('returns python for requirements.txt', async () => { + await fs.writeFile(path.join(tmpDir, 'requirements.txt'), ''); + expect(await guessLanguage(tmpDir)).toBe('python'); + }); + + test('returns python for pyproject.toml', async () => { + await fs.writeFile(path.join(tmpDir, 'pyproject.toml'), ''); + expect(await guessLanguage(tmpDir)).toBe('python'); + }); + + test('returns java for pom.xml', async () => { + await fs.writeFile(path.join(tmpDir, 'pom.xml'), ''); + expect(await guessLanguage(tmpDir)).toBe('java'); + }); + + test('returns dotnet for .csproj file', async () => { + await fs.writeFile(path.join(tmpDir, 'MyApp.csproj'), ''); + expect(await guessLanguage(tmpDir)).toBe('dotnet'); + }); + + test('returns go for go.mod', async () => { + await fs.writeFile(path.join(tmpDir, 'go.mod'), ''); + expect(await guessLanguage(tmpDir)).toBe('go'); + }); + + test('returns undefined for unknown project', async () => { + await fs.writeFile(path.join(tmpDir, 'README.md'), ''); + expect(await guessLanguage(tmpDir)).toBeUndefined(); + }); + + test('returns undefined for non-existent directory', async () => { + expect(await guessLanguage('/nonexistent/path')).toBeUndefined(); + }); +}); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts index a3b1d0b07..46f4373dd 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts @@ -488,6 +488,86 @@ describe(NoticesFilter, () => { expect((await filtered).map((f) => f.format()).join('\n')).toContain(`You are running ${nodeVersion}`); }); + test('language match', async () => { + const outDir = path.join(fixtures, 'built-with-2_12_0'); + const cliVersion = '1.0.0'; + + const filtered = await noticesFilter.filter({ + data: [ + { + title: 'title for typescript', + overview: 'This affects {resolve:LANGUAGE} users', + issueNumber: 1234, + schemaVersion: '1', + components: [{ name: 'language:typescript', version: '*' }], + }, + { + title: 'title for python', + overview: 'python issue', + issueNumber: 4321, + schemaVersion: '1', + components: [{ name: 'language:python', version: '*' }], + }, + ] satisfies Notice[], + cliVersion, + outDir, + bootstrappedEnvironments: [], + language: 'typescript', + }); + + expect(filtered.map((f) => f.notice.title)).toEqual(['title for typescript']); + expect(filtered.map((f) => f.format()).join('\n')).toContain('This affects TypeScript users'); + expect(filtered.map((f) => f.format()).join('\n')).toContain('TypeScript apps'); + }); + + test('no language match when language is not provided', async () => { + const outDir = path.join(fixtures, 'built-with-2_12_0'); + const cliVersion = '1.0.0'; + + const filtered = noticesFilter.filter({ + data: [ + { + title: 'typescript-only', + overview: 'ts issue', + issueNumber: 1, + schemaVersion: '1', + components: [{ name: 'language:typescript', version: '*' }], + }, + ] satisfies Notice[], + cliVersion, + outDir, + bootstrappedEnvironments: [], + }); + + expect((await filtered).map((f) => f.notice.title)).toEqual([]); + }); + + test('language combined with cli version in AND', async () => { + const outDir = path.join(fixtures, 'built-with-2_12_0'); + const cliVersion = '1.0.0'; + + const filtered = noticesFilter.filter({ + data: [ + { + title: 'combined', + overview: 'combined issue', + issueNumber: 1, + schemaVersion: '1', + components: [[ + { name: 'language:typescript', version: '*' }, + { name: 'cli', version: '<=1.0.0' }, + ]], + }, + ] satisfies Notice[], + cliVersion, + outDir, + bootstrappedEnvironments: [], + language: 'typescript', + }); + + expect((await filtered).map((f) => f.notice.title)).toEqual(['combined']); + }); + test.each([ // No components => doesnt match [[], false], @@ -901,6 +981,7 @@ describe(Notices, () => { cliVersion: '1.0.0', data: [], outDir: 'cdk.out', + language: undefined, }); }); }); diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index a0775adb0..95c0d267c 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -3,6 +3,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import type { ChangeSetDeployment, DeploymentMethod, DirectDeployment } from '@aws-cdk/toolkit-lib'; import { ToolkitError, Toolkit } from '@aws-cdk/toolkit-lib'; import * as chalk from 'chalk'; +import { guessLanguage } from '../util'; import { CdkToolkit, AssetBuildTime } from './cdk-toolkit'; import { ciSystemIsStdErrSafe } from './ci-systems'; import { displayVersionMessage } from './display-version'; @@ -28,6 +29,7 @@ import { docs } from '../commands/docs'; import { doctor } from '../commands/doctor'; import { FlagCommandHandler } from '../commands/flags/flags'; import { cliInit, printAvailableTemplates } from '../commands/init'; +import { getLanguageFromAlias } from '../commands/language'; import { getMigrateScanType } from '../commands/migrate'; import { execProgram, CloudExecutable } from '../cxapp'; import type { StackSelector, Synthesizer } from '../cxapp'; @@ -36,7 +38,6 @@ import { cdkCliErrorName } from './telemetry/error'; import type { ErrorDetails } from './telemetry/schema'; import { isCI } from './util/ci'; import { isDeveloperBuildVersion, versionWithBuild, versionNumber } from './version'; -import { getLanguageFromAlias } from '../commands/language'; export async function exec(args: string[], synthesizer?: Synthesizer): Promise { const argv = await parseCommandLineArguments(args); @@ -146,6 +147,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise