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