diff --git a/packages/@aws-cdk/user-input-gen/lib/yargs-gen.ts b/packages/@aws-cdk/user-input-gen/lib/yargs-gen.ts index 850750d8c..a40f0c789 100644 --- a/packages/@aws-cdk/user-input-gen/lib/yargs-gen.ts +++ b/packages/@aws-cdk/user-input-gen/lib/yargs-gen.ts @@ -109,6 +109,13 @@ function makeYargs(config: CliConfig, helpers: CliHelpers): Statement { commandCallArgs.push(optionsExpr); } + // Add implies calls if present + if (commandFacts.implies) { + for (const [key, value] of Object.entries(commandFacts.implies)) { + optionsExpr = optionsExpr.callMethod('implies', lit(key), lit(value)); + } + } + yargsExpr = yargsExpr.callMethod('command', ...commandCallArgs); } diff --git a/packages/@aws-cdk/user-input-gen/lib/yargs-types.ts b/packages/@aws-cdk/user-input-gen/lib/yargs-types.ts index 91c23b288..a19fff94e 100644 --- a/packages/@aws-cdk/user-input-gen/lib/yargs-types.ts +++ b/packages/@aws-cdk/user-input-gen/lib/yargs-types.ts @@ -7,6 +7,7 @@ interface YargsCommand { export interface CliAction extends YargsCommand { options?: { [optionName: string]: CliOption }; + implies?: { [key: string]: string }; } interface YargsArg { diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 3fc6143e9..873749f25 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -400,8 +400,11 @@ export async function makeConfig(): Promise { 'language': { type: 'string', alias: 'l', desc: 'The language to be used for the new project (default can be configured in ~/.cdk.json)', choices: await availableInitLanguages() }, 'list': { type: 'boolean', desc: 'List the available templates' }, 'generate-only': { type: 'boolean', default: false, desc: 'If true, only generates project files, without executing additional operations such as setting up a git repo, installing dependencies or compiling the project' }, - 'lib-version': { type: 'string', alias: 'V', default: undefined, desc: 'The version of the CDK library (aws-cdk-lib) to initialize the project with. Defaults to the version that was current when this CLI was built.' }, + '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 }, }, + implies: { 'template-path': 'from-path' }, }, 'migrate': { description: 'Migrate existing AWS resources into a CDK app', diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index a6bd9e976..f78fd2fc3 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -865,8 +865,24 @@ "lib-version": { "type": "string", "alias": "V", - "desc": "The version of the CDK library (aws-cdk-lib) to initialize the project with. Defaults to the version that was current when this CLI was built." + "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 } + }, + "implies": { + "template-path": "from-path" } }, "migrate": { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index fac4985cd..e1ba9710a 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -516,6 +516,10 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { default: undefined, type: 'string', alias: 'V', - desc: 'The version of the CDK library (aws-cdk-lib) to initialize the project with. Defaults to the version that was current when this CLI was built.', + 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.', + }) + .option('from-path', { + default: undefined, + type: 'string', + desc: 'Path to a local custom template directory or multi-template repository', + requiresArg: true, + conflicts: ['lib-version'], + }) + .option('template-path', { + default: undefined, + type: 'string', + desc: 'Path to a specific template within a multi-template repository', + requiresArg: true, }), ) .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 47b424eac..5402ef19b 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -1363,7 +1363,7 @@ export interface InitOptions { readonly generateOnly?: boolean; /** - * The version of the CDK library (aws-cdk-lib) to initialize the project with. Defaults to the version that was current when this CLI was built. + * 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. * * aliases: V * @@ -1371,6 +1371,20 @@ export interface InitOptions { */ readonly libVersion?: string; + /** + * Path to a local custom template directory or multi-template repository + * + * @default - undefined + */ + readonly fromPath?: string; + + /** + * Path to a specific template within a multi-template repository + * + * @default - undefined + */ + readonly templatePath?: 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 4493c23fb..61666ddc0 100644 --- a/packages/aws-cdk/lib/commands/init/init.ts +++ b/packages/aws-cdk/lib/commands/init/init.ts @@ -16,19 +16,62 @@ const camelCase = require('camelcase'); const decamelize = require('decamelize'); export interface CliInitOptions { + /** + * Template name to initialize + * @default undefined + */ readonly type?: string; + + /** + * Programming language for the project + * @default - Optional/auto-detected if template supports only one language, otherwise required + */ readonly language?: string; + + /** + * @default true + */ readonly canUseNetwork?: boolean; + + /** + * @default false + */ readonly generateOnly?: boolean; + + /** + * @default process.cwd() + */ readonly workDir?: string; + + /** + * @default undefined + */ readonly stackName?: string; + + /** + * @default undefined + */ readonly migrate?: boolean; /** * Override the built-in CDK version + * @default undefined */ readonly libVersion?: string; + /** + * Path to a local custom template directory + * @default undefined + */ + readonly fromPath?: string; + + /** + * Path to a specific template within a multi-template repository. + * This parameter requires --from-path to be specified. + * @default undefined + */ + readonly templatePath?: string; + readonly ioHelper: IoHelper; } @@ -40,36 +83,25 @@ export async function cliInit(options: CliInitOptions) { const canUseNetwork = options.canUseNetwork ?? true; const generateOnly = options.generateOnly ?? false; const workDir = options.workDir ?? process.cwd(); - if (!options.type && !options.language) { + + // Show available templates if no type and no language provided (main branch logic) + if (!options.fromPath && !options.type && !options.language) { await printAvailableTemplates(ioHelper); return; } - const type = options.type || 'default'; // "default" is the default type (and maps to "app") - - const template = (await availableInitTemplates()).find((t) => t.hasName(type!)); - if (!template) { - await printAvailableTemplates(ioHelper, options.language); - throw new ToolkitError(`Unknown init template: ${type}`); + // Step 1: Load template + let template: InitTemplate; + if (options.fromPath) { + template = await loadLocalTemplate(options.fromPath, options.templatePath); + } else { + template = await loadBuiltinTemplate(ioHelper, options.type, options.language); } - const language = await (async () => { - if (options.language) { - return options.language; - } - if (template.languages.length === 1) { - const templateLanguage = template.languages[0]; - await ioHelper.defaults.warn( - `No --language was provided, but '${type}' supports only '${templateLanguage}', so defaulting to --language=${templateLanguage}`, - ); - return templateLanguage; - } - await ioHelper.defaults.info( - `Available languages for ${chalk.green(type)}: ${template.languages.map((l) => chalk.blue(l)).join(', ')}`, - ); - throw new ToolkitError('No language was selected'); - })(); + // Step 2: Resolve language + const language = await resolveLanguage(ioHelper, template, options.language, options.type); + // Step 3: Initialize project following standard process await initializeProject( ioHelper, template, @@ -83,8 +115,192 @@ export async function cliInit(options: CliInitOptions) { ); } +/** + * Load a local custom template from file system path + * @param fromPath - Path to the local template directory or multi-template repository + * @param templatePath - Optional path to a specific template within a multi-template repository + * @returns Promise resolving to the loaded InitTemplate + */ +async function loadLocalTemplate(fromPath: string, templatePath?: string): Promise { + try { + let actualTemplatePath = fromPath; + + // If templatePath is provided, it's a multi-template repository + if (templatePath) { + actualTemplatePath = path.join(fromPath, templatePath); + + if (!await fs.pathExists(actualTemplatePath)) { + throw new ToolkitError(`Template path does not exist: ${actualTemplatePath}`); + } + } + + const template = await InitTemplate.fromPath(actualTemplatePath); + + if (template.languages.length === 0) { + // Check if this might be a multi-template repository + if (!templatePath) { + const availableTemplates = await findPotentialTemplates(fromPath); + if (availableTemplates.length > 0) { + throw new ToolkitError( + 'Use --template-path to specify which template to use.', + ); + } + } + throw new ToolkitError('Custom template must contain at least one language directory'); + } + + return template; + } catch (error: any) { + const displayPath = templatePath ? `${fromPath}/${templatePath}` : fromPath; + throw new ToolkitError(`Failed to load template from path: ${displayPath}. ${error.message}`); + } +} + +/** + * Load a built-in template by name + */ +async function loadBuiltinTemplate(ioHelper: IoHelper, type?: string, language?: string): Promise { + const templateType = type || 'default'; // "default" is the default type (and maps to "app") + + const template = (await availableInitTemplates()).find((t) => t.hasName(templateType)); + if (!template) { + await printAvailableTemplates(ioHelper, language); + throw new ToolkitError(`Unknown init template: ${templateType}`); + } + + return template; +} + +/** + * Resolve the programming language for the template + * @param ioHelper - IO helper for user interaction + * @param template - The template to resolve language for + * @param requestedLanguage - User-requested language (optional) + * @param type - The template type name for messages + * @default undefined + * @returns Promise resolving to the selected language + */ +async function resolveLanguage(ioHelper: IoHelper, template: InitTemplate, requestedLanguage?: string, type?: string): Promise { + return (async () => { + if (requestedLanguage) { + return requestedLanguage; + } + if (template.languages.length === 1) { + const templateLanguage = template.languages[0]; + // Only show auto-detection message for built-in templates + if (template.templateType !== TemplateType.CUSTOM) { + await ioHelper.defaults.warn( + `No --language was provided, but '${type || template.name}' supports only '${templateLanguage}', so defaulting to --language=${templateLanguage}`, + ); + } + return templateLanguage; + } + await ioHelper.defaults.info( + `Available languages for ${chalk.green(type || template.name)}: ${template.languages.map((l) => chalk.blue(l)).join(', ')}`, + ); + throw new ToolkitError('No language was selected'); + })(); +} + +/** + * Find potential template directories in a multi-template repository + * @param repositoryPath - Path to the repository root + * @returns Promise resolving to array of potential template directory names + */ +async function findPotentialTemplates(repositoryPath: string): Promise { + try { + const entries = await fs.readdir(repositoryPath, { withFileTypes: true }); + const potentialTemplates: string[] = []; + + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.')) { + const templatePath = path.join(repositoryPath, entry.name); + const languages = await getLanguageDirectories(templatePath); + if (languages.length > 0) { + potentialTemplates.push(entry.name); + } + } + } + + return potentialTemplates; + } catch (error: any) { + return []; + } +} + +/** + * Get valid CDK language directories from a template path + * @param templatePath - Path to the template directory + * @returns Promise resolving to array of supported language names + */ +async function getLanguageDirectories(templatePath: string): Promise { + const cdkSupportedLanguages = ['typescript', 'javascript', 'python', 'java', 'csharp', 'fsharp', 'go']; + const languageExtensions: Record = { + typescript: ['.ts', '.js'], + javascript: ['.js'], + python: ['.py'], + java: ['.java'], + csharp: ['.cs'], + fsharp: ['.fs'], + go: ['.go'], + }; + + try { + const entries = await fs.readdir(templatePath, { withFileTypes: true }); + + const languageValidationPromises = entries + .filter(directoryEntry => directoryEntry.isDirectory() && cdkSupportedLanguages.includes(directoryEntry.name)) + .map(async (directoryEntry) => { + const languageDirectoryPath = path.join(templatePath, directoryEntry.name); + try { + const hasValidLanguageFiles = await hasLanguageFiles(languageDirectoryPath, languageExtensions[directoryEntry.name]); + return hasValidLanguageFiles ? directoryEntry.name : null; + } catch (error: any) { + throw new ToolkitError(`Cannot read language directory '${directoryEntry.name}': ${error.message}`); + } + }); + + /* eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism */ // Limited to supported CDK languages (7 max) + const validationResults = await Promise.all(languageValidationPromises); + return validationResults.filter((languageName): languageName is string => languageName !== null); + } catch (error: any) { + throw new ToolkitError(`Cannot read template directory '${templatePath}': ${error.message}`); + } +} + +/** + * Iteratively check if a directory contains files with the specified extensions + * @param directoryPath - Path to search for language files + * @param extensions - Array of file extensions to look for + * @returns Promise resolving to true if language files are found + */ +async function hasLanguageFiles(directoryPath: string, extensions: string[]): Promise { + const dirsToCheck = [directoryPath]; + + while (dirsToCheck.length > 0) { + const currentDir = dirsToCheck.pop()!; + + try { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) { + return true; + } else if (entry.isDirectory()) { + dirsToCheck.push(path.join(currentDir, entry.name)); + } + } + } catch (error: any) { + throw error; + } + } + + return false; +} + /** * Returns the name of the Python executable for this OS + * @returns The Python executable name for the current platform */ function pythonExecutable() { let python = 'python3'; @@ -95,26 +311,55 @@ function pythonExecutable() { } const INFO_DOT_JSON = 'info.json'; +interface TemplateInitInfo { + readonly description: string; + readonly aliases?: string[]; +} + +enum TemplateType { + BUILT_IN = 'builtin', + CUSTOM = 'custom', +} + export class InitTemplate { public static async fromName(templatesDir: string, name: string) { const basePath = path.join(templatesDir, name); const languages = await listDirectory(basePath); const initInfo = await fs.readJson(path.join(basePath, INFO_DOT_JSON)); - return new InitTemplate(basePath, name, languages, initInfo); + return new InitTemplate(basePath, name, languages, initInfo, TemplateType.BUILT_IN); + } + + public static async fromPath(templatePath: string) { + const basePath = path.resolve(templatePath); + + if (!await fs.pathExists(basePath)) { + throw new ToolkitError(`Template path does not exist: ${basePath}`); + } + + const languages = await getLanguageDirectories(basePath); + const name = path.basename(basePath); + + return new InitTemplate(basePath, name, languages, null, TemplateType.CUSTOM); } - public readonly description: string; + public readonly description?: string; public readonly aliases = new Set(); + public readonly templateType: TemplateType; constructor( private readonly basePath: string, public readonly name: string, public readonly languages: string[], - initInfo: any, + initInfo: TemplateInitInfo | null, + templateType: TemplateType, ) { - this.description = initInfo.description; - for (const alias of initInfo.aliases || []) { - this.aliases.add(alias); + this.templateType = templateType; + // Only built-in templates have descriptions and aliases from info.json + if (templateType === TemplateType.BUILT_IN && initInfo) { + this.description = initInfo.description; + for (const alias of initInfo.aliases || []) { + this.aliases.add(alias); + } } } @@ -129,8 +374,12 @@ export class InitTemplate { /** * Creates a new instance of this ``InitTemplate`` for a given language to a specified folder. * - * @param language - the language to instantiate this template with + * @param language - the language to instantiate this template with * @param targetDirectory - the directory where the template is to be instantiated into + * @param stackName - the name of the stack to create + * @default undefined + * @param libVersion - the version of the CDK library to use + * @default undefined */ public async install(ioHelper: IoHelper, language: string, targetDirectory: string, stackName?: string, libVersion?: string) { if (this.languages.indexOf(language) === -1) { @@ -153,22 +402,30 @@ export class InitTemplate { const sourceDirectory = path.join(this.basePath, language); - await this.installFiles(sourceDirectory, targetDirectory, language, projectInfo); - await this.applyFutureFlags(targetDirectory); - await invokeBuiltinHooks( - ioHelper, - { targetDirectory, language, templateName: this.name }, - { - substitutePlaceholdersIn: async (...fileNames: string[]) => { - for (const fileName of fileNames) { - const fullPath = path.join(targetDirectory, fileName); - const template = await fs.readFile(fullPath, { encoding: 'utf-8' }); - await fs.writeFile(fullPath, expandPlaceholders(template, language, projectInfo)); - } + if (this.templateType === TemplateType.CUSTOM) { + // For custom templates, copy files without processing placeholders + await this.installFilesWithoutProcessing(sourceDirectory, targetDirectory); + } else { + // For built-in templates, process placeholders as usual + await this.installFiles(sourceDirectory, targetDirectory, language, projectInfo); + await this.applyFutureFlags(targetDirectory); + await invokeBuiltinHooks( + ioHelper, + { targetDirectory, language, templateName: this.name }, + { + substitutePlaceholdersIn: async (...fileNames: string[]) => { + const fileProcessingPromises = fileNames.map(async (fileName) => { + const fullPath = path.join(targetDirectory, fileName); + const template = await fs.readFile(fullPath, { encoding: 'utf-8' }); + await fs.writeFile(fullPath, expandPlaceholders(template, language, projectInfo)); + }); + /* eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism */ // Processing a small, known set of template files + await Promise.all(fileProcessingPromises); + }, + placeholder: (ph: string) => expandPlaceholders(`%${ph}%`, language, projectInfo), }, - placeholder: (ph: string) => expandPlaceholders(`%${ph}%`, language, projectInfo), - }, - ); + ); + } } private async installFiles(sourceDirectory: string, targetDirectory: string, language: string, project: ProjectInfo) { @@ -196,6 +453,18 @@ export class InitTemplate { await fs.writeFile(toFile, expandPlaceholders(template, language, project)); } + /** + * Copy template files without processing placeholders (for custom templates) + */ + private async installFilesWithoutProcessing(sourceDirectory: string, targetDirectory: string) { + await fs.copy(sourceDirectory, targetDirectory, { + filter: (src: string) => { + const filename = path.basename(src); + return !filename.match(/^.*\.hook\.(d.)?[^.]+$/); + }, + }); + } + /** * Adds context variables to `cdk.json` in the generated project directory to * enable future behavior for new projects. @@ -277,32 +546,33 @@ interface ProjectInfo { } export async function availableInitTemplates(): Promise { - return new Promise(async (resolve) => { - try { - const templatesDir = path.join(cliRootDir(), 'lib', 'init-templates'); - const templateNames = await listDirectory(templatesDir); - const templates = new Array(); - for (const templateName of templateNames) { - templates.push(await InitTemplate.fromName(templatesDir, templateName)); - } - resolve(templates); - } catch { - resolve([]); + try { + const templatesDir = path.join(cliRootDir(), 'lib', 'init-templates'); + const templateNames = await listDirectory(templatesDir); + const templatePromises = templateNames.map(templateName => + InitTemplate.fromName(templatesDir, templateName), + ); + /* eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism */ // Built-in templates are limited in number + return await Promise.all(templatePromises); + } catch (error: any) { + // Return empty array if templates directory doesn't exist or can't be read + // This allows the CLI to gracefully handle missing built-in templates + if (error.code === 'ENOENT' || error.code === 'EACCES') { + return []; } - }); + throw error; + } } export async function availableInitLanguages(): Promise { - return new Promise(async (resolve) => { - const templates = await availableInitTemplates(); - const result = new Set(); - for (const template of templates) { - for (const language of template.languages) { - result.add(language); - } + const templates = await availableInitTemplates(); + const result = new Set(); + for (const template of templates) { + for (const language of template.languages) { + result.add(language); } - resolve([...result]); - }); + } + return [...result]; } /** @@ -320,13 +590,19 @@ async function listDirectory(dirPath: string) { ); } +/** + * Print available templates to the user + * @param ioHelper - IO helper for user interaction + * @param language - Programming language filter + * @default undefined + */ export async function printAvailableTemplates(ioHelper: IoHelper, language?: string) { await ioHelper.defaults.info('Available templates:'); for (const template of await availableInitTemplates()) { if (language && template.languages.indexOf(language) === -1) { continue; } - await ioHelper.defaults.info(`* ${chalk.green(template.name)}: ${template.description}`); + await ioHelper.defaults.info(`* ${chalk.green(template.name)}: ${template.description!}`); const languageArg = language ? chalk.bold(language) : template.languages.length > 1 @@ -347,19 +623,27 @@ async function initializeProject( migrate?: boolean, cdkVersion?: string, ) { + // Step 1: Ensure target directory is empty await assertIsEmptyDirectory(workDir); + + // Step 2: Copy template files await ioHelper.defaults.info(`Applying project template ${chalk.green(template.name)} for ${chalk.blue(language)}`); await template.install(ioHelper, language, workDir, stackName, cdkVersion); + if (migrate) { await template.addMigrateContext(workDir); } + if (await fs.pathExists(`${workDir}/README.md`)) { const readme = await fs.readFile(`${workDir}/README.md`, { encoding: 'utf-8' }); await ioHelper.defaults.info(chalk.green(readme)); } if (!generateOnly) { + // Step 3: Initialize Git repository and create initial commit await initializeGitRepository(ioHelper, workDir); + + // Step 4: Post-install steps await postInstall(ioHelper, language, canUseNetwork, workDir); } @@ -367,9 +651,17 @@ async function initializeProject( } async function assertIsEmptyDirectory(workDir: string) { - const files = await fs.readdir(workDir); - if (files.filter((f) => !f.startsWith('.')).length !== 0) { - throw new ToolkitError('`cdk init` cannot be run in a non-empty directory!'); + try { + const files = await fs.readdir(workDir); + if (files.filter((f) => !f.startsWith('.')).length !== 0) { + throw new ToolkitError('`cdk init` cannot be run in a non-empty directory!'); + } + } catch (e: any) { + if (e.code === 'ENOENT') { + throw new ToolkitError(`Directory does not exist: ${workDir}. Please create the directory first.`); + } else { + throw e; + } } } @@ -399,6 +691,10 @@ async function postInstall(ioHelper: IoHelper, language: string, canUseNetwork: return postInstallPython(ioHelper, workDir); case 'go': return postInstallGo(ioHelper, canUseNetwork, workDir); + case 'csharp': + return postInstallCSharp(ioHelper, canUseNetwork, workDir); + case 'fsharp': + return postInstallFSharp(ioHelper, canUseNetwork, workDir); } } @@ -423,30 +719,66 @@ async function postInstallTypescript(ioHelper: IoHelper, canUseNetwork: boolean, } async function postInstallJava(ioHelper: IoHelper, canUseNetwork: boolean, cwd: string) { - const mvnPackageWarning = "Please run 'mvn package'!"; - if (!canUseNetwork) { - await ioHelper.defaults.warn(mvnPackageWarning); - return; - } + // Check if this is a Gradle or Maven project + const hasGradleBuild = await fs.pathExists(path.join(cwd, 'build.gradle')); + const hasMavenPom = await fs.pathExists(path.join(cwd, 'pom.xml')); + + if (hasGradleBuild) { + // Gradle project + const gradleWarning = "Please run './gradlew build'!"; + if (!canUseNetwork) { + await ioHelper.defaults.warn(gradleWarning); + return; + } - await ioHelper.defaults.info("Executing 'mvn package'"); - try { - await execute(ioHelper, 'mvn', ['package'], { cwd }); - } catch { - await ioHelper.defaults.warn('Unable to package compiled code as JAR'); - await ioHelper.defaults.warn(mvnPackageWarning); + await ioHelper.defaults.info("Executing './gradlew build'"); + try { + await execute(ioHelper, './gradlew', ['build'], { cwd }); + } catch { + await ioHelper.defaults.warn('Unable to build Gradle project'); + await ioHelper.defaults.warn(gradleWarning); + } + } else if (hasMavenPom) { + // Maven project + const mvnPackageWarning = "Please run 'mvn package'!"; + if (!canUseNetwork) { + await ioHelper.defaults.warn(mvnPackageWarning); + return; + } + + await ioHelper.defaults.info("Executing 'mvn package'"); + try { + await execute(ioHelper, 'mvn', ['package'], { cwd }); + } catch { + await ioHelper.defaults.warn('Unable to package compiled code as JAR'); + await ioHelper.defaults.warn(mvnPackageWarning); + } + } else { + // No recognized build file + await ioHelper.defaults.warn('No build.gradle or pom.xml found. Please set up your build system manually.'); } } async function postInstallPython(ioHelper: IoHelper, cwd: string) { const python = pythonExecutable(); - await ioHelper.defaults.warn(`Please run '${python} -m venv .venv'!`); - await ioHelper.defaults.info(`Executing ${chalk.green('Creating virtualenv...')}`); - try { - await execute(ioHelper, python, ['-m venv', '.venv'], { cwd }); - } catch { - await ioHelper.defaults.warn('Unable to create virtualenv automatically'); - await ioHelper.defaults.warn(`Please run '${python} -m venv .venv'!`); + + // Check if requirements.txt exists + const hasRequirements = await fs.pathExists(path.join(cwd, 'requirements.txt')); + + if (hasRequirements) { + await ioHelper.defaults.info(`Executing ${chalk.green('Creating virtualenv...')}`); + try { + await execute(ioHelper, python, ['-m', 'venv', '.venv'], { cwd }); + await ioHelper.defaults.info(`Executing ${chalk.green('Installing dependencies...')}`); + // Install dependencies in the virtual environment + const pipPath = process.platform === 'win32' ? '.venv\\Scripts\\pip' : '.venv/bin/pip'; + await execute(ioHelper, pipPath, ['install', '-r', 'requirements.txt'], { cwd }); + } catch { + await ioHelper.defaults.warn('Unable to create virtualenv or install dependencies automatically'); + await ioHelper.defaults.warn(`Please run '${python} -m venv .venv && .venv/bin/pip install -r requirements.txt'!`); + } + } else { + await ioHelper.defaults.warn('No requirements.txt found. Please set up your Python environment manually.'); } } @@ -464,6 +796,29 @@ async function postInstallGo(ioHelper: IoHelper, canUseNetwork: boolean, cwd: st } } +async function postInstallCSharp(ioHelper: IoHelper, canUseNetwork: boolean, cwd: string) { + const dotnetWarning = "Please run 'dotnet restore && dotnet build'!"; + if (!canUseNetwork) { + await ioHelper.defaults.warn(dotnetWarning); + return; + } + + await ioHelper.defaults.info(`Executing ${chalk.green('dotnet restore')}...`); + try { + await execute(ioHelper, 'dotnet', ['restore'], { cwd }); + await ioHelper.defaults.info(`Executing ${chalk.green('dotnet build')}...`); + await execute(ioHelper, 'dotnet', ['build'], { cwd }); + } catch (e: any) { + await ioHelper.defaults.warn('Unable to restore/build .NET project: ' + formatErrorMessage(e)); + await ioHelper.defaults.warn(dotnetWarning); + } +} + +async function postInstallFSharp(ioHelper: IoHelper, canUseNetwork: boolean, cwd: string) { + // F# uses the same build system as C# + return postInstallCSharp(ioHelper, canUseNetwork, cwd); +} + /** * @param dir - a directory to be checked * @returns true if ``dir`` is within a git repository. @@ -540,11 +895,9 @@ async function loadInitVersions(): Promise { 'aws-cdk': versionNumber(), }; for (const [key, value] of Object.entries(ret)) { - /* c8 ignore start */ if (!value) { throw new ToolkitError(`Missing init version from ${initVersionFile}: ${key}`); } - /* c8 ignore stop */ } return ret; diff --git a/packages/aws-cdk/test/_fixtures/init-templates/template-helpers.ts b/packages/aws-cdk/test/_fixtures/init-templates/template-helpers.ts new file mode 100644 index 000000000..0f680b7d2 --- /dev/null +++ b/packages/aws-cdk/test/_fixtures/init-templates/template-helpers.ts @@ -0,0 +1,82 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; + +export async function createSingleLanguageTemplate(baseDir: string, templateName: string, language: string): Promise { + const templateDir = path.join(baseDir, templateName); + const langDir = path.join(templateDir, language); + await fs.mkdirp(langDir); + + const fileContent = getLanguageFileContent(language); + const fileName = getLanguageFileName(language); + + await fs.writeFile(path.join(langDir, fileName), fileContent); + return templateDir; +} + +export async function createMultiLanguageTemplate(baseDir: string, templateName: string, languages: string[]): Promise { + const templateDir = path.join(baseDir, templateName); + + for (const language of languages) { + const langDir = path.join(templateDir, language); + await fs.mkdirp(langDir); + + const fileContent = getLanguageFileContent(language); + const fileName = getLanguageFileName(language); + + await fs.writeFile(path.join(langDir, fileName), fileContent); + } + + return templateDir; +} + +export async function createMultiTemplateRepository(baseDir: string, templates: Array<{ name: string; languages: string[] }>): Promise { + const repoDir = path.join(baseDir, 'template-repo'); + + for (const template of templates) { + await createMultiLanguageTemplate(repoDir, template.name, template.languages); + } + + return repoDir; +} + +function getLanguageFileContent(language: string): string { + switch (language) { + case 'typescript': + return 'console.log("TypeScript template");'; + case 'javascript': + return 'console.log("JavaScript template");'; + case 'python': + return 'print("Python template")'; + case 'java': + return 'public class App { }'; + case 'csharp': + return 'public class App { }'; + case 'fsharp': + return 'module App'; + case 'go': + return 'package main'; + default: + return `// ${language} template`; + } +} + +function getLanguageFileName(language: string): string { + switch (language) { + case 'typescript': + return 'app.ts'; + case 'javascript': + return 'app.js'; + case 'python': + return 'app.py'; + case 'java': + return 'App.java'; + case 'csharp': + return 'App.cs'; + case 'fsharp': + return 'App.fs'; + case 'go': + return 'app.go'; + default: + return 'app.txt'; + } +} diff --git a/packages/aws-cdk/test/commands/init.test.ts b/packages/aws-cdk/test/commands/init.test.ts index d30a823b3..247cad552 100644 --- a/packages/aws-cdk/test/commands/init.test.ts +++ b/packages/aws-cdk/test/commands/init.test.ts @@ -3,6 +3,7 @@ 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 { createSingleLanguageTemplate, createMultiLanguageTemplate, createMultiTemplateRepository } from '../_fixtures/init-templates/template-helpers'; import { TestIoHost } from '../_helpers/io-host'; const ioHost = new TestIoHost(); @@ -65,6 +66,24 @@ describe('constructs version', () => { })).rejects.toThrow(/No language/); }); + cliTest('cdk init --language defaults to app template with specified language', async (workDir) => { + await cliInit({ + ioHelper, + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir, + }); + + // Verify app template structure was created + expect(await fs.pathExists(path.join(workDir, 'package.json'))).toBeTruthy(); + expect(await fs.pathExists(path.join(workDir, 'bin'))).toBeTruthy(); + + // Verify it uses the specified language (TypeScript) + const binFiles = await fs.readdir(path.join(workDir, 'bin')); + expect(binFiles.some(file => file.endsWith('.ts'))).toBeTruthy(); + }); + cliTest('create a TypeScript app project', async (workDir) => { await cliInit({ ioHelper, @@ -254,6 +273,225 @@ describe('constructs version', () => { expect(await fs.pathExists(path.join(workDir, 'bin'))).toBeTruthy(); }); + cliTest('create project from single local custom template', async (workDir) => { + const templateDir = await createSingleLanguageTemplate(workDir, 'my-template', 'typescript'); + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await cliInit({ + ioHelper, + fromPath: templateDir, + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir: projectDir, + }); + + expect(await fs.pathExists(path.join(projectDir, 'app.ts'))).toBeTruthy(); + }); + + cliTest('single template auto-detects language when template has single language', async (workDir) => { + const templateDir = await createSingleLanguageTemplate(workDir, 'my-template', 'typescript'); + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await cliInit({ + ioHelper, + fromPath: templateDir, + canUseNetwork: false, + generateOnly: true, + workDir: projectDir, + }); + + expect(await fs.pathExists(path.join(projectDir, 'app.ts'))).toBeTruthy(); + }); + + cliTest('custom template with multiple languages fails if language not provided', async (workDir) => { + const templateDir = await createMultiLanguageTemplate(workDir, 'multi-lang-template', ['typescript', 'python']); + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await expect(cliInit({ + ioHelper, + fromPath: templateDir, + canUseNetwork: false, + generateOnly: true, + workDir: projectDir, + })).rejects.toThrow(/No language was selected/); + }); + + cliTest('custom template path does not exist throws error', async (workDir) => { + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await expect(cliInit({ + ioHelper, + fromPath: '/nonexistent/path', + language: 'typescript', + workDir: projectDir, + })).rejects.toThrow(/Template path does not exist/); + }); + + cliTest('create project from multi-template repository with template-path', async (workDir) => { + const repoDir = await createMultiTemplateRepository(workDir, [ + { name: 'my-custom-template', languages: ['typescript', 'python'] }, + { name: 'web-app-template', languages: ['java'] }, + ]); + + // Test 1: Initialize from my-custom-template with TypeScript + const projectDir1 = path.join(workDir, 'project1'); + await fs.mkdirp(projectDir1); + + await cliInit({ + ioHelper, + fromPath: repoDir, + templatePath: 'my-custom-template', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir: projectDir1, + }); + + expect(await fs.pathExists(path.join(projectDir1, 'app.ts'))).toBeTruthy(); + expect(await fs.pathExists(path.join(projectDir1, 'app.py'))).toBeFalsy(); + + // Test 2: Initialize from web-app-template with Java + const projectDir2 = path.join(workDir, 'project2'); + await fs.mkdirp(projectDir2); + + await cliInit({ + ioHelper, + fromPath: repoDir, + templatePath: 'web-app-template', + language: 'java', + canUseNetwork: false, + generateOnly: true, + workDir: projectDir2, + }); + + expect(await fs.pathExists(path.join(projectDir2, 'App.java'))).toBeTruthy(); + expect(await fs.pathExists(path.join(projectDir2, 'app.ts'))).toBeFalsy(); + }); + + cliTest('multi-template repository with non-existent template-path throws error', async (workDir) => { + const repoDir = await createMultiTemplateRepository(workDir, [ + { name: 'valid-template', languages: ['typescript'] }, + ]); + + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await expect(cliInit({ + ioHelper, + fromPath: repoDir, + templatePath: 'nonexistent-template', + language: 'typescript', + workDir: projectDir, + })).rejects.toThrow(/Template path does not exist/); + }); + + cliTest('template validation requires at least one language directory', async (workDir) => { + // Test that templates must contain at least one language subdirectory + const repoDir = path.join(workDir, 'cdk-templates'); + const invalidTemplateDir = path.join(repoDir, 'invalid-template'); + await fs.mkdirp(invalidTemplateDir); + // Create a file but no language directories + await fs.writeFile(path.join(invalidTemplateDir, 'README.md'), 'This template has no language directories'); + + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await expect(cliInit({ + ioHelper, + fromPath: repoDir, + templatePath: 'invalid-template', + language: 'typescript', + workDir: projectDir, + })).rejects.toThrow(/Custom template must contain at least one language directory/); + }); + + cliTest('template validation requires language files in language directory', async (workDir) => { + // Test that language directories must contain files of the matching language type + const repoDir = path.join(workDir, 'cdk-templates'); + const invalidTemplateDir = path.join(repoDir, 'empty-lang-template'); + const emptyTsDir = path.join(invalidTemplateDir, 'typescript'); + await fs.mkdirp(emptyTsDir); + // Create language directory but no files with matching extensions + await fs.writeFile(path.join(emptyTsDir, 'README.md'), 'No TypeScript files here'); + + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await expect(cliInit({ + ioHelper, + fromPath: repoDir, + templatePath: 'empty-lang-template', + language: 'typescript', + workDir: projectDir, + })).rejects.toThrow(/Custom template must contain at least one language directory/); + }); + + cliTest('multi-template repository auto-detects language when template has single language', async (workDir) => { + const repoDir = await createMultiTemplateRepository(workDir, [ + { name: 'single-lang-template', languages: ['typescript'] }, + ]); + + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await cliInit({ + ioHelper, + fromPath: repoDir, + templatePath: 'single-lang-template', + canUseNetwork: false, + generateOnly: true, + workDir: projectDir, + }); + + expect(await fs.pathExists(path.join(projectDir, 'app.ts'))).toBeTruthy(); + }); + + cliTest('multi-template repository supports all CDK languages', async (workDir) => { + const repoDir = await createMultiTemplateRepository(workDir, [ + { name: 'multi-lang-template', languages: ['typescript', 'javascript', 'python', 'java', 'csharp', 'fsharp', 'go'] }, + ]); + + // Test TypeScript selection + const tsProjectDir = path.join(workDir, 'ts-project'); + await fs.mkdirp(tsProjectDir); + + await cliInit({ + ioHelper, + fromPath: repoDir, + templatePath: 'multi-lang-template', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir: tsProjectDir, + }); + + expect(await fs.pathExists(path.join(tsProjectDir, 'app.ts'))).toBeTruthy(); + expect(await fs.pathExists(path.join(tsProjectDir, 'app.js'))).toBeFalsy(); + expect(await fs.pathExists(path.join(tsProjectDir, 'app.py'))).toBeFalsy(); + + // Test Python selection + const pyProjectDir = path.join(workDir, 'py-project'); + await fs.mkdirp(pyProjectDir); + + await cliInit({ + ioHelper, + fromPath: repoDir, + templatePath: 'multi-lang-template', + language: 'python', + canUseNetwork: false, + generateOnly: true, + workDir: pyProjectDir, + }); + + expect(await fs.pathExists(path.join(pyProjectDir, 'app.py'))).toBeTruthy(); + expect(await fs.pathExists(path.join(pyProjectDir, 'app.ts'))).toBeFalsy(); + }); + cliTest('CLI uses recommended feature flags from data file to initialize context', async (workDir) => { const recommendedFlagsFile = path.join(__dirname, '..', '..', 'lib', 'init-templates', '.recommended-feature-flags.json'); await withReplacedFile(recommendedFlagsFile, JSON.stringify({ banana: 'yellow' }), () => cliInit({ @@ -321,6 +559,161 @@ describe('constructs version', () => { }, // This is a lot to test, and it can be slow-ish, especially when ran with other tests. 30_000); + + cliTest('unstable flag functionality works correctly', async (workDir) => { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + const cdkBin = path.join(__dirname, '..', '..', 'bin', 'cdk'); + + const repoDir = await createMultiTemplateRepository(workDir, [ + { name: 'unstable-test', languages: ['typescript'] }, + ]); + const projectDir1 = path.join(workDir, 'project-without-unstable'); + const projectDir2 = path.join(workDir, 'project-with-unstable'); + await fs.mkdirp(projectDir1); + await fs.mkdirp(projectDir2); + + // Test that template-path fails WITHOUT --unstable=init flag + await expect(execAsync(`node ${cdkBin} init --from-path ${repoDir} --template-path unstable-test --language typescript --generate-only`, { + cwd: projectDir1, + env: { ...process.env, CDK_DISABLE_VERSION_CHECK: '1' }, + })).rejects.toThrow(); + + // Test that template-path succeeds WITH --unstable=init flag + const { stderr } = await execAsync(`node ${cdkBin} init --from-path ${repoDir} --template-path unstable-test --language typescript --unstable init --generate-only`, { + cwd: projectDir2, + env: { ...process.env, CDK_DISABLE_VERSION_CHECK: '1' }, + }); + + expect(stderr).not.toContain('error'); + expect(await fs.pathExists(path.join(projectDir2, 'app.ts'))).toBeTruthy(); + }); + + cliTest('conflict between lib-version and from-path is enforced', async (workDir) => { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + + const templateDir = await createSingleLanguageTemplate(workDir, 'conflict-test', 'typescript'); + + const cdkBin = path.join(__dirname, '..', '..', 'bin', 'cdk'); + + // Test that using both flags together causes an error + await expect(execAsync(`node ${cdkBin} init app --language typescript --lib-version 2.0.0 --from-path ${templateDir} --generate-only`, { + cwd: workDir, + env: { ...process.env, CDK_DISABLE_VERSION_CHECK: '1' }, + })).rejects.toThrow(); + }); + + cliTest('template-path implies from-path validation works', async (workDir) => { + // Test that the implication is properly configured + const { makeConfig } = await import('../../lib/cli/cli-config'); + const config = await makeConfig(); + expect(config.commands.init.implies).toEqual({ 'template-path': 'from-path' }); + + // Test that template-path functionality works when from-path is provided + const repoDir = await createMultiTemplateRepository(workDir, [ + { name: 'implies-test', languages: ['typescript'] }, + ]); + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await cliInit({ + ioHelper, + fromPath: repoDir, + templatePath: 'implies-test', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir: projectDir, + }); + + expect(await fs.pathExists(path.join(projectDir, 'app.ts'))).toBeTruthy(); + }); + + cliTest('hook files are ignored during template copy', async (workDir) => { + const templateDir = path.join(workDir, 'template-with-hooks'); + const tsDir = path.join(templateDir, 'typescript'); + await fs.mkdirp(tsDir); + + await fs.writeFile(path.join(tsDir, 'app.ts'), 'console.log("Hello CDK");'); + await fs.writeFile(path.join(tsDir, 'package.json'), '{}'); + await fs.writeFile(path.join(tsDir, 'setup.hook.js'), 'console.log("setup hook");'); + await fs.writeFile(path.join(tsDir, 'build.hook.d.ts'), 'export {};'); + await fs.writeFile(path.join(tsDir, 'deploy.hook.sh'), '#!/bin/bash\necho "deploy"'); + + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await cliInit({ + ioHelper, + fromPath: templateDir, + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir: projectDir, + }); + + expect(await fs.pathExists(path.join(projectDir, 'app.ts'))).toBeTruthy(); + expect(await fs.pathExists(path.join(projectDir, 'package.json'))).toBeTruthy(); + expect(await fs.pathExists(path.join(projectDir, 'setup.hook.js'))).toBeFalsy(); + expect(await fs.pathExists(path.join(projectDir, 'build.hook.d.ts'))).toBeFalsy(); + expect(await fs.pathExists(path.join(projectDir, 'deploy.hook.sh'))).toBeFalsy(); + }); + + cliTest('handles file permission failures gracefully', async (workDir) => { + const templateDir = await createSingleLanguageTemplate(workDir, 'permission-test-template', 'typescript'); + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await fs.chmod(projectDir, 0o444); + + try { + await expect(cliInit({ + ioHelper, + fromPath: templateDir, + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir: projectDir, + })).rejects.toThrow(); + } finally { + await fs.chmod(projectDir, 0o755); + } + }); + + cliTest('handles relative vs absolute paths correctly', async (workDir) => { + const templateDir = await createSingleLanguageTemplate(workDir, 'path-test-template', 'typescript'); + const projectDir = path.join(workDir, 'my-project'); + await fs.mkdirp(projectDir); + + await cliInit({ + ioHelper, + fromPath: path.resolve(templateDir), + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir: projectDir, + }); + + expect(await fs.pathExists(path.join(projectDir, 'app.ts'))).toBeTruthy(); + + await fs.remove(projectDir); + await fs.mkdirp(projectDir); + + const relativePath = path.relative(process.cwd(), templateDir); + await cliInit({ + ioHelper, + fromPath: relativePath, + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir: projectDir, + }); + + expect(await fs.pathExists(path.join(projectDir, 'app.ts'))).toBeTruthy(); + }); }); test('when no version number is present (e.g., local development), the v2 templates are chosen by default', async () => {