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