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