1import { Command } from '@expo/commander'; 2import aws from 'aws-sdk'; 3import chalk from 'chalk'; 4import fs from 'fs-extra'; 5import inquirer from 'inquirer'; 6import path from 'path'; 7 8import { EXPO_DIR } from '../Constants'; 9import { link } from '../Formatter'; 10import Git from '../Git'; 11import logger from '../Logger'; 12import { getNewestSDKVersionAsync } from '../ProjectVersions'; 13import { modifySdkVersionsAsync, getSdkVersionsAsync } from '../Versions'; 14import AndroidClientBuilder from '../client-build/AndroidClientBuilder'; 15import IosClientBuilder from '../client-build/IosClientBuilder'; 16import { ClientBuilder, ClientBuildFlavor, Platform } from '../client-build/types'; 17import askForPlatformAsync from '../utils/askForPlatformAsync'; 18import askForSdkVersionAsync from '../utils/askForSDKVersionAsync'; 19 20const s3Client = new aws.S3({ region: 'us-east-1' }); 21const { yellow, blue, magenta } = chalk; 22 23type ActionOptions = { 24 platform?: Platform; 25 release: boolean; 26 flavor: ClientBuildFlavor; 27}; 28 29const flavors = ['versioned', 'unversioned']; 30 31export default (program: Command) => { 32 program 33 .command('client-build') 34 .alias('cb') 35 .description( 36 'Builds Expo client for iOS simulator or APK for Android, uploads the archive to S3 and saves its url to versions endpoint.' 37 ) 38 .option('-p, --platform [string]', 'Platform for which the client will be built.') 39 .option( 40 '-r, --release', 41 'Whether to upload and release the client build to staging versions endpoint.', 42 false 43 ) 44 .option( 45 '-f, --flavor [string]', 46 `Which build flavor to use. Possible values: ${flavors}`, 47 flavors[0] 48 ) 49 .asyncAction(main); 50}; 51 52async function main(options: ActionOptions) { 53 const platform = options.platform || (await askForPlatformAsync()); 54 const sdkBranchVersion = await Git.getSDKVersionFromBranchNameAsync(); 55 56 if (options.release && !sdkBranchVersion) { 57 throw new Error(`Client builds can be released only from the release branch!`); 58 } 59 if (!Object.values(ClientBuildFlavor).includes(options.flavor)) { 60 throw new Error(`Flavor "${options.flavor}" is not valid, use one of: ${flavors}`); 61 } 62 63 const builder = getBuilderForPlatform(platform); 64 const sdkVersion = 65 sdkBranchVersion || 66 (await askForSdkVersionAsync(platform, await getNewestSDKVersionAsync(platform))); 67 const appVersion = await builder.getAppVersionAsync(); 68 69 await buildOrUseCacheAsync(builder, options.flavor); 70 71 if (sdkVersion && options.release) { 72 await uploadAsync(builder, sdkVersion, appVersion); 73 await releaseAsync(builder, sdkVersion, appVersion); 74 } 75} 76 77function getBuilderForPlatform(platform: Platform): ClientBuilder { 78 switch (platform) { 79 case 'ios': 80 return new IosClientBuilder(); 81 case 'android': 82 return new AndroidClientBuilder(); 83 default: { 84 throw new Error(`Platform "${platform}" is not supported yet!`); 85 } 86 } 87} 88 89async function askToRecreateSimulatorBuildAsync(): Promise<boolean> { 90 if (process.env.CI) { 91 return false; 92 } 93 const { createNew } = await inquirer.prompt<{ createNew: boolean }>([ 94 { 95 type: 'confirm', 96 name: 'createNew', 97 message: 'Do you want to create a fresh one?', 98 default: true, 99 }, 100 ]); 101 return createNew; 102} 103 104async function askToOverrideBuildAsync(): Promise<boolean> { 105 if (process.env.CI) { 106 // we should never override anything in CI, too easy to accidentally mess something up in prod 107 return false; 108 } 109 const { override } = await inquirer.prompt<{ override: boolean }>([ 110 { 111 type: 'confirm', 112 name: 'override', 113 message: 'Do you want to override it?', 114 default: true, 115 }, 116 ]); 117 return override; 118} 119 120async function buildOrUseCacheAsync( 121 builder: ClientBuilder, 122 flavor: ClientBuildFlavor 123): Promise<void> { 124 const appPath = builder.getAppPath(); 125 126 // Build directory already exists, we could reuse that one — especially useful on the CI. 127 if (await fs.pathExists(appPath)) { 128 const relativeAppPath = path.relative(EXPO_DIR, appPath); 129 logger.info(`Client build already exists at ${magenta.bold(relativeAppPath)}`); 130 131 if (!(await askToRecreateSimulatorBuildAsync())) { 132 logger.info('Skipped building the app, using cached build instead...'); 133 return; 134 } 135 } 136 await builder.buildAsync(flavor); 137} 138 139async function uploadAsync( 140 builder: ClientBuilder, 141 sdkVersion: string, 142 appVersion: string 143): Promise<void> { 144 const sdkVersions = await getSdkVersionsAsync(sdkVersion); 145 146 // Target app url already defined in versions endpoint. 147 // We make this check to reduce the risk of unintentional overrides. 148 if (sdkVersions?.[`${builder.platform}ClientUrl`] === builder.getClientUrl(appVersion)) { 149 logger.info(`Build ${yellow.bold(appVersion)} is already defined in versions endpoint.`); 150 logger.info('The new build would be uploaded onto the same URL.'); 151 152 if (!(await askToOverrideBuildAsync())) { 153 logger.warn('Refused overriding the build, exiting the proces...'); 154 process.exit(0); 155 return; 156 } 157 } 158 logger.info(`Uploading ${yellow.bold(appVersion)} build`); 159 160 await builder.uploadBuildAsync(s3Client, appVersion); 161} 162 163async function releaseAsync( 164 builder: ClientBuilder, 165 sdkVersion: string, 166 appVersion: string 167): Promise<void> { 168 const clientUrl = builder.getClientUrl(appVersion); 169 170 logger.info( 171 `Updating versions endpoint with client url ${blue.bold(link(clientUrl, clientUrl))}` 172 ); 173 174 await updateClientUrlAndVersionAsync(builder, sdkVersion, appVersion); 175} 176 177async function updateClientUrlAndVersionAsync( 178 builder: ClientBuilder, 179 sdkVersion: string, 180 appVersion: string 181) { 182 await modifySdkVersionsAsync(sdkVersion, (sdkVersions) => { 183 sdkVersions[`${builder.platform}ClientUrl`] = builder.getClientUrl(appVersion); 184 sdkVersions[`${builder.platform}ClientVersion`] = appVersion; 185 return sdkVersions; 186 }); 187} 188