xref: /expo/packages/@expo/config/src/Config.ts (revision 8c301ce0)
1import { ModConfig } from '@expo/config-plugins';
2import JsonFile, { JSONObject } from '@expo/json-file';
3import fs from 'fs';
4import { sync as globSync } from 'glob';
5import path from 'path';
6import resolveFrom from 'resolve-from';
7import semver from 'semver';
8import slugify from 'slugify';
9
10import {
11  AppJSONConfig,
12  ConfigFilePaths,
13  ExpoConfig,
14  GetConfigOptions,
15  PackageJSONConfig,
16  Platform,
17  ProjectConfig,
18  ProjectTarget,
19  WriteConfigOptions,
20} from './Config.types';
21import { getDynamicConfig, getStaticConfig } from './getConfig';
22import { getExpoSDKVersion } from './getExpoSDKVersion';
23import { withConfigPlugins } from './plugins/withConfigPlugins';
24import { withInternal } from './plugins/withInternal';
25import { getRootPackageJsonPath } from './resolvePackageJson';
26
27type SplitConfigs = { expo: ExpoConfig; mods: ModConfig };
28
29/**
30 * If a config has an `expo` object then that will be used as the config.
31 * This method reduces out other top level values if an `expo` object exists.
32 *
33 * @param config Input config object to reduce
34 */
35function reduceExpoObject(config?: any): SplitConfigs {
36  if (!config) return config === undefined ? null : config;
37
38  const { mods, ...expo } = config.expo ?? config;
39
40  return {
41    expo,
42    mods,
43  };
44}
45
46/**
47 * Get all platforms that a project is currently capable of running.
48 *
49 * @param projectRoot
50 * @param exp
51 */
52function getSupportedPlatforms(projectRoot: string): Platform[] {
53  const platforms: Platform[] = [];
54  if (resolveFrom.silent(projectRoot, 'react-native')) {
55    platforms.push('ios', 'android');
56  }
57  if (resolveFrom.silent(projectRoot, 'react-native-web')) {
58    platforms.push('web');
59  }
60  return platforms;
61}
62
63/**
64 * Evaluate the config for an Expo project.
65 * If a function is exported from the `app.config.js` then a partial config will be passed as an argument.
66 * The partial config is composed from any existing app.json, and certain fields from the `package.json` like name and description.
67 *
68 * If options.isPublicConfig is true, the Expo config will include only public-facing options (omitting private keys).
69 * The resulting config should be suitable for hosting or embedding in a publicly readable location.
70 *
71 * **Example**
72 * ```js
73 * module.exports = function({ config }) {
74 *   // mutate the config before returning it.
75 *   config.slug = 'new slug'
76 *   return { expo: config };
77 * }
78 * ```
79 *
80 * **Supports**
81 * - `app.config.ts`
82 * - `app.config.js`
83 * - `app.config.json`
84 * - `app.json`
85 *
86 * @param projectRoot the root folder containing all of your application code
87 * @param options enforce criteria for a project config
88 */
89export function getConfig(projectRoot: string, options: GetConfigOptions = {}): ProjectConfig {
90  const paths = getConfigFilePaths(projectRoot);
91
92  const rawStaticConfig = paths.staticConfigPath ? getStaticConfig(paths.staticConfigPath) : null;
93  // For legacy reasons, always return an object.
94  const rootConfig = (rawStaticConfig || {}) as AppJSONConfig;
95  const staticConfig = reduceExpoObject(rawStaticConfig) || {};
96
97  // Can only change the package.json location if an app.json or app.config.json exists
98  const [packageJson, packageJsonPath] = getPackageJsonAndPath(projectRoot);
99
100  function fillAndReturnConfig(config: SplitConfigs, dynamicConfigObjectType: string | null) {
101    const configWithDefaultValues = {
102      ...ensureConfigHasDefaultValues({
103        projectRoot,
104        exp: config.expo,
105        pkg: packageJson,
106        skipSDKVersionRequirement: options.skipSDKVersionRequirement,
107        paths,
108        packageJsonPath,
109      }),
110      mods: config.mods,
111      dynamicConfigObjectType,
112      rootConfig,
113      dynamicConfigPath: paths.dynamicConfigPath,
114      staticConfigPath: paths.staticConfigPath,
115    };
116
117    if (options.isModdedConfig) {
118      // @ts-ignore: Add the mods back to the object.
119      configWithDefaultValues.exp.mods = config.mods ?? null;
120    }
121
122    // Apply static json plugins, should be done after _internal
123    configWithDefaultValues.exp = withConfigPlugins(
124      configWithDefaultValues.exp,
125      !!options.skipPlugins
126    );
127
128    if (!options.isModdedConfig) {
129      // @ts-ignore: Delete mods added by static plugins when they won't have a chance to be evaluated
130      delete configWithDefaultValues.exp.mods;
131    }
132
133    if (options.isPublicConfig) {
134      // TODD(EvanBacon): Drop plugins array after it's been resolved.
135
136      // Remove internal values with references to user's file paths from the public config.
137      delete configWithDefaultValues.exp._internal;
138
139      if (configWithDefaultValues.exp.hooks) {
140        delete configWithDefaultValues.exp.hooks;
141      }
142      if (configWithDefaultValues.exp.ios?.config) {
143        delete configWithDefaultValues.exp.ios.config;
144      }
145      if (configWithDefaultValues.exp.android?.config) {
146        delete configWithDefaultValues.exp.android.config;
147      }
148
149      delete configWithDefaultValues.exp.updates?.codeSigningCertificate;
150      delete configWithDefaultValues.exp.updates?.codeSigningMetadata;
151    }
152
153    return configWithDefaultValues;
154  }
155
156  // Fill in the static config
157  function getContextConfig(config: SplitConfigs) {
158    return ensureConfigHasDefaultValues({
159      projectRoot,
160      exp: config.expo,
161      pkg: packageJson,
162      skipSDKVersionRequirement: true,
163      paths,
164      packageJsonPath,
165    }).exp;
166  }
167
168  if (paths.dynamicConfigPath) {
169    // No app.config.json or app.json but app.config.js
170    const { exportedObjectType, config: rawDynamicConfig } = getDynamicConfig(
171      paths.dynamicConfigPath,
172      {
173        projectRoot,
174        staticConfigPath: paths.staticConfigPath,
175        packageJsonPath,
176        config: getContextConfig(staticConfig),
177      }
178    );
179    // Allow for the app.config.js to `export default null;`
180    // Use `dynamicConfigPath` to detect if a dynamic config exists.
181    const dynamicConfig = reduceExpoObject(rawDynamicConfig) || {};
182    return fillAndReturnConfig(dynamicConfig, exportedObjectType);
183  }
184
185  // No app.config.js but json or no config
186  return fillAndReturnConfig(staticConfig || {}, null);
187}
188
189export function getPackageJson(projectRoot: string): PackageJSONConfig {
190  const [pkg] = getPackageJsonAndPath(projectRoot);
191  return pkg;
192}
193
194function getPackageJsonAndPath(projectRoot: string): [PackageJSONConfig, string] {
195  const packageJsonPath = getRootPackageJsonPath(projectRoot);
196  return [JsonFile.read(packageJsonPath), packageJsonPath];
197}
198
199/**
200 * Get the static and dynamic config paths for a project. Also accounts for custom paths.
201 *
202 * @param projectRoot
203 */
204export function getConfigFilePaths(projectRoot: string): ConfigFilePaths {
205  return {
206    dynamicConfigPath: getDynamicConfigFilePath(projectRoot),
207    staticConfigPath: getStaticConfigFilePath(projectRoot),
208  };
209}
210
211function getDynamicConfigFilePath(projectRoot: string): string | null {
212  for (const fileName of ['app.config.ts', 'app.config.js']) {
213    const configPath = path.join(projectRoot, fileName);
214    if (fs.existsSync(configPath)) {
215      return configPath;
216    }
217  }
218  return null;
219}
220
221function getStaticConfigFilePath(projectRoot: string): string | null {
222  for (const fileName of ['app.config.json', 'app.json']) {
223    const configPath = path.join(projectRoot, fileName);
224    if (fs.existsSync(configPath)) {
225      return configPath;
226    }
227  }
228  return null;
229}
230
231/**
232 * Attempt to modify an Expo project config.
233 * This will only fully work if the project is using static configs only.
234 * Otherwise 'warn' | 'fail' will return with a message about why the config couldn't be updated.
235 * The potentially modified config object will be returned for testing purposes.
236 *
237 * @param projectRoot
238 * @param modifications modifications to make to an existing config
239 * @param readOptions options for reading the current config file
240 * @param writeOptions If true, the static config file will not be rewritten
241 */
242export async function modifyConfigAsync(
243  projectRoot: string,
244  modifications: Partial<ExpoConfig>,
245  readOptions: GetConfigOptions = {},
246  writeOptions: WriteConfigOptions = {}
247): Promise<{
248  type: 'success' | 'warn' | 'fail';
249  message?: string;
250  config: AppJSONConfig | null;
251}> {
252  const config = getConfig(projectRoot, readOptions);
253  if (config.dynamicConfigPath) {
254    // We cannot automatically write to a dynamic config.
255    /* Currently we should just use the safest approach possible, informing the user that they'll need to manually modify their dynamic config.
256
257    if (config.staticConfigPath) {
258      // Both a dynamic and a static config exist.
259      if (config.dynamicConfigObjectType === 'function') {
260        // The dynamic config exports a function, this means it possibly extends the static config.
261      } else {
262        // Dynamic config ignores the static config, there isn't a reason to automatically write to it.
263        // Instead we should warn the user to add values to their dynamic config.
264      }
265    }
266    */
267    return {
268      type: 'warn',
269      message: `Cannot automatically write to dynamic config at: ${path.relative(
270        projectRoot,
271        config.dynamicConfigPath
272      )}`,
273      config: null,
274    };
275  } else if (config.staticConfigPath) {
276    // Static with no dynamic config, this means we can append to the config automatically.
277    let outputConfig: AppJSONConfig;
278    // If the config has an expo object (app.json) then append the options to that object.
279    if (config.rootConfig.expo) {
280      outputConfig = {
281        ...config.rootConfig,
282        expo: { ...config.rootConfig.expo, ...modifications },
283      };
284    } else {
285      // Otherwise (app.config.json) just add the config modification to the top most level.
286      outputConfig = { ...config.rootConfig, ...modifications };
287    }
288    if (!writeOptions.dryRun) {
289      await JsonFile.writeAsync(config.staticConfigPath, outputConfig, { json5: false });
290    }
291    return { type: 'success', config: outputConfig };
292  }
293
294  return { type: 'fail', message: 'No config exists', config: null };
295}
296
297function ensureConfigHasDefaultValues({
298  projectRoot,
299  exp,
300  pkg,
301  paths,
302  packageJsonPath,
303  skipSDKVersionRequirement = false,
304}: {
305  projectRoot: string;
306  exp: Partial<ExpoConfig> | null;
307  pkg: JSONObject;
308  skipSDKVersionRequirement?: boolean;
309  paths?: ConfigFilePaths;
310  packageJsonPath?: string;
311}): { exp: ExpoConfig; pkg: PackageJSONConfig } {
312  if (!exp) {
313    exp = {};
314  }
315  exp = withInternal(exp as any, {
316    projectRoot,
317    ...(paths ?? {}),
318    packageJsonPath,
319  });
320  // Defaults for package.json fields
321  const pkgName = typeof pkg.name === 'string' ? pkg.name : path.basename(projectRoot);
322  const pkgVersion = typeof pkg.version === 'string' ? pkg.version : '1.0.0';
323
324  const pkgWithDefaults = { ...pkg, name: pkgName, version: pkgVersion };
325
326  // Defaults for app.json/app.config.js fields
327  const name = exp.name ?? pkgName;
328  const slug = exp.slug ?? slugify(name.toLowerCase());
329  const version = exp.version ?? pkgVersion;
330  let description = exp.description;
331  if (!description && typeof pkg.description === 'string') {
332    description = pkg.description;
333  }
334
335  const expWithDefaults = { ...exp, name, slug, version, description };
336
337  let sdkVersion;
338  try {
339    sdkVersion = getExpoSDKVersion(projectRoot, expWithDefaults);
340  } catch (error) {
341    if (!skipSDKVersionRequirement) throw error;
342  }
343
344  let platforms = exp.platforms;
345  if (!platforms) {
346    platforms = getSupportedPlatforms(projectRoot);
347  }
348
349  return {
350    exp: { ...expWithDefaults, sdkVersion, platforms },
351    pkg: pkgWithDefaults,
352  };
353}
354
355const DEFAULT_BUILD_PATH = `web-build`;
356
357export function getWebOutputPath(config: { [key: string]: any } = {}): string {
358  if (process.env.WEBPACK_BUILD_OUTPUT_PATH) {
359    return process.env.WEBPACK_BUILD_OUTPUT_PATH;
360  }
361  const expo = config.expo || config || {};
362  return expo?.web?.build?.output || DEFAULT_BUILD_PATH;
363}
364
365export function getNameFromConfig(exp: Record<string, any> = {}): {
366  appName?: string;
367  webName?: string;
368} {
369  // For RN CLI support
370  const appManifest = exp.expo || exp;
371  const { web = {} } = appManifest;
372
373  // rn-cli apps use a displayName value as well.
374  const appName = exp.displayName || appManifest.displayName || appManifest.name;
375  const webName = web.name || appName;
376
377  return {
378    appName,
379    webName,
380  };
381}
382
383export function getDefaultTarget(
384  projectRoot: string,
385  exp?: Pick<ExpoConfig, 'sdkVersion'>
386): ProjectTarget {
387  exp ??= getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp;
388
389  // before SDK 37, always default to managed to preserve previous behavior
390  if (exp.sdkVersion && exp.sdkVersion !== 'UNVERSIONED' && semver.lt(exp.sdkVersion, '37.0.0')) {
391    return 'managed';
392  }
393  return isBareWorkflowProject(projectRoot) ? 'bare' : 'managed';
394}
395
396function isBareWorkflowProject(projectRoot: string): boolean {
397  const [pkg] = getPackageJsonAndPath(projectRoot);
398
399  // TODO: Drop this
400  if (pkg.dependencies && pkg.dependencies.expokit) {
401    return false;
402  }
403
404  const xcodeprojFiles = globSync('ios/**/*.xcodeproj', {
405    absolute: true,
406    cwd: projectRoot,
407  });
408  if (xcodeprojFiles.length) {
409    return true;
410  }
411  const gradleFiles = globSync('android/**/*.gradle', {
412    absolute: true,
413    cwd: projectRoot,
414  });
415  if (gradleFiles.length) {
416    return true;
417  }
418
419  return false;
420}
421
422/**
423 * Return a useful name describing the project config.
424 * - dynamic: app.config.js
425 * - static: app.json
426 * - custom path app config relative to root folder
427 * - both: app.config.js or app.json
428 */
429export function getProjectConfigDescription(projectRoot: string): string {
430  const paths = getConfigFilePaths(projectRoot);
431  return getProjectConfigDescriptionWithPaths(projectRoot, paths);
432}
433
434/**
435 * Returns a string describing the configurations used for the given project root.
436 * Will return null if no config is found.
437 *
438 * @param projectRoot
439 * @param projectConfig
440 */
441export function getProjectConfigDescriptionWithPaths(
442  projectRoot: string,
443  projectConfig: ConfigFilePaths
444): string {
445  if (projectConfig.dynamicConfigPath) {
446    const relativeDynamicConfigPath = path.relative(projectRoot, projectConfig.dynamicConfigPath);
447    if (projectConfig.staticConfigPath) {
448      return `${relativeDynamicConfigPath} or ${path.relative(
449        projectRoot,
450        projectConfig.staticConfigPath
451      )}`;
452    }
453    return relativeDynamicConfigPath;
454  } else if (projectConfig.staticConfigPath) {
455    return path.relative(projectRoot, projectConfig.staticConfigPath);
456  }
457  // If a config doesn't exist, our tooling will generate a static app.json
458  return 'app.json';
459}
460
461export * from './Config.types';
462