1import { Command } from '@expo/commander'; 2import spawnAsync from '@expo/spawn-async'; 3import chalk from 'chalk'; 4import fs from 'fs-extra'; 5import inquirer from 'inquirer'; 6import os from 'os'; 7import path from 'path'; 8 9import { runReactNativeCodegenAsync } from '../Codegen'; 10import { EXPO_DIR } from '../Constants'; 11import { GitDirectory } from '../Git'; 12import logger from '../Logger'; 13import { downloadPackageTarballAsync } from '../Npm'; 14import { PackageJson } from '../Packages'; 15import { updateBundledVersionsAsync } from '../ProjectVersions'; 16import * as Workspace from '../Workspace'; 17import { 18 getVendoringAvailablePlatforms, 19 listAvailableVendoredModulesAsync, 20 vendorPlatformAsync, 21} from '../vendoring'; 22import vendoredModulesConfig from '../vendoring/config'; 23import { legacyVendorModuleAsync } from '../vendoring/legacy'; 24import { VendoringModuleConfig, VendoringTargetConfig } from '../vendoring/types'; 25 26type ActionOptions = { 27 list: boolean; 28 listOutdated: boolean; 29 target: string; 30 module: string; 31 platform: string; 32 commit: string; 33 semverPrefix: string; 34 updateDependencies?: boolean; 35}; 36 37const EXPO_GO_TARGET = 'expo-go'; 38 39export default (program: Command) => { 40 program 41 .command('update-vendored-module') 42 .alias('update-module', 'uvm') 43 .description('Updates 3rd party modules.') 44 .option('-l, --list', 'Shows a list of available 3rd party modules.', false) 45 .option('-o, --list-outdated', 'Shows a list of outdated 3rd party modules.', false) 46 .option( 47 '-t, --target <string>', 48 'The target to update, e.g. Expo Go or development client.', 49 EXPO_GO_TARGET 50 ) 51 .option('-m, --module <string>', 'Name of the module to update.') 52 .option( 53 '-p, --platform <string>', 54 'A platform on which the vendored module will be updated.', 55 'all' 56 ) 57 .option( 58 '-c, --commit <string>', 59 'Git reference on which to checkout when copying 3rd party module.', 60 'master' 61 ) 62 .option( 63 '-s, --semver-prefix <string>', 64 'Setting this flag forces to use given semver prefix. Some modules may specify them by the config, but in case we want to update to alpha/beta versions we should use an empty prefix to be more strict.', 65 null 66 ) 67 .option( 68 '-u, --update-dependencies', 69 'Whether to update workspace dependencies and bundled native modules.', 70 true 71 ) 72 .asyncAction(action); 73}; 74 75async function action(options: ActionOptions) { 76 const target = await resolveTargetNameAsync(options.target); 77 const targetConfig = vendoredModulesConfig[target]; 78 79 if (options.list || options.listOutdated) { 80 if (target !== EXPO_GO_TARGET) { 81 throw new Error(`Listing vendored modules for target "${target}" is not supported.`); 82 } 83 await listAvailableVendoredModulesAsync(targetConfig.modules, options.listOutdated); 84 return; 85 } 86 87 const moduleName = await resolveModuleNameAsync(options.module, targetConfig); 88 const sourceDirectory = path.join(os.tmpdir(), 'ExpoVendoredModules', moduleName); 89 const moduleConfig = targetConfig.modules[moduleName]; 90 91 try { 92 await downloadSourceAsync(sourceDirectory, moduleName, moduleConfig, options); 93 94 const platforms = resolvePlatforms(options.platform); 95 96 for (const platform of platforms) { 97 if (!targetConfig.platforms[platform]) { 98 continue; 99 } 100 await runCodegenIfNeeded(sourceDirectory, moduleConfig, platform); 101 102 // TODO(@tsapeta): Remove this once all vendored modules are migrated to the new system. 103 if (!targetConfig.modules[moduleName][platform]) { 104 // If the target doesn't support this platform, maybe legacy vendoring does. 105 logger.info('‼️ Using legacy vendoring for platform %s', chalk.yellow(platform)); 106 await legacyVendorModuleAsync(moduleName, platform, sourceDirectory); 107 continue; 108 } 109 110 const relativeTargetDirectory = path.join( 111 targetConfig.platforms[platform].targetDirectory, 112 moduleName 113 ); 114 const targetDirectory = path.join(EXPO_DIR, relativeTargetDirectory); 115 116 logger.log( 117 ' Vendoring for %s to %s', 118 chalk.yellow(platform), 119 chalk.magenta(relativeTargetDirectory) 120 ); 121 122 // Clean up previous version 123 await fs.remove(targetDirectory); 124 125 // Delegate further steps to platform's provider 126 await vendorPlatformAsync(platform, sourceDirectory, targetDirectory, moduleConfig[platform]); 127 } 128 129 // Update dependency versions only for Expo Go target. 130 if (options.updateDependencies !== false && target === EXPO_GO_TARGET) { 131 const packageJsonPath = path.join( 132 sourceDirectory, 133 moduleConfig.packageJsonPath ?? 'package.json' 134 ); 135 const packageJson = require(packageJsonPath) as PackageJson; 136 const semverPrefix = 137 (options.semverPrefix != null ? options.semverPrefix : moduleConfig.semverPrefix) || ''; 138 const newVersionRange = `${semverPrefix}${packageJson.version}`; 139 140 await updateDependenciesAsync(moduleName, newVersionRange); 141 } 142 } finally { 143 // Clean cloned repo 144 await fs.remove(sourceDirectory); 145 } 146 logger.success(' Successfully updated %s\n', chalk.bold(moduleName)); 147} 148 149/** 150 * Downloads vendoring module source code either from git repository or npm 151 */ 152async function downloadSourceAsync( 153 sourceDirectory: string, 154 moduleName: string, 155 moduleConfig: VendoringModuleConfig, 156 options: ActionOptions 157) { 158 if (moduleConfig.sourceType === 'npm') { 159 const version = options.commit ?? 'latest'; 160 logger.log(' Downloading %s@%s from npm', chalk.green(moduleName), chalk.cyan(version)); 161 162 const tarball = await downloadPackageTarballAsync( 163 sourceDirectory, 164 moduleConfig.source, 165 version 166 ); 167 // `--strip-component 1` to extract files from package/ folder 168 await spawnAsync('tar', ['--strip-component', '1', '-xf', tarball], { cwd: sourceDirectory }); 169 return; 170 } 171 172 // Clone repository from the source 173 logger.log( 174 ' Cloning %s#%s from %s', 175 chalk.green(moduleName), 176 chalk.cyan(options.commit), 177 chalk.magenta(moduleConfig.source) 178 ); 179 180 await GitDirectory.shallowCloneAsync( 181 sourceDirectory, 182 moduleConfig.source, 183 options.commit ?? 'master' 184 ); 185} 186 187/** 188 * Updates versions in bundled native modules and workspace projects. 189 */ 190async function updateDependenciesAsync(moduleName: string, versionRange: string) { 191 logger.log('✍️ Updating bundled native modules'); 192 193 await updateBundledVersionsAsync({ 194 [moduleName]: versionRange, 195 }); 196 197 logger.log('✍️ Updating workspace dependencies'); 198 199 await Workspace.updateDependencyAsync(moduleName, versionRange); 200} 201 202/** 203 * Validates provided target name or prompts for the valid one. 204 */ 205async function resolveTargetNameAsync(providedTargetName: string): Promise<string> { 206 const targets = Object.keys(vendoredModulesConfig); 207 208 if (providedTargetName) { 209 if (targets.includes(providedTargetName)) { 210 return providedTargetName; 211 } 212 throw new Error(`Couldn't find config for ${providedTargetName} target.`); 213 } 214 const { targetName } = await inquirer.prompt([ 215 { 216 type: 'list', 217 name: 'targetName', 218 prefix: '❔', 219 message: 'In which target do you want to update vendored module?', 220 choices: targets.map((target) => ({ 221 name: vendoredModulesConfig[target].name, 222 value: target, 223 })), 224 }, 225 ]); 226 return targetName; 227} 228 229/** 230 * Validates provided module name or prompts for the valid one. 231 */ 232async function resolveModuleNameAsync( 233 providedModuleName: string, 234 targetConfig: VendoringTargetConfig 235): Promise<string> { 236 const moduleNames = Object.keys(targetConfig.modules); 237 238 if (providedModuleName) { 239 if (moduleNames.includes(providedModuleName)) { 240 return providedModuleName; 241 } 242 throw new Error(`Couldn't find config for ${providedModuleName} module.`); 243 } 244 const { moduleName } = await inquirer.prompt([ 245 { 246 type: 'list', 247 name: 'moduleName', 248 prefix: '❔', 249 message: 'Which vendored module do you want to update?', 250 choices: moduleNames, 251 }, 252 ]); 253 return moduleName; 254} 255 256function resolvePlatforms(platform: string): string[] { 257 const all = getVendoringAvailablePlatforms(); 258 return all.includes(platform) ? [platform] : all; 259} 260 261async function runCodegenIfNeeded( 262 sourceDirectory: string, 263 moduleConfig: VendoringModuleConfig, 264 platform: string 265) { 266 const packageJsonPath = path.join( 267 sourceDirectory, 268 moduleConfig.packageJsonPath ?? 'package.json' 269 ); 270 const packageJson = require(packageJsonPath) as PackageJson; 271 const libs = packageJson?.codegenConfig?.libraries ?? []; 272 const fabricDisabledLibs = libs.filter((lib) => lib.type !== 'components'); 273 if (!fabricDisabledLibs.length) { 274 return; 275 } 276 if (platform !== 'android' && platform !== 'ios') { 277 throw new Error(`Unsupported platform - ${platform}`); 278 } 279 280 const reactNativeRoot = path.join(EXPO_DIR, 'react-native-lab', 'react-native'); 281 const codegenPkgRoot = path.join(reactNativeRoot, 'packages', 'react-native-codegen'); 282 283 await Promise.all( 284 fabricDisabledLibs.map((lib) => 285 runReactNativeCodegenAsync({ 286 reactNativeRoot, 287 codegenPkgRoot, 288 outputDir: path.join(sourceDirectory, platform), 289 name: lib.name, 290 type: lib.type, 291 platform, 292 jsSrcsDir: path.join(sourceDirectory, lib.jsSrcsDir), 293 }) 294 ) 295 ); 296} 297