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