diff --git a/packages/expo-cli/src/commands/build/AndroidBuilder.ts b/packages/expo-cli/src/commands/build/AndroidBuilder.ts index b19ac9acd9..c1d34e026b 100644 --- a/packages/expo-cli/src/commands/build/AndroidBuilder.ts +++ b/packages/expo-cli/src/commands/build/AndroidBuilder.ts @@ -12,11 +12,18 @@ import BuildError from './BuildError'; import BaseBuilder from './BaseBuilder'; import * as utils from './utils'; import { PLATFORMS, Platform } from './constants'; +import { getOrPromptForPackage } from '../eject/ConfigValidation'; const { ANDROID } = PLATFORMS; export default class AndroidBuilder extends BaseBuilder { async run(): Promise { + // This gets run after all other validation to prevent users from having to answer this question multiple times. + this.options.type = await utils.askBuildType(this.options.type!, { + apk: 'Build a package to deploy to the store or install directly on Android devices', + 'app-bundle': 'Build an optimized bundle for the store', + }); + // Check SplashScreen images sizes await Android.checkSplashScreenImages(this.projectDir); @@ -43,17 +50,9 @@ export default class AndroidBuilder extends BaseBuilder { await utils.checkIfSdkIsSupported(this.manifest.sdkVersion!, ANDROID); // Check the android package name - // TODO: Attempt to automatically write this value. - const androidPackage = this.manifest.android?.package; - if (!androidPackage) { - throw new BuildError(`Your project must have an Android package set in app.json -See https://docs.expo.io/distribution/building-standalone-apps/#2-configure-appjson`); - } - if (!/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(androidPackage)) { - throw new BuildError( - "Invalid format of Android package name (only alphanumeric characters, '.' and '_' are allowed, and each '.' must be followed by a letter)" - ); - } + await getOrPromptForPackage(this.projectDir); + + this.updateProjectConfig(); } platform(): Platform { diff --git a/packages/expo-cli/src/commands/build/index.ts b/packages/expo-cli/src/commands/build/index.ts index 31a90532ff..d20d4be4fb 100644 --- a/packages/expo-cli/src/commands/build/index.ts +++ b/packages/expo-cli/src/commands/build/index.ts @@ -168,10 +168,7 @@ export default function (program: Command) { ); process.exit(1); } - options.type = await askBuildType(options.type, { - apk: 'Build a package to deploy to the store or install directly on Android devices', - 'app-bundle': 'Build an optimized bundle for the store', - }); + const androidBuilder = new AndroidBuilder(projectDir, options); return androidBuilder.command(); }, diff --git a/packages/expo-cli/src/commands/build/ios/IOSBuilder.ts b/packages/expo-cli/src/commands/build/ios/IOSBuilder.ts index 2278ffa067..cd32f46356 100644 --- a/packages/expo-cli/src/commands/build/ios/IOSBuilder.ts +++ b/packages/expo-cli/src/commands/build/ios/IOSBuilder.ts @@ -5,7 +5,6 @@ import { XDLError } from '@expo/xdl'; import terminalLink from 'terminal-link'; import semver from 'semver'; -import { modifyConfigAsync } from '@expo/config'; import BaseBuilder from '../BaseBuilder'; import { PLATFORMS } from '../constants'; import * as utils from '../utils'; @@ -37,6 +36,7 @@ import { useProvisioningProfileFromParams, } from '../../../credentials/views/IosProvisioningProfile'; import { IosAppCredentials, IosDistCredentials } from '../../../credentials/credentials'; +import { getOrPromptForBundleIdentifier } from '../../eject/ConfigValidation'; const noBundleIdMessage = `Your project must have a \`bundleIdentifier\` set in the Expo config (app.json or app.config.js).\nSee https://expo.fyi/bundle-identifier`; @@ -126,77 +126,9 @@ class IOSBuilder extends BaseBuilder { await this.validateIcon(); // Check the bundle ID and possibly prompt the user to add a new one. - const bundleIdentifier = this.manifest.ios?.bundleIdentifier; - - if (!bundleIdentifier) { - // Recommend a bundle ID based on the username and project slug. - const username = await this.getUsernameAsync(); - const recommendedBundleId = username ? `com.${username}.${this.manifest.slug}` : undefined; - - log.newLine(); - log( - log.chalk.cyan( - `Now we need to know your ${terminalLink( - 'iOS bundle identifier', - 'https://expo.fyi/bundle-identifier' - )}.\nYou can change this in the future if you need to.` - ) - ); - log.newLine(); - - // Prompt the user for the bundle ID. - // Even if the project is using a dynamic config we can still - // prompt a better error message, recommend a default value, and help the user - // validate their custom bundle ID upfront. - const bundleIdPrompt = await prompt( - [ - { - name: 'bundleIdentifier', - default: recommendedBundleId, - // The Apple helps people know this isn't an EAS feature. - message: `What would you like your iOS bundle identifier to be?`, - validate: (value: string) => /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(value), - }, - ], - { - nonInteractiveHelp: noBundleIdMessage, - } - ); - - const modification = await modifyConfigAsync( - this.projectDir, - { ios: { bundleIdentifier: bundleIdPrompt.bundleIdentifier } }, - { skipSDKVersionRequirement: true } - ); - if (modification.type === 'success') { - log.newLine(); - // Success! - log(`Your iOS bundle identifier is now: ${bundleIdPrompt.bundleIdentifier}`); - log.newLine(); - } else { - log.newLine(); - if (modification.type === 'warn') { - // The project is using a dynamic config, give the user a helpful log and bail out. - log(log.chalk.yellow(modification.message)); - } else { - log( - log.chalk.yellow( - 'No Expo config was found. Please create an Expo config (`app.config.js` or `app.json`) in your project root.' - ) - ); - } - - log(log.chalk.cyan(`Please add the following to your Expo config, and try again... `)); - log.newLine(); - log( - JSON.stringify({ ios: { bundleIdentifier: bundleIdPrompt.bundleIdentifier } }, null, 2) - ); - log.newLine(); - process.exit(1); - } - // Update with the latest bundle ID - this.updateProjectConfig(); - } + await getOrPromptForBundleIdentifier(this.projectDir); + // Update with the latest bundle ID + this.updateProjectConfig(); } private async getUsernameAsync(): Promise { diff --git a/packages/expo-cli/src/commands/eject/ConfigValidation.ts b/packages/expo-cli/src/commands/eject/ConfigValidation.ts new file mode 100644 index 0000000000..068700a371 --- /dev/null +++ b/packages/expo-cli/src/commands/eject/ConfigValidation.ts @@ -0,0 +1,194 @@ +import { ExpoConfig, getConfig, modifyConfigAsync } from '@expo/config'; +import { UserManager } from '@expo/xdl'; +import terminalLink from 'terminal-link'; + +import log from '../../log'; +import prompt from '../../prompt'; + +const noBundleIdMessage = `Your project must have a \`bundleIdentifier\` set in the Expo config (app.json or app.config.js).\nSee https://expo.fyi/bundle-identifier`; +const noPackageMessage = `Your project must have a \`package\` set in the Expo config (app.json or app.config.js).\nSee https://expo.fyi/android-package`; + +function validateBundleId(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(value); +} + +function validatePackage(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(value); +} + +export async function getOrPromptForBundleIdentifier(projectRoot: string): Promise { + let { exp } = getConfig(projectRoot); + + const currentBundleId = exp.ios?.bundleIdentifier; + if (currentBundleId) { + if (validateBundleId(currentBundleId)) { + return currentBundleId; + } + + log( + log.chalk.red( + `The ios.bundleIdentifier defined in your Expo config is not formatted properly. Only alphanumeric characters, '.', '-', and '_' are allowed, and each '.' must be followed by a letter.` + ) + ); + + process.exit(1); + } + + // Recommend a bundle ID based on the username and project slug. + let recommendedBundleId: string | undefined; + // Attempt to use the android package name first since it's convenient to have them aligned. + if (exp.android?.package && validateBundleId(exp.android?.package)) { + recommendedBundleId = exp.android?.package; + } else { + const username = exp.owner ?? (await UserManager.getCurrentUsernameAsync()); + const possibleId = `com.${username}.${exp.slug}`; + if (username && validateBundleId(possibleId)) { + recommendedBundleId = possibleId; + } + } + + log.newLine(); + log( + log.chalk.cyan( + `Now we need to know your ${terminalLink( + 'iOS bundle identifier', + 'https://expo.fyi/bundle-identifier' + )}.\nYou can change this in the future if you need to.` + ) + ); + log.newLine(); + + // Prompt the user for the bundle ID. + // Even if the project is using a dynamic config we can still + // prompt a better error message, recommend a default value, and help the user + // validate their custom bundle ID upfront. + const { bundleIdentifier } = await prompt( + [ + { + name: 'bundleIdentifier', + default: recommendedBundleId, + // The Apple helps people know this isn't an EAS feature. + message: `What would you like your iOS bundle identifier to be?`, + validate: validateBundleId, + }, + ], + { + nonInteractiveHelp: noBundleIdMessage, + } + ); + + await attemptModification(projectRoot, `Your iOS bundle identifier is now: ${bundleIdentifier}`, { + ios: { bundleIdentifier }, + }); + + return bundleIdentifier; +} + +export async function getOrPromptForPackage(projectRoot: string): Promise { + let { exp } = getConfig(projectRoot); + + const currentPackage = exp.android?.package; + if (currentPackage) { + if (validatePackage(currentPackage)) { + return currentPackage; + } + log( + log.chalk.red( + `Invalid format of Android package name. Only alphanumeric characters, '.' and '_' are allowed, and each '.' must be followed by a letter.` + ) + ); + + process.exit(1); + } + + // Recommend a package name based on the username and project slug. + let recommendedPackage: string | undefined; + // Attempt to use the ios bundle id first since it's convenient to have them aligned. + if (exp.ios?.bundleIdentifier && validatePackage(exp.ios.bundleIdentifier)) { + recommendedPackage = exp.ios.bundleIdentifier; + } else { + const username = exp.owner ?? (await UserManager.getCurrentUsernameAsync()); + const possibleId = `com.${username}.${exp.slug}`; + if (username && validatePackage(possibleId)) { + recommendedPackage = possibleId; + } + } + + log.newLine(); + log( + `Now we need to know your ${terminalLink( + 'Android package', + 'https://expo.fyi/android-package' + )}. You can change this in the future if you need to.` + ); + log.newLine(); + + // Prompt the user for the android package. + // Even if the project is using a dynamic config we can still + // prompt a better error message, recommend a default value, and help the user + // validate their custom android package upfront. + const { packageName } = await prompt( + [ + { + name: 'packageName', + default: recommendedPackage, + message: `What would you like your Android package to be named?`, + validate: validatePackage, + }, + ], + { + nonInteractiveHelp: noPackageMessage, + } + ); + + await attemptModification(projectRoot, `Your Android package is now: ${packageName}`, { + android: { package: packageName }, + }); + + return packageName; +} + +async function attemptModification( + projectRoot: string, + modificationSuccessMessage: string, + edits: Partial +): Promise { + const modification = await modifyConfigAsync(projectRoot, edits, { + skipSDKVersionRequirement: true, + }); + if (modification.type === 'success') { + log.newLine(); + log(modificationSuccessMessage); + log.newLine(); + } else { + warnAboutConfigAndExit(modification.type, modification.message!, edits); + } +} + +function logNoConfig() { + log( + log.chalk.yellow( + 'No Expo config was found. Please create an Expo config (`app.config.js` or `app.json`) in your project root.' + ) + ); +} + +function warnAboutConfigAndExit(type: string, message: string, edits: Partial) { + log.newLine(); + if (type === 'warn') { + // The project is using a dynamic config, give the user a helpful log and bail out. + log(log.chalk.yellow(message)); + } else { + logNoConfig(); + } + + notifyAboutManualConfigEdits(edits); + process.exit(1); +} + +function notifyAboutManualConfigEdits(edits: Partial) { + log(log.chalk.cyan(`Please add the following to your Expo config, and try again... `)); + log.newLine(); + log(JSON.stringify(edits, null, 2)); + log.newLine(); +} diff --git a/packages/expo-cli/src/commands/eject/Eject.ts b/packages/expo-cli/src/commands/eject/Eject.ts index 67a75af6c1..3487a6111d 100644 --- a/packages/expo-cli/src/commands/eject/Eject.ts +++ b/packages/expo-cli/src/commands/eject/Eject.ts @@ -28,6 +28,7 @@ import configureIOSProjectAsync from '../apply/configureIOSProjectAsync'; import { logConfigWarningsAndroid, logConfigWarningsIOS } from '../utils/logConfigWarnings'; import maybeBailOnGitStatusAsync from '../utils/maybeBailOnGitStatusAsync'; import { usesOldExpoUpdatesAsync } from '../utils/ProjectUtils'; +import { getOrPromptForBundleIdentifier, getOrPromptForPackage } from './ConfigValidation'; type ValidationErrorMessage = string; @@ -280,14 +281,16 @@ async function createNativeProjectsFromTemplateAsync(projectRoot: string): Promi let name = await promptForNativeAppNameAsync(projectRoot); appJson.expo.name = name; + // Prompt for the Android package first because it's more strict than the bundle identifier + // this means you'll have a better chance at matching the bundle identifier with the package name. + let packageName = await getOrPromptForPackage(projectRoot); + appJson.expo.android = appJson.expo.android ?? {}; + appJson.expo.android.package = packageName; + let bundleIdentifier = await getOrPromptForBundleIdentifier(projectRoot); appJson.expo.ios = appJson.expo.ios ?? {}; appJson.expo.ios.bundleIdentifier = bundleIdentifier; - let packageName = await getOrPromptForPackage(projectRoot, bundleIdentifier); - appJson.expo.android = appJson.expo.android ?? {}; - appJson.expo.android.package = packageName; - // TODO: remove entryPoint and log about it for sdk 37 changes if (appJson.expo.entryPoint && appJson.expo.entryPoint !== EXPO_APP_ENTRY) { log(`- expo.entryPoint is already configured, we recommend using "${EXPO_APP_ENTRY}`); @@ -511,65 +514,6 @@ async function promptForNativeAppNameAsync(projectRoot: string): Promise return name!; } -async function getOrPromptForBundleIdentifier( - projectRoot: string, - defaultValue?: string -): Promise { - let { exp } = getConfig(projectRoot); - - if (exp.ios?.bundleIdentifier) { - return exp.ios.bundleIdentifier; - } - - // TODO: add example based on slug or name - log( - `Now we need to know your ${terminalLink( - 'iOS bundle identifier', - 'https://expo.fyi/bundle-identifier' - )}. You can change this in the future if you need to.` - ); - - const { bundleIdentifier } = await prompt([ - { - name: 'bundleIdentifier', - default: defaultValue, - message: `What would you like your bundle identifier to be?`, - validate: (value: string) => /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(value), - }, - ]); - - log.newLine(); - return bundleIdentifier; -} - -async function getOrPromptForPackage(projectRoot: string, defaultValue?: string): Promise { - let { exp } = getConfig(projectRoot); - - if (exp.android?.package) { - return exp.android.package; - } - - // TODO: add example based on slug or name - log( - `Now we need to know your ${terminalLink( - 'Android package', - 'https://expo.fyi/android-package' - )}. You can change this in the future if you need to.` - ); - - const { packageName } = await prompt([ - { - name: 'packageName', - default: defaultValue, - message: `What would you like your package to be named?`, - validate: (value: string) => /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(value), - }, - ]); - - log.newLine(); - return packageName; -} - /** * Merge two gitignore files together and add a generated header. *