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