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, Range as SemverRange } 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    /** @deprecated Required packages are not overwritten, only added when missing */
118    skipDependencyUpdate?: string[];
119  }
120): DependenciesModificationResults {
121  if (!pkg.devDependencies) {
122    pkg.devDependencies = {};
123  }
124  const { dependencies, devDependencies } = templatePkg;
125  const defaultDependencies = createDependenciesMap(dependencies);
126  const defaultDevDependencies = createDependenciesMap(devDependencies);
127
128  const combinedDependencies: DependenciesMap = createDependenciesMap({
129    ...defaultDependencies,
130    ...pkg.dependencies,
131  });
132
133  // These dependencies are only added, not overwritten from the project
134  const requiredDependencies = ['expo', 'expo-splash-screen', 'react', 'react-native'].filter(
135    (depKey) => !!defaultDependencies[depKey]
136  );
137
138  const symlinkedPackages: string[] = [];
139  const nonRecommendedPackages: string[] = [];
140
141  for (const dependenciesKey of requiredDependencies) {
142    // If the local package.json defined the dependency that we want to overwrite...
143    if (pkg.dependencies?.[dependenciesKey]) {
144      // Then ensure it isn't symlinked (i.e. the user has a custom version in their yarn workspace).
145      if (isModuleSymlinked(projectRoot, { moduleId: dependenciesKey, isSilent: true })) {
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
151      // Do not modify manually skipped dependencies
152      if (skipDependencyUpdate.includes(dependenciesKey)) {
153        continue;
154      }
155
156      // Warn users for outdated dependencies when prebuilding
157      const hasRecommendedVersion = versionRangesIntersect(
158        pkg.dependencies[dependenciesKey],
159        String(defaultDependencies[dependenciesKey])
160      );
161      if (!hasRecommendedVersion) {
162        nonRecommendedPackages.push(`${dependenciesKey}@${defaultDependencies[dependenciesKey]}`);
163      }
164    }
165  }
166
167  if (symlinkedPackages.length) {
168    Log.log(
169      `\u203A Using symlinked ${symlinkedPackages
170        .map((pkg) => chalk.bold(pkg))
171        .join(', ')} instead of recommended version(s).`
172    );
173  }
174
175  if (nonRecommendedPackages.length) {
176    Log.warn(
177      `\u203A Using current versions instead of recommended ${nonRecommendedPackages
178        .map((pkg) => chalk.bold(pkg))
179        .join(', ')}.`
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.android?.includes('run')) {
247    pkg.scripts.android = 'expo run:android';
248  }
249  if (!pkg.scripts.ios?.includes('run')) {
250    pkg.scripts.ios = 'expo run:ios';
251  }
252}
253
254function normalizeDependencyMap(deps: DependenciesMap): string[] {
255  return Object.keys(deps)
256    .map((dependency) => `${dependency}@${deps[dependency]}`)
257    .sort();
258}
259
260export function hashForDependencyMap(deps: DependenciesMap = {}): string {
261  const depsList = normalizeDependencyMap(deps);
262  const depsString = depsList.join('\n');
263  return createFileHash(depsString);
264}
265
266export function createFileHash(contents: string): string {
267  // this doesn't need to be secure, the shorter the better.
268  return crypto.createHash('sha1').update(contents).digest('hex');
269}
270
271/**
272 * Determine if two semver ranges are overlapping or intersecting.
273 * This is a safe version of `semver.intersects` that does not throw.
274 */
275function versionRangesIntersect(rangeA: string | SemverRange, rangeB: string | SemverRange) {
276  try {
277    return semverIntersects(rangeA, rangeB);
278  } catch {
279    return false;
280  }
281}
282