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