1import spawnAsync, { SpawnOptions, SpawnResult } from '@expo/spawn-async';
2import chalk from 'chalk';
3import { existsSync } from 'fs';
4import { Ora } from 'ora';
5import os from 'os';
6import path from 'path';
7
8import { spawnSudoAsync } from '../utils/spawn';
9
10export type CocoaPodsErrorCode = 'NON_INTERACTIVE' | 'NO_CLI' | 'COMMAND_FAILED';
11
12export class CocoaPodsError extends Error {
13  readonly name = 'CocoaPodsError';
14  readonly isPackageManagerError = true;
15
16  constructor(message: string, public code: CocoaPodsErrorCode, public cause?: Error) {
17    super(cause ? `${message}\n└─ Cause: ${cause.message}` : message);
18  }
19}
20
21export function extractMissingDependencyError(errorOutput: string): [string, string] | null {
22  // [!] Unable to find a specification for `expo-dev-menu-interface` depended upon by `expo-dev-launcher`
23  const results = errorOutput.match(
24    /Unable to find a specification for ['"`]([\w-_\d\s]+)['"`] depended upon by ['"`]([\w-_\d\s]+)['"`]/
25  );
26  if (results) {
27    return [results[1], results[2]];
28  }
29  return null;
30}
31
32export class CocoaPodsPackageManager {
33  options: SpawnOptions;
34
35  private silent: boolean;
36
37  static getPodProjectRoot(projectRoot: string): string | null {
38    if (CocoaPodsPackageManager.isUsingPods(projectRoot)) return projectRoot;
39    const iosProject = path.join(projectRoot, 'ios');
40    if (CocoaPodsPackageManager.isUsingPods(iosProject)) return iosProject;
41    const macOsProject = path.join(projectRoot, 'macos');
42    if (CocoaPodsPackageManager.isUsingPods(macOsProject)) return macOsProject;
43    return null;
44  }
45
46  static isUsingPods(projectRoot: string): boolean {
47    return existsSync(path.join(projectRoot, 'Podfile'));
48  }
49
50  static async gemInstallCLIAsync(
51    nonInteractive: boolean = false,
52    spawnOptions: SpawnOptions = { stdio: 'inherit' }
53  ): Promise<void> {
54    const options = ['install', 'cocoapods', '--no-document'];
55
56    try {
57      // In case the user has run sudo before running the command we can properly install CocoaPods without prompting for an interaction.
58      await spawnAsync('gem', options, spawnOptions);
59    } catch (error: any) {
60      if (nonInteractive) {
61        throw new CocoaPodsError(
62          'Failed to install CocoaPods CLI with gem (recommended)',
63          'COMMAND_FAILED',
64          error
65        );
66      }
67      // If the user doesn't have permission then we can prompt them to use sudo.
68      await spawnSudoAsync(['gem', ...options], spawnOptions);
69    }
70  }
71
72  static async brewLinkCLIAsync(spawnOptions: SpawnOptions = { stdio: 'inherit' }): Promise<void> {
73    await spawnAsync('brew', ['link', 'cocoapods'], spawnOptions);
74  }
75
76  static async brewInstallCLIAsync(
77    spawnOptions: SpawnOptions = { stdio: 'inherit' }
78  ): Promise<void> {
79    await spawnAsync('brew', ['install', 'cocoapods'], spawnOptions);
80  }
81
82  static async installCLIAsync({
83    nonInteractive = false,
84    spawnOptions = { stdio: 'inherit' },
85  }: {
86    nonInteractive?: boolean;
87    spawnOptions?: SpawnOptions;
88  }): Promise<boolean> {
89    if (!spawnOptions) {
90      spawnOptions = { stdio: 'inherit' };
91    }
92    const silent = !!spawnOptions.ignoreStdio;
93
94    try {
95      !silent && console.log(`\u203A Attempting to install CocoaPods CLI with Gem`);
96      await CocoaPodsPackageManager.gemInstallCLIAsync(nonInteractive, spawnOptions);
97      !silent && console.log(`\u203A Successfully installed CocoaPods CLI with Gem`);
98      return true;
99    } catch (error: any) {
100      if (!silent) {
101        console.log(chalk.yellow(`\u203A Failed to install CocoaPods CLI with Gem`));
102        console.log(chalk.red(error.stderr ?? error.message));
103        console.log(`\u203A Attempting to install CocoaPods CLI with Homebrew`);
104      }
105      try {
106        await CocoaPodsPackageManager.brewInstallCLIAsync(spawnOptions);
107        if (!(await CocoaPodsPackageManager.isCLIInstalledAsync(spawnOptions))) {
108          try {
109            await CocoaPodsPackageManager.brewLinkCLIAsync(spawnOptions);
110            // Still not available after linking? Bail out
111            if (!(await CocoaPodsPackageManager.isCLIInstalledAsync(spawnOptions))) {
112              throw new CocoaPodsError(
113                'CLI could not be installed automatically with gem or Homebrew, please install CocoaPods manually and try again',
114                'NO_CLI',
115                error
116              );
117            }
118          } catch (error: any) {
119            throw new CocoaPodsError(
120              'Homebrew installation appeared to succeed but CocoaPods CLI not found in PATH and unable to link.',
121              'NO_CLI',
122              error
123            );
124          }
125        }
126
127        !silent && console.log(`\u203A Successfully installed CocoaPods CLI with Homebrew`);
128        return true;
129      } catch (error: any) {
130        !silent &&
131          console.warn(
132            chalk.yellow(
133              `\u203A Failed to install CocoaPods with Homebrew. Please install CocoaPods CLI manually and try again.`
134            )
135          );
136        throw new CocoaPodsError(
137          `Failed to install CocoaPods with Homebrew. Please install CocoaPods CLI manually and try again.`,
138          'NO_CLI',
139          error
140        );
141      }
142    }
143  }
144
145  static isAvailable(projectRoot: string, silent: boolean): boolean {
146    if (process.platform !== 'darwin') {
147      !silent && console.log(chalk.red('CocoaPods is only supported on macOS machines'));
148      return false;
149    }
150    if (!CocoaPodsPackageManager.isUsingPods(projectRoot)) {
151      !silent && console.log(chalk.yellow('CocoaPods is not supported in this project'));
152      return false;
153    }
154    return true;
155  }
156
157  static async isCLIInstalledAsync(
158    spawnOptions: SpawnOptions = { stdio: 'inherit' }
159  ): Promise<boolean> {
160    try {
161      await spawnAsync('pod', ['--version'], spawnOptions);
162      return true;
163    } catch {
164      return false;
165    }
166  }
167
168  constructor({ cwd, silent }: { cwd: string; silent?: boolean }) {
169    this.silent = !!silent;
170    this.options = {
171      cwd,
172      // We use pipe by default instead of inherit so that we can capture stderr/stdout and process it for errors.
173      // Later we'll also pipe the stdout/stderr to the terminal when silent is false.
174      stdio: 'pipe',
175    };
176  }
177
178  get name() {
179    return 'CocoaPods';
180  }
181
182  /** Runs `pod install` and attempts to automatically run known troubleshooting steps automatically. */
183  async installAsync({ spinner }: { spinner?: Ora } = {}) {
184    await this._installAsync({ spinner });
185  }
186
187  public isCLIInstalledAsync() {
188    return CocoaPodsPackageManager.isCLIInstalledAsync(this.options);
189  }
190
191  public installCLIAsync() {
192    return CocoaPodsPackageManager.installCLIAsync({
193      nonInteractive: true,
194      spawnOptions: this.options,
195    });
196  }
197
198  async handleInstallErrorAsync({
199    error,
200    shouldUpdate = true,
201    updatedPackages = [],
202    spinner,
203  }: {
204    error: any;
205    spinner?: Ora;
206    shouldUpdate?: boolean;
207    updatedPackages?: string[];
208  }) {
209    // Unknown errors are rethrown.
210    if (!error.output) {
211      throw error;
212    }
213
214    // To emulate a `pod install --repo-update` error, enter your `ios/Podfile.lock` and change one of `PODS` version numbers to some lower value.
215    // const isPodRepoUpdateError = shouldPodRepoUpdate(output);
216    if (!shouldUpdate) {
217      // If we can't automatically fix the error, we'll just rethrow it with some known troubleshooting info.
218      throw getImprovedPodInstallError(error, {
219        cwd: this.options.cwd,
220      });
221    }
222
223    // Collect all of the spawn info.
224    const errorOutput = error.output.join(os.EOL).trim();
225
226    // Extract useful information from the error message and push it to the spinner.
227    const { updatePackage, shouldUpdateRepo } = getPodUpdateMessage(errorOutput);
228
229    if (!updatePackage || updatedPackages.includes(updatePackage)) {
230      // `pod install --repo-update`...
231      // Attempt to install again but this time with install --repo-update enabled.
232      return await this._installAsync({
233        spinner,
234        shouldRepoUpdate: true,
235        // Include a boolean to ensure pod install --repo-update isn't invoked in the unlikely case where the pods fail to update.
236        shouldUpdate: false,
237        updatedPackages,
238      });
239    }
240    // Store the package we should update to prevent a loop.
241    updatedPackages.push(updatePackage);
242
243    // If a single package is broken, we'll try to update it.
244    // You can manually test this by changing a version number in your `Podfile.lock`.
245
246    // Attempt `pod update <package> <--no-repo-update>` and then try again.
247    return await this.runInstallTypeCommandAsync(
248      ['update', updatePackage, shouldUpdateRepo ? '' : '--no-repo-update'].filter(Boolean),
249      {
250        formatWarning() {
251          const updateMessage = `Failed to update ${chalk.bold(
252            updatePackage
253          )}. Attempting to update the repo instead.`;
254          return updateMessage;
255        },
256        spinner,
257        updatedPackages,
258      }
259    );
260    // // If update succeeds, we'll try to install again (skipping `pod install --repo-update`).
261    // return await this._installAsync({
262    //   spinner,
263    //   shouldUpdate: false,
264    //   updatedPackages,
265    // });
266  }
267
268  private async _installAsync({
269    shouldRepoUpdate,
270    ...props
271  }: {
272    spinner?: Ora;
273    shouldUpdate?: boolean;
274    updatedPackages?: string[];
275    shouldRepoUpdate?: boolean;
276  } = {}): Promise<SpawnResult> {
277    return await this.runInstallTypeCommandAsync(
278      ['install', shouldRepoUpdate ? '--repo-update' : ''].filter(Boolean),
279      {
280        formatWarning(error: any) {
281          // Extract useful information from the error message and push it to the spinner.
282          return getPodRepoUpdateMessage(error.output.join(os.EOL).trim()).message;
283        },
284        ...props,
285      }
286    );
287  }
288
289  private async runInstallTypeCommandAsync(
290    command: string[],
291    {
292      formatWarning,
293      ...props
294    }: {
295      formatWarning?: (error: Error) => string;
296      spinner?: Ora;
297      shouldUpdate?: boolean;
298      updatedPackages?: string[];
299    } = {}
300  ): Promise<SpawnResult> {
301    try {
302      return await this._runAsync(command);
303    } catch (error: any) {
304      if (formatWarning) {
305        const warning = formatWarning(error);
306        if (props.spinner) {
307          props.spinner.text = chalk.bold(warning);
308        }
309        if (!this.silent) {
310          console.warn(chalk.yellow(warning));
311        }
312      }
313
314      return await this.handleInstallErrorAsync({ error, ...props });
315    }
316  }
317
318  async addWithParametersAsync(names: string[], parameters: string[]) {
319    throw new Error('Unimplemented');
320  }
321
322  addAsync(names: string[] = []) {
323    throw new Error('Unimplemented');
324  }
325
326  addDevAsync(names: string[] = []) {
327    throw new Error('Unimplemented');
328  }
329
330  addGlobalAsync(names: string[] = []) {
331    throw new Error('Unimplemented');
332  }
333
334  removeAsync(names: string[] = []) {
335    throw new Error('Unimplemented');
336  }
337
338  removeDevAsync(names: string[] = []) {
339    throw new Error('Unimplemented');
340  }
341
342  removeGlobalAsync(names: string[] = []) {
343    throw new Error('Unimplemented');
344  }
345
346  async versionAsync() {
347    const { stdout } = await spawnAsync('pod', ['--version'], this.options);
348    return stdout.trim();
349  }
350
351  async configAsync(key: string): Promise<string> {
352    throw new Error('Unimplemented');
353  }
354
355  async removeLockfileAsync() {
356    throw new Error('Unimplemented');
357  }
358
359  async uninstallAsync() {
360    throw new Error('Unimplemented');
361  }
362
363  // Private
364  private async podRepoUpdateAsync(): Promise<void> {
365    try {
366      await this._runAsync(['repo', 'update']);
367    } catch (error: any) {
368      error.message = error.message || (error.stderr ?? error.stdout);
369
370      throw new CocoaPodsError(
371        'The command `pod install --repo-update` failed',
372        'COMMAND_FAILED',
373        error
374      );
375    }
376  }
377
378  // Exposed for testing
379  async _runAsync(args: string[]): Promise<SpawnResult> {
380    if (!this.silent) {
381      console.log(`> pod ${args.join(' ')}`);
382    }
383    const promise = spawnAsync(
384      'pod',
385      [
386        ...args,
387        // Enables colors while collecting output.
388        '--ansi',
389      ],
390      {
391        // Add the cwd and other options to the spawn options.
392        ...this.options,
393        // We use pipe by default instead of inherit so that we can capture stderr/stdout and process it for errors.
394        // This is particularly required for the `pod install --repo-update` error.
395
396        // Later we'll also pipe the stdout/stderr to the terminal when silent is false,
397        // currently this means we lose out on the ansi colors unless passing the `--ansi` flag to every command.
398        stdio: 'pipe',
399      }
400    );
401
402    if (!this.silent) {
403      // If not silent, pipe the stdout/stderr to the terminal.
404      // We only do this when the `stdio` is set to `pipe` (collect the results for parsing), `inherit` won't contain `promise.child`.
405      if (promise.child.stdout) {
406        promise.child.stdout.pipe(process.stdout);
407      }
408    }
409
410    return await promise;
411  }
412}
413
414/** When pods are outdated, they'll throw an error informing you to run "pod install --repo-update" */
415function shouldPodRepoUpdate(errorOutput: string) {
416  const output = errorOutput;
417  const isPodRepoUpdateError =
418    output.includes('pod repo update') || output.includes('--no-repo-update');
419  return isPodRepoUpdateError;
420}
421
422export function getPodUpdateMessage(output: string) {
423  const props = output.match(
424    /run ['"`]pod update ([\w-_\d/]+)( --no-repo-update)?['"`] to apply changes/
425  );
426
427  return {
428    updatePackage: props?.[1] ?? null,
429    shouldUpdateRepo: !props?.[2],
430  };
431}
432
433export function getPodRepoUpdateMessage(errorOutput: string) {
434  const warningInfo = extractMissingDependencyError(errorOutput);
435  const brokenPackage = getPodUpdateMessage(errorOutput);
436
437  let message: string;
438  if (warningInfo) {
439    message = `Couldn't install: ${warningInfo[1]} » ${chalk.underline(warningInfo[0])}.`;
440  } else if (brokenPackage?.updatePackage) {
441    message = `Couldn't install: ${brokenPackage?.updatePackage}.`;
442  } else {
443    message = `Couldn't install Pods.`;
444  }
445  message += ` Updating the Pods project and trying again...`;
446  return { message, ...brokenPackage };
447}
448
449/**
450 * Format the CocoaPods CLI install error.
451 *
452 * @param error Error from CocoaPods CLI `pod install` command.
453 * @returns
454 */
455export function getImprovedPodInstallError(
456  error: SpawnResult & Error,
457  { cwd = process.cwd() }: Pick<SpawnOptions, 'cwd'>
458): Error {
459  // Collect all of the spawn info.
460  const errorOutput = error.output.join(os.EOL).trim();
461
462  if (error.stdout.match(/No [`'"]Podfile[`'"] found in the project directory/)) {
463    // Ran pod install but no Podfile was found.
464    error.message = `No Podfile found in directory: ${cwd}. Ensure CocoaPods is setup any try again.`;
465  } else if (shouldPodRepoUpdate(errorOutput)) {
466    // Ran pod install but the install --repo-update step failed.
467    const warningInfo = extractMissingDependencyError(errorOutput);
468    let reason: string;
469    if (warningInfo) {
470      reason = `Couldn't install: ${warningInfo[1]} » ${chalk.underline(warningInfo[0])}`;
471    } else {
472      reason = `This is often due to native package versions mismatching`;
473    }
474
475    // Attempt to provide a helpful message about the missing NPM dependency (containing a CocoaPod) since React Native
476    // developers will almost always be using autolinking and not interacting with CocoaPods directly.
477    let solution: string;
478    if (warningInfo?.[0]) {
479      // If the missing package is named `expo-dev-menu`, `react-native`, etc. then it might not be installed in the project.
480      if (warningInfo[0].match(/^(?:@?expo|@?react)(-|\/)/)) {
481        solution = `Ensure the node module "${warningInfo[0]}" is installed in your project, then run 'npx pod-install' to try again.`;
482      } else {
483        solution = `Ensure the CocoaPod "${warningInfo[0]}" is installed in your project, then run 'npx pod-install' to try again.`;
484      }
485    } else {
486      // Brute force
487      solution = `Try deleting the 'ios/Pods' folder or the 'ios/Podfile.lock' file and running 'npx pod-install' to resolve.`;
488    }
489    error.message = `${reason}. ${solution}`;
490
491    // Attempt to provide the troubleshooting info from CocoaPods CLI at the bottom of the error message.
492    if (error.stdout) {
493      const cocoapodsDebugInfo = error.stdout.split(os.EOL);
494      // The troubleshooting info starts with `[!]`, capture everything after that.
495      const firstWarning = cocoapodsDebugInfo.findIndex((v) => v.startsWith('[!]'));
496      if (firstWarning !== -1) {
497        const warning = cocoapodsDebugInfo.slice(firstWarning).join(os.EOL);
498        error.message += `\n\n${chalk.gray(warning)}`;
499      }
500    }
501    return new CocoaPodsError(
502      'Command `pod install --repo-update` failed.',
503      'COMMAND_FAILED',
504      error
505    );
506  } else {
507    let stderr: string | null = error.stderr.trim();
508
509    // CocoaPods CLI prints the useful error to stdout...
510    const usefulError = error.stdout.match(/\[!\]\s((?:.|\n)*)/)?.[1];
511
512    // If there is a useful error message then prune the less useful info.
513    if (usefulError) {
514      // Delete unhelpful CocoaPods CLI error message.
515      if (error.message?.match(/pod exited with non-zero code: 1/)) {
516        error.message = '';
517      }
518      stderr = null;
519    }
520
521    error.message = [usefulError, error.message, stderr].filter(Boolean).join('\n');
522  }
523
524  return new CocoaPodsError('Command `pod install` failed.', 'COMMAND_FAILED', error);
525}
526