1import { getConfig } from '@expo/config';
2import * as PackageManager from '@expo/package-manager';
3import chalk from 'chalk';
4
5import { checkPackagesAsync } from './checkPackages';
6import { Options } from './resolveOptions';
7import * as Log from '../log';
8import {
9  getOperationLog,
10  getVersionedPackagesAsync,
11} from '../start/doctor/dependencies/getVersionedPackages';
12import { getVersionedDependenciesAsync } from '../start/doctor/dependencies/validateDependenciesVersions';
13import { groupBy } from '../utils/array';
14import { findUpProjectRootOrAssert } from '../utils/findUp';
15import { learnMore } from '../utils/link';
16import { setNodeEnv } from '../utils/nodeEnv';
17import { joinWithCommasAnd } from '../utils/strings';
18
19export async function installAsync(
20  packages: string[],
21  options: Options & { projectRoot?: string },
22  packageManagerArguments: string[] = []
23) {
24  setNodeEnv('development');
25  // Locate the project root based on the process current working directory.
26  // This enables users to run `npx expo install` from a subdirectory of the project.
27  const projectRoot = options.projectRoot ?? findUpProjectRootOrAssert(process.cwd());
28  require('@expo/env').load(projectRoot);
29
30  // Resolve the package manager used by the project, or based on the provided arguments.
31  const packageManager = PackageManager.createForProject(projectRoot, {
32    npm: options.npm,
33    yarn: options.yarn,
34    pnpm: options.pnpm,
35    silent: options.silent,
36    log: Log.log,
37  });
38
39  if (options.check || options.fix) {
40    return await checkPackagesAsync(projectRoot, {
41      packages,
42      options,
43      packageManager,
44      packageManagerArguments,
45    });
46  }
47
48  // Read the project Expo config without plugins.
49  const { exp } = getConfig(projectRoot, {
50    // Sometimes users will add a plugin to the config before installing the library,
51    // this wouldn't work unless we dangerously disable plugin serialization.
52    skipPlugins: true,
53  });
54
55  // Resolve the versioned packages, then install them.
56  return installPackagesAsync(projectRoot, {
57    packageManager,
58    packages,
59    packageManagerArguments,
60    sdkVersion: exp.sdkVersion!,
61  });
62}
63
64/** Version packages and install in a project. */
65export async function installPackagesAsync(
66  projectRoot: string,
67  {
68    packages,
69    packageManager,
70    sdkVersion,
71    packageManagerArguments,
72  }: {
73    /**
74     * List of packages to version, grouped by the type of dependency.
75     * @example ['uuid', 'react-native-reanimated@latest']
76     */
77    packages: string[];
78    /** Package manager to use when installing the versioned packages. */
79    packageManager: PackageManager.NodePackageManager;
80    /**
81     * SDK to version `packages` for.
82     * @example '44.0.0'
83     */
84    sdkVersion: string;
85    /**
86     * Extra parameters to pass to the `packageManager` when installing versioned packages.
87     * @example ['--no-save']
88     */
89    packageManagerArguments: string[];
90  }
91): Promise<void> {
92  // Read the project Expo config without plugins.
93  const { pkg } = getConfig(projectRoot, {
94    // Sometimes users will add a plugin to the config before installing the library,
95    // this wouldn't work unless we dangerously disable plugin serialization.
96    skipPlugins: true,
97  });
98
99  //assertNotInstallingExcludedPackages(projectRoot, packages, pkg);
100
101  const versioning = await getVersionedPackagesAsync(projectRoot, {
102    packages,
103    // sdkVersion is always defined because we don't skipSDKVersionRequirement in getConfig.
104    sdkVersion,
105    pkg,
106  });
107
108  Log.log(
109    chalk`\u203A Installing ${
110      versioning.messages.length ? versioning.messages.join(' and ') + ' ' : ''
111    }using {bold ${packageManager.name}}`
112  );
113
114  if (versioning.excludedNativeModules.length) {
115    Log.log(
116      chalk`\u203A Using latest version instead of ${joinWithCommasAnd(
117        versioning.excludedNativeModules.map(
118          ({ bundledNativeVersion, name }) => `${bundledNativeVersion} for ${name}`
119        )
120      )} because ${
121        versioning.excludedNativeModules.length > 1 ? 'they are' : 'it is'
122      } listed in {bold expo.install.exclude} in package.json. ${learnMore(
123        'https://expo.dev/more/expo-cli/#configuring-dependency-validation'
124      )}`
125    );
126  }
127
128  await packageManager.addAsync([...packageManagerArguments, ...versioning.packages]);
129
130  await applyPluginsAsync(projectRoot, versioning.packages);
131}
132
133export async function fixPackagesAsync(
134  projectRoot: string,
135  {
136    packages,
137    packageManager,
138    sdkVersion,
139    packageManagerArguments,
140  }: {
141    packages: Awaited<ReturnType<typeof getVersionedDependenciesAsync>>;
142    /** Package manager to use when installing the versioned packages. */
143    packageManager: PackageManager.NodePackageManager;
144    /**
145     * SDK to version `packages` for.
146     * @example '44.0.0'
147     */
148    sdkVersion: string;
149    /**
150     * Extra parameters to pass to the `packageManager` when installing versioned packages.
151     * @example ['--no-save']
152     */
153    packageManagerArguments: string[];
154  }
155): Promise<void> {
156  if (!packages.length) {
157    return;
158  }
159
160  const { dependencies = [], devDependencies = [] } = groupBy(packages, (dep) => dep.packageType);
161  const versioningMessages = getOperationLog({
162    othersCount: 0, // All fixable packages are versioned
163    nativeModulesCount: packages.length,
164    sdkVersion,
165  });
166
167  Log.log(
168    chalk`\u203A Installing ${
169      versioningMessages.length ? versioningMessages.join(' and ') + ' ' : ''
170    }using {bold ${packageManager.name}}`
171  );
172
173  if (dependencies.length) {
174    const versionedPackages = dependencies.map(
175      (dep) => `${dep.packageName}@${dep.expectedVersionOrRange}`
176    );
177
178    await packageManager.addAsync([...packageManagerArguments, ...versionedPackages]);
179
180    await applyPluginsAsync(projectRoot, versionedPackages);
181  }
182
183  if (devDependencies.length) {
184    await packageManager.addDevAsync([
185      ...packageManagerArguments,
186      ...devDependencies.map((dep) => `${dep.packageName}@${dep.expectedVersionOrRange}`),
187    ]);
188  }
189}
190
191/**
192 * A convenience feature for automatically applying Expo Config Plugins to the `app.json` after installing them.
193 * This should be dropped in favor of autolinking in the future.
194 */
195async function applyPluginsAsync(projectRoot: string, packages: string[]) {
196  const { autoAddConfigPluginsAsync } = await import('./utils/autoAddConfigPlugins');
197
198  try {
199    const { exp } = getConfig(projectRoot, { skipSDKVersionRequirement: true });
200
201    // Only auto add plugins if the plugins array is defined or if the project is using SDK +42.
202    await autoAddConfigPluginsAsync(
203      projectRoot,
204      exp,
205      // Split any possible NPM tags. i.e. `expo@latest` -> `expo`
206      packages.map((pkg) => pkg.split('@')[0]).filter(Boolean)
207    );
208  } catch (error: any) {
209    // If we fail to apply plugins, the log a warning and continue.
210    if (error.isPluginError) {
211      Log.warn(`Skipping config plugin check: ` + error.message);
212      return;
213    }
214    // Any other error, rethrow.
215    throw error;
216  }
217}
218