packages/ros-cdk-cli/lib/init.ts (320 lines of code) (raw):

import * as cxapi from '@alicloud/ros-cdk-cxapi'; import * as childProcess from 'child_process'; import * as colors from 'colors/safe'; import * as fs from 'fs-extra'; import * as path from 'path'; import { error, print, warning } from './logging'; import { cdkHomeDir } from './util/directories'; import { writeAndUpdateLanguageInfo } from './cdk-toolkit' export type InvokeHook = (targetDirectory: string) => Promise<void>; // tslint:disable:no-var-requires those libraries don't have up-to-date @types modules // 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'); // tslint:enable:no-var-requires const TEMPLATES_DIR = path.join(__dirname, 'init-templates'); /** * Initialize a CDK package in the current directory */ export async function cliInit( appName?: string, language?: string, canUseNetwork = true, generateOnly = false, workDir = process.cwd(), ) { if (!appName && !language) { await printAvailableTemplates(); return; } appName = appName || 'default'; // "default" is the default appName (and maps to "app") const template = (await availableInitTemplates).find((t) => t.hasName(appName!)); if (!template) { await printAvailableTemplates(language); throw new Error(`Unknown init template: ${appName}`); } if (!language && template.languages.length === 1) { language = template.languages[0]; warning( `No --language was provided, but '${appName}' supports only '${language}', so defaulting to --language=${language}`, ); } if (!language) { print( `Available languages for ${colors.green(appName)}: ${template.languages.map((l) => colors.blue(l)).join(', ')}`, ); throw new Error('No language was selected'); } await initializeProject(template, language, canUseNetwork, generateOnly, workDir); writeAndUpdateLanguageInfo(language); } /** * 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(name: string) { const basePath = path.join(TEMPLATES_DIR, name); const languages = (await listDirectory(basePath)).filter((f) => f !== INFO_DOT_JSON); const info = await fs.readJson(path.join(basePath, INFO_DOT_JSON)); return new InitTemplate(basePath, name, languages, info); } public readonly description: string; public readonly aliases = new Set<string>(); constructor( private readonly basePath: string, public readonly name: string, public readonly languages: string[], info: any, ) { this.description = info.description; for (const alias of info.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) { if (this.languages.indexOf(language) === -1) { error( `The ${colors.blue(language)} language is not supported for ${colors.green(this.name)} ` + `(it supports: ${this.languages.map((l) => colors.blue(l)).join(', ')})`, ); throw new Error(`Unsupported language: ${language}`); } const sourceDirectory = path.join(this.basePath, language); const hookTempDirectory = path.join(targetDirectory, 'tmp'); await fs.mkdir(hookTempDirectory); await this.installFiles(sourceDirectory, targetDirectory, { name: decamelize(path.basename(path.resolve(targetDirectory))), }); await this.applyFutureFlags(targetDirectory); await this.invokeHooks(hookTempDirectory, targetDirectory); await fs.remove(hookTempDirectory); } private async installFiles(sourceDirectory: string, targetDirectory: string, project: ProjectInfo) { for (const file of await fs.readdir(sourceDirectory)) { const fromFile = path.join(sourceDirectory, file); const toFile = path.join(targetDirectory, this.expand(unescape(file), project)); if ((await fs.stat(fromFile)).isDirectory()) { await fs.mkdir(toFile); await this.installFiles(fromFile, toFile, project); continue; } else if (file.match(/^.*\.template\.[^.]+$/)) { await this.installProcessed(fromFile, toFile.replace(/\.template(\.[^.]+)$/, '$1'), project); continue; } else if (file.match(/^.*\.hook\.(d.)?[^.]+$/)) { await this.installProcessed(fromFile, path.join(targetDirectory, 'tmp', file), project); continue; } else if (file.endsWith('.template')) { await this.installProcessed(fromFile, toFile.substring(0, toFile.length - 9), project); continue; } else { await fs.copy(fromFile, toFile); } } } /** * @summary Invoke any javascript hooks that exist in the template. * @description Sometimes templates need more complex logic than just replacing tokens. A 'hook' is * any file that ends in .hook.js. It should export a single function called "invoke" * that accepts a single string parameter. When the template is installed, each hook * will be invoked, passing the target directory as the only argument. Hooks are invoked * in lexical order. */ private async invokeHooks(sourceDirectory: string, targetDirectory: string) { const files = await fs.readdir(sourceDirectory); files.sort(); // Sorting allows template authors to control the order in which hooks are invoked. for (const file of files) { if (file.match(/^.*\.hook\.js$/)) { // eslint-disable-next-line @typescript-eslint/no-require-imports const invoke: InvokeHook = require(path.join(sourceDirectory, file)).invoke; await invoke(targetDirectory); const invokeUnitTest: InvokeHook = require(path.join(sourceDirectory, file)).invokeUnitTest; await invokeUnitTest(targetDirectory); } } } private async installProcessed(templatePath: string, toFile: string, project: ProjectInfo) { const template = await fs.readFile(templatePath, { encoding: 'utf-8' }); await fs.writeFile(toFile, this.expand(template, project)); } private expand(template: string, project: ProjectInfo) { const MATCH_VER_BUILD = /\+[a-f0-9]+$/; // Matches "+BUILD" in "x.y.z-beta+BUILD" // eslint-disable-next-line @typescript-eslint/no-require-imports const cdkInfo = require('../cdk-info.json'); const cdkVersion = cdkInfo.cdk_sdk_version.replace(MATCH_VER_BUILD, ''); const cdkServiceVersion = cdkInfo.cdk_service_sdk_version.replace(MATCH_VER_BUILD, ''); return template .replace(/%name%/g, project.name) .replace(/%name\.camelCased%/g, camelCase(project.name)) .replace(/%name\.PascalCased%/g, camelCase(project.name, { pascalCase: true })) .replace(/%cdk-version%/g, cdkVersion) .replace(/%cdk-service-version%/g, cdkServiceVersion) .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, '-')); } /** * 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, ...cxapi.FUTURE_FLAGS, }; await fs.writeJson(cdkJson, config, { spaces: 2 }); } } interface ProjectInfo { /** The value used for %name% */ readonly name: string; } export const availableInitTemplates: Promise<InitTemplate[]> = new Promise(async (resolve) => { const templateNames = await listDirectory(TEMPLATES_DIR); const templates = new Array<InitTemplate>(); for (const templateName of templateNames) { templates.push(await InitTemplate.fromName(templateName)); } resolve(templates); }); export const availableInitLanguages: Promise<string[]> = 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('.')).sort(); } export async function printAvailableTemplates(language?: string) { print('Available templates:'); for (const template of await availableInitTemplates) { if (language && template.languages.indexOf(language) === -1) { continue; } print(`* ${colors.green(template.name)}: ${template.description}`); const languageArg = language ? colors.bold(language) : template.languages.length > 1 ? `[${template.languages.map((t) => colors.bold(t)).join('|')}]` : colors.bold(template.languages[0]); print(` └─ ${colors.blue(`cdk init ${colors.bold(template.name)} --language=${languageArg}`)}`); } } async function initializeProject( template: InitTemplate, language: string, canUseNetwork: boolean, generateOnly: boolean, workDir: string, ) { await assertIsEmptyDirectory(workDir); print(`Applying project template ${colors.green(template.name)} for ${colors.blue(language)}`); await template.install(language, workDir); if (await fs.pathExists('README.md')) { print(colors.green(await fs.readFile('README.md', { encoding: 'utf-8' }))); } if (!generateOnly) { await initializeGitRepository(workDir); await postInstall(language, canUseNetwork, workDir); } print('✅ All done!'); } async function assertIsEmptyDirectory(workDir: string) { const files = await fs.readdir(workDir); if (files.filter((f) => !f.startsWith('.')).length !== 0) { throw new Error('`ros init` cannot be run in a non-empty directory!'); } } async function initializeGitRepository(workDir: string) { if (await isInGitRepository(workDir)) { return; } print('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 (e) { warning('Unable to initialize git repository for your project.'); } } async function postInstall(language: string, canUseNetwork: boolean, workDir: string) { switch (language) { case 'javascript': return await postInstallJavascript(canUseNetwork, workDir); case 'typescript': return await postInstallTypescript(canUseNetwork, workDir); case 'java': return await postInstallJava(canUseNetwork, workDir); case 'python': return await 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; } print(`Executing ${colors.green(`${command} install`)}...`); try { await execute(command, ['install'], { cwd }); } catch (e) { warning(`${command} install failed: ` + e.message); } } async function postInstallJava(canUseNetwork: boolean, cwd: string) { const mvnPackageWarning = "Please run 'mvn package'!"; if (!canUseNetwork) { warning(mvnPackageWarning); return; } print("Executing 'mvn package'"); try { await execute('mvn', ['package'], { cwd }); } catch (e) { warning('Unable to package compiled code as JAR'); warning(mvnPackageWarning); } } async function postInstallPython(cwd: string) { const python = pythonExecutable(); warning(`Please run ${python} -m venv .env'!`); print(`Executing ${colors.green('Creating virtualenv...')}`); try { await execute(python, ['-m venv', '.env'], { cwd }); } catch (e) { warning('Unable to create virtualenv automatically'); warning(`Please run '${python} -m venv .env'!`); } } /** * @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 { process.stderr.write(stdout); return fail(new Error(`${cmd} exited with status ${status}`)); } }); }); }