diff --git a/packages/@aws-cdk/cloudformation-diff/lib/format-table.ts b/packages/@aws-cdk/cloudformation-diff/lib/format-table.ts index 3cb423655..f73612921 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/format-table.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/format-table.ts @@ -7,12 +7,15 @@ import * as table from 'table'; * * First row is considered the table header. */ -export function formatTable(cells: string[][], columns: number | undefined): string { +export function formatTable(cells: string[][], columns: number | undefined, noHorizontalLines?: boolean): string { return table.table(cells, { border: TABLE_BORDER_CHARACTERS, columns: buildColumnConfig(columns !== undefined ? calculateColumnWidths(cells, columns) : undefined), drawHorizontalLine: (line) => { // Numbering like this: [line 0] [header = row[0]] [line 1] [row 1] [line 2] [content 2] [line 3] + if (noHorizontalLines) { + return line < 2 || line === cells.length; + } return (line < 2 || line === cells.length) || lineBetween(cells[line - 1], cells[line]); }, }).trimRight(); diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index c2ec51e86..5cab7ddc4 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -1276,29 +1276,46 @@ export class Toolkit extends CloudAssemblySourceBuilder { /** * Retrieve feature flag information from the cloud assembly */ - public async flags(cx: ICloudAssemblySource): Promise { this.requireUnstableFeature('flags'); const ioHelper = asIoHelper(this.ioHost, 'flags'); await using assembly = await assemblyFromSource(ioHelper, cx); - const artifacts = assembly.cloudAssembly.manifest.artifacts; + const artifacts = Object.values(assembly.cloudAssembly.manifest.artifacts ?? {}); + const featureFlagReports = artifacts.filter(a => a.type === ArtifactType.FEATURE_FLAG_REPORT); + + const flags = featureFlagReports.flatMap(report => { + const properties = report.properties as FeatureFlagReportProperties; + const moduleName = properties.module; + + const flagsWithUnconfiguredBehavesLike = Object.entries(properties.flags) + .filter(([_, flagInfo]) => flagInfo.unconfiguredBehavesLike != undefined); - return Object.values(artifacts!) - .filter(a => a.type === ArtifactType.FEATURE_FLAG_REPORT) - .flatMap(report => { - const properties = report.properties as FeatureFlagReportProperties; - const moduleName = properties.module; + const shouldIncludeUnconfiguredBehavesLike = flagsWithUnconfiguredBehavesLike.length > 0; - return Object.entries(properties.flags).map(([flagName, flagInfo]) => ({ + return Object.entries(properties.flags).map(([flagName, flagInfo]) => { + const baseFlag = { module: moduleName, name: flagName, recommendedValue: flagInfo.recommendedValue, userValue: flagInfo.userValue ?? undefined, explanation: flagInfo.explanation ?? '', - unconfiguredBehavesLike: flagInfo.unconfiguredBehavesLike, - })); + }; + + if (shouldIncludeUnconfiguredBehavesLike) { + return { + ...baseFlag, + unconfiguredBehavesLike: { + v2: flagInfo.unconfiguredBehavesLike?.v2 ?? false, + }, + }; + } + + return baseFlag; }); + }); + + return flags; } private requireUnstableFeature(requestedFeature: UnstableFeature) { 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/flag-operations.ts b/packages/aws-cdk/lib/commands/flag-operations.ts index ede63d990..334be5740 100644 --- a/packages/aws-cdk/lib/commands/flag-operations.ts +++ b/packages/aws-cdk/lib/commands/flag-operations.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import { formatTable } from '@aws-cdk/cloudformation-diff'; import type { FeatureFlag, Toolkit } from '@aws-cdk/toolkit-lib'; import { CdkAppMultiContext, MemoryContext, DiffMethod } from '@aws-cdk/toolkit-lib'; import * as chalk from 'chalk'; @@ -92,7 +93,7 @@ export async function handleFlags(flagData: FeatureFlag[], ioHelper: IoHelper, o default: true, unconfigured: true, }; - await setMultipleFlags(params); + await setMultipleFlagsIfSupported(params); } else if (answer == FlagsMenuOptions.MODIFY_SPECIFIC_FLAG) { await setFlag(params, true); } else if (answer == FlagsMenuOptions.EXIT) { @@ -172,8 +173,7 @@ export async function handleFlags(flagData: FeatureFlag[], ioHelper: IoHelper, o } if (options.set && options.all && options.default) { - await setMultipleFlags(params); - return; + await setMultipleFlagsIfSupported(params); } if (options.set && options.unconfigured && options.recommended) { @@ -182,9 +182,20 @@ export async function handleFlags(flagData: FeatureFlag[], ioHelper: IoHelper, o } if (options.set && options.unconfigured && options.default) { + await setMultipleFlagsIfSupported(params); + } +} + +/** + * Sets flag configurations to default values if `unconfiguredBehavesLike` is populated + */ +async function setMultipleFlagsIfSupported(params: FlagOperationsParams) { + const { flagData, ioHelper } = params; + if (flagData[0].unconfiguredBehavesLike) { await setMultipleFlags(params); return; } + await 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.'); } async function setFlag(params: FlagOperationsParams, interactive?: boolean) { @@ -378,38 +389,6 @@ async function modifyValues(params: FlagOperationsParams, flagNames: string[]): await fs.writeFile(cdkJsonPath, JSON.stringify(cdkJson, null, 2), 'utf-8'); } -function formatTable(headers: string[], rows: string[][]): string { - const columnWidths = [ - Math.max(headers[0].length, ...rows.map(row => row[0].length)), - Math.max(headers[1].length, ...rows.map(row => row[1].length)), - Math.max(headers[2].length, ...rows.map(row => row[2].length)), - ]; - - const createSeparator = () => { - return '+' + columnWidths.map(width => '-'.repeat(width + 2)).join('+') + '+'; - }; - - const formatRow = (values: string[]) => { - return '|' + values.map((value, i) => ` ${value.padEnd(columnWidths[i])} `).join('|') + '|'; - }; - - const separator = createSeparator(); - let table = separator + '\n'; - table += formatRow(headers) + '\n'; - table += separator + '\n'; - - rows.forEach(row => { - if (row[1] === '' && row[2] === '') { - table += ` ${row[0].padEnd(columnWidths[0])} \n`; - } else { - table += formatRow(row) + '\n'; - } - }); - - table += separator; - return table; -} - function getFlagSortOrder(flag: FeatureFlag): number { if (flag.userValue === undefined) { return 3; @@ -421,9 +400,9 @@ function getFlagSortOrder(flag: FeatureFlag): number { } async function displayFlagTable(flags: FeatureFlag[], ioHelper: IoHelper): Promise { - const headers = ['Feature Flag Name', 'Recommended Value', 'User Value']; + const filteredFlags = flags.filter(flag => flag.unconfiguredBehavesLike?.v2 !== flag.recommendedValue); - const sortedFlags = [...flags].sort((a, b) => { + const sortedFlags = [...filteredFlags].sort((a, b) => { const orderA = getFlagSortOrder(a); const orderB = getFlagSortOrder(b); @@ -437,6 +416,7 @@ async function displayFlagTable(flags: FeatureFlag[], ioHelper: IoHelper): Promi }); const rows: string[][] = []; + rows.push(['Feature Flag Name', 'Recommended Value', 'User Value']); let currentModule = ''; sortedFlags.forEach((flag) => { @@ -445,13 +425,13 @@ async function displayFlagTable(flags: FeatureFlag[], ioHelper: IoHelper): Promi currentModule = flag.module; } rows.push([ - flag.name, + ` ${flag.name}`, String(flag.recommendedValue), flag.userValue === undefined ? '' : String(flag.userValue), ]); }); - const formattedTable = formatTable(headers, rows); + const formattedTable = formatTable(rows, undefined, true); await ioHelper.defaults.info(formattedTable); } 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/flag-operations.test.ts b/packages/aws-cdk/test/commands/flag-operations.test.ts index 6de073ef1..01c4f1fc6 100644 --- a/packages/aws-cdk/test/commands/flag-operations.test.ts +++ b/packages/aws-cdk/test/commands/flag-operations.test.ts @@ -42,6 +42,14 @@ const mockFlagsData: FeatureFlag[] = [ 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 { @@ -121,8 +129,8 @@ describe('displayFlags', () => { await displayFlags(params); 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 () => { @@ -181,6 +189,19 @@ describe('displayFlags', () => { expect(plainTextOutput).toContain('different-module'); }); + test('does not display flag when unconfigured behavior is the same as recommended behavior', async () => { + const params = { + flagData: mockFlagsData, + toolkit: mockToolkit, + ioHelper, + all: true, + }; + await displayFlags(params); + + const plainTextOutput = output(); + expect(plainTextOutput).not.toContain(' @aws-cdk/core:anotherMatchingFlag'); + }); + test('displays single flag details when only one substring match is found', async () => { const params = { flagData: mockFlagsData, @@ -221,9 +242,10 @@ describe('displayFlags', () => { await displayFlags(params); 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).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'); }); test('returns all matching flags if user enters multiple substrings', async () => { @@ -236,9 +258,10 @@ describe('displayFlags', () => { await displayFlags(params); 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).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'); }); }); @@ -264,8 +287,8 @@ describe('handleFlags', () => { await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); 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('displays only differing flags when no specific options are provided', async () => { @@ -275,9 +298,9 @@ describe('handleFlags', () => { await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); const plainTextOutput = output(); - expect(plainTextOutput).toContain('@aws-cdk/core:testFlag'); - expect(plainTextOutput).toContain('@aws-cdk/s3:anotherFlag'); - expect(plainTextOutput).not.toContain('@aws-cdk/core:matchingFlag'); + expect(plainTextOutput).toContain(' @aws-cdk/core:testFlag'); + expect(plainTextOutput).toContain(' @aws-cdk/s3:anotherFlag'); + expect(plainTextOutput).not.toContain(' @aws-cdk/core:matchingFlag'); }); test('handles flag not found for specific flag query', async () => { @@ -560,6 +583,65 @@ describe('modifyValues', () => { }); }); +describe('checkDefaultBehavior', () => { + test('calls setMultipleFlags when unconfiguredBehavesLike is present', async () => { + const flagsWithUnconfiguredBehavior: FeatureFlag[] = [ + { + module: 'aws-cdk-lib', + name: '@aws-cdk/core:testFlag', + recommendedValue: 'true', + userValue: undefined, + explanation: 'Test flag', + unconfiguredBehavesLike: { v2: 'true' }, + }, + ]; + + const cdkJsonPath = await createCdkJsonFile({}); + setupMockToolkitForPrototyping(mockToolkit); + + const requestResponseSpy = jest.spyOn(ioHelper, 'requestResponse'); + requestResponseSpy.mockResolvedValue(true); + + const options: FlagsOptions = { + set: true, + all: true, + default: true, + }; + + await handleFlags(flagsWithUnconfiguredBehavior, ioHelper, options, mockToolkit); + + expect(mockToolkit.fromCdkApp).toHaveBeenCalled(); + expect(mockToolkit.synth).toHaveBeenCalled(); + + await cleanupCdkJsonFile(cdkJsonPath); + requestResponseSpy.mockRestore(); + }); + + test('shows error when unconfiguredBehavesLike is not present', async () => { + const flagsWithoutUnconfiguredBehavior: FeatureFlag[] = [ + { + module: 'aws-cdk-lib', + name: '@aws-cdk/core:testFlag', + recommendedValue: 'true', + userValue: undefined, + explanation: 'Test flag', + }, + ]; + + const options: FlagsOptions = { + set: true, + all: true, + default: true, + }; + + await handleFlags(flagsWithoutUnconfiguredBehavior, ioHelper, options, mockToolkit); + + const plainTextOutput = output(); + expect(plainTextOutput).toContain('The --default options are not compatible with the AWS CDK library used by your application.'); + expect(mockToolkit.fromCdkApp).not.toHaveBeenCalled(); + }); +}); + describe('interactive prompts lead to the correct function calls', () => { beforeEach(() => { setupMockToolkitForPrototyping(mockToolkit); @@ -642,11 +724,30 @@ describe('interactive prompts lead to the correct function calls', () => { const requestResponseSpy = jest.spyOn(ioHelper, 'requestResponse'); requestResponseSpy.mockResolvedValue(true); + const flagsWithUnconfiguredBehavior: FeatureFlag[] = [ + { + module: 'aws-cdk-lib', + name: '@aws-cdk/core:testFlag', + recommendedValue: 'true', + userValue: 'false', + explanation: 'Test flag for unit tests', + unconfiguredBehavesLike: { v2: 'true' }, + }, + { + module: 'aws-cdk-lib', + name: '@aws-cdk/s3:anotherFlag', + recommendedValue: 'false', + userValue: undefined, + explanation: 'Another test flag', + unconfiguredBehavesLike: { v2: 'false' }, + }, + ]; + const options: FlagsOptions = { interactive: true, }; - await handleFlags(mockFlagsData, ioHelper, options, mockToolkit); + await handleFlags(flagsWithUnconfiguredBehavior, ioHelper, options, mockToolkit); expect(mockToolkit.fromCdkApp).toHaveBeenCalledTimes(2); expect(mockToolkit.synth).toHaveBeenCalledTimes(2); 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 () => {