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