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