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