1import { Command } from '@expo/commander';
2import JsonFile from '@expo/json-file';
3import chalk from 'chalk';
4import { hashElement } from 'folder-hash';
5import fs from 'fs-extra';
6import os from 'os';
7import path from 'path';
8import process from 'process';
9import semver from 'semver';
10
11import * as ExpoCLI from '../ExpoCLI';
12import { getNewestSDKVersionAsync } from '../ProjectVersions';
13import { deepCloneObject } from '../Utils';
14import { Directories, EASUpdate } from '../expotools';
15import AppConfig from '../typings/AppConfig';
16
17type ActionOptions = {
18  sdkVersion?: string;
19};
20
21type ExpoCliStateObject = {
22  auth?: {
23    username?: string;
24  };
25};
26
27const EXPO_HOME_PATH = Directories.getExpoHomeJSDir();
28const { EXPO_HOME_DEV_ACCOUNT_USERNAME, EXPO_HOME_DEV_ACCOUNT_PASSWORD } = process.env;
29
30/**
31 * Finds target SDK version for home app based on the newest SDK versions of all supported platforms.
32 * If multiple different versions have been found then the highest one is used.
33 */
34async function findTargetSdkVersionAsync(): Promise<string> {
35  const iosSdkVersion = await getNewestSDKVersionAsync('ios');
36  const androidSdkVersion = await getNewestSDKVersionAsync('android');
37
38  if (!iosSdkVersion || !androidSdkVersion) {
39    throw new Error('Unable to find target SDK version.');
40  }
41
42  const sdkVersions: string[] = [iosSdkVersion, androidSdkVersion];
43  return sdkVersions.sort(semver.rcompare)[0];
44}
45
46/**
47 * Sets `sdkVersion` and `version` fields in app configuration if needed.
48 */
49async function maybeUpdateHomeSdkVersionAsync(
50  appJson: AppConfig,
51  explicitSdkVersion?: string | null
52): Promise<void> {
53  const targetSdkVersion = explicitSdkVersion ?? (await findTargetSdkVersionAsync());
54
55  if (appJson.expo.sdkVersion !== targetSdkVersion) {
56    console.log(`Updating home's sdkVersion to ${chalk.cyan(targetSdkVersion)}...`);
57
58    // When publishing the sdkVersion needs to be set to the target sdkVersion. The Expo client will
59    // load it as UNVERSIONED, but the server uses this field to know which clients to serve the
60    // bundle to.
61    appJson.expo.version = targetSdkVersion;
62    appJson.expo.sdkVersion = targetSdkVersion;
63  }
64}
65
66/**
67 * Returns path to production's expo-cli state file.
68 */
69function getExpoCliStatePath(): string {
70  return path.join(os.homedir(), '.expo/state.json');
71}
72
73/**
74 * Reads expo-cli state file which contains, among other things, session credentials to the account that you're logged in.
75 */
76async function getExpoCliStateAsync(): Promise<ExpoCliStateObject> {
77  return JsonFile.readAsync<ExpoCliStateObject>(getExpoCliStatePath());
78}
79
80/**
81 * Sets expo-cli state file which contains, among other things, session credentials to the account that you're logged in.
82 */
83async function setExpoCliStateAsync(newState: object): Promise<void> {
84  await JsonFile.writeAsync<ExpoCliStateObject>(getExpoCliStatePath(), newState);
85}
86
87/**
88 * Deletes kernel fields that needs to be removed from published manifest.
89 */
90function deleteKernelFields(appJson: AppConfig): void {
91  console.log(`Deleting kernel-related fields...`);
92
93  // @tsapeta: Using `delete` keyword here would change the order of keys in app.json.
94  appJson.expo.kernel = undefined;
95  appJson.expo.isKernel = undefined;
96  appJson.expo.ios.publishBundlePath = undefined;
97  appJson.expo.android.publishBundlePath = undefined;
98}
99
100/**
101 * Restores kernel fields that have been removed in previous steps - we don't want them to be present in published manifest.
102 */
103function restoreKernelFields(appJson: AppConfig, appJsonBackup: AppConfig): void {
104  console.log('Restoring kernel-related fields...');
105
106  appJson.expo.kernel = appJsonBackup.expo.kernel;
107  appJson.expo.isKernel = appJsonBackup.expo.isKernel;
108  appJson.expo.ios.publishBundlePath = appJsonBackup.expo.ios.publishBundlePath;
109  appJson.expo.android.publishBundlePath = appJsonBackup.expo.android.publishBundlePath;
110}
111
112/**
113 * Publishes dev home app on EAS Update.
114 */
115async function publishAppAsync({
116  slug,
117  message,
118}: {
119  slug: string;
120  message: string;
121}): Promise<{ createdUpdateGroupId: string }> {
122  console.log(`Publishing ${chalk.green(slug)}...`);
123
124  const result = await EASUpdate.publishProjectWithEasCliAsync(EXPO_HOME_PATH, {
125    userpass: {
126      username: EXPO_HOME_DEV_ACCOUNT_USERNAME!,
127      password: EXPO_HOME_DEV_ACCOUNT_PASSWORD!,
128    },
129    message,
130  });
131
132  console.log(
133    `Done publishing ${chalk.green(slug)}. Update Group ID is: ${chalk.blue(
134      result.createdUpdateGroupId
135    )}`
136  );
137
138  return result;
139}
140
141/**
142 * Updates `dev-home-config.json` file with the new app url. It's then used by the client to load published home app.
143 */
144async function updateDevHomeConfigAsync(url: string): Promise<void> {
145  const devHomeConfigFilename = 'dev-home-config.json';
146  const devHomeConfigPath = path.join(
147    Directories.getExpoRepositoryRootDir(),
148    devHomeConfigFilename
149  );
150  const devManifestsFile = new JsonFile(devHomeConfigPath);
151
152  console.log(`Updating dev home config at ${chalk.magenta(devHomeConfigFilename)}...`);
153  await devManifestsFile.writeAsync({ url });
154}
155
156/**
157 * Main action that runs once the command is invoked.
158 */
159async function action(options: ActionOptions): Promise<void> {
160  if (!EXPO_HOME_DEV_ACCOUNT_USERNAME) {
161    throw new Error('EXPO_HOME_DEV_ACCOUNT_USERNAME must be set in your environment.');
162  }
163  if (!EXPO_HOME_DEV_ACCOUNT_PASSWORD) {
164    throw new Error('EXPO_HOME_DEV_ACCOUNT_PASSWORD must be set in your environment.');
165  }
166
167  const expoHomeHashNode = await hashElement(EXPO_HOME_PATH, {
168    encoding: 'hex',
169    folders: { exclude: ['.expo', 'node_modules'] },
170  });
171  const appJsonFilePath = path.join(EXPO_HOME_PATH, 'app.json');
172  const slug = `home`;
173  const appJsonFile = new JsonFile<AppConfig>(appJsonFilePath);
174  const appJson = await appJsonFile.readAsync();
175
176  const projectId = appJson.expo.extra?.eas?.projectId;
177  if (!projectId) {
178    throw new Error('No configured EAS project ID in app.json');
179  }
180
181  console.log(`Creating backup of ${chalk.magenta('app.json')} file...`);
182  const appJsonBackup = deepCloneObject<AppConfig>(appJson);
183
184  console.log('Getting expo-cli state of the current session...');
185  const cliStateBackup = await getExpoCliStateAsync();
186
187  await maybeUpdateHomeSdkVersionAsync(appJson, options.sdkVersion);
188
189  console.log(`Modifying home's slug to ${chalk.green(slug)}...`);
190  appJson.expo.slug = slug;
191
192  deleteKernelFields(appJson);
193
194  // Save the modified `appJson` to the file so it'll be used as a manifest.
195  await appJsonFile.writeAsync(appJson);
196
197  const cliUsername = cliStateBackup?.auth?.username;
198
199  if (cliUsername) {
200    console.log(`Logging out from ${chalk.green(cliUsername)} account...`);
201    await ExpoCLI.runExpoCliAsync('logout', [], {
202      stdio: 'ignore',
203    });
204  }
205
206  const createdUpdateGroupId = (await publishAppAsync({ slug, message: expoHomeHashNode.hash }))
207    .createdUpdateGroupId;
208
209  restoreKernelFields(appJson, appJsonBackup);
210
211  console.log(`Restoring home's slug to ${chalk.green(appJsonBackup.expo.slug)}...`);
212  appJson.expo.slug = appJsonBackup.expo.slug;
213
214  if (cliUsername) {
215    console.log(`Restoring ${chalk.green(cliUsername)} session in expo-cli...`);
216    await setExpoCliStateAsync(cliStateBackup);
217  } else {
218    console.log(`Logging out from ${chalk.green(EXPO_HOME_DEV_ACCOUNT_USERNAME)} account...`);
219    await fs.remove(getExpoCliStatePath());
220  }
221
222  console.log(`Updating ${chalk.magenta('app.json')} file...`);
223  await appJsonFile.writeAsync(appJson);
224
225  const url = `exps://u.expo.dev/${projectId}/group/${createdUpdateGroupId}`;
226  await updateDevHomeConfigAsync(url);
227
228  console.log(
229    chalk.yellow(
230      `Finished publishing. Remember to commit changes of ${chalk.magenta(
231        'home/app.json'
232      )} and ${chalk.magenta('dev-home-config.json')}.`
233    )
234  );
235}
236
237export default (program: Command) => {
238  program
239    .command('publish-dev-home')
240    .alias('pdh')
241    .description(
242      `Automatically logs in your eas-cli to ${chalk.magenta(
243        EXPO_HOME_DEV_ACCOUNT_USERNAME!
244      )} account, publishes home app for development on EAS Update and logs back to your account.`
245    )
246    .option(
247      '-s, --sdkVersion [string]',
248      'SDK version the published app should use. Defaults to the newest available SDK set in the Expo Go project.'
249    )
250    .asyncAction(action);
251};
252