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