1import { getPackageJson, PackageJSONConfig } from '@expo/config'; 2import chalk from 'chalk'; 3import crypto from 'crypto'; 4import fs from 'fs'; 5import path from 'path'; 6import { intersects as semverIntersects } from 'semver'; 7 8import * as Log from '../log'; 9import { isModuleSymlinked } from '../utils/isModuleSymlinked'; 10import { logNewSection } from '../utils/ora'; 11 12export type DependenciesMap = { [key: string]: string | number }; 13 14export type DependenciesModificationResults = { 15 /** Indicates that new values were added to the `dependencies` object in the `package.json`. */ 16 hasNewDependencies: boolean; 17 /** Indicates that new values were added to the `devDependencies` object in the `package.json`. */ 18 hasNewDevDependencies: boolean; 19}; 20 21/** Modifies the `package.json` with `modifyPackageJson` and format/displays the results. */ 22export async function updatePackageJSONAsync( 23 projectRoot: string, 24 { 25 templateDirectory, 26 pkg, 27 skipDependencyUpdate, 28 }: { 29 templateDirectory: string; 30 pkg: PackageJSONConfig; 31 skipDependencyUpdate?: string[]; 32 } 33): Promise<DependenciesModificationResults> { 34 const updatingPackageJsonStep = logNewSection( 35 'Updating your package.json scripts, dependencies, and main file' 36 ); 37 38 const templatePkg = getPackageJson(templateDirectory); 39 40 const results = modifyPackageJson(projectRoot, { 41 templatePkg, 42 pkg, 43 skipDependencyUpdate, 44 }); 45 46 await fs.promises.writeFile( 47 path.resolve(projectRoot, 'package.json'), 48 // Add new line to match the format of running yarn. 49 // This prevents the `package.json` from changing when running `prebuild --no-install` multiple times. 50 JSON.stringify(pkg, null, 2) + '\n' 51 ); 52 53 updatingPackageJsonStep.succeed( 54 'Updated package.json and added index.js entry point for iOS and Android' 55 ); 56 57 return results; 58} 59 60/** 61 * Make required modifications to the `package.json` file as a JSON object. 62 * 63 * 1. Update `package.json` `scripts`. 64 * 2. Update `package.json` `dependencies` and `devDependencies`. 65 * 3. Update `package.json` `main`. 66 * 67 * @param projectRoot The root directory of the project. 68 * @param props.templatePkg Template project package.json as JSON. 69 * @param props.pkg Current package.json as JSON. 70 * @param props.skipDependencyUpdate Array of dependencies to skip updating. 71 * @returns 72 */ 73function modifyPackageJson( 74 projectRoot: string, 75 { 76 templatePkg, 77 pkg, 78 skipDependencyUpdate, 79 }: { 80 templatePkg: PackageJSONConfig; 81 pkg: PackageJSONConfig; 82 skipDependencyUpdate?: string[]; 83 } 84) { 85 updatePkgScripts({ pkg }); 86 87 // TODO: Move to `npx expo-doctor` 88 return updatePkgDependencies(projectRoot, { 89 pkg, 90 templatePkg, 91 skipDependencyUpdate, 92 }); 93} 94 95/** 96 * Update package.json dependencies by combining the dependencies in the project we are ejecting 97 * with the dependencies in the template project. Does the same for devDependencies. 98 * 99 * - The template may have some dependencies beyond react/react-native/react-native-unimodules, 100 * for example RNGH and Reanimated. We should prefer the version that is already being used 101 * in the project for those, but swap the react/react-native/react-native-unimodules versions 102 * with the ones in the template. 103 * - The same applies to expo-updates -- since some native project configuration may depend on the 104 * version, we should always use the version of expo-updates in the template. 105 * 106 * > Exposed for testing. 107 */ 108export function updatePkgDependencies( 109 projectRoot: string, 110 { 111 pkg, 112 templatePkg, 113 skipDependencyUpdate = [], 114 }: { 115 pkg: PackageJSONConfig; 116 templatePkg: PackageJSONConfig; 117 skipDependencyUpdate?: string[]; 118 } 119): DependenciesModificationResults { 120 if (!pkg.devDependencies) { 121 pkg.devDependencies = {}; 122 } 123 const { dependencies, devDependencies } = templatePkg; 124 const defaultDependencies = createDependenciesMap(dependencies); 125 const defaultDevDependencies = createDependenciesMap(devDependencies); 126 127 const combinedDependencies: DependenciesMap = createDependenciesMap({ 128 ...defaultDependencies, 129 ...pkg.dependencies, 130 }); 131 132 const addWhenMissingDependencies = ['react', 'react-native'].filter( 133 (depKey) => !!defaultDependencies[depKey] 134 ); 135 const requiredDependencies = ['expo', 'expo-splash-screen', 'react', 'react-native'].filter( 136 (depKey) => !!defaultDependencies[depKey] 137 ); 138 139 const symlinkedPackages: string[] = []; 140 const nonRecommendedPackages: string[] = []; 141 142 for (const dependenciesKey of requiredDependencies) { 143 // If the local package.json defined the dependency that we want to overwrite... 144 if (pkg.dependencies?.[dependenciesKey]) { 145 // Then ensure it isn't symlinked (i.e. the user has a custom version in their yarn workspace). 146 if (isModuleSymlinked(projectRoot, { moduleId: dependenciesKey, isSilent: true })) { 147 // If the package is in the project's package.json and it's symlinked, then skip overwriting it. 148 symlinkedPackages.push(dependenciesKey); 149 continue; 150 } 151 152 // Do not modify manually skipped dependencies 153 if (skipDependencyUpdate.includes(dependenciesKey)) { 154 continue; 155 } 156 157 // Ensure the package only needs to be added when missing 158 if (addWhenMissingDependencies.includes(dependenciesKey)) { 159 let projectHasRecommended: boolean | null = null; 160 // Check if the version intersects with the recommended versions 161 try { 162 projectHasRecommended = semverIntersects( 163 pkg.dependencies[dependenciesKey], 164 String(defaultDependencies[dependenciesKey]) 165 ); 166 } catch { 167 // If the version is invalid, just warn the user 168 } 169 // When the versions can't be parsed `null`, or does not intersect `false`, warn the user 170 if (projectHasRecommended !== true) { 171 nonRecommendedPackages.push(`${dependenciesKey}@${defaultDependencies[dependenciesKey]}`); 172 } 173 // Do not modify add-only dependencies 174 continue; 175 } 176 } 177 combinedDependencies[dependenciesKey] = defaultDependencies[dependenciesKey]; 178 } 179 180 if (symlinkedPackages.length) { 181 Log.log( 182 `\u203A Using symlinked ${symlinkedPackages 183 .map((pkg) => chalk.bold(pkg)) 184 .join(', ')} instead of recommended version(s).` 185 ); 186 } 187 188 if (nonRecommendedPackages.length) { 189 Log.warn( 190 `\u203A Using current versions instead of recommended ${nonRecommendedPackages 191 .map((pkg) => chalk.bold(pkg)) 192 .join(', ')}.` 193 ); 194 } 195 196 const combinedDevDependencies: DependenciesMap = createDependenciesMap({ 197 ...defaultDevDependencies, 198 ...pkg.devDependencies, 199 }); 200 201 // Only change the dependencies if the normalized hash changes, this helps to reduce meaningless changes. 202 const hasNewDependencies = 203 hashForDependencyMap(pkg.dependencies) !== hashForDependencyMap(combinedDependencies); 204 const hasNewDevDependencies = 205 hashForDependencyMap(pkg.devDependencies) !== hashForDependencyMap(combinedDevDependencies); 206 // Save the dependencies 207 if (hasNewDependencies) { 208 // Use Object.assign to preserve the original order of dependencies, this makes it easier to see what changed in the git diff. 209 pkg.dependencies = Object.assign(pkg.dependencies ?? {}, combinedDependencies); 210 } 211 if (hasNewDevDependencies) { 212 // Same as with dependencies 213 pkg.devDependencies = Object.assign(pkg.devDependencies ?? {}, combinedDevDependencies); 214 } 215 216 return { 217 hasNewDependencies, 218 hasNewDevDependencies, 219 }; 220} 221 222/** 223 * Create an object of type DependenciesMap a dependencies object or throw if not valid. 224 * 225 * @param dependencies - ideally an object of type {[key]: string} - if not then this will error. 226 */ 227export function createDependenciesMap(dependencies: any): DependenciesMap { 228 if (typeof dependencies !== 'object') { 229 throw new Error(`Dependency map is invalid, expected object but got ${typeof dependencies}`); 230 } else if (!dependencies) { 231 return {}; 232 } 233 234 const outputMap: DependenciesMap = {}; 235 236 for (const key of Object.keys(dependencies)) { 237 const value = dependencies[key]; 238 if (typeof value === 'string') { 239 outputMap[key] = value; 240 } else { 241 throw new Error( 242 `Dependency for key \`${key}\` should be a \`string\`, instead got: \`{ ${key}: ${JSON.stringify( 243 value 244 )} }\`` 245 ); 246 } 247 } 248 return outputMap; 249} 250 251/** 252 * Update package.json scripts - `npm start` should default to `expo 253 * start --dev-client` rather than `expo start` after ejecting, for example. 254 */ 255function updatePkgScripts({ pkg }: { pkg: PackageJSONConfig }) { 256 if (!pkg.scripts) { 257 pkg.scripts = {}; 258 } 259 if (!pkg.scripts.start?.includes('--dev-client')) { 260 pkg.scripts.start = 'expo start --dev-client'; 261 } 262 if (!pkg.scripts.android?.includes('run')) { 263 pkg.scripts.android = 'expo run:android'; 264 } 265 if (!pkg.scripts.ios?.includes('run')) { 266 pkg.scripts.ios = 'expo run:ios'; 267 } 268} 269 270function normalizeDependencyMap(deps: DependenciesMap): string[] { 271 return Object.keys(deps) 272 .map((dependency) => `${dependency}@${deps[dependency]}`) 273 .sort(); 274} 275 276export function hashForDependencyMap(deps: DependenciesMap = {}): string { 277 const depsList = normalizeDependencyMap(deps); 278 const depsString = depsList.join('\n'); 279 return createFileHash(depsString); 280} 281 282export function createFileHash(contents: string): string { 283 // this doesn't need to be secure, the shorter the better. 284 return crypto.createHash('sha1').update(contents).digest('hex'); 285} 286