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