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