1import { Command } from '@expo/commander'; 2import JsonFile from '@expo/json-file'; 3import chalk from 'chalk'; 4import inquirer from 'inquirer'; 5import fetch from 'node-fetch'; 6import path from 'path'; 7import semver from 'semver'; 8 9import { EXPO_DIR, LOCAL_API_HOST, STAGING_API_HOST, PRODUCTION_API_HOST } from '../Constants'; 10import logger from '../Logger'; 11 12type ActionOptions = { 13 env: string; 14}; 15 16type Env = 'local' | 'staging' | 'production'; 17type BundledNativeModules = Record<string, string>; 18interface NativeModule { 19 npmPackage: string; 20 versionRange: string; 21} 22type BundledNativeModulesList = NativeModule[]; 23interface SyncPayload { 24 nativeModules: BundledNativeModulesList; 25} 26interface GetBundledNativeModulesResult { 27 data: BundledNativeModulesList; 28} 29 30const EXPO_PACKAGE_PATH = path.join(EXPO_DIR, 'packages/expo'); 31 32async function main(options: ActionOptions) { 33 logger.info('\nSyncing bundledNativeModules.json with www...'); 34 35 const env = resolveEnv(options); 36 await confirmEnvAsync(env); 37 const secret = await resolveSecretAsync(); 38 39 const sdkVersion = await resolveTargetSdkVersionAsync(); 40 const bundledNativeModules = await readBundledNativeModulesAsync(); 41 const syncPayload = prepareSyncPayload(bundledNativeModules); 42 43 const currentBundledNativeModules = await getCurrentBundledNativeModules(env, sdkVersion); 44 await compareAndConfirmAsync(currentBundledNativeModules, syncPayload.nativeModules); 45 46 await syncModulesAsync({ env, secret }, sdkVersion, syncPayload); 47 logger.success(`Successfully synced the modules for SDK ${sdkVersion}!`); 48} 49 50function resolveEnv({ env }: ActionOptions): Env { 51 if (env === 'staging' || env === 'production' || env === 'local') { 52 return env; 53 } else { 54 throw new Error(`Unknown env name: ${env}`); 55 } 56} 57 58async function confirmEnvAsync(env: Env): Promise<void> { 59 const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ 60 { 61 type: 'confirm', 62 name: 'confirmed', 63 message: `Are you sure to run this script against the ${chalk.green(env)} environment?`, 64 default: true, 65 }, 66 ]); 67 if (!confirmed) { 68 logger.info('No worries, come back soon!'); 69 process.exit(1); 70 } 71} 72 73async function resolveSecretAsync(): Promise<string> { 74 if (process.env.EXPO_SDK_NATIVE_MODULES_SECRET) { 75 return process.env.EXPO_SDK_NATIVE_MODULES_SECRET; 76 } 77 78 logger.info( 79 `We need the secret to authenticate you with Expo servers.\nPlease set the ${chalk.green( 80 'EXPO_SDK_NATIVE_MODULES_SECRET' 81 )} env var if you want to skip the prompt in the future.` 82 ); 83 84 const { secret } = await inquirer.prompt<{ secret: string }>([ 85 { 86 type: 'password', 87 name: 'secret', 88 message: 'Secret:', 89 validate: (val) => (val ? true : 'The secret cannot be empty'), 90 }, 91 ]); 92 return secret; 93} 94 95async function resolveTargetSdkVersionAsync(): Promise<string> { 96 const expoPackageJsonPath = path.join(EXPO_PACKAGE_PATH, 'package.json'); 97 const contents = await JsonFile.readAsync<Record<string, string>>(expoPackageJsonPath); 98 const majorVersion = semver.major(contents.version); 99 100 const sdkVersion = `${majorVersion}.0.0`; 101 102 const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ 103 { 104 type: 'confirm', 105 name: 'confirmed', 106 message: `Do you want to sync bundledNativeModules.json for ${chalk.green( 107 `SDK ${sdkVersion}` 108 )}?`, 109 default: true, 110 }, 111 ]); 112 113 if (!confirmed) { 114 logger.info('No worries, come back soon!'); 115 process.exit(1); 116 } else { 117 return sdkVersion; 118 } 119} 120 121async function readBundledNativeModulesAsync(): Promise<BundledNativeModules> { 122 const bundledNativeModulesPath = path.join(EXPO_PACKAGE_PATH, 'bundledNativeModules.json'); 123 return await JsonFile.readAsync<BundledNativeModules>(bundledNativeModulesPath); 124} 125 126async function getCurrentBundledNativeModules( 127 env: Env, 128 sdkVersion: string 129): Promise<BundledNativeModulesList> { 130 const baseApiUrl = resolveBaseApiUrl(env); 131 const result = await fetch(`${baseApiUrl}/--/api/v2/sdks/${sdkVersion}/native-modules`); 132 const resultJson: GetBundledNativeModulesResult = await result.json(); 133 return resultJson.data; 134} 135 136async function compareAndConfirmAsync( 137 current: BundledNativeModulesList, 138 next: BundledNativeModulesList 139): Promise<void> { 140 const currentMap = current.reduce((acc, i) => { 141 acc[i.npmPackage] = i; 142 return acc; 143 }, {} as Record<string, NativeModule>); 144 145 logger.info('Changes:'); 146 let hasChanges = false; 147 for (const { npmPackage, versionRange } of next) { 148 if (versionRange !== currentMap[npmPackage]?.versionRange) { 149 hasChanges = true; 150 logger.info( 151 ` - ${npmPackage}: ${chalk.red( 152 currentMap[npmPackage]?.versionRange ?? '(none)' 153 )} -> ${chalk.green(versionRange)}` 154 ); 155 } 156 } 157 if (!hasChanges) { 158 logger.info(chalk.gray('(no changes found)')); 159 // there's no need to proceed with the script 160 process.exit(0); 161 } 162 163 const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ 164 { 165 type: 'confirm', 166 name: 'confirmed', 167 message: `Are you sure to make these changes?`, 168 default: true, 169 }, 170 ]); 171 if (!confirmed) { 172 logger.info('No worries, come back soon!'); 173 process.exit(1); 174 } 175} 176 177async function syncModulesAsync( 178 { env, secret }: { env: Env; secret: string }, 179 sdkVersion: string, 180 payload: SyncPayload 181): Promise<void> { 182 const baseApiUrl = resolveBaseApiUrl(env); 183 const result = await fetch(`${baseApiUrl}/--/api/v2/sdks/${sdkVersion}/native-modules/sync`, { 184 method: 'put', 185 body: JSON.stringify(payload), 186 headers: { 187 'Content-Type': 'application/json', 188 'expo-sdk-native-modules-secret': secret, 189 }, 190 }); 191 192 if (result.status !== 200) { 193 throw new Error(`Failed to sync the modules: ${await result.text()}`); 194 } 195} 196 197function resolveBaseApiUrl(env: Env): string { 198 if (env === 'production') { 199 return `https://${PRODUCTION_API_HOST}`; 200 } else if (env === 'staging') { 201 return `https://${STAGING_API_HOST}`; 202 } else { 203 return `http://${LOCAL_API_HOST}`; 204 } 205} 206 207/** 208 * converts 209 * { 210 * "expo-ads-admob": "~10.0.4", 211 * "expo-ads-facebook": "~12.0.4" 212 * } 213 * to 214 * { 215 * "nativeModules": [ 216 * { "npmPackage": "expo-ads-admob", "versionRange": "~10.0.4" }, 217 * { "npmPackage": "expo-ads-facebook", "versionRange": "~12.0.4" } 218 * ] 219 * } 220 */ 221function prepareSyncPayload(bundledNativeModules: BundledNativeModules): SyncPayload { 222 return { 223 nativeModules: Object.entries(bundledNativeModules).map(([npmPackage, versionRange]) => ({ 224 npmPackage, 225 versionRange, 226 })), 227 }; 228} 229 230export default (program: Command) => { 231 program 232 .command('sync-bundled-native-modules') 233 .description( 234 'Sync configuration from bundledNativeModules.json to the corresponding API endpoint.' 235 ) 236 .alias('sbnm') 237 .option('-e, --env <local|staging|production>', 'www environment', 'staging') 238 .asyncAction(main); 239}; 240