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