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