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