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