xref: /expo/tools/src/commands/ClientBuild.ts (revision a272999e)
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