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 * Publishes dev home app on EAS Update.
89 */
90async function publishAppOnDevelopmentBranchAsync({
91  slug,
92  message,
93}: {
94  slug: string;
95  message: string;
96}): Promise<{ createdUpdateGroupId: string }> {
97  console.log(`Publishing ${chalk.green(slug)}...`);
98
99  const result = await EASUpdate.setAuthAndPublishProjectWithEasCliAsync(EXPO_HOME_PATH, {
100    userpass: {
101      username: EXPO_HOME_DEV_ACCOUNT_USERNAME!,
102      password: EXPO_HOME_DEV_ACCOUNT_PASSWORD!,
103    },
104    branch: 'development',
105    message,
106  });
107
108  console.log(
109    `Done publishing ${chalk.green(slug)}. Update Group ID is: ${chalk.blue(
110      result.createdUpdateGroupId
111    )}`
112  );
113
114  return result;
115}
116
117/**
118 * Updates `dev-home-config.json` file with the new app url. It's then used by the client to load published home app.
119 */
120async function updateDevHomeConfigAsync(url: string): Promise<void> {
121  const devHomeConfigFilename = 'dev-home-config.json';
122  const devHomeConfigPath = path.join(
123    Directories.getExpoRepositoryRootDir(),
124    devHomeConfigFilename
125  );
126  const devManifestsFile = new JsonFile(devHomeConfigPath);
127
128  console.log(`Updating dev home config at ${chalk.magenta(devHomeConfigFilename)}...`);
129  await devManifestsFile.writeAsync({ url });
130}
131
132/**
133 * Main action that runs once the command is invoked.
134 */
135async function action(options: ActionOptions): Promise<void> {
136  if (!EXPO_HOME_DEV_ACCOUNT_USERNAME) {
137    throw new Error('EXPO_HOME_DEV_ACCOUNT_USERNAME must be set in your environment.');
138  }
139  if (!EXPO_HOME_DEV_ACCOUNT_PASSWORD) {
140    throw new Error('EXPO_HOME_DEV_ACCOUNT_PASSWORD must be set in your environment.');
141  }
142
143  const expoHomeHashNode = await hashElement(EXPO_HOME_PATH, {
144    encoding: 'hex',
145    folders: { exclude: ['.expo', 'node_modules'] },
146  });
147  const appJsonFilePath = path.join(EXPO_HOME_PATH, 'app.json');
148  const slug = `home`;
149  const appJsonFile = new JsonFile<AppConfig>(appJsonFilePath);
150  const appJson = await appJsonFile.readAsync();
151
152  const projectId = appJson.expo.extra?.eas?.projectId;
153  if (!projectId) {
154    throw new Error('No configured EAS project ID in app.json');
155  }
156
157  console.log(`Creating backup of ${chalk.magenta('app.json')} file...`);
158  const appJsonBackup = deepCloneObject<AppConfig>(appJson);
159
160  console.log('Getting expo-cli state of the current session...');
161  const cliStateBackup = await getExpoCliStateAsync();
162
163  await maybeUpdateHomeSdkVersionAsync(appJson, options.sdkVersion);
164
165  console.log(`Modifying home's slug to ${chalk.green(slug)}...`);
166  appJson.expo.slug = slug;
167
168  // Save the modified `appJson` to the file so it'll be used as a manifest.
169  await appJsonFile.writeAsync(appJson);
170
171  const cliUsername = cliStateBackup?.auth?.username;
172
173  if (cliUsername) {
174    console.log(`Logging out from ${chalk.green(cliUsername)} account...`);
175    await ExpoCLI.runExpoCliAsync('logout', [], {
176      stdio: 'ignore',
177    });
178  }
179
180  const createdUpdateGroupId = (
181    await publishAppOnDevelopmentBranchAsync({ slug, message: expoHomeHashNode.hash })
182  ).createdUpdateGroupId;
183
184  console.log(`Restoring home's slug to ${chalk.green(appJsonBackup.expo.slug)}...`);
185  appJson.expo.slug = appJsonBackup.expo.slug;
186
187  if (cliUsername) {
188    console.log(`Restoring ${chalk.green(cliUsername)} session in expo-cli...`);
189    await setExpoCliStateAsync(cliStateBackup);
190  } else {
191    console.log(`Logging out from ${chalk.green(EXPO_HOME_DEV_ACCOUNT_USERNAME)} account...`);
192    await fs.remove(getExpoCliStatePath());
193  }
194
195  console.log(`Updating ${chalk.magenta('app.json')} file...`);
196  await appJsonFile.writeAsync(appJson);
197
198  const url = `exps://u.expo.dev/${projectId}/group/${createdUpdateGroupId}`;
199  await updateDevHomeConfigAsync(url);
200
201  console.log(
202    chalk.yellow(
203      `Finished publishing. Remember to commit changes of ${chalk.magenta(
204        'home/app.json'
205      )} and ${chalk.magenta('dev-home-config.json')}.`
206    )
207  );
208}
209
210export default (program: Command) => {
211  program
212    .command('publish-dev-home')
213    .alias('pdh')
214    .description(
215      `Automatically logs in your eas-cli to ${chalk.magenta(
216        EXPO_HOME_DEV_ACCOUNT_USERNAME!
217      )} account, publishes home app for development on EAS Update and logs back to your account.`
218    )
219    .option(
220      '-s, --sdkVersion [string]',
221      'SDK version the published app should use. Defaults to the newest available SDK set in the Expo Go project.'
222    )
223    .asyncAction(action);
224};
225