import { Command } from '@expo/commander'; import spawnAsync from '@expo/spawn-async'; import chalk from 'chalk'; import fs from 'fs-extra'; import inquirer from 'inquirer'; import os from 'os'; import path from 'path'; import { runReactNativeCodegenAsync } from '../Codegen'; import { EXPO_DIR } from '../Constants'; import { GitDirectory } from '../Git'; import logger from '../Logger'; import { downloadPackageTarballAsync } from '../Npm'; import { PackageJson } from '../Packages'; import { updateBundledVersionsAsync } from '../ProjectVersions'; import * as Workspace from '../Workspace'; import { getVendoringAvailablePlatforms, listAvailableVendoredModulesAsync, vendorPlatformAsync, } from '../vendoring'; import vendoredModulesConfig from '../vendoring/config'; import { legacyVendorModuleAsync } from '../vendoring/legacy'; import { VendoringModuleConfig, VendoringTargetConfig } from '../vendoring/types'; type ActionOptions = { list: boolean; listOutdated: boolean; target: string; module: string; platform: string; commit: string; semverPrefix: string; updateDependencies?: boolean; }; const EXPO_GO_TARGET = 'expo-go'; export default (program: Command) => { program .command('update-vendored-module') .alias('update-module', 'uvm') .description('Updates 3rd party modules.') .option('-l, --list', 'Shows a list of available 3rd party modules.', false) .option('-o, --list-outdated', 'Shows a list of outdated 3rd party modules.', false) .option( '-t, --target ', 'The target to update, e.g. Expo Go or development client.', EXPO_GO_TARGET ) .option('-m, --module ', 'Name of the module to update.') .option( '-p, --platform ', 'A platform on which the vendored module will be updated.', 'all' ) .option( '-c, --commit ', 'Git reference on which to checkout when copying 3rd party module.', 'master' ) .option( '-s, --semver-prefix ', '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.', null ) .option( '-u, --update-dependencies', 'Whether to update workspace dependencies and bundled native modules.', true ) .asyncAction(action); }; async function action(options: ActionOptions) { const target = await resolveTargetNameAsync(options.target); const targetConfig = vendoredModulesConfig[target]; if (options.list || options.listOutdated) { if (target !== EXPO_GO_TARGET) { throw new Error(`Listing vendored modules for target "${target}" is not supported.`); } await listAvailableVendoredModulesAsync(targetConfig.modules, options.listOutdated); return; } const moduleName = await resolveModuleNameAsync(options.module, targetConfig); const downloadSourceDir = path.join(os.tmpdir(), 'ExpoVendoredModules', moduleName); const moduleConfig = targetConfig.modules[moduleName]; try { await downloadSourceAsync(downloadSourceDir, moduleName, moduleConfig, options); const sourceDirectory = moduleConfig.rootDir ? path.join(downloadSourceDir, moduleConfig.rootDir) : downloadSourceDir; const platforms = resolvePlatforms(options.platform); for (const platform of platforms) { if (!targetConfig.platforms[platform]) { continue; } await runCodegenIfNeeded(sourceDirectory, moduleConfig, platform); // TODO(@tsapeta): Remove this once all vendored modules are migrated to the new system. if (!targetConfig.modules[moduleName][platform]) { // If the target doesn't support this platform, maybe legacy vendoring does. logger.info('‼️ Using legacy vendoring for platform %s', chalk.yellow(platform)); await legacyVendorModuleAsync(moduleName, platform, sourceDirectory); continue; } const relativeTargetDirectory = path.join( targetConfig.platforms[platform].targetDirectory, moduleName ); const targetDirectory = path.join(EXPO_DIR, relativeTargetDirectory); logger.log( '🎯 Vendoring for %s to %s', chalk.yellow(platform), chalk.magenta(relativeTargetDirectory) ); // Clean up previous version await fs.remove(targetDirectory); // Delegate further steps to platform's provider await vendorPlatformAsync(platform, sourceDirectory, targetDirectory, moduleConfig[platform]); } // Update dependency versions only for Expo Go target. if (options.updateDependencies !== false && target === EXPO_GO_TARGET) { const packageJsonPath = path.join( sourceDirectory, moduleConfig.packageJsonPath ?? 'package.json' ); const packageJson = require(packageJsonPath) as PackageJson; const semverPrefix = (options.semverPrefix != null ? options.semverPrefix : moduleConfig.semverPrefix) || ''; const newVersionRange = `${semverPrefix}${packageJson.version}`; await updateDependenciesAsync(moduleName, newVersionRange); } } finally { // Clean cloned repo await fs.remove(downloadSourceDir); } logger.success('💪 Successfully updated %s\n', chalk.bold(moduleName)); } /** * Downloads vendoring module source code either from git repository or npm */ async function downloadSourceAsync( sourceDirectory: string, moduleName: string, moduleConfig: VendoringModuleConfig, options: ActionOptions ) { if (moduleConfig.sourceType === 'npm') { const version = options.commit ?? 'latest'; logger.log('📥 Downloading %s@%s from npm', chalk.green(moduleName), chalk.cyan(version)); const tarball = await downloadPackageTarballAsync( sourceDirectory, moduleConfig.source, version ); // `--strip-component 1` to extract files from package/ folder await spawnAsync('tar', ['--strip-component', '1', '-xf', tarball], { cwd: sourceDirectory }); return; } // Clone repository from the source logger.log( '📥 Cloning %s#%s from %s', chalk.green(moduleName), chalk.cyan(options.commit), chalk.magenta(moduleConfig.source) ); await GitDirectory.shallowCloneAsync( sourceDirectory, moduleConfig.source, options.commit ?? 'master' ); } /** * Updates versions in bundled native modules and workspace projects. */ async function updateDependenciesAsync(moduleName: string, versionRange: string) { logger.log('✍️ Updating bundled native modules'); await updateBundledVersionsAsync({ [moduleName]: versionRange, }); logger.log('✍️ Updating workspace dependencies'); await Workspace.updateDependencyAsync(moduleName, versionRange); } /** * Validates provided target name or prompts for the valid one. */ async function resolveTargetNameAsync(providedTargetName: string): Promise { const targets = Object.keys(vendoredModulesConfig); if (providedTargetName) { if (targets.includes(providedTargetName)) { return providedTargetName; } throw new Error(`Couldn't find config for ${providedTargetName} target.`); } const { targetName } = await inquirer.prompt([ { type: 'list', name: 'targetName', prefix: '❔', message: 'In which target do you want to update vendored module?', choices: targets.map((target) => ({ name: vendoredModulesConfig[target].name, value: target, })), }, ]); return targetName; } /** * Validates provided module name or prompts for the valid one. */ async function resolveModuleNameAsync( providedModuleName: string, targetConfig: VendoringTargetConfig ): Promise { const moduleNames = Object.keys(targetConfig.modules); if (providedModuleName) { if (moduleNames.includes(providedModuleName)) { return providedModuleName; } throw new Error(`Couldn't find config for ${providedModuleName} module.`); } const { moduleName } = await inquirer.prompt([ { type: 'list', name: 'moduleName', prefix: '❔', message: 'Which vendored module do you want to update?', choices: moduleNames, }, ]); return moduleName; } function resolvePlatforms(platform: string): string[] { const all = getVendoringAvailablePlatforms(); return all.includes(platform) ? [platform] : all; } async function runCodegenIfNeeded( sourceDirectory: string, moduleConfig: VendoringModuleConfig, platform: string ) { const packageJsonPath = path.join( sourceDirectory, moduleConfig.packageJsonPath ?? 'package.json' ); const packageJson = require(packageJsonPath) as PackageJson; const libs = packageJson?.codegenConfig?.libraries ?? []; const fabricDisabledLibs = libs.filter((lib) => lib.type !== 'components'); if (!fabricDisabledLibs.length) { return; } if (platform !== 'android' && platform !== 'ios') { throw new Error(`Unsupported platform - ${platform}`); } const reactNativeRoot = path.join(EXPO_DIR, 'react-native-lab', 'react-native'); const codegenPkgRoot = path.join(reactNativeRoot, 'packages', 'react-native-codegen'); await Promise.all( fabricDisabledLibs.map((lib) => runReactNativeCodegenAsync({ reactNativeRoot, codegenPkgRoot, outputDir: path.join(sourceDirectory, platform), name: lib.name, type: lib.type, platform, jsSrcsDir: path.join(sourceDirectory, lib.jsSrcsDir), }) ) ); }