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 downloadSourceDir = path.join(os.tmpdir(), 'ExpoVendoredModules', moduleName); 89 const moduleConfig = targetConfig.modules[moduleName]; 90 91 try { 92 await downloadSourceAsync(downloadSourceDir, moduleName, moduleConfig, options); 93 const sourceDirectory = moduleConfig.rootDir 94 ? path.join(downloadSourceDir, moduleConfig.rootDir) 95 : downloadSourceDir; 96 97 const platforms = resolvePlatforms(options.platform); 98 99 for (const platform of platforms) { 100 if (!targetConfig.platforms[platform]) { 101 continue; 102 } 103 await runCodegenIfNeeded(sourceDirectory, moduleConfig, platform); 104 105 // TODO(@tsapeta): Remove this once all vendored modules are migrated to the new system. 106 if (!targetConfig.modules[moduleName][platform]) { 107 // If the target doesn't support this platform, maybe legacy vendoring does. 108 logger.info('‼️ Using legacy vendoring for platform %s', chalk.yellow(platform)); 109 await legacyVendorModuleAsync(moduleName, platform, sourceDirectory); 110 continue; 111 } 112 113 const relativeTargetDirectory = path.join( 114 targetConfig.platforms[platform].targetDirectory, 115 moduleName 116 ); 117 const targetDirectory = path.join(EXPO_DIR, relativeTargetDirectory); 118 119 logger.log( 120 ' Vendoring for %s to %s', 121 chalk.yellow(platform), 122 chalk.magenta(relativeTargetDirectory) 123 ); 124 125 // Clean up previous version 126 await fs.remove(targetDirectory); 127 128 // Delegate further steps to platform's provider 129 await vendorPlatformAsync(platform, sourceDirectory, targetDirectory, moduleConfig[platform]); 130 } 131 132 // Update dependency versions only for Expo Go target. 133 if (options.updateDependencies !== false && target === EXPO_GO_TARGET) { 134 const packageJsonPath = path.join( 135 sourceDirectory, 136 moduleConfig.packageJsonPath ?? 'package.json' 137 ); 138 const packageJson = require(packageJsonPath) as PackageJson; 139 const semverPrefix = 140 (options.semverPrefix != null ? options.semverPrefix : moduleConfig.semverPrefix) || ''; 141 const newVersionRange = `${semverPrefix}${packageJson.version}`; 142 143 await updateDependenciesAsync(moduleName, newVersionRange); 144 } 145 } finally { 146 // Clean cloned repo 147 await fs.remove(downloadSourceDir); 148 } 149 logger.success(' Successfully updated %s\n', chalk.bold(moduleName)); 150} 151 152/** 153 * Downloads vendoring module source code either from git repository or npm 154 */ 155async function downloadSourceAsync( 156 sourceDirectory: string, 157 moduleName: string, 158 moduleConfig: VendoringModuleConfig, 159 options: ActionOptions 160) { 161 if (moduleConfig.sourceType === 'npm') { 162 const version = options.commit ?? 'latest'; 163 logger.log(' Downloading %s@%s from npm', chalk.green(moduleName), chalk.cyan(version)); 164 165 const tarball = await downloadPackageTarballAsync( 166 sourceDirectory, 167 moduleConfig.source, 168 version 169 ); 170 // `--strip-component 1` to extract files from package/ folder 171 await spawnAsync('tar', ['--strip-component', '1', '-xf', tarball], { cwd: sourceDirectory }); 172 return; 173 } 174 175 // Clone repository from the source 176 logger.log( 177 ' Cloning %s#%s from %s', 178 chalk.green(moduleName), 179 chalk.cyan(options.commit), 180 chalk.magenta(moduleConfig.source) 181 ); 182 183 await GitDirectory.shallowCloneAsync( 184 sourceDirectory, 185 moduleConfig.source, 186 options.commit ?? 'master' 187 ); 188} 189 190/** 191 * Updates versions in bundled native modules and workspace projects. 192 */ 193async function updateDependenciesAsync(moduleName: string, versionRange: string) { 194 logger.log('✍️ Updating bundled native modules'); 195 196 await updateBundledVersionsAsync({ 197 [moduleName]: versionRange, 198 }); 199 200 logger.log('✍️ Updating workspace dependencies'); 201 202 await Workspace.updateDependencyAsync(moduleName, versionRange); 203} 204 205/** 206 * Validates provided target name or prompts for the valid one. 207 */ 208async function resolveTargetNameAsync(providedTargetName: string): Promise<string> { 209 const targets = Object.keys(vendoredModulesConfig); 210 211 if (providedTargetName) { 212 if (targets.includes(providedTargetName)) { 213 return providedTargetName; 214 } 215 throw new Error(`Couldn't find config for ${providedTargetName} target.`); 216 } 217 const { targetName } = await inquirer.prompt([ 218 { 219 type: 'list', 220 name: 'targetName', 221 prefix: '❔', 222 message: 'In which target do you want to update vendored module?', 223 choices: targets.map((target) => ({ 224 name: vendoredModulesConfig[target].name, 225 value: target, 226 })), 227 }, 228 ]); 229 return targetName; 230} 231 232/** 233 * Validates provided module name or prompts for the valid one. 234 */ 235async function resolveModuleNameAsync( 236 providedModuleName: string, 237 targetConfig: VendoringTargetConfig 238): Promise<string> { 239 const moduleNames = Object.keys(targetConfig.modules); 240 241 if (providedModuleName) { 242 if (moduleNames.includes(providedModuleName)) { 243 return providedModuleName; 244 } 245 throw new Error(`Couldn't find config for ${providedModuleName} module.`); 246 } 247 const { moduleName } = await inquirer.prompt([ 248 { 249 type: 'list', 250 name: 'moduleName', 251 prefix: '❔', 252 message: 'Which vendored module do you want to update?', 253 choices: moduleNames, 254 }, 255 ]); 256 return moduleName; 257} 258 259function resolvePlatforms(platform: string): string[] { 260 const all = getVendoringAvailablePlatforms(); 261 return all.includes(platform) ? [platform] : all; 262} 263 264async function runCodegenIfNeeded( 265 sourceDirectory: string, 266 moduleConfig: VendoringModuleConfig, 267 platform: string 268) { 269 const packageJsonPath = path.join( 270 sourceDirectory, 271 moduleConfig.packageJsonPath ?? 'package.json' 272 ); 273 const packageJson = require(packageJsonPath) as PackageJson; 274 const libs = packageJson?.codegenConfig?.libraries ?? []; 275 const fabricDisabledLibs = libs.filter((lib) => lib.type !== 'components'); 276 if (!fabricDisabledLibs.length) { 277 return; 278 } 279 if (platform !== 'android' && platform !== 'ios') { 280 throw new Error(`Unsupported platform - ${platform}`); 281 } 282 283 const reactNativeRoot = path.join(EXPO_DIR, 'react-native-lab', 'react-native'); 284 const codegenPkgRoot = path.join(reactNativeRoot, 'packages', 'react-native-codegen'); 285 286 await Promise.all( 287 fabricDisabledLibs.map((lib) => 288 runReactNativeCodegenAsync({ 289 reactNativeRoot, 290 codegenPkgRoot, 291 outputDir: path.join(sourceDirectory, platform), 292 name: lib.name, 293 type: lib.type, 294 platform, 295 jsSrcsDir: path.join(sourceDirectory, lib.jsSrcsDir), 296 }) 297 ) 298 ); 299} 300