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 * Publishes dev home app on EAS Update. 89 */ 90async function publishAppOnDevelopmentBranchAsync({ 91 slug, 92 message, 93}: { 94 slug: string; 95 message: string; 96}): Promise<{ createdUpdateGroupId: string }> { 97 console.log(`Publishing ${chalk.green(slug)}...`); 98 99 const result = await EASUpdate.setAuthAndPublishProjectWithEasCliAsync(EXPO_HOME_PATH, { 100 userpass: { 101 username: EXPO_HOME_DEV_ACCOUNT_USERNAME!, 102 password: EXPO_HOME_DEV_ACCOUNT_PASSWORD!, 103 }, 104 branch: 'development', 105 message, 106 }); 107 108 console.log( 109 `Done publishing ${chalk.green(slug)}. Update Group ID is: ${chalk.blue( 110 result.createdUpdateGroupId 111 )}` 112 ); 113 114 return result; 115} 116 117/** 118 * Updates `dev-home-config.json` file with the new app url. It's then used by the client to load published home app. 119 */ 120async function updateDevHomeConfigAsync(url: string): Promise<void> { 121 const devHomeConfigFilename = 'dev-home-config.json'; 122 const devHomeConfigPath = path.join( 123 Directories.getExpoRepositoryRootDir(), 124 devHomeConfigFilename 125 ); 126 const devManifestsFile = new JsonFile(devHomeConfigPath); 127 128 console.log(`Updating dev home config at ${chalk.magenta(devHomeConfigFilename)}...`); 129 await devManifestsFile.writeAsync({ url }); 130} 131 132/** 133 * Main action that runs once the command is invoked. 134 */ 135async function action(options: ActionOptions): Promise<void> { 136 if (!EXPO_HOME_DEV_ACCOUNT_USERNAME) { 137 throw new Error('EXPO_HOME_DEV_ACCOUNT_USERNAME must be set in your environment.'); 138 } 139 if (!EXPO_HOME_DEV_ACCOUNT_PASSWORD) { 140 throw new Error('EXPO_HOME_DEV_ACCOUNT_PASSWORD must be set in your environment.'); 141 } 142 143 const expoHomeHashNode = await hashElement(EXPO_HOME_PATH, { 144 encoding: 'hex', 145 folders: { exclude: ['.expo', 'node_modules'] }, 146 }); 147 const appJsonFilePath = path.join(EXPO_HOME_PATH, 'app.json'); 148 const slug = `home`; 149 const appJsonFile = new JsonFile<AppConfig>(appJsonFilePath); 150 const appJson = await appJsonFile.readAsync(); 151 152 const projectId = appJson.expo.extra?.eas?.projectId; 153 if (!projectId) { 154 throw new Error('No configured EAS project ID in app.json'); 155 } 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, options.sdkVersion); 164 165 console.log(`Modifying home's slug to ${chalk.green(slug)}...`); 166 appJson.expo.slug = slug; 167 168 // Save the modified `appJson` to the file so it'll be used as a manifest. 169 await appJsonFile.writeAsync(appJson); 170 171 const cliUsername = cliStateBackup?.auth?.username; 172 173 if (cliUsername) { 174 console.log(`Logging out from ${chalk.green(cliUsername)} account...`); 175 await ExpoCLI.runExpoCliAsync('logout', [], { 176 stdio: 'ignore', 177 }); 178 } 179 180 const createdUpdateGroupId = ( 181 await publishAppOnDevelopmentBranchAsync({ slug, message: expoHomeHashNode.hash }) 182 ).createdUpdateGroupId; 183 184 console.log(`Restoring home's slug to ${chalk.green(appJsonBackup.expo.slug)}...`); 185 appJson.expo.slug = appJsonBackup.expo.slug; 186 187 if (cliUsername) { 188 console.log(`Restoring ${chalk.green(cliUsername)} session in expo-cli...`); 189 await setExpoCliStateAsync(cliStateBackup); 190 } else { 191 console.log(`Logging out from ${chalk.green(EXPO_HOME_DEV_ACCOUNT_USERNAME)} account...`); 192 await fs.remove(getExpoCliStatePath()); 193 } 194 195 console.log(`Updating ${chalk.magenta('app.json')} file...`); 196 await appJsonFile.writeAsync(appJson); 197 198 const url = `exps://u.expo.dev/${projectId}/group/${createdUpdateGroupId}`; 199 await updateDevHomeConfigAsync(url); 200 201 console.log( 202 chalk.yellow( 203 `Finished publishing. Remember to commit changes of ${chalk.magenta( 204 'home/app.json' 205 )} and ${chalk.magenta('dev-home-config.json')}.` 206 ) 207 ); 208} 209 210export default (program: Command) => { 211 program 212 .command('publish-dev-home') 213 .alias('pdh') 214 .description( 215 `Automatically logs in your eas-cli to ${chalk.magenta( 216 EXPO_HOME_DEV_ACCOUNT_USERNAME! 217 )} account, publishes home app for development on EAS Update and logs back to your account.` 218 ) 219 .option( 220 '-s, --sdkVersion [string]', 221 'SDK version the published app should use. Defaults to the newest available SDK set in the Expo Go project.' 222 ) 223 .asyncAction(action); 224}; 225