diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 162b6eca5..841056488 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -4,6 +4,7 @@ import { CliHelpers, type CliConfig } from '@aws-cdk/user-input-gen'; import * as cdk_from_cfn from 'cdk-from-cfn'; import { StackActivityProgress } from '../commands/deploy'; import { availableInitLanguages } from '../commands/init'; +import { JS_PACKAGE_MANAGERS } from '../commands/init/package-manager'; import { getLanguageAlias } from '../commands/language'; export const YARGS_HELPERS = new CliHelpers('./util/yargs-helpers'); @@ -407,6 +408,7 @@ export async function makeConfig(): Promise { 'lib-version': { type: 'string', alias: 'V', default: undefined, desc: 'The version of the CDK library (aws-cdk-lib) to initialize built-in templates with. Defaults to the version that was current when this CLI was built.' }, 'from-path': { type: 'string', desc: 'Path to a local custom template directory or multi-template repository', requiresArg: true, conflicts: ['lib-version'] }, 'template-path': { type: 'string', desc: 'Path to a specific template within a multi-template repository', requiresArg: true }, + 'package-manager': { type: 'string', desc: 'The package manager to use to install dependencies. Only applicable for TypeScript and JavaScript projects. Defaults to npm in TypeScript and JavaScript projects.', choices: JS_PACKAGE_MANAGERS }, }, implies: { 'template-path': 'from-path' }, }, diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index db494fc7e..439316754 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -903,6 +903,16 @@ "type": "string", "desc": "Path to a specific template within a multi-template repository", "requiresArg": true + }, + "package-manager": { + "type": "string", + "desc": "The package manager to use to install dependencies. Only applicable for TypeScript and JavaScript projects. Defaults to npm in TypeScript and JavaScript projects.", + "choices": [ + "npm", + "yarn", + "pnpm", + "bun" + ] } }, "implies": { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 4bc38f10f..37b083648 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -565,6 +565,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { type: 'string', desc: 'Path to a specific template within a multi-template repository', requiresArg: true, + }) + .option('package-manager', { + default: undefined, + type: 'string', + desc: 'The package manager to use to install dependencies. Only applicable for TypeScript and JavaScript projects. Defaults to npm in TypeScript and JavaScript projects.', + choices: ['npm', 'yarn', 'pnpm', 'bun'], }), ) .command('migrate', 'Migrate existing AWS resources into a CDK app', (yargs: Argv) => diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 735198270..9d3f9cc11 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -1408,6 +1408,13 @@ export interface InitOptions { */ readonly templatePath?: string; + /** + * The package manager to use to install dependencies. Only applicable for TypeScript and JavaScript projects. Defaults to npm in TypeScript and JavaScript projects. + * + * @default - undefined + */ + readonly packageManager?: string; + /** * Positional argument for init */ diff --git a/packages/aws-cdk/lib/commands/init/init.ts b/packages/aws-cdk/lib/commands/init/init.ts index 76ac95c26..9b9f3de65 100644 --- a/packages/aws-cdk/lib/commands/init/init.ts +++ b/packages/aws-cdk/lib/commands/init/init.ts @@ -10,6 +10,7 @@ import { versionNumber } from '../../cli/version'; import { cdkHomeDir, formatErrorMessage, rangeFromSemver } from '../../util'; import type { LanguageInfo } from '../language'; import { getLanguageAlias, getLanguageExtensions, SUPPORTED_LANGUAGES } from '../language'; +import type { JsPackageManager } from './package-manager'; /* eslint-disable @typescript-eslint/no-var-requires */ // Packages don't have @types module // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -76,6 +77,12 @@ export interface CliInitOptions { */ readonly templatePath?: string; + /** + * The package manager to use for installing dependencies. Only applicable for TypeScript and JavaScript projects. + * @default - If specified language is 'typescript' or 'javascript', 'npm' is selected. Otherwise, no package manager is used. + */ + readonly packageManager?: JsPackageManager; + readonly ioHelper: IoHelper; } @@ -83,6 +90,8 @@ export interface CliInitOptions { * Initialize a CDK package in the current directory */ export async function cliInit(options: CliInitOptions) { + await ensureValidCliInitOptions(options, options.ioHelper); + const ioHelper = options.ioHelper; const canUseNetwork = options.canUseNetwork ?? true; const generateOnly = options.generateOnly ?? false; @@ -116,9 +125,19 @@ export async function cliInit(options: CliInitOptions) { options.stackName, options.migrate, options.libVersion, + options.packageManager, ); } +/** + * Validate CLI init options and handle invalid or incompatible option combinations + */ +async function ensureValidCliInitOptions(options: CliInitOptions, ioHelper: IoHelper) { + if (options.packageManager && !['javascript', 'typescript'].includes(options.language ?? '')) { + await ioHelper.defaults.warn(`--package-manager option is only applicable for JavaScript and TypeScript projects. Ignoring the provided value: ${options.packageManager}`); + } +} + /** * Load a local custom template from file system path * @param fromPath - Path to the local template directory or multi-template repository @@ -653,6 +672,7 @@ async function initializeProject( stackName?: string, migrate?: boolean, cdkVersion?: string, + packageManager?: JsPackageManager, ) { // Step 1: Ensure target directory is empty await assertIsEmptyDirectory(workDir); @@ -675,7 +695,7 @@ async function initializeProject( await initializeGitRepository(ioHelper, workDir); // Step 4: Post-install steps - await postInstall(ioHelper, language, canUseNetwork, workDir); + await postInstall(ioHelper, language, canUseNetwork, workDir, packageManager); } await ioHelper.defaults.info('✅ All done!'); @@ -728,12 +748,12 @@ async function initializeGitRepository(ioHelper: IoHelper, workDir: string) { } } -async function postInstall(ioHelper: IoHelper, language: string, canUseNetwork: boolean, workDir: string) { +async function postInstall(ioHelper: IoHelper, language: string, canUseNetwork: boolean, workDir: string, packageManager?: JsPackageManager) { switch (language) { case 'javascript': - return postInstallJavascript(ioHelper, canUseNetwork, workDir); + return postInstallJavascript(ioHelper, canUseNetwork, workDir, packageManager); case 'typescript': - return postInstallTypescript(ioHelper, canUseNetwork, workDir); + return postInstallTypescript(ioHelper, canUseNetwork, workDir, packageManager); case 'java': return postInstallJava(ioHelper, canUseNetwork, workDir); case 'python': @@ -747,12 +767,12 @@ async function postInstall(ioHelper: IoHelper, language: string, canUseNetwork: } } -async function postInstallJavascript(ioHelper: IoHelper, canUseNetwork: boolean, cwd: string) { - return postInstallTypescript(ioHelper, canUseNetwork, cwd); +async function postInstallJavascript(ioHelper: IoHelper, canUseNetwork: boolean, cwd: string, packageManager?: JsPackageManager) { + return postInstallTypescript(ioHelper, canUseNetwork, cwd, packageManager); } -async function postInstallTypescript(ioHelper: IoHelper, canUseNetwork: boolean, cwd: string) { - const command = 'npm'; +async function postInstallTypescript(ioHelper: IoHelper, canUseNetwork: boolean, cwd: string, packageManager?: JsPackageManager) { + const command = packageManager ?? 'npm'; if (!canUseNetwork) { await ioHelper.defaults.warn(`Please run '${command} install'!`); diff --git a/packages/aws-cdk/lib/commands/init/package-manager.ts b/packages/aws-cdk/lib/commands/init/package-manager.ts new file mode 100644 index 000000000..3c62fb5eb --- /dev/null +++ b/packages/aws-cdk/lib/commands/init/package-manager.ts @@ -0,0 +1,3 @@ +export const JS_PACKAGE_MANAGERS = ['npm', 'yarn', 'pnpm', 'bun'] as const; + +export type JsPackageManager = (typeof JS_PACKAGE_MANAGERS)[number]; diff --git a/packages/aws-cdk/test/commands/init.test.ts b/packages/aws-cdk/test/commands/init.test.ts index c79321fca..7b6480364 100644 --- a/packages/aws-cdk/test/commands/init.test.ts +++ b/packages/aws-cdk/test/commands/init.test.ts @@ -1,8 +1,10 @@ +import * as child_process from 'child_process'; import * as os from 'os'; import * as path from 'path'; import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs-extra'; import { availableInitLanguages, availableInitTemplates, cliInit, currentlyRecommendedAwsCdkLibFlags, expandPlaceholders, printAvailableTemplates } from '../../lib/commands/init'; +import type { JsPackageManager } from '../../lib/commands/init/package-manager'; import { createSingleLanguageTemplate, createMultiLanguageTemplate, createMultiTemplateRepository } from '../_fixtures/init-templates/template-helpers'; import { TestIoHost } from '../_helpers/io-host'; @@ -1352,6 +1354,143 @@ describe('constructs version', () => { // cdk.json should not exist since template didn't have one expect(await fs.pathExists(path.join(projectDir, 'cdk.json'))).toBeFalsy(); }); + + describe('package-manager option', () => { + let spawnSpy: jest.SpyInstance; + + beforeEach(async () => { + // Mock child_process.spawn to track which package manager is called + spawnSpy = jest.spyOn(child_process, 'spawn').mockImplementation(() => ({ + stdout: { on: jest.fn() }, + }) as unknown as child_process.ChildProcess); + }); + + afterEach(() => { + spawnSpy.mockRestore(); + }); + + test.each([ + { language: 'typescript', packageManager: 'npm' }, + { language: 'typescript', packageManager: 'yarn' }, + { language: 'typescript', packageManager: 'pnpm' }, + { language: 'typescript', packageManager: 'bun' }, + { language: 'javascript', packageManager: 'npm' }, + { language: 'javascript', packageManager: 'yarn' }, + { language: 'javascript', packageManager: 'pnpm' }, + { language: 'javascript', packageManager: 'bun' }, + ])('uses $packageManager for $language project', async ({ language, packageManager }) => { + await withTempDir(async (workDir) => { + await cliInit({ + ioHelper, + type: 'app', + language, + packageManager: packageManager as JsPackageManager, + workDir, + }); + + const installCalls = spawnSpy.mock.calls.filter( + ([cmd, args]) => cmd === packageManager && args.includes('install'), + ); + expect(installCalls.length).toBeGreaterThan(0); + }); + }); + + cliTest('uses npm as default when package manager not specified', async (workDir) => { + await cliInit({ + ioHelper, + type: 'app', + language: 'typescript', + workDir, + }); + + const installCalls = spawnSpy.mock.calls.filter( + ([cmd, args]) => cmd === 'npm' && args.includes('install'), + ); + expect(installCalls.length).toBeGreaterThan(0); + }); + + cliTest('ignores package manager option for non-JavaScript languages', async (workDir) => { + await cliInit({ + ioHelper, + type: 'app', + language: 'python', + packageManager: 'yarn', + canUseNetwork: false, + generateOnly: true, + workDir, + }); + + expect(await fs.pathExists(path.join(workDir, 'requirements.txt'))).toBeTruthy(); + }); + }); + + describe('validate CLI init options', () => { + const cdkBin = path.join(__dirname, '..', '..', 'bin', 'cdk'); + const commonEnv = { ...process.env, CDK_DISABLE_VERSION_CHECK: '1', CI: 'true', FORCE_COLOR: '0' }; + + test.each([ + 'python', + 'java', + 'go', + 'csharp', + 'fsharp', + ])('warns when package-manager option is specified for non-JS language=%s', async (language) => { + await withTempDir(async (workDir) => { + const output = child_process.execSync( + `node ${cdkBin} init app --language ${language} --package-manager npm --generate-only`, + { + cwd: workDir, + env: commonEnv, + encoding: 'utf-8', + }, + ); + + expect(output).toContain('--package-manager option is only applicable for JavaScript and TypeScript projects'); + expect(output).toContain(`Applying project template app for ${language}`); + }); + }); + + test.each([ + 'python', + 'java', + 'go', + 'csharp', + 'fsharp', + ])('does not warn when package-manager option is omitted for non-JS language=%s', async (language) => { + await withTempDir(async (workDir) => { + const output = child_process.execSync( + `node ${cdkBin} init app --language ${language} --generate-only`, + { + cwd: workDir, + env: commonEnv, + encoding: 'utf-8', + }, + ); + + expect(output).not.toContain('--package-manager option is only applicable for JavaScript and TypeScript projects'); + expect(output).toContain(`Applying project template app for ${language}`); + }); + }); + + test.each([ + 'typescript', + 'javascript', + ])('does not warn when package-manager option is specified for language=%s', async (language) => { + await withTempDir(async (workDir) => { + const output = child_process.execSync( + `node ${cdkBin} init app --language ${language} --generate-only`, + { + cwd: workDir, + env: commonEnv, + encoding: 'utf-8', + }, + ); + + expect(output).not.toContain('--package-manager option is only applicable for JavaScript and TypeScript projects'); + expect(output).toContain(`Applying project template app for ${language}`); + }); + }); + }); }); test('when no version number is present (e.g., local development), the v2 templates are chosen by default', async () => {