import * as chalk from 'chalk'; import {execSync} from 'child_process'; import {DotenvParseOutput} from 'dotenv'; import {defaultValue, flag, help, param} from 'oo-cli'; import {AppContext, appContext} from '../../lib/AppContext'; import {AppPackager} from '../../lib/AppPackager'; import {AppUpdater} from '../../lib/AppUpdater'; import {AppUploader} from '../../lib/AppUploader'; import {die} from '../../lib/die'; import {directoryExists} from '../../lib/directoryExists'; import {formatBuildState} from '../../lib/formatBuildState'; import {gatherAppEnv, validateAppEnvs} from '../../lib/gatherAppEnv'; import {handleInterrupt} from '../../lib/handleInterrupt'; import {TerminalSpinner} from '../../lib/TerminalSpinner'; import {ErrorList, watchBuild} from '../../lib/build'; import {Rivendell} from '../../lib/Rivendell'; import {formatError} from '../../lib/formatError'; import {RivendellApi} from '../../lib/RivendellApi'; import {applicableShards, targetShards} from '../../lib/Shards'; import ApiError = RivendellApi.ApiError; import Build = Rivendell.Build; import BuildState = Rivendell.BuildState; import AppVersion = Rivendell.AppVersion; import AppVersionState = Rivendell.AppVersionState; import ReviewStatus = Rivendell.ReviewStatus; export abstract class BaseBuildCommand { @flag('no-progress') @help("Don't display any progress indicators") public noProgress!: boolean; @flag('upgrade-deps') @help('Automatically update dependencies') public upgradeDeps!: boolean; @param @help('The root directory of the app') @defaultValue('./') public path!: string; @flag('rc-dependencies') @help('Use unstable OCP dependencies. Only allowed for dev builds.') public rcDependencies: boolean = false; @flag('no-prompt') @help('Use default answer to any prompt question (default is yes, except for destructive operations)') public noPrompt: boolean = false; protected async run(upload: boolean, publish: boolean, usePreviousAppEnvValues: boolean) { handleInterrupt(); if (!directoryExists(this.path)) { die('Specified path to app does not exist'); } process.chdir(this.path); const context = appContext(); if (!context.appId || !context.version || !context.runtime) { die('Unable to read App ID or version info. Ensure you are uploading the right directory.'); return; } if (this.rcDependencies && !this.isDevVersion(`${context.appId}@${context.version}`)) { die('Cannot use --rc-dependencies with non-dev version'); return; } if (!usePreviousAppEnvValues) { validateAppEnvs(await targetShards()); } const shards: string[] = await applicableShards(); await AppUpdater.ensureDependencies( upload, this.noPrompt || this.upgradeDeps, context.runtime, true, this.rcDependencies ? 'rc' : 'stable' ); console.log(chalk.gray('Performing dependency installation...')); try { execSync('yarn install', {stdio: 'inherit'}); } catch (e) { process.exit(1); } console.log(chalk.gray('Performing local validation...')); try { execSync('yarn --silent validate', {stdio: 'inherit'}); } catch (e) { process.exit(1); } if (upload) { console.log(chalk.gray('Verifying upload constraints...')); try { execSync('git rev-parse --is-inside-work-tree'); } catch (e) { console.log(chalk.red('\nApp must be in an initialized Git repository')); process.exit(1); } try { const version = await this.fetchAppVersion(context, 'us'); const packageUrl = version?.state === AppVersionState.RUNNING ? version?.packageUrl : await this.uploadPackage(context); const registrationShards = shards.includes('us') ? shards : ['us'].concat(shards); for (const shard of registrationShards) { let appEnv = gatherAppEnv(shard); if (usePreviousAppEnvValues && Object.keys(appEnv).length > 0) { console.log(chalk.yellow('\n--use-previous-app-env-values is on. Ignoring values from .env file')); appEnv = {}; } await this.registerOrUpdateVersion(context, packageUrl, appEnv, usePreviousAppEnvValues, shard); } if (version?.state !== AppVersionState.RUNNING) { await this.build(context, publish); } } catch (e: any) { die(formatError(e)); } } } protected async fetchAppVersion(context: AppContext, shard: string): Promise { try { return await Rivendell.fetchAppVersion(context, shard); } catch (e) { if (e instanceof ApiError && e.response?.status === 404) { console.log(chalk.gray('Existing version not found...')); return null; } throw e; } } protected isRelease(appVersionId: string) { return !this.isDevVersion(appVersionId); } protected isDevVersion(appVersionId?: string) { return appVersionId?.includes('-dev'); } protected async reviewAppVersion(appId: string, version: string) { if (await this.requiresReview(version)) { const appVersion = await this.fetchAppVersion({appId, version}, 'us'); if (appVersion?.state === AppVersionState.PUBLISHED && appVersion.reviewStatus !== ReviewStatus.NOT_REQUIRED) { await Rivendell.reviewAppVersion(appId, version); } } } protected async isInReview(appId: string, version: string) { const appVersion = await this.fetchAppVersion({appId, version}, 'us'); if (appVersion) { const reviewStatus = appVersion.reviewStatus as ReviewStatus; return reviewStatus === ReviewStatus.IN_REVIEW; } else { return false; } } protected async requiresReview(version: string) { return this.isRelease(version) && !await Rivendell.isAdmin(); } private async uploadPackage(context: AppContext): Promise { try { await Rivendell.fetchApp(context.appId!); const packageData = await new AppPackager('./').package(); return await new AppUploader(context, packageData).upload(); } catch (e) { console.log('App ID must be registered before it can be uploaded. Try:'); console.log(' ocp app register'); throw e; } } private async build(context: AppContext, publish: boolean) { try { const build = await this.startBuild(context, publish); await this.watchForBuildCompletion(context, build.id, publish); } catch (e) { console.log('App ID must be registered before it can be uploaded. Try:'); console.log(' ocp app register'); throw e; } } private async registerOrUpdateVersion( context: AppContext, packageUrl: string, appEnv: DotenvParseOutput, usePreviousAppEnvValues: boolean, shard: string) : Promise { try { console.log(chalk.gray(`Checking for existing version (${shard})...`)); const version = await this.fetchAppVersion(context, shard); if (version?.state === AppVersionState.RUNNING) { console.log(`AppVersion already running (${shard})`); } else if (version) { await this.updateVersion(context, packageUrl, appEnv, usePreviousAppEnvValues, shard); } else { await this.registerVersion(context, packageUrl, appEnv, usePreviousAppEnvValues, shard); } } catch (e: any) { die(`${formatError(e)} (${shard})`); } } private async registerVersion( context: AppContext, packageUrl: string, appEnv: DotenvParseOutput, usePreviousAppEnvValues: boolean, shard: string ): Promise { console.log(chalk.gray(`Registering a new version (${shard})...`)); await Rivendell.registerAppVersion( context.appId!!, context.version!!, packageUrl, appEnv, usePreviousAppEnvValues, shard ); } private async updateVersion( context: AppContext, packageUrl: string, appEnv: DotenvParseOutput, usePreviousAppEnvValues: boolean, shard: string) : Promise { console.log(chalk.gray(`Updating existing version (${shard})...`)); await Rivendell.updateAppVersion( context.appId!!, context.version!!, packageUrl, appEnv, usePreviousAppEnvValues, shard ); } private async startBuild(context: AppContext, publish: boolean): Promise { console.log(chalk.gray(`Starting ${publish ? 'build' : 'validation '}...`)); return publish ? await Rivendell.build(context.appId!, context.version!, this.rcDependencies) : await Rivendell.verify(context.appId!, context.version!); } private async watchForBuildCompletion(context: AppContext, id: number, publish: boolean) { console.log( chalk.gray(`Waiting for ${publish ? 'build' : 'validation'} (id=${id}) to complete... CTRL+C to stop checking.`) ); const spinner = this.noProgress ? null : new TerminalSpinner().start(`Status: ${formatBuildState(BuildState.NEW)}`); const requiresReview = await this.requiresReview(context.version as string); return watchBuild(id, (state) => { spinner?.update(`Status: ${formatBuildState(state)}`); }).then((errors: ErrorList) => { spinner?.stop(); if (errors && errors.length > 0) { console.log(chalk.gray(`\nTo view detailed logs, run: ${chalk.cyan(`ocp app logs --buildId=${id}`)}\n`)); die(`${publish ? 'Build' : 'Validation'} failed:\n${errors.map((e) => ` * ${e}`).join('\n')}`); } else if (publish) { if (requiresReview) { console.log(chalk.green('Build complete.')); } else { console.log(chalk.green('Build complete. Ready to be published.')); } } else { console.log(chalk.green('Validation complete. No errors found.')); } }).catch((err) => { die(formatError(err)); }); } }