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