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