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