xref: /expo/packages/@expo/config/src/Config.ts (revision f5ebbab0)
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  ExpRc,
15  GetConfigOptions,
16  PackageJSONConfig,
17  Platform,
18  ProjectConfig,
19  ProjectTarget,
20  WriteConfigOptions,
21} from './Config.types';
22import { ConfigError } from './Errors';
23import { getExpoSDKVersion } from './Project';
24import { getDynamicConfig, getStaticConfig } from './getConfig';
25import { getFullName } from './getFullName';
26import { withConfigPlugins } from './plugins/withConfigPlugins';
27import { withInternal } from './plugins/withInternal';
28import { getRootPackageJsonPath } from './resolvePackageJson';
29
30type SplitConfigs = { expo: ExpoConfig; mods: ModConfig };
31
32/**
33 * If a config has an `expo` object then that will be used as the config.
34 * This method reduces out other top level values if an `expo` object exists.
35 *
36 * @param config Input config object to reduce
37 */
38function reduceExpoObject(config?: any): SplitConfigs {
39  if (!config) return config === undefined ? null : config;
40
41  const { mods, ...expo } = config.expo ?? config;
42
43  return {
44    expo,
45    mods,
46  };
47}
48
49/**
50 * Get all platforms that a project is currently capable of running.
51 *
52 * @param projectRoot
53 * @param exp
54 */
55function getSupportedPlatforms(projectRoot: string): Platform[] {
56  const platforms: Platform[] = [];
57  if (resolveFrom.silent(projectRoot, 'react-native')) {
58    platforms.push('ios', 'android');
59  }
60  if (resolveFrom.silent(projectRoot, 'react-native-web')) {
61    platforms.push('web');
62  }
63  return platforms;
64}
65
66/**
67 * Evaluate the config for an Expo project.
68 * If a function is exported from the `app.config.js` then a partial config will be passed as an argument.
69 * The partial config is composed from any existing app.json, and certain fields from the `package.json` like name and description.
70 *
71 * If options.isPublicConfig is true, the Expo config will include only public-facing options (omitting private keys).
72 * The resulting config should be suitable for hosting or embedding in a publicly readable location.
73 *
74 * **Example**
75 * ```js
76 * module.exports = function({ config }) {
77 *   // mutate the config before returning it.
78 *   config.slug = 'new slug'
79 *   return { expo: config };
80 * }
81 * ```
82 *
83 * **Supports**
84 * - `app.config.ts`
85 * - `app.config.js`
86 * - `app.config.json`
87 * - `app.json`
88 *
89 * @param projectRoot the root folder containing all of your application code
90 * @param options enforce criteria for a project config
91 */
92export function getConfig(projectRoot: string, options: GetConfigOptions = {}): ProjectConfig {
93  const paths = getConfigFilePaths(projectRoot);
94
95  const rawStaticConfig = paths.staticConfigPath ? getStaticConfig(paths.staticConfigPath) : null;
96  // For legacy reasons, always return an object.
97  const rootConfig = (rawStaticConfig || {}) as AppJSONConfig;
98  const staticConfig = reduceExpoObject(rawStaticConfig) || {};
99
100  // Can only change the package.json location if an app.json or app.config.json exists
101  const [packageJson, packageJsonPath] = getPackageJsonAndPath(projectRoot);
102
103  function fillAndReturnConfig(config: SplitConfigs, dynamicConfigObjectType: string | null) {
104    const configWithDefaultValues = {
105      ...ensureConfigHasDefaultValues({
106        projectRoot,
107        exp: config.expo,
108        pkg: packageJson,
109        skipSDKVersionRequirement: options.skipSDKVersionRequirement,
110        paths,
111        packageJsonPath,
112      }),
113      mods: config.mods,
114      dynamicConfigObjectType,
115      rootConfig,
116      dynamicConfigPath: paths.dynamicConfigPath,
117      staticConfigPath: paths.staticConfigPath,
118    };
119
120    if (options.isModdedConfig) {
121      // @ts-ignore: Add the mods back to the object.
122      configWithDefaultValues.exp.mods = config.mods ?? null;
123    }
124
125    // Apply static json plugins, should be done after _internal
126    configWithDefaultValues.exp = withConfigPlugins(
127      configWithDefaultValues.exp,
128      !!options.skipPlugins
129    );
130
131    if (!options.isModdedConfig) {
132      // @ts-ignore: Delete mods added by static plugins when they won't have a chance to be evaluated
133      delete configWithDefaultValues.exp.mods;
134    }
135
136    if (options.isPublicConfig) {
137      // Remove internal values with references to user's file paths from the public config.
138      delete configWithDefaultValues.exp._internal;
139
140      if (configWithDefaultValues.exp.hooks) {
141        delete configWithDefaultValues.exp.hooks;
142      }
143      if (configWithDefaultValues.exp.ios?.config) {
144        delete configWithDefaultValues.exp.ios.config;
145      }
146      if (configWithDefaultValues.exp.android?.config) {
147        delete configWithDefaultValues.exp.android.config;
148      }
149
150      // These value will be overwritten when the manifest is being served from the host (i.e. not completely accurate).
151      // @ts-ignore: currentFullName not on type yet.
152      configWithDefaultValues.exp.currentFullName = getFullName(configWithDefaultValues.exp);
153      // @ts-ignore: originalFullName not on type yet.
154      configWithDefaultValues.exp.originalFullName = getFullName(configWithDefaultValues.exp);
155
156      delete configWithDefaultValues.exp.updates?.codeSigningCertificate;
157      delete configWithDefaultValues.exp.updates?.codeSigningMetadata;
158    }
159
160    return configWithDefaultValues;
161  }
162
163  // Fill in the static config
164  function getContextConfig(config: SplitConfigs) {
165    return ensureConfigHasDefaultValues({
166      projectRoot,
167      exp: config.expo,
168      pkg: packageJson,
169      skipSDKVersionRequirement: true,
170      paths,
171      packageJsonPath,
172    }).exp;
173  }
174
175  if (paths.dynamicConfigPath) {
176    // No app.config.json or app.json but app.config.js
177    const { exportedObjectType, config: rawDynamicConfig } = getDynamicConfig(
178      paths.dynamicConfigPath,
179      {
180        projectRoot,
181        staticConfigPath: paths.staticConfigPath,
182        packageJsonPath,
183        config: getContextConfig(staticConfig),
184      }
185    );
186    // Allow for the app.config.js to `export default null;`
187    // Use `dynamicConfigPath` to detect if a dynamic config exists.
188    const dynamicConfig = reduceExpoObject(rawDynamicConfig) || {};
189    return fillAndReturnConfig(dynamicConfig, exportedObjectType);
190  }
191
192  // No app.config.js but json or no config
193  return fillAndReturnConfig(staticConfig || {}, null);
194}
195
196export function getPackageJson(projectRoot: string): PackageJSONConfig {
197  const [pkg] = getPackageJsonAndPath(projectRoot);
198  return pkg;
199}
200
201function getPackageJsonAndPath(projectRoot: string): [PackageJSONConfig, string] {
202  const packageJsonPath = getRootPackageJsonPath(projectRoot);
203  return [JsonFile.read(packageJsonPath), packageJsonPath];
204}
205
206export function readConfigJson(
207  projectRoot: string,
208  skipValidation: boolean = false,
209  skipSDKVersionRequirement: boolean = false
210): ProjectConfig {
211  const paths = getConfigFilePaths(projectRoot);
212
213  const rawStaticConfig = paths.staticConfigPath ? getStaticConfig(paths.staticConfigPath) : null;
214
215  const getConfigName = (): string => {
216    if (paths.staticConfigPath) return ` \`${path.basename(paths.staticConfigPath)}\``;
217    return '';
218  };
219
220  let outputRootConfig = rawStaticConfig as JSONObject | null;
221  if (outputRootConfig === null || typeof outputRootConfig !== 'object') {
222    if (skipValidation) {
223      outputRootConfig = { expo: {} };
224    } else {
225      throw new ConfigError(
226        `Project at path ${path.resolve(
227          projectRoot
228        )} does not contain a valid Expo config${getConfigName()}`,
229        'NOT_OBJECT'
230      );
231    }
232  }
233  let exp = outputRootConfig.expo as Partial<ExpoConfig>;
234  if (exp === null || typeof exp !== 'object') {
235    throw new ConfigError(
236      `Property 'expo' in${getConfigName()} for project at path ${path.resolve(
237        projectRoot
238      )} is not an object. Please make sure${getConfigName()} includes a managed Expo app config like this: ${APP_JSON_EXAMPLE}`,
239      'NO_EXPO'
240    );
241  }
242
243  exp = { ...exp };
244
245  const [pkg, packageJsonPath] = getPackageJsonAndPath(projectRoot);
246
247  return {
248    ...ensureConfigHasDefaultValues({
249      projectRoot,
250      exp,
251      pkg,
252      skipSDKVersionRequirement,
253      paths,
254      packageJsonPath,
255    }),
256    mods: null,
257    dynamicConfigObjectType: null,
258    rootConfig: { ...outputRootConfig } as AppJSONConfig,
259    ...paths,
260  };
261}
262
263/**
264 * Get the static and dynamic config paths for a project. Also accounts for custom paths.
265 *
266 * @param projectRoot
267 */
268export function getConfigFilePaths(projectRoot: string): ConfigFilePaths {
269  const customPaths = getCustomConfigFilePaths(projectRoot);
270  if (customPaths) {
271    return customPaths;
272  }
273
274  return {
275    dynamicConfigPath: getDynamicConfigFilePath(projectRoot),
276    staticConfigPath: getStaticConfigFilePath(projectRoot),
277  };
278}
279
280function getCustomConfigFilePaths(projectRoot: string): ConfigFilePaths | null {
281  if (!customConfigPaths[projectRoot]) {
282    return null;
283  }
284  // If the user picks a custom config path, we will only use that and skip searching for a secondary config.
285  if (isDynamicFilePath(customConfigPaths[projectRoot])) {
286    return {
287      dynamicConfigPath: customConfigPaths[projectRoot],
288      staticConfigPath: null,
289    };
290  }
291  // Anything that's not js or ts will be treated as json.
292  return { staticConfigPath: customConfigPaths[projectRoot], dynamicConfigPath: null };
293}
294
295function getDynamicConfigFilePath(projectRoot: string): string | null {
296  for (const fileName of ['app.config.ts', 'app.config.js']) {
297    const configPath = path.join(projectRoot, fileName);
298    if (fs.existsSync(configPath)) {
299      return configPath;
300    }
301  }
302  return null;
303}
304
305function getStaticConfigFilePath(projectRoot: string): string | null {
306  for (const fileName of ['app.config.json', 'app.json']) {
307    const configPath = path.join(projectRoot, fileName);
308    if (fs.existsSync(configPath)) {
309      return configPath;
310    }
311  }
312  return null;
313}
314
315// TODO: This should account for dynamic configs
316export function findConfigFile(projectRoot: string): {
317  configPath: string;
318  configName: string;
319  configNamespace: 'expo';
320} {
321  let configPath: string;
322  // Check for a custom config path first.
323  if (customConfigPaths[projectRoot]) {
324    configPath = customConfigPaths[projectRoot];
325    // We shouldn't verify if the file exists because
326    // the user manually specified that this path should be used.
327    return {
328      configPath,
329      configName: path.basename(configPath),
330      configNamespace: 'expo',
331    };
332  } else {
333    // app.config.json takes higher priority over app.json
334    configPath = path.join(projectRoot, 'app.config.json');
335    if (!fs.existsSync(configPath)) {
336      configPath = path.join(projectRoot, 'app.json');
337    }
338  }
339
340  return {
341    configPath,
342    configName: path.basename(configPath),
343    configNamespace: 'expo',
344  };
345}
346
347// TODO: deprecate
348export function configFilename(projectRoot: string): string {
349  return findConfigFile(projectRoot).configName;
350}
351
352export async function readExpRcAsync(projectRoot: string): Promise<ExpRc> {
353  const expRcPath = path.join(projectRoot, '.exprc');
354  return await JsonFile.readAsync(expRcPath, { json5: true, cantReadFileDefault: {} });
355}
356
357const customConfigPaths: { [projectRoot: string]: string } = {};
358
359export function resetCustomConfigPaths(): void {
360  for (const key of Object.keys(customConfigPaths)) {
361    delete customConfigPaths[key];
362  }
363}
364
365export function setCustomConfigPath(projectRoot: string, configPath: string): void {
366  customConfigPaths[projectRoot] = configPath;
367}
368
369/**
370 * Attempt to modify an Expo project config.
371 * This will only fully work if the project is using static configs only.
372 * Otherwise 'warn' | 'fail' will return with a message about why the config couldn't be updated.
373 * The potentially modified config object will be returned for testing purposes.
374 *
375 * @param projectRoot
376 * @param modifications modifications to make to an existing config
377 * @param readOptions options for reading the current config file
378 * @param writeOptions If true, the static config file will not be rewritten
379 */
380export async function modifyConfigAsync(
381  projectRoot: string,
382  modifications: Partial<ExpoConfig>,
383  readOptions: GetConfigOptions = {},
384  writeOptions: WriteConfigOptions = {}
385): Promise<{
386  type: 'success' | 'warn' | 'fail';
387  message?: string;
388  config: AppJSONConfig | null;
389}> {
390  const config = getConfig(projectRoot, readOptions);
391  if (config.dynamicConfigPath) {
392    // We cannot automatically write to a dynamic config.
393    /* Currently we should just use the safest approach possible, informing the user that they'll need to manually modify their dynamic config.
394
395    if (config.staticConfigPath) {
396      // Both a dynamic and a static config exist.
397      if (config.dynamicConfigObjectType === 'function') {
398        // The dynamic config exports a function, this means it possibly extends the static config.
399      } else {
400        // Dynamic config ignores the static config, there isn't a reason to automatically write to it.
401        // Instead we should warn the user to add values to their dynamic config.
402      }
403    }
404    */
405    return {
406      type: 'warn',
407      message: `Cannot automatically write to dynamic config at: ${path.relative(
408        projectRoot,
409        config.dynamicConfigPath
410      )}`,
411      config: null,
412    };
413  } else if (config.staticConfigPath) {
414    // Static with no dynamic config, this means we can append to the config automatically.
415    let outputConfig: AppJSONConfig;
416    // If the config has an expo object (app.json) then append the options to that object.
417    if (config.rootConfig.expo) {
418      outputConfig = {
419        ...config.rootConfig,
420        expo: { ...config.rootConfig.expo, ...modifications },
421      };
422    } else {
423      // Otherwise (app.config.json) just add the config modification to the top most level.
424      outputConfig = { ...config.rootConfig, ...modifications };
425    }
426    if (!writeOptions.dryRun) {
427      await JsonFile.writeAsync(config.staticConfigPath, outputConfig, { json5: false });
428    }
429    return { type: 'success', config: outputConfig };
430  }
431
432  return { type: 'fail', message: 'No config exists', config: null };
433}
434
435const APP_JSON_EXAMPLE = JSON.stringify({
436  expo: {
437    name: 'My app',
438    slug: 'my-app',
439    sdkVersion: '...',
440  },
441});
442
443function ensureConfigHasDefaultValues({
444  projectRoot,
445  exp,
446  pkg,
447  paths,
448  packageJsonPath,
449  skipSDKVersionRequirement = false,
450}: {
451  projectRoot: string;
452  exp: Partial<ExpoConfig> | null;
453  pkg: JSONObject;
454  skipSDKVersionRequirement?: boolean;
455  paths?: ConfigFilePaths;
456  packageJsonPath?: string;
457}): { exp: ExpoConfig; pkg: PackageJSONConfig } {
458  if (!exp) {
459    exp = {};
460  }
461  exp = withInternal(exp as any, {
462    projectRoot,
463    ...(paths ?? {}),
464    packageJsonPath,
465  });
466  // Defaults for package.json fields
467  const pkgName = typeof pkg.name === 'string' ? pkg.name : path.basename(projectRoot);
468  const pkgVersion = typeof pkg.version === 'string' ? pkg.version : '1.0.0';
469
470  const pkgWithDefaults = { ...pkg, name: pkgName, version: pkgVersion };
471
472  // Defaults for app.json/app.config.js fields
473  const name = exp.name ?? pkgName;
474  const slug = exp.slug ?? slugify(name.toLowerCase());
475  const version = exp.version ?? pkgVersion;
476  let description = exp.description;
477  if (!description && typeof pkg.description === 'string') {
478    description = pkg.description;
479  }
480
481  const expWithDefaults = { ...exp, name, slug, version, description };
482
483  let sdkVersion;
484  try {
485    sdkVersion = getExpoSDKVersion(projectRoot, expWithDefaults);
486  } catch (error) {
487    if (!skipSDKVersionRequirement) throw error;
488  }
489
490  let platforms = exp.platforms;
491  if (!platforms) {
492    platforms = getSupportedPlatforms(projectRoot);
493  }
494
495  return {
496    exp: { ...expWithDefaults, sdkVersion, platforms },
497    pkg: pkgWithDefaults,
498  };
499}
500
501export async function writeConfigJsonAsync(
502  projectRoot: string,
503  options: object
504): Promise<ProjectConfig> {
505  const paths = getConfigFilePaths(projectRoot);
506  let { exp, pkg, rootConfig, dynamicConfigObjectType } = readConfigJson(projectRoot);
507  exp = { ...rootConfig.expo, ...options };
508  rootConfig = { ...rootConfig, expo: exp };
509
510  if (paths.staticConfigPath) {
511    await JsonFile.writeAsync(paths.staticConfigPath, rootConfig, { json5: false });
512  } else {
513    console.log('Failed to write to config: ', options);
514  }
515
516  return {
517    exp,
518    pkg,
519    rootConfig,
520    dynamicConfigObjectType,
521    ...paths,
522  };
523}
524const DEFAULT_BUILD_PATH = `web-build`;
525
526export function getWebOutputPath(config: { [key: string]: any } = {}): string {
527  if (process.env.WEBPACK_BUILD_OUTPUT_PATH) {
528    return process.env.WEBPACK_BUILD_OUTPUT_PATH;
529  }
530  const expo = config.expo || config || {};
531  return expo?.web?.build?.output || DEFAULT_BUILD_PATH;
532}
533
534export function getNameFromConfig(exp: Record<string, any> = {}): {
535  appName?: string;
536  webName?: string;
537} {
538  // For RN CLI support
539  const appManifest = exp.expo || exp;
540  const { web = {} } = appManifest;
541
542  // rn-cli apps use a displayName value as well.
543  const appName = exp.displayName || appManifest.displayName || appManifest.name;
544  const webName = web.name || appName;
545
546  return {
547    appName,
548    webName,
549  };
550}
551
552export function getDefaultTarget(
553  projectRoot: string,
554  exp?: Pick<ExpoConfig, 'sdkVersion'>
555): ProjectTarget {
556  exp ??= getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp;
557
558  // before SDK 37, always default to managed to preserve previous behavior
559  if (exp.sdkVersion && exp.sdkVersion !== 'UNVERSIONED' && semver.lt(exp.sdkVersion, '37.0.0')) {
560    return 'managed';
561  }
562  return isBareWorkflowProject(projectRoot) ? 'bare' : 'managed';
563}
564
565function isBareWorkflowProject(projectRoot: string): boolean {
566  const [pkg] = getPackageJsonAndPath(projectRoot);
567
568  if (pkg.dependencies && pkg.dependencies.expokit) {
569    return false;
570  }
571
572  const xcodeprojFiles = globSync('ios/**/*.xcodeproj', {
573    absolute: true,
574    cwd: projectRoot,
575  });
576  if (xcodeprojFiles.length) {
577    return true;
578  }
579  const gradleFiles = globSync('android/**/*.gradle', {
580    absolute: true,
581    cwd: projectRoot,
582  });
583  if (gradleFiles.length) {
584    return true;
585  }
586
587  return false;
588}
589
590/**
591 * true if the file is .js or .ts
592 *
593 * @param filePath
594 */
595function isDynamicFilePath(filePath: string): boolean {
596  return !!filePath.match(/\.[j|t]s$/);
597}
598
599/**
600 * Return a useful name describing the project config.
601 * - dynamic: app.config.js
602 * - static: app.json
603 * - custom path app config relative to root folder
604 * - both: app.config.js or app.json
605 */
606export function getProjectConfigDescription(projectRoot: string): string {
607  const paths = getConfigFilePaths(projectRoot);
608  return getProjectConfigDescriptionWithPaths(projectRoot, paths);
609}
610
611/**
612 * Returns a string describing the configurations used for the given project root.
613 * Will return null if no config is found.
614 *
615 * @param projectRoot
616 * @param projectConfig
617 */
618export function getProjectConfigDescriptionWithPaths(
619  projectRoot: string,
620  projectConfig: ConfigFilePaths
621): string {
622  if (projectConfig.dynamicConfigPath) {
623    const relativeDynamicConfigPath = path.relative(projectRoot, projectConfig.dynamicConfigPath);
624    if (projectConfig.staticConfigPath) {
625      return `${relativeDynamicConfigPath} or ${path.relative(
626        projectRoot,
627        projectConfig.staticConfigPath
628      )}`;
629    }
630    return relativeDynamicConfigPath;
631  } else if (projectConfig.staticConfigPath) {
632    return path.relative(projectRoot, projectConfig.staticConfigPath);
633  }
634  // If a config doesn't exist, our tooling will generate a static app.json
635  return 'app.json';
636}
637
638export * from './Config.types';
639
640export { isLegacyImportsEnabled } from './isLegacyImportsEnabled';
641