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 { APISettings } from '../../../api/settings';
9import * as Log from '../../../log';
10import { CommandError } from '../../../utils/errors';
11import { BundledNativeModules } from './bundledNativeModules';
12import { getCombinedKnownVersionsAsync } from './getVersionedPackages';
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 (APISettings.isOffline) {
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  const incorrectDeps = findIncorrectDependencies(pkg, packageVersions, combinedKnownPackages);
119  debug(`Incorrect dependencies: %O`, incorrectDeps);
120
121  return incorrectDeps;
122}
123
124function getFilteredObject(keys: string[], object: Record<string, string>) {
125  return keys.reduce<Record<string, string>>((acc, key) => {
126    acc[key] = object[key];
127    return acc;
128  }, {});
129}
130
131function getPackagesToCheck(
132  dependencies: Record<string, string> | null | undefined,
133  bundledNativeModules: BundledNativeModules
134): { known: string[]; unknown: string[] } {
135  const dependencyNames = Object.keys(dependencies ?? {});
136  const known: string[] = [];
137  const unknown: string[] = [];
138  for (const dependencyName of dependencyNames) {
139    if (dependencyName in bundledNativeModules) {
140      known.push(dependencyName);
141    } else {
142      unknown.push(dependencyName);
143    }
144  }
145  return { known, unknown };
146}
147
148async function resolvePackageVersionsAsync(
149  projectRoot: string,
150  packages: string[]
151): Promise<Record<string, string>> {
152  const packageVersionsFromPackageJSON = await Promise.all(
153    packages.map((packageName) => getPackageVersionAsync(projectRoot, packageName))
154  );
155  return packages.reduce((acc, packageName, idx) => {
156    acc[packageName] = packageVersionsFromPackageJSON[idx];
157    return acc;
158  }, {} as Record<string, string>);
159}
160
161async function getPackageVersionAsync(projectRoot: string, packageName: string): Promise<string> {
162  let packageJsonPath: string | undefined;
163  try {
164    packageJsonPath = resolveFrom(projectRoot, `${packageName}/package.json`);
165  } catch (error: any) {
166    // This is a workaround for packages using `exports`. If this doesn't
167    // include `package.json`, we have to use the error message to get the location.
168    if (error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
169      packageJsonPath = error.message.match(/("exports"|defined) in (.*)$/i)?.[2];
170    }
171  }
172  if (!packageJsonPath) {
173    throw new CommandError(
174      `"${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.`
175    );
176  }
177  const packageJson = await JsonFile.readAsync<BundledNativeModules>(packageJsonPath);
178  return packageJson.version;
179}
180
181function findIncorrectDependencies(
182  pkg: PackageJSONConfig,
183  packageVersions: Record<string, string>,
184  bundledNativeModules: BundledNativeModules
185): IncorrectDependency[] {
186  const packages = Object.keys(packageVersions);
187  const incorrectDeps: IncorrectDependency[] = [];
188  for (const packageName of packages) {
189    const expectedVersionOrRange = bundledNativeModules[packageName];
190    const actualVersion = packageVersions[packageName];
191    if (
192      typeof expectedVersionOrRange === 'string' &&
193      !semver.intersects(expectedVersionOrRange, actualVersion)
194    ) {
195      incorrectDeps.push({
196        packageName,
197        packageType: findDependencyType(pkg, packageName),
198        expectedVersionOrRange,
199        actualVersion,
200      });
201    }
202  }
203  return incorrectDeps;
204}
205
206function findDependencyType(
207  pkg: PackageJSONConfig,
208  packageName: string
209): IncorrectDependency['packageType'] {
210  if (pkg.devDependencies && packageName in pkg.devDependencies) {
211    return 'devDependencies';
212  }
213
214  return 'dependencies';
215}
216