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