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