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