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