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