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 = [
147    'react',
148    'react-native-unimodules',
149    'react-native',
150    'expo-updates',
151  ].filter((depKey) => !!defaultDependencies[depKey]);
152
153  const symlinkedPackages: string[] = [];
154
155  for (const dependenciesKey of requiredDependencies) {
156    if (
157      // If the local package.json defined the dependency that we want to overwrite...
158      pkg.dependencies?.[dependenciesKey]
159    ) {
160      if (
161        // Then ensure it isn't symlinked (i.e. the user has a custom version in their yarn workspace).
162        isModuleSymlinked(projectRoot, { moduleId: dependenciesKey, isSilent: true })
163      ) {
164        // If the package is in the project's package.json and it's symlinked, then skip overwriting it.
165        symlinkedPackages.push(dependenciesKey);
166        continue;
167      }
168      if (skipDependencyUpdate.includes(dependenciesKey)) {
169        continue;
170      }
171    }
172    combinedDependencies[dependenciesKey] = defaultDependencies[dependenciesKey];
173  }
174
175  if (symlinkedPackages.length) {
176    Log.log(
177      `\u203A Using symlinked ${symlinkedPackages
178        .map((pkg) => chalk.bold(pkg))
179        .join(', ')} instead of recommended version(s).`
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.start?.includes('--dev-client')) {
247    pkg.scripts.start = 'expo start --dev-client';
248  }
249  if (!pkg.scripts.android?.includes('run')) {
250    pkg.scripts.android = 'expo run:android';
251  }
252  if (!pkg.scripts.ios?.includes('run')) {
253    pkg.scripts.ios = 'expo run:ios';
254  }
255}
256
257/**
258 * Add new app entry points
259 */
260function updatePkgMain({ pkg }: { pkg: PackageJSONConfig }): string | null {
261  let removedPkgMain: null | string = null;
262  // Check that the pkg.main doesn't match:
263  // - ./node_modules/expo/AppEntry
264  // - ./node_modules/expo/AppEntry.js
265  // - node_modules/expo/AppEntry.js
266  // - expo/AppEntry.js
267  // - expo/AppEntry
268  if (shouldDeleteMainField(pkg.main)) {
269    // Save the custom
270    removedPkgMain = pkg.main;
271    delete pkg.main;
272  }
273
274  return removedPkgMain;
275}
276
277/**
278 * Returns true if the input string matches the default expo main field.
279 *
280 * - ./node_modules/expo/AppEntry
281 * - ./node_modules/expo/AppEntry.js
282 * - node_modules/expo/AppEntry.js
283 * - expo/AppEntry.js
284 * - expo/AppEntry
285 *
286 * @param input package.json main field
287 */
288export function isPkgMainExpoAppEntry(input?: string): boolean {
289  const main = input || '';
290  if (main.startsWith('./')) {
291    return main.includes('node_modules/expo/AppEntry');
292  }
293  return main.includes('expo/AppEntry');
294}
295
296function normalizeDependencyMap(deps: DependenciesMap): string[] {
297  return Object.keys(deps)
298    .map((dependency) => `${dependency}@${deps[dependency]}`)
299    .sort();
300}
301
302export function hashForDependencyMap(deps: DependenciesMap = {}): string {
303  const depsList = normalizeDependencyMap(deps);
304  const depsString = depsList.join('\n');
305  return createFileHash(depsString);
306}
307
308export function createFileHash(contents: string): string {
309  // this doesn't need to be secure, the shorter the better.
310  return crypto.createHash('sha1').update(contents).digest('hex');
311}
312
313export function shouldDeleteMainField(main?: any): boolean {
314  if (!main || !isPkgMainExpoAppEntry(main)) {
315    return false;
316  }
317
318  return !main?.startsWith('index.');
319}
320