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