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