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 packageJson = require(path.join(sourceDirectory, 'package.json')) as PackageJson;
140      const semverPrefix =
141        (options.semverPrefix != null ? options.semverPrefix : moduleConfig.semverPrefix) || '';
142      const newVersionRange = `${semverPrefix}${packageJson.version}`;
143
144      await updateDependenciesAsync(moduleName, newVersionRange);
145    }
146  } finally {
147    // Clean cloned repo
148    await fs.remove(sourceDirectory);
149  }
150  logger.success('�� Successfully updated %s\n', chalk.bold(moduleName));
151}
152
153/**
154 * Updates versions in bundled native modules and workspace projects.
155 */
156async function updateDependenciesAsync(moduleName: string, versionRange: string) {
157  logger.log('✍️  Updating bundled native modules');
158
159  await updateBundledVersionsAsync({
160    [moduleName]: versionRange,
161  });
162
163  logger.log('✍️  Updating workspace dependencies');
164
165  await Workspace.updateDependencyAsync(moduleName, versionRange);
166}
167
168/**
169 * Validates provided target name or prompts for the valid one.
170 */
171async function resolveTargetNameAsync(providedTargetName: string): Promise<string> {
172  const targets = Object.keys(vendoredModulesConfig);
173
174  if (providedTargetName) {
175    if (targets.includes(providedTargetName)) {
176      return providedTargetName;
177    }
178    throw new Error(`Couldn't find config for ${providedTargetName} target.`);
179  }
180  const { targetName } = await inquirer.prompt([
181    {
182      type: 'list',
183      name: 'targetName',
184      prefix: '❔',
185      message: 'In which target do you want to update vendored module?',
186      choices: targets.map((target) => ({
187        name: vendoredModulesConfig[target].name,
188        value: target,
189      })),
190    },
191  ]);
192  return targetName;
193}
194
195/**
196 * Validates provided module name or prompts for the valid one.
197 */
198async function resolveModuleNameAsync(
199  providedModuleName: string,
200  targetConfig: VendoringTargetConfig
201): Promise<string> {
202  const moduleNames = Object.keys(targetConfig.modules);
203
204  if (providedModuleName) {
205    if (moduleNames.includes(providedModuleName)) {
206      return providedModuleName;
207    }
208    throw new Error(`Couldn't find config for ${providedModuleName} module.`);
209  }
210  const { moduleName } = await inquirer.prompt([
211    {
212      type: 'list',
213      name: 'moduleName',
214      prefix: '❔',
215      message: 'Which vendored module do you want to update?',
216      choices: moduleNames,
217    },
218  ]);
219  return moduleName;
220}
221
222function resolvePlatforms(platform: string): string[] {
223  const all = getVendoringAvailablePlatforms();
224  return all.includes(platform) ? [platform] : all;
225}
226