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 } 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    skipDependencyUpdate?: string[];
118  }
119): DependenciesModificationResults {
120  if (!pkg.devDependencies) {
121    pkg.devDependencies = {};
122  }
123  const { dependencies, devDependencies } = templatePkg;
124  const defaultDependencies = createDependenciesMap(dependencies);
125  const defaultDevDependencies = createDependenciesMap(devDependencies);
126
127  const combinedDependencies: DependenciesMap = createDependenciesMap({
128    ...defaultDependencies,
129    ...pkg.dependencies,
130  });
131
132  const addWhenMissingDependencies = ['react', 'react-native'].filter(
133    (depKey) => !!defaultDependencies[depKey]
134  );
135  const requiredDependencies = ['expo', 'expo-splash-screen', 'react', 'react-native'].filter(
136    (depKey) => !!defaultDependencies[depKey]
137  );
138
139  const symlinkedPackages: string[] = [];
140  const nonRecommendedPackages: string[] = [];
141
142  for (const dependenciesKey of requiredDependencies) {
143    // If the local package.json defined the dependency that we want to overwrite...
144    if (pkg.dependencies?.[dependenciesKey]) {
145      // Then ensure it isn't symlinked (i.e. the user has a custom version in their yarn workspace).
146      if (isModuleSymlinked(projectRoot, { moduleId: dependenciesKey, isSilent: true })) {
147        // If the package is in the project's package.json and it's symlinked, then skip overwriting it.
148        symlinkedPackages.push(dependenciesKey);
149        continue;
150      }
151
152      // Do not modify manually skipped dependencies
153      if (skipDependencyUpdate.includes(dependenciesKey)) {
154        continue;
155      }
156
157      // Ensure the package only needs to be added when missing
158      if (addWhenMissingDependencies.includes(dependenciesKey)) {
159        let projectHasRecommended: boolean | null = null;
160        // Check if the version intersects with the recommended versions
161        try {
162          projectHasRecommended = semverIntersects(
163            pkg.dependencies[dependenciesKey],
164            String(defaultDependencies[dependenciesKey])
165          );
166        } catch {
167          // If the version is invalid, just warn the user
168        }
169        // When the versions can't be parsed `null`, or does not intersect `false`, warn the user
170        if (projectHasRecommended !== true) {
171          nonRecommendedPackages.push(`${dependenciesKey}@${defaultDependencies[dependenciesKey]}`);
172        }
173        // Do not modify add-only dependencies
174        continue;
175      }
176    }
177    combinedDependencies[dependenciesKey] = defaultDependencies[dependenciesKey];
178  }
179
180  if (symlinkedPackages.length) {
181    Log.log(
182      `\u203A Using symlinked ${symlinkedPackages
183        .map((pkg) => chalk.bold(pkg))
184        .join(', ')} instead of recommended version(s).`
185    );
186  }
187
188  if (nonRecommendedPackages.length) {
189    Log.warn(
190      `\u203A Using current versions instead of recommended ${nonRecommendedPackages
191        .map((pkg) => chalk.bold(pkg))
192        .join(', ')}.`
193    );
194  }
195
196  const combinedDevDependencies: DependenciesMap = createDependenciesMap({
197    ...defaultDevDependencies,
198    ...pkg.devDependencies,
199  });
200
201  // Only change the dependencies if the normalized hash changes, this helps to reduce meaningless changes.
202  const hasNewDependencies =
203    hashForDependencyMap(pkg.dependencies) !== hashForDependencyMap(combinedDependencies);
204  const hasNewDevDependencies =
205    hashForDependencyMap(pkg.devDependencies) !== hashForDependencyMap(combinedDevDependencies);
206  // Save the dependencies
207  if (hasNewDependencies) {
208    // Use Object.assign to preserve the original order of dependencies, this makes it easier to see what changed in the git diff.
209    pkg.dependencies = Object.assign(pkg.dependencies ?? {}, combinedDependencies);
210  }
211  if (hasNewDevDependencies) {
212    // Same as with dependencies
213    pkg.devDependencies = Object.assign(pkg.devDependencies ?? {}, combinedDevDependencies);
214  }
215
216  return {
217    hasNewDependencies,
218    hasNewDevDependencies,
219  };
220}
221
222/**
223 * Create an object of type DependenciesMap a dependencies object or throw if not valid.
224 *
225 * @param dependencies - ideally an object of type {[key]: string} - if not then this will error.
226 */
227export function createDependenciesMap(dependencies: any): DependenciesMap {
228  if (typeof dependencies !== 'object') {
229    throw new Error(`Dependency map is invalid, expected object but got ${typeof dependencies}`);
230  } else if (!dependencies) {
231    return {};
232  }
233
234  const outputMap: DependenciesMap = {};
235
236  for (const key of Object.keys(dependencies)) {
237    const value = dependencies[key];
238    if (typeof value === 'string') {
239      outputMap[key] = value;
240    } else {
241      throw new Error(
242        `Dependency for key \`${key}\` should be a \`string\`, instead got: \`{ ${key}: ${JSON.stringify(
243          value
244        )} }\``
245      );
246    }
247  }
248  return outputMap;
249}
250
251/**
252 * Update package.json scripts - `npm start` should default to `expo
253 * start --dev-client` rather than `expo start` after ejecting, for example.
254 */
255function updatePkgScripts({ pkg }: { pkg: PackageJSONConfig }) {
256  if (!pkg.scripts) {
257    pkg.scripts = {};
258  }
259  if (!pkg.scripts.start?.includes('--dev-client')) {
260    pkg.scripts.start = 'expo start --dev-client';
261  }
262  if (!pkg.scripts.android?.includes('run')) {
263    pkg.scripts.android = 'expo run:android';
264  }
265  if (!pkg.scripts.ios?.includes('run')) {
266    pkg.scripts.ios = 'expo run:ios';
267  }
268}
269
270function normalizeDependencyMap(deps: DependenciesMap): string[] {
271  return Object.keys(deps)
272    .map((dependency) => `${dependency}@${deps[dependency]}`)
273    .sort();
274}
275
276export function hashForDependencyMap(deps: DependenciesMap = {}): string {
277  const depsList = normalizeDependencyMap(deps);
278  const depsString = depsList.join('\n');
279  return createFileHash(depsString);
280}
281
282export function createFileHash(contents: string): string {
283  // this doesn't need to be secure, the shorter the better.
284  return crypto.createHash('sha1').update(contents).digest('hex');
285}
286