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