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