1import assert from 'assert';
2import findUp from 'find-up';
3import * as path from 'path';
4import resolveFrom from 'resolve-from';
5
6import { ConfigPlugin, StaticPlugin } from '../Plugin.types';
7import { PluginError } from './errors';
8import { fileExists } from './modules';
9
10// Default plugin entry file name.
11export const pluginFileName = 'app.plugin.js';
12
13function findUpPackageJson(root: string): string {
14  const packageJson = findUp.sync('package.json', { cwd: root });
15  assert(packageJson, `No package.json found for module "${root}"`);
16  return packageJson;
17}
18
19export function resolvePluginForModule(projectRoot: string, modulePath: string) {
20  const resolved = resolveFrom.silent(projectRoot, modulePath);
21  if (!resolved) {
22    throw new PluginError(
23      `Failed to resolve plugin for module "${modulePath}" relative to "${projectRoot}"`,
24      'PLUGIN_NOT_FOUND'
25    );
26  }
27  // If the modulePath is something like `@bacon/package/index.js` or `expo-foo/build/app`
28  // then skip resolving the module `app.plugin.js`
29  if (moduleNameIsDirectFileReference(modulePath)) {
30    return { isPluginFile: false, filePath: resolved };
31  }
32  return findUpPlugin(resolved);
33}
34
35// TODO: Test windows
36function pathIsFilePath(name: string): boolean {
37  // Matches lines starting with: . / ~/
38  return !!name.match(/^(\.|~\/|\/)/g);
39}
40
41export function moduleNameIsDirectFileReference(name: string): boolean {
42  if (pathIsFilePath(name)) {
43    return true;
44  }
45
46  const slashCount = name.split(path.sep)?.length;
47  // Orgs (like @expo/config ) should have more than one slash to be a direct file.
48  if (name.startsWith('@')) {
49    return slashCount > 2;
50  }
51
52  // Regular packages should be considered direct reference if they have more than one slash.
53  return slashCount > 1;
54}
55
56function resolveExpoPluginFile(root: string): string | null {
57  // Find the expo plugin root file
58  const pluginModuleFile = resolveFrom.silent(
59    root,
60    // use ./ so it isn't resolved as a node module
61    `./${pluginFileName}`
62  );
63
64  // If the default expo plugin file exists use it.
65  if (pluginModuleFile && fileExists(pluginModuleFile)) {
66    return pluginModuleFile;
67  }
68  return null;
69}
70
71function findUpPlugin(root: string): { filePath: string; isPluginFile: boolean } {
72  // Get the closest package.json to the node module
73  const packageJson = findUpPackageJson(root);
74  // resolve the root folder for the node module
75  const moduleRoot = path.dirname(packageJson);
76  // use whatever the initial resolved file was ex: `node_modules/my-package/index.js` or `./something.js`
77  const pluginFile = resolveExpoPluginFile(moduleRoot);
78  return { filePath: pluginFile ?? root, isPluginFile: !!pluginFile };
79}
80
81export function normalizeStaticPlugin(plugin: StaticPlugin | ConfigPlugin | string): StaticPlugin {
82  if (Array.isArray(plugin)) {
83    assert(
84      plugin.length > 0 && plugin.length < 3,
85      `Wrong number of arguments provided for static config plugin, expected either 1 or 2, got ${plugin.length}`
86    );
87    return plugin;
88  }
89  return [plugin, undefined];
90}
91
92export function assertInternalProjectRoot(projectRoot?: string): asserts projectRoot {
93  assert(
94    projectRoot,
95    `Unexpected: Config \`_internal.projectRoot\` isn't defined by expo-cli, this is a bug.`
96  );
97}
98
99// Resolve the module function and assert type
100export function resolveConfigPluginFunction(projectRoot: string, pluginReference: string) {
101  const { plugin } = resolveConfigPluginFunctionWithInfo(projectRoot, pluginReference);
102  return plugin;
103}
104
105// Resolve the module function and assert type
106export function resolveConfigPluginFunctionWithInfo(projectRoot: string, pluginReference: string) {
107  const { filePath: pluginFile, isPluginFile } = resolvePluginForModule(
108    projectRoot,
109    pluginReference
110  );
111  let result: any;
112  try {
113    result = requirePluginFile(pluginFile);
114  } catch (error) {
115    if (error instanceof SyntaxError) {
116      const learnMoreLink = `Learn more: https://docs.expo.dev/guides/config-plugins/#creating-a-plugin`;
117      // If the plugin reference is a node module, and that node module has a syntax error, then it probably doesn't have an official config plugin.
118      if (!isPluginFile && !moduleNameIsDirectFileReference(pluginReference)) {
119        const pluginError = new PluginError(
120          `Package "${pluginReference}" does not contain a valid config plugin.\n${learnMoreLink}\n\n${error.message}`,
121          'INVALID_PLUGIN_IMPORT'
122        );
123        pluginError.stack = error.stack;
124        throw pluginError;
125      }
126    }
127    throw error;
128  }
129
130  const plugin = resolveConfigPluginExport({
131    plugin: result,
132    pluginFile,
133    pluginReference,
134    isPluginFile,
135  });
136  return { plugin, pluginFile, pluginReference, isPluginFile };
137}
138
139/**
140 * - Resolve the exported contents of an Expo config (be it default or module.exports)
141 * - Assert no promise exports
142 * - Return config type
143 * - Serialize config
144 *
145 * @param props.plugin plugin results
146 * @param props.pluginFile plugin file path
147 * @param props.pluginReference the string used to reference the plugin
148 * @param props.isPluginFile is file path from the app.plugin.js module root
149 */
150export function resolveConfigPluginExport({
151  plugin,
152  pluginFile,
153  pluginReference,
154  isPluginFile,
155}: {
156  plugin: any;
157  pluginFile: string;
158  pluginReference: string;
159  isPluginFile: boolean;
160}): ConfigPlugin<unknown> {
161  if (plugin.default != null) {
162    plugin = plugin.default;
163  }
164  if (typeof plugin !== 'function') {
165    const learnMoreLink = `Learn more: https://docs.expo.dev/guides/config-plugins/#creating-a-plugin`;
166    // If the plugin reference is a node module, and that node module does not export a function then it probably doesn't have a config plugin.
167    if (!isPluginFile && !moduleNameIsDirectFileReference(pluginReference)) {
168      throw new PluginError(
169        `Package "${pluginReference}" does not contain a valid config plugin. Module must export a function from file: ${pluginFile}\n${learnMoreLink}`,
170        'INVALID_PLUGIN_TYPE'
171      );
172    }
173    throw new PluginError(
174      `Plugin "${pluginReference}" must export a function from file: ${pluginFile}. ${learnMoreLink}`,
175      'INVALID_PLUGIN_TYPE'
176    );
177  }
178
179  return plugin;
180}
181
182function requirePluginFile(filePath: string): any {
183  try {
184    return require(filePath);
185  } catch (error) {
186    // TODO: Improve error messages
187    throw error;
188  }
189}
190