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