18d307f52SEvan Baconimport { getPackageJson, PackageJSONConfig } from '@expo/config'; 28d307f52SEvan Baconimport chalk from 'chalk'; 38d307f52SEvan Baconimport crypto from 'crypto'; 48d307f52SEvan Baconimport fs from 'fs'; 58d307f52SEvan Baconimport path from 'path'; 67e91778dSCedric van Puttenimport { intersects as semverIntersects, Range as SemverRange } from 'semver'; 78d307f52SEvan Bacon 88d307f52SEvan Baconimport * as Log from '../log'; 98d307f52SEvan Baconimport { isModuleSymlinked } from '../utils/isModuleSymlinked'; 108d307f52SEvan Baconimport { logNewSection } from '../utils/ora'; 118d307f52SEvan Bacon 128d307f52SEvan Baconexport type DependenciesMap = { [key: string]: string | number }; 138d307f52SEvan Bacon 148d307f52SEvan Baconexport type DependenciesModificationResults = { 158d307f52SEvan Bacon /** Indicates that new values were added to the `dependencies` object in the `package.json`. */ 168d307f52SEvan Bacon hasNewDependencies: boolean; 178d307f52SEvan Bacon /** Indicates that new values were added to the `devDependencies` object in the `package.json`. */ 188d307f52SEvan Bacon hasNewDevDependencies: boolean; 198d307f52SEvan Bacon}; 208d307f52SEvan Bacon 21c7137f9cSEvan Bacon/** Modifies the `package.json` with `modifyPackageJson` and format/displays the results. */ 228d307f52SEvan Baconexport async function updatePackageJSONAsync( 238d307f52SEvan Bacon projectRoot: string, 248d307f52SEvan Bacon { 258d307f52SEvan Bacon templateDirectory, 268d307f52SEvan Bacon pkg, 278d307f52SEvan Bacon skipDependencyUpdate, 288d307f52SEvan Bacon }: { 298d307f52SEvan Bacon templateDirectory: string; 308d307f52SEvan Bacon pkg: PackageJSONConfig; 318d307f52SEvan Bacon skipDependencyUpdate?: string[]; 328d307f52SEvan Bacon } 338d307f52SEvan Bacon): Promise<DependenciesModificationResults> { 348d307f52SEvan Bacon const updatingPackageJsonStep = logNewSection( 357977e438SEvan Bacon 'Updating your package.json scripts, dependencies, and main file' 368d307f52SEvan Bacon ); 378d307f52SEvan Bacon 388d307f52SEvan Bacon const templatePkg = getPackageJson(templateDirectory); 398d307f52SEvan Bacon 40c7137f9cSEvan Bacon const results = modifyPackageJson(projectRoot, { 418d307f52SEvan Bacon templatePkg, 428d307f52SEvan Bacon pkg, 438d307f52SEvan Bacon skipDependencyUpdate, 448d307f52SEvan Bacon }); 458d307f52SEvan Bacon 468d307f52SEvan Bacon await fs.promises.writeFile( 478d307f52SEvan Bacon path.resolve(projectRoot, 'package.json'), 488d307f52SEvan Bacon // Add new line to match the format of running yarn. 498d307f52SEvan Bacon // This prevents the `package.json` from changing when running `prebuild --no-install` multiple times. 508d307f52SEvan Bacon JSON.stringify(pkg, null, 2) + '\n' 518d307f52SEvan Bacon ); 528d307f52SEvan Bacon 537977e438SEvan Bacon updatingPackageJsonStep.succeed( 547977e438SEvan Bacon 'Updated package.json and added index.js entry point for iOS and Android' 557977e438SEvan Bacon ); 567977e438SEvan Bacon 578d307f52SEvan Bacon return results; 588d307f52SEvan Bacon} 598d307f52SEvan Bacon 608d307f52SEvan Bacon/** 618d307f52SEvan Bacon * Make required modifications to the `package.json` file as a JSON object. 628d307f52SEvan Bacon * 638d307f52SEvan Bacon * 1. Update `package.json` `scripts`. 648d307f52SEvan Bacon * 2. Update `package.json` `dependencies` and `devDependencies`. 657977e438SEvan Bacon * 3. Update `package.json` `main`. 668d307f52SEvan Bacon * 678d307f52SEvan Bacon * @param projectRoot The root directory of the project. 688d307f52SEvan Bacon * @param props.templatePkg Template project package.json as JSON. 698d307f52SEvan Bacon * @param props.pkg Current package.json as JSON. 708d307f52SEvan Bacon * @param props.skipDependencyUpdate Array of dependencies to skip updating. 718d307f52SEvan Bacon * @returns 728d307f52SEvan Bacon */ 73c7137f9cSEvan Baconfunction modifyPackageJson( 748d307f52SEvan Bacon projectRoot: string, 758d307f52SEvan Bacon { 768d307f52SEvan Bacon templatePkg, 778d307f52SEvan Bacon pkg, 788d307f52SEvan Bacon skipDependencyUpdate, 798d307f52SEvan Bacon }: { 808d307f52SEvan Bacon templatePkg: PackageJSONConfig; 818d307f52SEvan Bacon pkg: PackageJSONConfig; 828d307f52SEvan Bacon skipDependencyUpdate?: string[]; 838d307f52SEvan Bacon } 844149568cSEvan Bacon) { 858d307f52SEvan Bacon updatePkgScripts({ pkg }); 868d307f52SEvan Bacon 874149568cSEvan Bacon // TODO: Move to `npx expo-doctor` 884149568cSEvan Bacon return updatePkgDependencies(projectRoot, { 898d307f52SEvan Bacon pkg, 908d307f52SEvan Bacon templatePkg, 918d307f52SEvan Bacon skipDependencyUpdate, 928d307f52SEvan Bacon }); 938d307f52SEvan Bacon} 948d307f52SEvan Bacon 958d307f52SEvan Bacon/** 968d307f52SEvan Bacon * Update package.json dependencies by combining the dependencies in the project we are ejecting 978d307f52SEvan Bacon * with the dependencies in the template project. Does the same for devDependencies. 988d307f52SEvan Bacon * 998d307f52SEvan Bacon * - The template may have some dependencies beyond react/react-native/react-native-unimodules, 1008d307f52SEvan Bacon * for example RNGH and Reanimated. We should prefer the version that is already being used 1018d307f52SEvan Bacon * in the project for those, but swap the react/react-native/react-native-unimodules versions 1028d307f52SEvan Bacon * with the ones in the template. 1038d307f52SEvan Bacon * - The same applies to expo-updates -- since some native project configuration may depend on the 1048d307f52SEvan Bacon * version, we should always use the version of expo-updates in the template. 1058d307f52SEvan Bacon * 1068d307f52SEvan Bacon * > Exposed for testing. 1078d307f52SEvan Bacon */ 1088d307f52SEvan Baconexport function updatePkgDependencies( 1098d307f52SEvan Bacon projectRoot: string, 1108d307f52SEvan Bacon { 1118d307f52SEvan Bacon pkg, 1128d307f52SEvan Bacon templatePkg, 1138d307f52SEvan Bacon skipDependencyUpdate = [], 1148d307f52SEvan Bacon }: { 1158d307f52SEvan Bacon pkg: PackageJSONConfig; 1168d307f52SEvan Bacon templatePkg: PackageJSONConfig; 1177e91778dSCedric van Putten /** @deprecated Required packages are not overwritten, only added when missing */ 1188d307f52SEvan Bacon skipDependencyUpdate?: string[]; 1198d307f52SEvan Bacon } 1208d307f52SEvan Bacon): DependenciesModificationResults { 1218d307f52SEvan Bacon if (!pkg.devDependencies) { 1228d307f52SEvan Bacon pkg.devDependencies = {}; 1238d307f52SEvan Bacon } 1248d307f52SEvan Bacon const { dependencies, devDependencies } = templatePkg; 1258d307f52SEvan Bacon const defaultDependencies = createDependenciesMap(dependencies); 1268d307f52SEvan Bacon const defaultDevDependencies = createDependenciesMap(devDependencies); 1278d307f52SEvan Bacon 1288d307f52SEvan Bacon const combinedDependencies: DependenciesMap = createDependenciesMap({ 1298d307f52SEvan Bacon ...defaultDependencies, 1308d307f52SEvan Bacon ...pkg.dependencies, 1318d307f52SEvan Bacon }); 1328d307f52SEvan Bacon 1337e91778dSCedric van Putten // These dependencies are only added, not overwritten from the project 134c7137f9cSEvan Bacon const requiredDependencies = ['expo', 'expo-splash-screen', 'react', 'react-native'].filter( 135c7137f9cSEvan Bacon (depKey) => !!defaultDependencies[depKey] 136c7137f9cSEvan Bacon ); 1378d307f52SEvan Bacon 1388d307f52SEvan Bacon const symlinkedPackages: string[] = []; 139624497a6SCedric van Putten const nonRecommendedPackages: string[] = []; 1408d307f52SEvan Bacon 1418d307f52SEvan Bacon for (const dependenciesKey of requiredDependencies) { 1428d307f52SEvan Bacon // If the local package.json defined the dependency that we want to overwrite... 143624497a6SCedric van Putten if (pkg.dependencies?.[dependenciesKey]) { 1448d307f52SEvan Bacon // Then ensure it isn't symlinked (i.e. the user has a custom version in their yarn workspace). 145624497a6SCedric van Putten if (isModuleSymlinked(projectRoot, { moduleId: dependenciesKey, isSilent: true })) { 1468d307f52SEvan Bacon // If the package is in the project's package.json and it's symlinked, then skip overwriting it. 1478d307f52SEvan Bacon symlinkedPackages.push(dependenciesKey); 1488d307f52SEvan Bacon continue; 1498d307f52SEvan Bacon } 150624497a6SCedric van Putten 151624497a6SCedric van Putten // Do not modify manually skipped dependencies 1528d307f52SEvan Bacon if (skipDependencyUpdate.includes(dependenciesKey)) { 1538d307f52SEvan Bacon continue; 1548d307f52SEvan Bacon } 155624497a6SCedric van Putten 1567e91778dSCedric van Putten // Warn users for outdated dependencies when prebuilding 1577e91778dSCedric van Putten const hasRecommendedVersion = versionRangesIntersect( 158624497a6SCedric van Putten pkg.dependencies[dependenciesKey], 159624497a6SCedric van Putten String(defaultDependencies[dependenciesKey]) 160624497a6SCedric van Putten ); 1617e91778dSCedric van Putten if (!hasRecommendedVersion) { 162624497a6SCedric van Putten nonRecommendedPackages.push(`${dependenciesKey}@${defaultDependencies[dependenciesKey]}`); 163624497a6SCedric van Putten } 164624497a6SCedric van Putten } 1658d307f52SEvan Bacon } 1668d307f52SEvan Bacon 1678d307f52SEvan Bacon if (symlinkedPackages.length) { 1688d307f52SEvan Bacon Log.log( 1698d307f52SEvan Bacon `\u203A Using symlinked ${symlinkedPackages 1708d307f52SEvan Bacon .map((pkg) => chalk.bold(pkg)) 1718d307f52SEvan Bacon .join(', ')} instead of recommended version(s).` 1728d307f52SEvan Bacon ); 1738d307f52SEvan Bacon } 1748d307f52SEvan Bacon 175624497a6SCedric van Putten if (nonRecommendedPackages.length) { 176624497a6SCedric van Putten Log.warn( 177624497a6SCedric van Putten `\u203A Using current versions instead of recommended ${nonRecommendedPackages 178624497a6SCedric van Putten .map((pkg) => chalk.bold(pkg)) 179624497a6SCedric van Putten .join(', ')}.` 180624497a6SCedric van Putten ); 181624497a6SCedric van Putten } 182624497a6SCedric van Putten 1838d307f52SEvan Bacon const combinedDevDependencies: DependenciesMap = createDependenciesMap({ 1848d307f52SEvan Bacon ...defaultDevDependencies, 1858d307f52SEvan Bacon ...pkg.devDependencies, 1868d307f52SEvan Bacon }); 1878d307f52SEvan Bacon 1888d307f52SEvan Bacon // Only change the dependencies if the normalized hash changes, this helps to reduce meaningless changes. 1898d307f52SEvan Bacon const hasNewDependencies = 1908d307f52SEvan Bacon hashForDependencyMap(pkg.dependencies) !== hashForDependencyMap(combinedDependencies); 1918d307f52SEvan Bacon const hasNewDevDependencies = 1928d307f52SEvan Bacon hashForDependencyMap(pkg.devDependencies) !== hashForDependencyMap(combinedDevDependencies); 1938d307f52SEvan Bacon // Save the dependencies 1948d307f52SEvan Bacon if (hasNewDependencies) { 1958d307f52SEvan Bacon // Use Object.assign to preserve the original order of dependencies, this makes it easier to see what changed in the git diff. 1968d307f52SEvan Bacon pkg.dependencies = Object.assign(pkg.dependencies ?? {}, combinedDependencies); 1978d307f52SEvan Bacon } 1988d307f52SEvan Bacon if (hasNewDevDependencies) { 1998d307f52SEvan Bacon // Same as with dependencies 2008d307f52SEvan Bacon pkg.devDependencies = Object.assign(pkg.devDependencies ?? {}, combinedDevDependencies); 2018d307f52SEvan Bacon } 2028d307f52SEvan Bacon 2038d307f52SEvan Bacon return { 2048d307f52SEvan Bacon hasNewDependencies, 2058d307f52SEvan Bacon hasNewDevDependencies, 2068d307f52SEvan Bacon }; 2078d307f52SEvan Bacon} 2088d307f52SEvan Bacon 2098d307f52SEvan Bacon/** 2108d307f52SEvan Bacon * Create an object of type DependenciesMap a dependencies object or throw if not valid. 2118d307f52SEvan Bacon * 2128d307f52SEvan Bacon * @param dependencies - ideally an object of type {[key]: string} - if not then this will error. 2138d307f52SEvan Bacon */ 2148d307f52SEvan Baconexport function createDependenciesMap(dependencies: any): DependenciesMap { 2158d307f52SEvan Bacon if (typeof dependencies !== 'object') { 2168d307f52SEvan Bacon throw new Error(`Dependency map is invalid, expected object but got ${typeof dependencies}`); 2178d307f52SEvan Bacon } else if (!dependencies) { 2188d307f52SEvan Bacon return {}; 2198d307f52SEvan Bacon } 2208d307f52SEvan Bacon 2218d307f52SEvan Bacon const outputMap: DependenciesMap = {}; 2228d307f52SEvan Bacon 2238d307f52SEvan Bacon for (const key of Object.keys(dependencies)) { 2248d307f52SEvan Bacon const value = dependencies[key]; 2258d307f52SEvan Bacon if (typeof value === 'string') { 2268d307f52SEvan Bacon outputMap[key] = value; 2278d307f52SEvan Bacon } else { 2288d307f52SEvan Bacon throw new Error( 2298d307f52SEvan Bacon `Dependency for key \`${key}\` should be a \`string\`, instead got: \`{ ${key}: ${JSON.stringify( 2308d307f52SEvan Bacon value 2318d307f52SEvan Bacon )} }\`` 2328d307f52SEvan Bacon ); 2338d307f52SEvan Bacon } 2348d307f52SEvan Bacon } 2358d307f52SEvan Bacon return outputMap; 2368d307f52SEvan Bacon} 2378d307f52SEvan Bacon 2388d307f52SEvan Bacon/** 2398d307f52SEvan Bacon * Update package.json scripts - `npm start` should default to `expo 2408d307f52SEvan Bacon * start --dev-client` rather than `expo start` after ejecting, for example. 2418d307f52SEvan Bacon */ 2428d307f52SEvan Baconfunction updatePkgScripts({ pkg }: { pkg: PackageJSONConfig }) { 2438d307f52SEvan Bacon if (!pkg.scripts) { 2448d307f52SEvan Bacon pkg.scripts = {}; 2458d307f52SEvan Bacon } 2468d307f52SEvan Bacon if (!pkg.scripts.android?.includes('run')) { 2478d307f52SEvan Bacon pkg.scripts.android = 'expo run:android'; 2488d307f52SEvan Bacon } 2498d307f52SEvan Bacon if (!pkg.scripts.ios?.includes('run')) { 2508d307f52SEvan Bacon pkg.scripts.ios = 'expo run:ios'; 2518d307f52SEvan Bacon } 2528d307f52SEvan Bacon} 2538d307f52SEvan Bacon 2548d307f52SEvan Baconfunction normalizeDependencyMap(deps: DependenciesMap): string[] { 2558d307f52SEvan Bacon return Object.keys(deps) 2568d307f52SEvan Bacon .map((dependency) => `${dependency}@${deps[dependency]}`) 2578d307f52SEvan Bacon .sort(); 2588d307f52SEvan Bacon} 2598d307f52SEvan Bacon 2608d307f52SEvan Baconexport function hashForDependencyMap(deps: DependenciesMap = {}): string { 2618d307f52SEvan Bacon const depsList = normalizeDependencyMap(deps); 2628d307f52SEvan Bacon const depsString = depsList.join('\n'); 2638d307f52SEvan Bacon return createFileHash(depsString); 2648d307f52SEvan Bacon} 2658d307f52SEvan Bacon 2668d307f52SEvan Baconexport function createFileHash(contents: string): string { 2678d307f52SEvan Bacon // this doesn't need to be secure, the shorter the better. 2688d307f52SEvan Bacon return crypto.createHash('sha1').update(contents).digest('hex'); 2698d307f52SEvan Bacon} 2707e91778dSCedric van Putten 2717e91778dSCedric van Putten/** 2727e91778dSCedric van Putten * Determine if two semver ranges are overlapping or intersecting. 2737e91778dSCedric van Putten * This is a safe version of `semver.intersects` that does not throw. 2747e91778dSCedric van Putten */ 2757e91778dSCedric van Puttenfunction versionRangesIntersect(rangeA: string | SemverRange, rangeB: string | SemverRange) { 2767e91778dSCedric van Putten try { 2777e91778dSCedric van Putten return semverIntersects(rangeA, rangeB); 278*7c236bbeSCedric van Putten } catch { 2797e91778dSCedric van Putten return false; 2807e91778dSCedric van Putten } 2817e91778dSCedric van Putten} 282