packages/aws-cdk/lib/commands/init/init.ts (415 lines of code) (raw):
import * as childProcess from 'child_process';
import * as path from 'path';
import * as chalk from 'chalk';
import * as fs from 'fs-extra';
import { invokeBuiltinHooks } from './init-hooks';
import { ToolkitError } from '../../../../@aws-cdk/toolkit-lib/lib/api';
import { cliRootDir } from '../../cli/root-dir';
import { versionNumber } from '../../cli/version';
import { error, info, warning } from '../../logging';
import { cdkHomeDir, formatErrorMessage, rangeFromSemver } from '../../util';
/* eslint-disable @typescript-eslint/no-var-requires */ // Packages don't have @types module
// eslint-disable-next-line @typescript-eslint/no-require-imports
const camelCase = require('camelcase');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const decamelize = require('decamelize');
export interface CliInitOptions {
readonly type?: string;
readonly language?: string;
readonly canUseNetwork?: boolean;
readonly generateOnly?: boolean;
readonly workDir?: string;
readonly stackName?: string;
readonly migrate?: boolean;
/**
* Override the built-in CDK version
*/
readonly libVersion?: string;
}
/**
* Initialize a CDK package in the current directory
*/
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) {
await printAvailableTemplates();
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(options.language);
throw new ToolkitError(`Unknown init template: ${type}`);
}
if (!options.language && template.languages.length === 1) {
const language = template.languages[0];
warning(
`No --language was provided, but '${type}' supports only '${language}', so defaulting to --language=${language}`,
);
}
if (!options.language) {
info(`Available languages for ${chalk.green(type)}: ${template.languages.map((l) => chalk.blue(l)).join(', ')}`);
throw new ToolkitError('No language was selected');
}
await initializeProject(
template,
options.language,
canUseNetwork,
generateOnly,
workDir,
options.stackName,
options.migrate,
options.libVersion,
);
}
/**
* Returns the name of the Python executable for this OS
*/
function pythonExecutable() {
let python = 'python3';
if (process.platform === 'win32') {
python = 'python';
}
return python;
}
const INFO_DOT_JSON = 'info.json';
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);
}
public readonly description: string;
public readonly aliases = new Set<string>();
constructor(
private readonly basePath: string,
public readonly name: string,
public readonly languages: string[],
initInfo: any,
) {
this.description = initInfo.description;
for (const alias of initInfo.aliases || []) {
this.aliases.add(alias);
}
}
/**
* @param name the name that is being checked
* @returns ``true`` if ``name`` is the name of this template or an alias of it.
*/
public hasName(name: string): boolean {
return name === this.name || this.aliases.has(name);
}
/**
* 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 targetDirectory the directory where the template is to be instantiated into
*/
public async install(language: string, targetDirectory: string, stackName?: string, libVersion?: string) {
if (this.languages.indexOf(language) === -1) {
error(
`The ${chalk.blue(language)} language is not supported for ${chalk.green(this.name)} ` +
`(it supports: ${this.languages.map((l) => chalk.blue(l)).join(', ')})`,
);
throw new ToolkitError(`Unsupported language: ${language}`);
}
const projectInfo: ProjectInfo = {
name: decamelize(path.basename(path.resolve(targetDirectory))),
stackName,
versions: await loadInitVersions(),
};
if (libVersion) {
projectInfo.versions['aws-cdk-lib'] = libVersion;
}
const sourceDirectory = path.join(this.basePath, language);
await this.installFiles(sourceDirectory, targetDirectory, language, projectInfo);
await this.applyFutureFlags(targetDirectory);
await invokeBuiltinHooks(
{ 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));
}
},
placeholder: (ph: string) => expandPlaceholders(`%${ph}%`, language, projectInfo),
},
);
}
private async installFiles(sourceDirectory: string, targetDirectory: string, language: string, project: ProjectInfo) {
for (const file of await fs.readdir(sourceDirectory)) {
const fromFile = path.join(sourceDirectory, file);
const toFile = path.join(targetDirectory, expandPlaceholders(file, language, project));
if ((await fs.stat(fromFile)).isDirectory()) {
await fs.mkdir(toFile);
await this.installFiles(fromFile, toFile, language, project);
continue;
} else if (file.match(/^.*\.template\.[^.]+$/)) {
await this.installProcessed(fromFile, toFile.replace(/\.template(\.[^.]+)$/, '$1'), language, project);
continue;
} else if (file.match(/^.*\.hook\.(d.)?[^.]+$/)) {
// Ignore
continue;
} else {
await fs.copy(fromFile, toFile);
}
}
}
private async installProcessed(templatePath: string, toFile: string, language: string, project: ProjectInfo) {
const template = await fs.readFile(templatePath, { encoding: 'utf-8' });
await fs.writeFile(toFile, expandPlaceholders(template, language, project));
}
/**
* Adds context variables to `cdk.json` in the generated project directory to
* enable future behavior for new projects.
*/
private async applyFutureFlags(projectDir: string) {
const cdkJson = path.join(projectDir, 'cdk.json');
if (!(await fs.pathExists(cdkJson))) {
return;
}
const config = await fs.readJson(cdkJson);
config.context = {
...config.context,
...await currentlyRecommendedAwsCdkLibFlags(),
};
await fs.writeJson(cdkJson, config, { spaces: 2 });
}
public async addMigrateContext(projectDir: string) {
const cdkJson = path.join(projectDir, 'cdk.json');
if (!(await fs.pathExists(cdkJson))) {
return;
}
const config = await fs.readJson(cdkJson);
config.context = {
...config.context,
'cdk-migrate': true,
};
await fs.writeJson(cdkJson, config, { spaces: 2 });
}
}
export function expandPlaceholders(template: string, language: string, project: ProjectInfo) {
const cdkVersion = project.versions['aws-cdk-lib'];
const cdkCliVersion = project.versions['aws-cdk'];
let constructsVersion = project.versions.constructs;
switch (language) {
case 'java':
case 'csharp':
case 'fsharp':
constructsVersion = rangeFromSemver(constructsVersion, 'bracket');
break;
case 'python':
constructsVersion = rangeFromSemver(constructsVersion, 'pep');
break;
}
return template
.replace(/%name%/g, project.name)
.replace(/%stackname%/, project.stackName ?? '%name.PascalCased%Stack')
.replace(
/%PascalNameSpace%/,
project.stackName ? camelCase(project.stackName + 'Stack', { pascalCase: true }) : '%name.PascalCased%',
)
.replace(
/%PascalStackProps%/,
project.stackName ? camelCase(project.stackName, { pascalCase: true }) + 'StackProps' : 'StackProps',
)
.replace(/%name\.camelCased%/g, camelCase(project.name))
.replace(/%name\.PascalCased%/g, camelCase(project.name, { pascalCase: true }))
.replace(/%cdk-version%/g, cdkVersion)
.replace(/%cdk-cli-version%/g, cdkCliVersion)
.replace(/%constructs-version%/g, constructsVersion)
.replace(/%cdk-home%/g, cdkHomeDir())
.replace(/%name\.PythonModule%/g, project.name.replace(/-/g, '_'))
.replace(/%python-executable%/g, pythonExecutable())
.replace(/%name\.StackName%/g, project.name.replace(/[^A-Za-z0-9-]/g, '-'));
}
interface ProjectInfo {
/** The value used for %name% */
readonly name: string;
readonly stackName?: string;
readonly versions: Versions;
}
export async function availableInitTemplates(): Promise<InitTemplate[]> {
return new Promise(async (resolve) => {
try {
const templatesDir = path.join(cliRootDir(), 'lib', 'init-templates');
const templateNames = await listDirectory(templatesDir);
const templates = new Array<InitTemplate>();
for (const templateName of templateNames) {
templates.push(await InitTemplate.fromName(templatesDir, templateName));
}
resolve(templates);
} catch {
resolve([]);
}
});
}
export async function availableInitLanguages(): Promise<string[]> {
return new Promise(async (resolve) => {
const templates = await availableInitTemplates();
const result = new Set<string>();
for (const template of templates) {
for (const language of template.languages) {
result.add(language);
}
}
resolve([...result]);
});
}
/**
* @param dirPath is the directory to be listed.
* @returns the list of file or directory names contained in ``dirPath``, excluding any dot-file, and sorted.
*/
async function listDirectory(dirPath: string) {
return (
(await fs.readdir(dirPath))
.filter((p) => !p.startsWith('.'))
.filter((p) => !(p === 'LICENSE'))
// if, for some reason, the temp folder for the hook doesn't get deleted we don't want to display it in this list
.filter((p) => !(p === INFO_DOT_JSON))
.sort()
);
}
export async function printAvailableTemplates(language?: string) {
info('Available templates:');
for (const template of await availableInitTemplates()) {
if (language && template.languages.indexOf(language) === -1) {
continue;
}
info(`* ${chalk.green(template.name)}: ${template.description}`);
const languageArg = language
? chalk.bold(language)
: template.languages.length > 1
? `[${template.languages.map((t) => chalk.bold(t)).join('|')}]`
: chalk.bold(template.languages[0]);
info(` └─ ${chalk.blue(`cdk init ${chalk.bold(template.name)} --language=${languageArg}`)}`);
}
}
async function initializeProject(
template: InitTemplate,
language: string,
canUseNetwork: boolean,
generateOnly: boolean,
workDir: string,
stackName?: string,
migrate?: boolean,
cdkVersion?: string,
) {
await assertIsEmptyDirectory(workDir);
info(`Applying project template ${chalk.green(template.name)} for ${chalk.blue(language)}`);
await template.install(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' });
info(chalk.green(readme));
}
if (!generateOnly) {
await initializeGitRepository(workDir);
await postInstall(language, canUseNetwork, workDir);
}
info('✅ All done!');
}
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!');
}
}
async function initializeGitRepository(workDir: string) {
if (await isInGitRepository(workDir)) {
return;
}
info('Initializing a new git repository...');
try {
await execute('git', ['init'], { cwd: workDir });
await execute('git', ['add', '.'], { cwd: workDir });
await execute('git', ['commit', '--message="Initial commit"', '--no-gpg-sign'], { cwd: workDir });
} catch {
warning('Unable to initialize git repository for your project.');
}
}
async function postInstall(language: string, canUseNetwork: boolean, workDir: string) {
switch (language) {
case 'javascript':
return postInstallJavascript(canUseNetwork, workDir);
case 'typescript':
return postInstallTypescript(canUseNetwork, workDir);
case 'java':
return postInstallJava(canUseNetwork, workDir);
case 'python':
return postInstallPython(workDir);
}
}
async function postInstallJavascript(canUseNetwork: boolean, cwd: string) {
return postInstallTypescript(canUseNetwork, cwd);
}
async function postInstallTypescript(canUseNetwork: boolean, cwd: string) {
const command = 'npm';
if (!canUseNetwork) {
warning(`Please run '${command} install'!`);
return;
}
info(`Executing ${chalk.green(`${command} install`)}...`);
try {
await execute(command, ['install'], { cwd });
} catch (e: any) {
warning(`${command} install failed: ` + formatErrorMessage(e));
}
}
async function postInstallJava(canUseNetwork: boolean, cwd: string) {
const mvnPackageWarning = "Please run 'mvn package'!";
if (!canUseNetwork) {
warning(mvnPackageWarning);
return;
}
info("Executing 'mvn package'");
try {
await execute('mvn', ['package'], { cwd });
} catch {
warning('Unable to package compiled code as JAR');
warning(mvnPackageWarning);
}
}
async function postInstallPython(cwd: string) {
const python = pythonExecutable();
warning(`Please run '${python} -m venv .venv'!`);
info(`Executing ${chalk.green('Creating virtualenv...')}`);
try {
await execute(python, ['-m venv', '.venv'], { cwd });
} catch {
warning('Unable to create virtualenv automatically');
warning(`Please run '${python} -m venv .venv'!`);
}
}
/**
* @param dir a directory to be checked
* @returns true if ``dir`` is within a git repository.
*/
async function isInGitRepository(dir: string) {
while (true) {
if (await fs.pathExists(path.join(dir, '.git'))) {
return true;
}
if (isRoot(dir)) {
return false;
}
dir = path.dirname(dir);
}
}
/**
* @param dir a directory to be checked.
* @returns true if ``dir`` is the root of a filesystem.
*/
function isRoot(dir: string) {
return path.dirname(dir) === dir;
}
/**
* Executes `command`. STDERR is emitted in real-time.
*
* If command exits with non-zero exit code, an exceprion is thrown and includes
* the contents of STDOUT.
*
* @returns STDOUT (if successful).
*/
async function execute(cmd: string, args: string[], { cwd }: { cwd: string }) {
const child = childProcess.spawn(cmd, args, {
cwd,
shell: true,
stdio: ['ignore', 'pipe', 'inherit'],
});
let stdout = '';
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
return new Promise<string>((ok, fail) => {
child.once('error', (err) => fail(err));
child.once('exit', (status) => {
if (status === 0) {
return ok(stdout);
} else {
error(stdout);
return fail(new ToolkitError(`${cmd} exited with status ${status}`));
}
});
});
}
interface Versions {
['aws-cdk']: string;
['aws-cdk-lib']: string;
constructs: string;
}
/**
* Return the 'aws-cdk-lib' version we will init
*
* This has been built into the CLI at build time.
*/
async function loadInitVersions(): Promise<Versions> {
const initVersionFile = path.join(cliRootDir(), 'lib', 'init-templates', '.init-version.json');
const contents = JSON.parse(await fs.readFile(initVersionFile, { encoding: 'utf-8' }));
const ret = {
'aws-cdk-lib': contents['aws-cdk-lib'],
'constructs': contents.constructs,
'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;
}
/**
* Return the currently recommended flags for `aws-cdk-lib`.
*
* These have been built into the CLI at build time.
*/
export async function currentlyRecommendedAwsCdkLibFlags() {
const recommendedFlagsFile = path.join(cliRootDir(), 'lib', 'init-templates', '.recommended-feature-flags.json');
return JSON.parse(await fs.readFile(recommendedFlagsFile, { encoding: 'utf-8' }));
}