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