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