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