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