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