import 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; 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 (!usePreviousAppEnvValues) { validateAppEnvs(await targetShards()); } const shards: string[] = await applicableShards(); await AppUpdater.ensureDependencies(upload, this.upgradeDeps, context.runtime); 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); for (const shard of shards) { 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) { 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 !await this.isAdmin() && this.isRelease(version); } private async uploadPackage(context: AppContext): Promise { try { await Rivendell.fetchApp(context.appId!); const packageData = 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(' opti 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(' opti 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) { 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!) : 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) { 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)); }); } private async isAdmin() { const me = await Rivendell.whoami(); return me.role === 'administrator' || me.role === 'app_administrator'; } }