import * as chalk from 'chalk'; import * as columnify from 'columnify'; import * as fs from 'fs'; import { marked } from 'marked'; import MarkedTerminal from 'marked-terminal'; import {command, help, namespace, optional, param} from 'oo-cli'; import * as path from 'path'; import {terminal} from 'terminal-kit'; import {handleInterrupt} from '../../lib/handleInterrupt'; import {TerminalPassthru} from '../../lib/TeminalPassthru'; import {fetchTemplatesManifest} from '../../lib/templating/fetchTemplatesManifest'; import {TemplateRenderer} from '../../lib/templating/TemplateRenderer'; import {TemplateManifest, TemplateVariable} from '../../lib/templating/types'; import {TerminalConfirm} from '../../lib/TerminalConfirm'; import {TerminalInput} from '../../lib/TerminalInput'; import {TerminalMenu} from '../../lib/TerminalMenu'; import {TerminalSpinner} from '../../lib/TerminalSpinner'; import titleCase = require('title-case'); import { getDependencyFileUrl } from '../../lib/Config'; import {Rivendell} from '../../lib/Rivendell'; import {die} from '../../lib/die'; import {formatError} from '../../lib/formatError'; import {DependencyStage} from '../../lib/DependencyStage'; export const APP_ID_FORMAT = /^[a-z][a-z_0-9]{2,31}$/; export const VERSION_FORMAT = /^\d+\.\d+\.\d+(-(((dev|beta)(\.\d+)?)|private))?$/; @namespace('app') export class CreateCommand { @param @optional @help('The app id / directory to create') public id?: string; private variables!: TemplateVariable[]; private variableId = 0; private manifest!: TemplateManifest; private substitutions: {[key: string]: string} = {}; private warnings: any[] = []; @command @help('Create a new app project') public async init() { try { handleInterrupt(); this.manifest = await fetchTemplatesManifest(); // start with base variables this.variables = this.manifest.baseTemplateVariables; // setting developer vendor this.variables.find((v) => v.name === 'vendor')!.suggest = (await Rivendell.whoami()).vendor; this.variables.push({ name: 'template', question: 'Select a template project' }); if (!await this.askQuestion()) { process.exit(1); } } catch (e: any) { die(formatError(e)); } } private async renderTemplate() { const {app_id, template: templateName} = this.substitutions; delete this.substitutions.template; let fullPath: string | undefined; if (fs.readdirSync(process.cwd()).length === 0) { if (await TerminalConfirm.ask(`Use current directory (${chalk.yellow(process.cwd())})?`)) { fullPath = process.cwd(); console.log(chalk.gray(`Using ${chalk.yellow(fullPath)}`)); } } if (!fullPath) { const suggestion = app_id.replace(/_/g, '-'); let dirName = await TerminalInput.ask(`Project directory name:`, {defaultValue: suggestion}); while (fs.existsSync(path.resolve(dirName)) && fs.readdirSync(path.resolve(dirName)).length !== 0) { console.log(chalk.red(`Directory ${path.resolve(dirName)} is not empty!`)); dirName = await TerminalInput.ask(`Project directory name:`, {defaultValue: suggestion}); } fullPath = path.resolve(dirName); console.log(chalk.gray('Creating directory', chalk.yellow(fullPath))); fs.mkdirSync(fullPath); } // add latest sdk versions to the substitutions const spinner = new TerminalSpinner().start(chalk.gray('Checking latest SDK versions...')); await this.getSDKVersions(); const template = this.manifest.templates.find((t) => t.name === templateName); await new TemplateRenderer(fullPath, template!, (status) => spinner.update(status)) .render(this.substitutions); spinner.stop(); console.log(chalk.gray('Performing initial Yarn install')); const result = TerminalPassthru.exec('yarn'); if (result.status !== 0) { console.log(chalk.red( 'There was a problem running yarn. Make sure yarn is installed and then run ' + `${chalk.cyan('yarn install')} from ${chalk.cyan(fullPath)} to install dependencies.` )); } if (this.warnings.length > 0) { this.warnings.forEach((warning) => console.warn(warning)); } marked.setOptions({renderer: new MarkedTerminal() as any}); console.log('\n' + marked( `New OCP app project created at \`${fullPath}\`\n` + '- View README.md for information on getting started\n' + '- Check out the [documentation](https://docs.developers.optimizely.com/optimizely-connect-platform/docs)' )); } private get variable() { return this.variables[this.variableId]; } private get templateOptions() { return columnify(this.manifest.templates, {columns: ['name', 'sp', 'summary']}).split('\n').slice(1); } private get question() { if (this.suggestion) { return `${this.variable.question} [${chalk.gray(this.suggestion)}]`; } if (this.variable.hint) { return `${this.variable.question} (${chalk.gray(this.variable.hint)})`; } return this.variable.question; } private async askQuestion() { if (this.variables.length <= this.variableId) { return false; } if (this.variable.hidden) { await this.onAnswer(undefined, this.variable.suggest || ''); return true; } // special case questions: switch (this.variable.name) { case 'template': const index = (await TerminalMenu.ask(`${this.question}:`, this.templateOptions, {layout: 'row'})).id as number; await this.onAnswer(undefined, this.manifest.templates[index].name); break; case 'category': const categories = ['Commerce Platform', 'Point of Sale', 'Lead Capture', 'Advertising', 'Marketing', 'Channel', 'Loyalty & Rewards', 'Customer Experience', 'Analytics & Reporting', 'Surveys & Feedback', 'Reviews & Ratings', 'Content Management', 'Data Quality & Enrichment', 'Productivity', 'CRM', 'Accounting & Finance', 'CDP / DMP', 'Attribution & Linking', 'Testing & Utilities', 'Personalization & Content', 'Offers', 'Merchandising & Products', 'Site & Content Experience', 'Subscriptions', 'Audience Sync', 'Opal'].sort(); await this.onAnswer(undefined, (await TerminalMenu.ask(`${this.question}:`, categories)).text); break; // standard questions default: await this.onAnswer(undefined, await TerminalInput.ask( `${this.question}:`, {getHint: this.getHint, validate: this.validateInput} )); } return true; } private onAnswer = async (error: any, input: string) => { if (error) { terminal.eraseDisplayBelow().nextLine(1); console.error(chalk.red(error)); process.exit(1); } input = input.trim(); if (this.suggestion && input === '') { input = this.suggestion; } if (!this.validateInput(input)) { if (this.variable.required && input.length === 0) { terminal.red(' This value is required').nextLine(1); } else { terminal.red(` Invalid value for ${this.variable.name}`).nextLine(1); } if (this.variable.hidden) { die(`Invalid value of '${this.variable.name}' field: ${input}. Contact support`); } else { await this.askQuestion(); } } else { if (!this.variable.hidden) { terminal(chalk.gray(this.question + ': ')).white(input).nextLine(1); } this.substitutions[this.variable.name] = input; this.variableId++; if (!await this.askQuestion()) { await this.renderTemplate(); } } } private getHint = (input: string): string => { if (input.trim() === '' && this.suggestion) { return `[Enter] to accept ${this.suggestion}`; } switch (this.variable.name) { case 'display_name': if (input.length === 0) { return chalk.yellow('Display Name is required'); } break; case 'app_id': if (!input) { return chalk.yellow('App ID is required'); } else if (!input.match(/^[a-z]/)) { return chalk.yellow('must start with a lower case letter'); } else if (!input.match(/^[a-z_0-9]+$/)) { return chalk.yellow('can only contain lower case letters, numbers, and underscores'); } else if (input.match(/_$/)) { return chalk.yellow('should not end with an underscore'); } else if (input.length < 3 || input.length > 32) { return chalk.yellow('must be between 3 and 32 characters long'); } break; case 'version': if (!input.match(VERSION_FORMAT)) { return chalk.yellow('must be semver. -beta, -dev, -private pre-releases are allowed'); } break; } return ''; } private validateInput = (input: string): boolean => { if (!input) { input = this.suggestion; } switch (this.variable.name) { case 'app_id': return APP_ID_FORMAT.test(input); case 'version': return VERSION_FORMAT.test(input); } if (this.variable.required && input.length === 0) { return false; } return true; } private get suggestion(): string { switch (this.variable.name) { case 'display_name': if (this.id || fs.readdirSync(process.cwd()).length === 0) { const dir = titleCase(this.id || path.basename(process.cwd()).replace(/[^a-zA-Z0-9]+/g, ' ').trim()); if (dir && this.validateInput(dir)) { return dir; } } break; case 'app_id': const appId = (this.substitutions['display_name'] || '').toLowerCase().replace(/[^0-9a-z_]+/g, '_'); if (appId.length > 0 && this.validateInput(appId)) { return appId; } break; } return this.variable.suggest || ''; } private getLatestSDKVersion(versions: {[key: string]: {[key: string]: string}}, stage: DependencyStage = 'stable') { const major = Object.keys(versions).map((x) => parseInt(x, 10)).sort().reverse()[0].toString(); return versions[major][stage]; } private async getSDKVersions(stage: DependencyStage = 'stable') { try { // TODO: use semver to only get the latest allowable by the templates to avoid major version incompatibilities const deps = await fetch(getDependencyFileUrl('node22')); const json = await deps.json() as any; this.substitutions['app_sdk_version'] = this.getLatestSDKVersion(json['@zaiusinc/app-sdk'], stage); this.substitutions['node_sdk_version'] = this.getLatestSDKVersion(json['@zaiusinc/node-sdk'], stage); } catch (e: any) { this.warnings.push('Error fetching sdk versions, defaulting to latest: ', e.message); this.substitutions['app_sdk_version'] = 'latest'; this.substitutions['node_sdk_version'] = 'latest'; } } }