xref: /expo/packages/@expo/cli/src/utils/cocoapods.ts (revision bb5069cd)
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 * as Log from '../log';
9import { hashForDependencyMap } from '../prebuild/updatePackageJson';
10import { ensureDirectoryAsync } from './dir';
11import { env } from './env';
12import { AbortCommandError } from './errors';
13import { logNewSection } from './ora';
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,
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({ spinner: step });
114    // Create cached list for later
115    await hasPackageJsonDependencyListChangedAsync(projectRoot).catch(() => null);
116    step.succeed('Installed pods and initialized Xcode workspace.');
117    return true;
118  } catch (error: any) {
119    step.stopAndPersist({
120      symbol: '⚠️ ',
121      text: chalk.red('Something went wrong running `pod install` in the `ios` directory.'),
122    });
123    if (error instanceof PackageManager.CocoaPodsError) {
124      Log.log(error.message);
125    } else {
126      Log.log(`Unknown error: ${error.message}`);
127    }
128    return false;
129  }
130}
131
132function doesProjectUseCocoaPods(projectRoot: string): boolean {
133  return fs.existsSync(path.join(projectRoot, 'ios', 'Podfile'));
134}
135
136function isLockfileCreated(projectRoot: string): boolean {
137  const podfileLockPath = path.join(projectRoot, 'ios', 'Podfile.lock');
138  return fs.existsSync(podfileLockPath);
139}
140
141function isPodFolderCreated(projectRoot: string): boolean {
142  const podFolderPath = path.join(projectRoot, 'ios', 'Pods');
143  return fs.existsSync(podFolderPath);
144}
145
146// TODO: Same process but with app.config changes + default plugins.
147// This will ensure the user is prompted for extra setup.
148export async function maybePromptToSyncPodsAsync(projectRoot: string) {
149  if (!doesProjectUseCocoaPods(projectRoot)) {
150    // Project does not use CocoaPods
151    return;
152  }
153  if (!isLockfileCreated(projectRoot) || !isPodFolderCreated(projectRoot)) {
154    if (!(await installCocoaPodsAsync(projectRoot))) {
155      throw new AbortCommandError();
156    }
157    return;
158  }
159
160  // Getting autolinked packages can be heavy, optimize around checking every time.
161  if (!(await hasPackageJsonDependencyListChangedAsync(projectRoot))) {
162    return;
163  }
164
165  await promptToInstallPodsAsync(projectRoot, []);
166}
167
168async function promptToInstallPodsAsync(projectRoot: string, missingPods?: string[]) {
169  if (missingPods?.length) {
170    Log.log(
171      `Could not find the following native modules: ${missingPods
172        .map((pod) => chalk.bold(pod))
173        .join(', ')}. Did you forget to run "${chalk.bold('pod install')}" ?`
174    );
175  }
176
177  try {
178    if (!(await installCocoaPodsAsync(projectRoot))) {
179      throw new AbortCommandError();
180    }
181  } catch (error) {
182    await fs.promises.rm(path.join(getTempPrebuildFolder(projectRoot), CACHED_PACKAGE_JSON), {
183      recursive: true,
184      force: true,
185    });
186    throw error;
187  }
188}
189