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