xref: /expo/packages/@expo/cli/src/utils/cocoapods.ts (revision 1a3a1db5)
1import { getPackageJson, PackageJSONConfig } from '@expo/config';
2import JsonFile from '@expo/json-file';
3import * as PackageManager from '@expo/package-manager';
4import chalk from 'chalk';
5import fs from 'fs';
6import path from 'path';
7
8import { ensureDirectoryAsync } from './dir';
9import { env } from './env';
10import { AbortCommandError } from './errors';
11import { logNewSection } from './ora';
12import * as Log from '../log';
13import { hashForDependencyMap } from '../prebuild/updatePackageJson';
14
15type PackageChecksums = {
16  /** checksum for the `package.json` dependency object. */
17  dependencies: string;
18  /** checksum for the `package.json` devDependency object. */
19  devDependencies: string;
20};
21
22const PROJECT_PREBUILD_SETTINGS = '.expo/prebuild';
23const CACHED_PACKAGE_JSON = 'cached-packages.json';
24
25function getTempPrebuildFolder(projectRoot: string): string {
26  return path.join(projectRoot, PROJECT_PREBUILD_SETTINGS);
27}
28
29function hasNewDependenciesSinceLastBuild(
30  projectRoot: string,
31  packageChecksums: PackageChecksums
32): boolean {
33  // TODO: Maybe comparing lock files would be better...
34  const templateDirectory = getTempPrebuildFolder(projectRoot);
35  const tempPkgJsonPath = path.join(templateDirectory, CACHED_PACKAGE_JSON);
36  if (!fs.existsSync(tempPkgJsonPath)) {
37    return true;
38  }
39  const { dependencies, devDependencies } = JsonFile.read(tempPkgJsonPath);
40  // Only change the dependencies if the normalized hash changes, this helps to reduce meaningless changes.
41  const hasNewDependencies = packageChecksums.dependencies !== dependencies;
42  const hasNewDevDependencies = packageChecksums.devDependencies !== devDependencies;
43
44  return hasNewDependencies || hasNewDevDependencies;
45}
46
47function createPackageChecksums(pkg: PackageJSONConfig): PackageChecksums {
48  return {
49    dependencies: hashForDependencyMap(pkg.dependencies || {}),
50    devDependencies: hashForDependencyMap(pkg.devDependencies || {}),
51  };
52}
53
54/** @returns `true` if the package.json dependency hash does not match the cached hash from the last run. */
55export async function hasPackageJsonDependencyListChangedAsync(
56  projectRoot: string
57): Promise<boolean> {
58  const pkg = getPackageJson(projectRoot);
59
60  const packages = createPackageChecksums(pkg);
61  const hasNewDependencies = hasNewDependenciesSinceLastBuild(projectRoot, packages);
62
63  // Cache package.json
64  await ensureDirectoryAsync(getTempPrebuildFolder(projectRoot));
65  const templateDirectory = path.join(getTempPrebuildFolder(projectRoot), CACHED_PACKAGE_JSON);
66  await JsonFile.writeAsync(templateDirectory, packages);
67
68  return hasNewDependencies;
69}
70
71export async function installCocoaPodsAsync(projectRoot: string): Promise<boolean> {
72  let step = logNewSection('Installing CocoaPods...');
73  if (process.platform !== 'darwin') {
74    step.succeed('Skipped installing CocoaPods because operating system is not on macOS.');
75    return false;
76  }
77
78  const packageManager = new PackageManager.CocoaPodsPackageManager({
79    cwd: path.join(projectRoot, 'ios'),
80    silent: !(env.EXPO_DEBUG || env.CI),
81  });
82
83  if (!(await packageManager.isCLIInstalledAsync())) {
84    try {
85      // prompt user -- do you want to install cocoapods right now?
86      step.text = 'CocoaPods CLI not found in your PATH, installing it now.';
87      step.stopAndPersist();
88      await PackageManager.CocoaPodsPackageManager.installCLIAsync({
89        nonInteractive: true,
90        spawnOptions: {
91          ...packageManager.options,
92          // Don't silence this part
93          stdio: ['inherit', 'inherit', 'pipe'],
94        },
95      });
96      step.succeed('Installed CocoaPods CLI.');
97      step = logNewSection('Running `pod install` in the `ios` directory.');
98    } catch (error: any) {
99      step.stopAndPersist({
100        symbol: '⚠️ ',
101        text: chalk.red('Unable to install the CocoaPods CLI.'),
102      });
103      if (error instanceof PackageManager.CocoaPodsError) {
104        Log.log(error.message);
105      } else {
106        Log.log(`Unknown error: ${error.message}`);
107      }
108      return false;
109    }
110  }
111
112  try {
113    await packageManager.installAsync({
114      // @ts-expect-error: multiple versions in the monorepo
115      spinner: step,
116    });
117    // Create cached list for later
118    await hasPackageJsonDependencyListChangedAsync(projectRoot).catch(() => null);
119    step.succeed('Installed pods and initialized Xcode workspace.');
120    return true;
121  } catch (error: any) {
122    step.stopAndPersist({
123      symbol: '⚠️ ',
124      text: chalk.red('Something went wrong running `pod install` in the `ios` directory.'),
125    });
126    if (error instanceof PackageManager.CocoaPodsError) {
127      Log.log(error.message);
128    } else {
129      Log.log(`Unknown error: ${error.message}`);
130    }
131    return false;
132  }
133}
134
135function doesProjectUseCocoaPods(projectRoot: string): boolean {
136  return fs.existsSync(path.join(projectRoot, 'ios', 'Podfile'));
137}
138
139function isLockfileCreated(projectRoot: string): boolean {
140  const podfileLockPath = path.join(projectRoot, 'ios', 'Podfile.lock');
141  return fs.existsSync(podfileLockPath);
142}
143
144function isPodFolderCreated(projectRoot: string): boolean {
145  const podFolderPath = path.join(projectRoot, 'ios', 'Pods');
146  return fs.existsSync(podFolderPath);
147}
148
149// TODO: Same process but with app.config changes + default plugins.
150// This will ensure the user is prompted for extra setup.
151export async function maybePromptToSyncPodsAsync(projectRoot: string) {
152  if (!doesProjectUseCocoaPods(projectRoot)) {
153    // Project does not use CocoaPods
154    return;
155  }
156  if (!isLockfileCreated(projectRoot) || !isPodFolderCreated(projectRoot)) {
157    if (!(await installCocoaPodsAsync(projectRoot))) {
158      throw new AbortCommandError();
159    }
160    return;
161  }
162
163  // Getting autolinked packages can be heavy, optimize around checking every time.
164  if (!(await hasPackageJsonDependencyListChangedAsync(projectRoot))) {
165    return;
166  }
167
168  await promptToInstallPodsAsync(projectRoot, []);
169}
170
171async function promptToInstallPodsAsync(projectRoot: string, missingPods?: string[]) {
172  if (missingPods?.length) {
173    Log.log(
174      `Could not find the following native modules: ${missingPods
175        .map((pod) => chalk.bold(pod))
176        .join(', ')}. Did you forget to run "${chalk.bold('pod install')}" ?`
177    );
178  }
179
180  try {
181    if (!(await installCocoaPodsAsync(projectRoot))) {
182      throw new AbortCommandError();
183    }
184  } catch (error) {
185    await fs.promises.rm(path.join(getTempPrebuildFolder(projectRoot), CACHED_PACKAGE_JSON), {
186      recursive: true,
187      force: true,
188    });
189    throw error;
190  }
191}
192