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