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