1import { ExpoConfig, PackageJSONConfig } from '@expo/config';
2import JsonFile from '@expo/json-file';
3import assert from 'assert';
4import chalk from 'chalk';
5import resolveFrom from 'resolve-from';
6import semver from 'semver';
7
8import { BundledNativeModules } from './bundledNativeModules';
9import { getCombinedKnownVersionsAsync } from './getVersionedPackages';
10import * as Log from '../../../log';
11import { env } from '../../../utils/env';
12import { CommandError } from '../../../utils/errors';
13
14const debug = require('debug')('expo:doctor:dependencies:validate') as typeof console.log;
15
16interface IncorrectDependency {
17  packageName: string;
18  packageType: 'dependencies' | 'devDependencies';
19  expectedVersionOrRange: string;
20  actualVersion: string;
21}
22
23/**
24 * Print a list of incorrect dependency versions.
25 * This only checks dependencies when not running in offline mode.
26 *
27 * @param projectRoot Expo project root.
28 * @param exp Expo project config.
29 * @param pkg Project's `package.json`.
30 * @param packagesToCheck A list of packages to check, if undefined or empty, all will be checked.
31 * @returns `true` if there are no incorrect dependencies.
32 */
33export async function validateDependenciesVersionsAsync(
34  projectRoot: string,
35  exp: Pick<ExpoConfig, 'sdkVersion'>,
36  pkg: PackageJSONConfig,
37  packagesToCheck?: string[]
38): Promise<boolean | null> {
39  if (env.EXPO_OFFLINE) {
40    Log.warn('Skipping dependency validation in offline mode');
41    return null;
42  }
43
44  const incorrectDeps = await getVersionedDependenciesAsync(projectRoot, exp, pkg, packagesToCheck);
45  return logIncorrectDependencies(incorrectDeps);
46}
47
48function logInvalidDependency({
49  packageName,
50  expectedVersionOrRange,
51  actualVersion,
52}: IncorrectDependency) {
53  Log.warn(
54    // chalk` - {underline ${packageName}} - expected version: {underline ${expectedVersionOrRange}} - actual version installed: {underline ${actualVersion}}`
55    chalk`  {bold ${packageName}}{cyan @}{red ${actualVersion}} - expected version: {green ${expectedVersionOrRange}}`
56  );
57}
58
59export function logIncorrectDependencies(incorrectDeps: IncorrectDependency[]) {
60  if (!incorrectDeps.length) {
61    return true;
62  }
63
64  Log.warn(chalk`Some dependencies are incompatible with the installed {bold expo} version:`);
65  incorrectDeps.forEach((dep) => logInvalidDependency(dep));
66
67  Log.warn(
68    'Your project may not work correctly until you install the correct versions of the packages.\n' +
69      chalk`Fix with: {bold npx expo install --fix}`
70  );
71  return false;
72}
73
74/**
75 * Return a list of versioned dependencies for the project SDK version.
76 *
77 * @param projectRoot Expo project root.
78 * @param exp Expo project config.
79 * @param pkg Project's `package.json`.
80 * @param packagesToCheck A list of packages to check, if undefined or empty, all will be checked.
81 * @returns A list of incorrect dependencies.
82 */
83export async function getVersionedDependenciesAsync(
84  projectRoot: string,
85  exp: Pick<ExpoConfig, 'sdkVersion'>,
86  pkg: PackageJSONConfig,
87  packagesToCheck?: string[]
88): Promise<IncorrectDependency[]> {
89  // This should never happen under normal circumstances since
90  // the CLI is versioned in the `expo` package.
91  assert(exp.sdkVersion, 'SDK Version is missing');
92
93  // Get from both endpoints and combine the known package versions.
94  const combinedKnownPackages = await getCombinedKnownVersionsAsync({
95    projectRoot,
96    sdkVersion: exp.sdkVersion,
97  });
98  // debug(`Known dependencies: %O`, combinedKnownPackages);
99
100  const resolvedDependencies = packagesToCheck?.length
101    ? // Diff the provided packages to ensure we only check against installed packages.
102      getFilteredObject(packagesToCheck, { ...pkg.dependencies, ...pkg.devDependencies })
103    : // If no packages are provided, check against the `package.json` `dependencies` + `devDependencies` object.
104      { ...pkg.dependencies, ...pkg.devDependencies };
105  debug(`Checking dependencies for ${exp.sdkVersion}: %O`, resolvedDependencies);
106
107  // intersection of packages from package.json and bundled native modules
108  const { known: resolvedPackagesToCheck, unknown } = getPackagesToCheck(
109    resolvedDependencies,
110    combinedKnownPackages
111  );
112  debug(`Comparing known versions: %O`, resolvedPackagesToCheck);
113  debug(`Skipping packages that cannot be versioned automatically: %O`, unknown);
114  // read package versions from the file system (node_modules)
115  const packageVersions = await resolvePackageVersionsAsync(projectRoot, resolvedPackagesToCheck);
116  debug(`Package versions: %O`, packageVersions);
117  // find incorrect dependencies by comparing the actual package versions with the bundled native module version ranges
118  let incorrectDeps = findIncorrectDependencies(pkg, packageVersions, combinedKnownPackages);
119  debug(`Incorrect dependencies: %O`, incorrectDeps);
120
121  if (pkg?.expo?.install?.exclude) {
122    const packagesToExclude = pkg.expo.install.exclude;
123    const incorrectAndExcludedDeps = incorrectDeps.filter((dep) =>
124      packagesToExclude.includes(dep.packageName)
125    );
126    debug(
127      `Incorrect dependency warnings filtered out by expo.install.exclude: %O`,
128      incorrectAndExcludedDeps.map((dep) => dep.packageName)
129    );
130    incorrectDeps = incorrectDeps.filter((dep) => !packagesToExclude.includes(dep.packageName));
131  }
132
133  return incorrectDeps;
134}
135
136function getFilteredObject(keys: string[], object: Record<string, string>) {
137  return keys.reduce<Record<string, string>>((acc, key) => {
138    acc[key] = object[key];
139    return acc;
140  }, {});
141}
142
143function getPackagesToCheck(
144  dependencies: Record<string, string> | null | undefined,
145  bundledNativeModules: BundledNativeModules
146): { known: string[]; unknown: string[] } {
147  const dependencyNames = Object.keys(dependencies ?? {});
148  const known: string[] = [];
149  const unknown: string[] = [];
150  for (const dependencyName of dependencyNames) {
151    if (dependencyName in bundledNativeModules) {
152      known.push(dependencyName);
153    } else {
154      unknown.push(dependencyName);
155    }
156  }
157  return { known, unknown };
158}
159
160async function resolvePackageVersionsAsync(
161  projectRoot: string,
162  packages: string[]
163): Promise<Record<string, string>> {
164  const packageVersionsFromPackageJSON = await Promise.all(
165    packages.map((packageName) => getPackageVersionAsync(projectRoot, packageName))
166  );
167  return packages.reduce(
168    (acc, packageName, idx) => {
169      acc[packageName] = packageVersionsFromPackageJSON[idx];
170      return acc;
171    },
172    {} as Record<string, string>
173  );
174}
175
176async function getPackageVersionAsync(projectRoot: string, packageName: string): Promise<string> {
177  let packageJsonPath: string | undefined;
178  try {
179    packageJsonPath = resolveFrom(projectRoot, `${packageName}/package.json`);
180  } catch (error: any) {
181    // This is a workaround for packages using `exports`. If this doesn't
182    // include `package.json`, we have to use the error message to get the location.
183    if (error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
184      packageJsonPath = error.message.match(/("exports"|defined) in (.*)$/i)?.[2];
185    }
186  }
187  if (!packageJsonPath) {
188    throw new CommandError(
189      `"${packageName}" is added as a dependency in your project's package.json but it doesn't seem to be installed. Please run "yarn" or "npm install" to fix this issue.`
190    );
191  }
192  const packageJson = await JsonFile.readAsync<BundledNativeModules>(packageJsonPath);
193  return packageJson.version;
194}
195
196function findIncorrectDependencies(
197  pkg: PackageJSONConfig,
198  packageVersions: Record<string, string>,
199  bundledNativeModules: BundledNativeModules
200): IncorrectDependency[] {
201  const packages = Object.keys(packageVersions);
202  const incorrectDeps: IncorrectDependency[] = [];
203  for (const packageName of packages) {
204    const expectedVersionOrRange = bundledNativeModules[packageName];
205    const actualVersion = packageVersions[packageName];
206    if (
207      typeof expectedVersionOrRange === 'string' &&
208      !semver.intersects(expectedVersionOrRange, actualVersion)
209    ) {
210      incorrectDeps.push({
211        packageName,
212        packageType: findDependencyType(pkg, packageName),
213        expectedVersionOrRange,
214        actualVersion,
215      });
216    }
217  }
218  return incorrectDeps;
219}
220
221function findDependencyType(
222  pkg: PackageJSONConfig,
223  packageName: string
224): IncorrectDependency['packageType'] {
225  if (pkg.devDependencies && packageName in pkg.devDependencies) {
226    return 'devDependencies';
227  }
228
229  return 'dependencies';
230}
231