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