1import { AndroidConfig, IOSConfig, ModPlatform } from '@expo/config-plugins';
2import chalk from 'chalk';
3import fs from 'fs';
4import path from 'path';
5
6import * as Log from '../log';
7import { directoryExistsAsync } from '../utils/dir';
8import { isInteractive } from '../utils/interactive';
9import { logNewSection } from '../utils/ora';
10import { confirmAsync } from '../utils/prompts';
11
12/** Delete the input native folders and print a loading step. */
13export async function clearNativeFolder(projectRoot: string, folders: string[]) {
14  const step = logNewSection(`Clearing ${folders.join(', ')}`);
15  try {
16    await Promise.all(
17      folders.map((folderName) =>
18        fs.promises.rm(path.join(projectRoot, folderName), {
19          recursive: true,
20          force: true,
21        })
22      )
23    );
24    step.succeed(`Cleared ${folders.join(', ')} code`);
25  } catch (error: any) {
26    step.fail(`Failed to delete ${folders.join(', ')} code: ${error.message}`);
27    throw error;
28  }
29}
30
31/**
32 * Returns `true` if a certain subset of required Android project files are intact.
33 *
34 * This isn't perfect but it serves the purpose of indicating that the user should
35 * be warned to nuke the project files, most commonly when git is cleared and the root folder
36 * remains in memory.
37 */
38export async function hasRequiredAndroidFilesAsync(projectRoot: string): Promise<boolean> {
39  try {
40    await Promise.all([
41      AndroidConfig.Paths.getAppBuildGradleAsync(projectRoot),
42      AndroidConfig.Paths.getProjectBuildGradleAsync(projectRoot),
43      AndroidConfig.Paths.getAndroidManifestAsync(projectRoot),
44      AndroidConfig.Paths.getMainApplicationAsync(projectRoot),
45    ]);
46    return true;
47  } catch {
48    return false;
49  }
50}
51
52/** Returns `true` if a certain subset of required iOS project files are intact. */
53export async function hasRequiredIOSFilesAsync(projectRoot: string) {
54  try {
55    // If any of the following required files are missing, then the project is malformed.
56    await Promise.all([
57      IOSConfig.Paths.getAllXcodeProjectPaths(projectRoot),
58      IOSConfig.Paths.getAllPBXProjectPaths(projectRoot),
59    ]);
60    return true;
61  } catch {
62    return false;
63  }
64}
65
66/**
67 * Filter out platforms that do not have an existing platform folder.
68 * If the user wants to validate that neither of ['ios', 'android'] are malformed then we should
69 * first check that both `ios` and `android` folders exist.
70 *
71 * This optimization prevents us from prompting to clear a "malformed" project that doesn't exist yet.
72 */
73async function filterPlatformsThatDoNotExistAsync(
74  projectRoot: string,
75  platforms: ModPlatform[]
76): Promise<ModPlatform[]> {
77  const valid = await Promise.all(
78    platforms.map(async (platform) => {
79      if (await directoryExistsAsync(path.join(projectRoot, platform))) {
80        return platform;
81      }
82      return null;
83    })
84  );
85  return valid.filter(Boolean) as ModPlatform[];
86}
87
88/** Get a list of native platforms that have existing directories which contain malformed projects. */
89export async function getMalformedNativeProjectsAsync(
90  projectRoot: string,
91  platforms: ModPlatform[]
92): Promise<ModPlatform[]> {
93  const VERIFIERS: Record<ModPlatform, (root: string) => Promise<boolean>> = {
94    android: hasRequiredAndroidFilesAsync,
95    ios: hasRequiredIOSFilesAsync,
96  };
97
98  const checkablePlatforms = platforms.filter((platform) => platform in VERIFIERS);
99  const checkPlatforms = await filterPlatformsThatDoNotExistAsync(projectRoot, checkablePlatforms);
100  return (
101    await Promise.all(
102      checkPlatforms.map(async (platform) => {
103        if (await VERIFIERS[platform](projectRoot)) {
104          return false;
105        }
106        return platform;
107      })
108    )
109  ).filter(Boolean) as ModPlatform[];
110}
111
112export async function promptToClearMalformedNativeProjectsAsync(
113  projectRoot: string,
114  checkPlatforms: ModPlatform[]
115) {
116  const platforms = await getMalformedNativeProjectsAsync(projectRoot, checkPlatforms);
117
118  if (!platforms.length) {
119    return;
120  }
121
122  const displayPlatforms = platforms.map((platform) => chalk.cyan(platform));
123  // Prompt which platforms to reset.
124  const message =
125    platforms.length > 1
126      ? `The ${displayPlatforms[0]} and ${displayPlatforms[1]} projects are malformed`
127      : `The ${displayPlatforms[0]} project is malformed`;
128
129  if (
130    // If the process is non-interactive, default to clearing the malformed native project.
131    // This would only happen on re-running eject.
132    !isInteractive() ||
133    // Prompt to clear the native folders.
134    (await confirmAsync({
135      message: `${message}, would you like to clear the project files and reinitialize them?`,
136      initial: true,
137    }))
138  ) {
139    if (!isInteractive()) {
140      Log.warn(`${message}, project files will be cleared and reinitialized.`);
141    }
142    await clearNativeFolder(projectRoot, platforms);
143  } else {
144    // Warn the user that the process may fail.
145    Log.warn('Continuing with malformed native projects');
146  }
147}
148