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