1import { Command } from '@expo/commander'; 2import JsonFile from '@expo/json-file'; 3import { 4 isMultipartPartWithName, 5 parseMultipartMixedResponseAsync, 6} from '@expo/multipart-body-parser'; 7import chalk from 'chalk'; 8import fs from 'fs/promises'; 9import fetch, { Response } from 'node-fetch'; 10import nullthrows from 'nullthrows'; 11import os from 'os'; 12import path from 'path'; 13 14import { ANDROID_DIR, IOS_DIR } from '../Constants'; 15import { deepCloneObject } from '../Utils'; 16import { Directories, EASUpdate } from '../expotools'; 17import AppConfig from '../typings/AppConfig'; 18 19type ExpoCliStateObject = { 20 auth?: { 21 username?: string; 22 }; 23}; 24 25const EXPO_HOME_PATH = Directories.getExpoHomeJSDir(); 26 27const iosPublishBundlePath = path.join(IOS_DIR, 'Exponent', 'Supporting', 'kernel.ios.bundle'); 28const androidPublishBundlePath = path.join( 29 ANDROID_DIR, 30 'app', 31 'src', 32 'main', 33 'assets', 34 'kernel.android.bundle' 35); 36const iosManifestPath = path.join(IOS_DIR, 'Exponent', 'Supporting', 'kernel-manifest.json'); 37const androidManifestPath = path.join( 38 ANDROID_DIR, 39 'app', 40 'src', 41 'main', 42 'assets', 43 'kernel-manifest.json' 44); 45 46/** 47 * Returns path to production's expo-cli state file. 48 */ 49function getExpoCliStatePath(): string { 50 return path.join(os.homedir(), '.expo/state.json'); 51} 52 53/** 54 * Reads expo-cli state file which contains, among other things, session credentials to the account that you're logged in. 55 */ 56async function getExpoCliStateAsync(): Promise<ExpoCliStateObject> { 57 return JsonFile.readAsync<ExpoCliStateObject>(getExpoCliStatePath()); 58} 59 60/** 61 * Publishes @exponent/home app on EAS Update. 62 */ 63async function publishAppAsync({ 64 slug, 65 message, 66}: { 67 slug: string; 68 message: string; 69}): Promise<{ createdUpdateGroupId: string }> { 70 console.log(`Publishing ${chalk.green(slug)}...`); 71 72 const result = await EASUpdate.publishProjectWithEasCliAsync(EXPO_HOME_PATH, { 73 branch: 'production', 74 message, 75 }); 76 77 console.log( 78 `Done publishing ${chalk.green(slug)}. Update Group ID is: ${chalk.blue( 79 result.createdUpdateGroupId 80 )}` 81 ); 82 83 return result; 84} 85 86interface Manifest { 87 id: string; 88 launchAsset: { 89 key: string; 90 url: string; 91 }; 92} 93 94type AssetRequestHeaders = { authorization: string }; 95type Extensions = { assetRequestHeaders: { [key: string]: AssetRequestHeaders } }; 96 97async function getManifestAndExtensionsAsync(response: Response): Promise<{ 98 manifest: Manifest; 99 extensions: Extensions; 100}> { 101 const contentType = response.headers.get('content-type'); 102 if (!contentType) { 103 throw new Error('The multipart manifest response is missing the content-type header'); 104 } 105 106 const bodyBuffer = await response.arrayBuffer(); 107 const multipartParts = await parseMultipartMixedResponseAsync( 108 contentType, 109 Buffer.from(bodyBuffer) 110 ); 111 112 const manifestPart = multipartParts.find((part) => isMultipartPartWithName(part, 'manifest')); 113 if (!manifestPart) { 114 throw new Error('The multipart manifest response is missing the manifest part'); 115 } 116 const manifest: Manifest = JSON.parse(manifestPart.body); 117 118 const extensionsPart = multipartParts.find((part) => isMultipartPartWithName(part, 'extensions')); 119 if (!extensionsPart) { 120 throw new Error('The multipart manifest response is missing the extensions part'); 121 } 122 const extensions: Extensions = JSON.parse(extensionsPart.body); 123 124 return { manifest, extensions }; 125} 126 127async function fetchManifestAndBundleAsync( 128 projectId: string, 129 groupId: string, 130 platform: 'ios' | 'android' 131): Promise<void> { 132 const manifestUrl = `https://staging-u.expo.dev/${projectId}/group/${groupId}`; 133 const manifestResponse = await fetch(manifestUrl, { 134 method: 'GET', 135 headers: { 136 accept: 'multipart/mixed', 137 'expo-platform': platform, 138 }, 139 }); 140 const { manifest, extensions } = await getManifestAndExtensionsAsync(manifestResponse); 141 142 const bundleUrl = manifest.launchAsset.url; 143 const bundleRequestHeaders = nullthrows( 144 extensions?.assetRequestHeaders[manifest.launchAsset.key] 145 ); 146 147 const bundleResponse = await fetch(bundleUrl, { 148 method: 'GET', 149 headers: { 150 ...bundleRequestHeaders, 151 }, 152 }); 153 154 const manifestPath = platform === 'ios' ? iosManifestPath : androidManifestPath; 155 await fs.writeFile(path.resolve(manifestPath), JSON.stringify(manifest)); 156 157 const bundlePath = platform === 'ios' ? iosPublishBundlePath : androidPublishBundlePath; 158 await fs.writeFile(path.resolve(bundlePath), await bundleResponse.buffer()); 159} 160 161/** 162 * Main action that runs once the command is invoked. 163 */ 164async function action(): Promise<void> { 165 console.log('Getting expo-cli state of the current session...'); 166 const cliState = await getExpoCliStateAsync(); 167 const cliUsername = cliState?.auth?.username; 168 if (cliUsername !== 'exponent') { 169 throw new Error('Must be logged in as `exponent` account to publish'); 170 } 171 172 const appJsonFilePath = path.join(EXPO_HOME_PATH, 'app.json'); 173 174 const slug = 'home'; 175 const owner = 'exponent'; 176 const easProjectId = '6b6c6660-df76-11e6-b9b4-59d1587e6774'; 177 const easUpdateURL = `https://u.expo.dev/${easProjectId}`; 178 179 const appJsonFile = new JsonFile<AppConfig>(appJsonFilePath); 180 const appJson = await appJsonFile.readAsync(); 181 182 if (!appJson.expo.owner) { 183 throw new Error('app.json missing owner'); 184 } 185 if (!appJson.expo.extra || !appJson.expo.extra.eas || !appJson.expo.extra.eas.projectId) { 186 throw new Error('app.json missing extra.eas.projectId'); 187 } 188 if (!appJson.expo.updates || !appJson.expo.updates.url) { 189 throw new Error('app.json missing updates.url'); 190 } 191 192 console.log(`Creating backup of ${chalk.magenta('app.json')} file...`); 193 const appJsonBackup = deepCloneObject<AppConfig>(appJson); 194 195 console.log(`Modifying home's slug to ${chalk.green(slug)}...`); 196 appJson.expo.slug = slug; 197 198 console.log(`Modifying home's owner to ${chalk.green(owner)}...`); 199 appJson.expo.owner = owner; 200 201 console.log(`Modifying home's EAS project ID to ${chalk.green(easProjectId)}...`); 202 appJson.expo.extra.eas.projectId = easProjectId; 203 204 console.log(`Modifying home's update URL to ${chalk.green(easUpdateURL)}...`); 205 appJson.expo.updates.url = easUpdateURL; 206 207 // Save the modified `appJson` to the file so it'll be used as a manifest. 208 await appJsonFile.writeAsync(appJson); 209 210 const createdUpdateGroupId = ( 211 await publishAppAsync({ slug, message: `Publish ${appJson.expo.sdkVersion}` }) 212 ).createdUpdateGroupId; 213 214 console.log(`Restoring ${chalk.magenta('app.json')} file...`); 215 await appJsonFile.writeAsync(appJsonBackup); 216 217 console.log(`Downloading published manifests and bundles...`); 218 await Promise.all([ 219 fetchManifestAndBundleAsync(easProjectId, createdUpdateGroupId, 'ios'), 220 fetchManifestAndBundleAsync(easProjectId, createdUpdateGroupId, 'android'), 221 ]); 222 223 console.log( 224 chalk.yellow( 225 `Finished publishing. Remember to commit changes of the embedded manifests and bundles.` 226 ) 227 ); 228} 229 230export default (program: Command) => { 231 program 232 .command('publish-prod-home') 233 .alias('pph') 234 .description('Publishes home app for production on EAS Update.') 235 .asyncAction(action); 236}; 237