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