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