1import * as PackageManager from '@expo/package-manager'; 2import requireGlobal from 'requireg'; 3import resolveFrom from 'resolve-from'; 4import semver from 'semver'; 5 6import * as Log from '../../../log'; 7import { delayAsync } from '../../../utils/delay'; 8import { env } from '../../../utils/env'; 9import { CommandError } from '../../../utils/errors'; 10import { confirmAsync } from '../../../utils/prompts'; 11 12const debug = require('debug')('expo:doctor:externalModule') as typeof console.log; 13 14/** An error that is thrown when a package is installed but doesn't meet the version criteria. */ 15export class ExternalModuleVersionError extends CommandError { 16 constructor(message: string, public readonly shouldGloballyInstall: boolean) { 17 super('EXTERNAL_MODULE_VERSION', message); 18 } 19} 20 21interface PromptOptions { 22 /** Should prompt the user to install, when false the module will just assert on missing packages, default `true`. Ignored when `autoInstall` is true. */ 23 shouldPrompt?: boolean; 24 /** Should automatically install the package without prompting, default `false` */ 25 autoInstall?: boolean; 26} 27 28export interface InstallPromptOptions extends PromptOptions { 29 /** Should install the package globally, default `false` */ 30 shouldGloballyInstall?: boolean; 31} 32 33export interface ResolvePromptOptions extends PromptOptions { 34 /** 35 * Prefer to install the package globally, this can be overridden if the function 36 * detects that a locally installed package simply needs an upgrade, default `false` 37 */ 38 prefersGlobalInstall?: boolean; 39} 40 41/** Resolves a local or globally installed package, prompts to install if missing. */ 42export class ExternalModule<TModule> { 43 private instance: TModule | null = null; 44 45 constructor( 46 /** Project root for checking if the package is installed locally. */ 47 private projectRoot: string, 48 /** Info on the external package. */ 49 private pkg: { 50 /** NPM package name. */ 51 name: string; 52 /** Required semver range, ex: `^1.0.0`. */ 53 versionRange: string; 54 }, 55 /** A function used to create the installation prompt message. */ 56 private promptMessage: (pkgName: string) => string 57 ) {} 58 59 /** Resolve the globally or locally installed instance, or prompt to install. */ 60 async resolveAsync({ 61 prefersGlobalInstall, 62 ...options 63 }: ResolvePromptOptions = {}): Promise<TModule> { 64 try { 65 return ( 66 this.getVersioned() ?? 67 this.installAsync({ 68 ...options, 69 shouldGloballyInstall: prefersGlobalInstall, 70 }) 71 ); 72 } catch (error: any) { 73 if (error instanceof ExternalModuleVersionError) { 74 // If the module version in not compliant with the version range, 75 // we should prompt the user to install the package where it already exists. 76 return this.installAsync({ 77 ...options, 78 shouldGloballyInstall: error.shouldGloballyInstall ?? prefersGlobalInstall, 79 }); 80 } 81 throw error; 82 } 83 } 84 85 /** Prompt the user to install the package and try again. */ 86 async installAsync({ 87 shouldPrompt = true, 88 autoInstall, 89 shouldGloballyInstall, 90 }: InstallPromptOptions = {}): Promise<TModule> { 91 const packageName = [this.pkg.name, this.pkg.versionRange].join('@'); 92 if (!autoInstall) { 93 // Delay the prompt so it doesn't conflict with other dev tool logs 94 await delayAsync(100); 95 } 96 const answer = 97 autoInstall || 98 (shouldPrompt && 99 (await confirmAsync({ 100 message: this.promptMessage(packageName), 101 initial: true, 102 }))); 103 if (answer) { 104 Log.log(`Installing ${packageName}...`); 105 106 // Always use npm for global installs 107 const packageManager = shouldGloballyInstall 108 ? new PackageManager.NpmPackageManager({ 109 cwd: this.projectRoot, 110 log: Log.log, 111 silent: !env.EXPO_DEBUG, 112 }) 113 : PackageManager.createForProject(this.projectRoot, { 114 silent: !env.EXPO_DEBUG, 115 }); 116 117 try { 118 if (shouldGloballyInstall) { 119 await packageManager.addGlobalAsync(packageName); 120 } else { 121 await packageManager.addDevAsync(packageName); 122 } 123 Log.log(`Installed ${packageName}`); 124 } catch (error: any) { 125 error.message = `Failed to install ${packageName} ${ 126 shouldGloballyInstall ? 'globally' : 'locally' 127 }: ${error.message}`; 128 throw error; 129 } 130 return await this.resolveAsync({ shouldPrompt: false }); 131 } 132 133 throw new CommandError( 134 'EXTERNAL_MODULE_AVAILABILITY', 135 `Please install ${packageName} and try again` 136 ); 137 } 138 139 /** Get the module. */ 140 get(): TModule | null { 141 try { 142 return this.getVersioned(); 143 } catch { 144 return null; 145 } 146 } 147 148 /** Get the module, throws if the module is not versioned correctly. */ 149 getVersioned(): TModule | null { 150 this.instance ??= this._resolveModule(true) ?? this._resolveModule(false); 151 return this.instance; 152 } 153 154 /** Exposed for testing. */ 155 _require(moduleId: string): any { 156 return require(moduleId); 157 } 158 159 /** Resolve a copy that's installed in the project. Exposed for testing. */ 160 _resolveLocal(moduleId: string): string { 161 return resolveFrom(this.projectRoot, moduleId); 162 } 163 164 /** Resolve a copy that's installed globally. Exposed for testing. */ 165 _resolveGlobal(moduleId: string): string { 166 return requireGlobal.resolve(moduleId); 167 } 168 169 /** Resolve the module and verify the version. Exposed for testing. */ 170 _resolveModule(isLocal: boolean): TModule | null { 171 const resolver = isLocal ? this._resolveLocal.bind(this) : this._resolveGlobal.bind(this); 172 try { 173 const packageJsonPath = resolver(`${this.pkg.name}/package.json`); 174 const packageJson = this._require(packageJsonPath); 175 if (packageJson) { 176 if (semver.satisfies(packageJson.version, this.pkg.versionRange)) { 177 const modulePath = resolver(this.pkg.name); 178 const requiredModule = this._require(modulePath); 179 if (requiredModule == null) { 180 throw new CommandError( 181 'EXTERNAL_MODULE_EXPORT', 182 `${this.pkg.name} exports a nullish value, which is not allowed.` 183 ); 184 } 185 return requiredModule; 186 } 187 throw new ExternalModuleVersionError( 188 `Required module '${this.pkg.name}@${packageJson.version}' does not satisfy ${this.pkg.versionRange}. Installed at: ${packageJsonPath}`, 189 !isLocal 190 ); 191 } 192 } catch (error: any) { 193 if (error instanceof CommandError) { 194 throw error; 195 } else if (error.code !== 'MODULE_NOT_FOUND') { 196 debug('Failed to resolve module', error.message); 197 } 198 } 199 return null; 200 } 201} 202