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