1import { ExpoConfig, getAccountUsername, getConfig } from '@expo/config';
2import chalk from 'chalk';
3
4import * as Log from '../log';
5import { learnMore } from './link';
6import { attemptModification } from './modifyConfigAsync';
7import prompt, { confirmAsync } from './prompts';
8import {
9  assertValidBundleId,
10  assertValidPackage,
11  getBundleIdWarningAsync,
12  getPackageNameWarningAsync,
13  validateBundleId,
14  validatePackage,
15} from './validateApplicationId';
16
17function getUsernameAsync(exp: ExpoConfig) {
18  // TODO: Use XDL's UserManager
19  // import { UserManager } from 'xdl';
20  return getAccountUsername(exp);
21}
22
23const NO_BUNDLE_ID_MESSAGE = `Project must have a \`ios.bundleIdentifier\` set in the Expo config (app.json or app.config.js).`;
24
25const NO_PACKAGE_MESSAGE = `Project must have a \`android.package\` set in the Expo config (app.json or app.config.js).`;
26
27/**
28 * Get the bundle identifier from the Expo config or prompt the user to choose a new bundle identifier.
29 * Prompted value will be validated against the App Store and a local regex.
30 * If the project Expo config is a static JSON file, the bundle identifier will be updated in the config automatically.
31 */
32export async function getOrPromptForBundleIdentifier(projectRoot: string): Promise<string> {
33  const { exp } = getConfig(projectRoot);
34
35  const current = exp.ios?.bundleIdentifier;
36  if (current) {
37    assertValidBundleId(current);
38    return current;
39  }
40
41  Log.log(
42    chalk`\n{bold ��  iOS Bundle Identifier} {dim ${learnMore(
43      'https://expo.fyi/bundle-identifier'
44    )}}\n`
45  );
46
47  return await promptForBundleIdAsync(projectRoot, exp);
48}
49
50async function promptForBundleIdAsync(projectRoot: string, exp: ExpoConfig): Promise<string> {
51  // Prompt the user for the bundle ID.
52  // Even if the project is using a dynamic config we can still
53  // prompt a better error message, recommend a default value, and help the user
54  // validate their custom bundle ID upfront.
55  const { bundleIdentifier } = await prompt(
56    {
57      type: 'text',
58      name: 'bundleIdentifier',
59      initial: (await getRecommendedBundleIdAsync(exp)) ?? undefined,
60      // The Apple helps people know this isn't an EAS feature.
61      message: `What would you like your iOS bundle identifier to be?`,
62      validate: validateBundleId,
63    },
64    {
65      nonInteractiveHelp: NO_BUNDLE_ID_MESSAGE,
66    }
67  );
68
69  // Warn the user if the bundle ID is already in use.
70  const warning = await getBundleIdWarningAsync(bundleIdentifier);
71  if (warning && !(await warnAndConfirmAsync(warning))) {
72    // Cycle the Bundle ID prompt to try again.
73    return await promptForBundleIdAsync(projectRoot, exp);
74  }
75
76  // Apply the changes to the config.
77  await attemptModification(
78    projectRoot,
79    {
80      ios: { ...(exp.ios || {}), bundleIdentifier },
81    },
82    { ios: { bundleIdentifier } }
83  );
84
85  return bundleIdentifier;
86}
87
88async function warnAndConfirmAsync(warning: string): Promise<boolean> {
89  Log.log();
90  Log.warn(warning);
91  Log.log();
92  if (
93    !(await confirmAsync({
94      message: `Continue?`,
95      initial: true,
96    }))
97  ) {
98    return false;
99  }
100  return true;
101}
102
103// Recommend a bundle identifier based on the username and project slug.
104async function getRecommendedBundleIdAsync(exp: ExpoConfig): Promise<string | null> {
105  // Attempt to use the android package name first since it's convenient to have them aligned.
106  if (exp.android?.package && validateBundleId(exp.android?.package)) {
107    return exp.android?.package;
108  } else {
109    const username = await getUsernameAsync(exp);
110    const possibleId = `com.${username}.${exp.slug}`;
111    if (username && validateBundleId(possibleId)) {
112      return possibleId;
113    }
114  }
115
116  return null;
117}
118
119// Recommend a package name based on the username and project slug.
120async function getRecommendedPackageNameAsync(exp: ExpoConfig): Promise<string | null> {
121  // Attempt to use the ios bundle id first since it's convenient to have them aligned.
122  if (exp.ios?.bundleIdentifier && validatePackage(exp.ios.bundleIdentifier)) {
123    return exp.ios.bundleIdentifier;
124  } else {
125    const username = await getUsernameAsync(exp);
126    // It's common to use dashes in your node project name, strip them from the suggested package name.
127    const possibleId = `com.${username}.${exp.slug}`.split('-').join('');
128    if (username && validatePackage(possibleId)) {
129      return possibleId;
130    }
131  }
132  return null;
133}
134
135/**
136 * Get the package name from the Expo config or prompt the user to choose a new package name.
137 * Prompted value will be validated against the Play Store and a local regex.
138 * If the project Expo config is a static JSON file, the package name will be updated in the config automatically.
139 */
140export async function getOrPromptForPackage(projectRoot: string): Promise<string> {
141  const { exp } = getConfig(projectRoot);
142
143  const current = exp.android?.package;
144  if (current) {
145    assertValidPackage(current);
146    return current;
147  }
148
149  Log.log(
150    chalk`\n{bold ��  Android package} {dim ${learnMore('https://expo.fyi/android-package')}}\n`
151  );
152
153  return await promptForPackageAsync(projectRoot, exp);
154}
155
156async function promptForPackageAsync(projectRoot: string, exp: ExpoConfig): Promise<string> {
157  // Prompt the user for the android package.
158  // Even if the project is using a dynamic config we can still
159  // prompt a better error message, recommend a default value, and help the user
160  // validate their custom android package upfront.
161  const { packageName } = await prompt(
162    {
163      type: 'text',
164      name: 'packageName',
165      initial: (await getRecommendedPackageNameAsync(exp)) ?? undefined,
166      message: `What would you like your Android package name to be?`,
167      validate: validatePackage,
168    },
169    {
170      nonInteractiveHelp: NO_PACKAGE_MESSAGE,
171    }
172  );
173
174  // Warn the user if the package name is already in use.
175  const warning = await getPackageNameWarningAsync(packageName);
176  if (warning && !(await warnAndConfirmAsync(warning))) {
177    // Cycle the Package name prompt to try again.
178    return await promptForPackageAsync(projectRoot, exp);
179  }
180
181  // Apply the changes to the config.
182  await attemptModification(
183    projectRoot,
184    {
185      android: { ...(exp.android || {}), package: packageName },
186    },
187    {
188      android: { package: packageName },
189    }
190  );
191
192  return packageName;
193}
194