1import { PackageJSONConfig } from '@expo/config';
2import { ModPlatform } from '@expo/config-plugins';
3import { MergeResults } from '@expo/config-plugins/build/utils/generateCode';
4import { getBareExtensions, getFileWithExtensions } from '@expo/config/paths';
5import chalk from 'chalk';
6import fs from 'fs';
7import path from 'path';
8
9import * as Log from '../log';
10import { copySync, directoryExistsAsync } from '../utils/dir';
11import { mergeGitIgnorePaths } from '../utils/mergeGitIgnorePaths';
12import { isPkgMainExpoAppEntry } from './updatePackageJson';
13
14type CopyFilesResults = {
15  /** Merge results for the root `.gitignore` file */
16  gitignore: MergeResults | null;
17  /** List of file paths that were copied from the template into the project. */
18  copiedPaths: string[];
19  /** List of file paths that were skipped due to a number of factors. */
20  skippedPaths: string[];
21};
22
23/**
24 * Return true if the given platforms all have an internal `.gitignore` file.
25 *
26 * @param projectRoot
27 * @param platforms
28 */
29function hasAllPlatformSpecificGitIgnores(projectRoot: string, platforms: ModPlatform[]): boolean {
30  return platforms.reduce<boolean>(
31    (p, platform) => p && fs.existsSync(path.join(projectRoot, platform, '.gitignore')),
32    true
33  );
34}
35
36/** Create a custom log message based on the copy file results. */
37export function createCopyFilesSuccessMessage(
38  platforms: ModPlatform[],
39  { skippedPaths, gitignore }: CopyFilesResults
40): string {
41  let message = `Created native project${platforms.length > 1 ? 's' : ''}`;
42
43  if (skippedPaths.length) {
44    message += chalk.dim(
45      ` | ${skippedPaths.map((path) => chalk.bold(`/${path}`)).join(', ')} already created`
46    );
47  }
48  if (!gitignore) {
49    message += chalk.dim(` | gitignore skipped`);
50  } else if (!gitignore.didMerge) {
51    message += chalk.dim(` | gitignore already synced`);
52  } else if (gitignore.didMerge && gitignore.didClear) {
53    message += chalk.dim(` | synced gitignore`);
54  }
55  return message;
56}
57
58/** Copy template files into the project and possibly merge the `.gitignore` files.  */
59export async function copyTemplateFilesAsync(
60  projectRoot: string,
61  {
62    pkg,
63    templateDirectory,
64    platforms,
65  }: {
66    /** Project `package.json` as JSON. */
67    pkg: PackageJSONConfig;
68    /** File path to the template directory. */
69    templateDirectory: string;
70    /** List of platforms to copy against. */
71    platforms: ModPlatform[];
72  }
73): Promise<CopyFilesResults> {
74  const copyFilePaths = getFilePathsToCopy(projectRoot, pkg, platforms);
75
76  const copyResults = await copyPathsFromTemplateAsync(projectRoot, {
77    templateDirectory,
78    copyFilePaths,
79  });
80
81  const hasPlatformSpecificGitIgnores = hasAllPlatformSpecificGitIgnores(
82    templateDirectory,
83    platforms
84  );
85  Log.debug(`All platforms have an internal gitignore: ${hasPlatformSpecificGitIgnores}`);
86  const gitignore = hasPlatformSpecificGitIgnores
87    ? null
88    : mergeGitIgnorePaths(
89        path.join(projectRoot, '.gitignore'),
90        path.join(templateDirectory, '.gitignore')
91      );
92
93  return { ...copyResults, gitignore };
94}
95
96async function copyPathsFromTemplateAsync(
97  /** File path to the project. */
98  projectRoot: string,
99  {
100    templateDirectory,
101    copyFilePaths,
102  }: {
103    /** File path to the template project. */
104    templateDirectory: string;
105    /** List of relative paths to copy from the template to the project. */
106    copyFilePaths: string[];
107  }
108): Promise<Pick<CopyFilesResults, 'copiedPaths' | 'skippedPaths'>> {
109  const copiedPaths = [];
110  const skippedPaths = [];
111  for (const copyFilePath of copyFilePaths) {
112    const projectPath = path.join(projectRoot, copyFilePath);
113    if (!(await directoryExistsAsync(projectPath))) {
114      copiedPaths.push(copyFilePath);
115      copySync(path.join(templateDirectory, copyFilePath), projectPath);
116    } else {
117      skippedPaths.push(copyFilePath);
118    }
119  }
120  return { copiedPaths, skippedPaths };
121}
122
123/** Get a list of relative file paths to copy from the template folder. Example: `['ios', 'android', 'index.js']` */
124function getFilePathsToCopy(projectRoot: string, pkg: PackageJSONConfig, platforms: ModPlatform[]) {
125  const targetPaths: string[] = [...platforms];
126
127  const bareEntryFile = resolveBareEntryFile(projectRoot, pkg.main);
128  // Only create index.js if we cannot resolve the existing entry point (after replacing the expo entry).
129  if (!bareEntryFile) {
130    targetPaths.push('index.js');
131  }
132
133  return targetPaths;
134}
135
136export function resolveBareEntryFile(projectRoot: string, main: any) {
137  // expo app entry is not needed for bare projects.
138  if (isPkgMainExpoAppEntry(main)) {
139    return null;
140  }
141  // Look at the `package.json`s `main` field for the main file.
142  const resolvedMainField = main ?? './index';
143  // Get a list of possible extensions for the main file.
144  const extensions = getBareExtensions(['ios', 'android']);
145  // Testing the main field against all of the provided extensions - for legacy reasons we can't use node module resolution as the package.json allows you to pass in a file without a relative path and expect it as a relative path.
146  return getFileWithExtensions(projectRoot, resolvedMainField, extensions);
147}
148