xref: /expo/packages/create-expo/src/Template.ts (revision b7d15820)
1import JsonFile from '@expo/json-file';
2import * as PackageManager from '@expo/package-manager';
3import chalk from 'chalk';
4import fs from 'fs';
5import ora from 'ora';
6import path from 'path';
7
8import { Log } from './log';
9import { formatRunCommand, PackageManagerName } from './resolvePackageManager';
10import { env } from './utils/env';
11import {
12  applyBetaTag,
13  applyKnownNpmPackageNameRules,
14  downloadAndExtractNpmModuleAsync,
15  getResolvedTemplateName,
16} from './utils/npm';
17
18const debug = require('debug')('expo:init:template') as typeof console.log;
19
20const isMacOS = process.platform === 'darwin';
21
22const FORBIDDEN_NAMES = [
23  'react-native',
24  'react',
25  'react-dom',
26  'react-native-web',
27  'expo',
28  'expo-router',
29];
30
31export function isFolderNameForbidden(folderName: string): boolean {
32  return FORBIDDEN_NAMES.includes(folderName);
33}
34
35function deepMerge(target: any, source: any) {
36  if (typeof target !== 'object') {
37    return source;
38  }
39  if (Array.isArray(target) && Array.isArray(source)) {
40    return target.concat(source);
41  }
42  Object.keys(source).forEach((key) => {
43    if (typeof source[key] === 'object' && source[key] !== null) {
44      target[key] = deepMerge(target[key], source[key]);
45    } else {
46      target[key] = source[key];
47    }
48  });
49  return target;
50}
51
52export function resolvePackageModuleId(moduleId: string) {
53  if (
54    // Supports `file:./path/to/template.tgz`
55    moduleId?.startsWith('file:') ||
56    // Supports `../path/to/template.tgz`
57    moduleId?.startsWith('.') ||
58    // Supports `\\path\\to\\template.tgz`
59    moduleId?.startsWith(path.sep)
60  ) {
61    if (moduleId?.startsWith('file:')) {
62      moduleId = moduleId.substring(5);
63    }
64    debug(`Resolved moduleId to file path:`, moduleId);
65    return { type: 'file', uri: path.resolve(moduleId) };
66  } else {
67    debug(`Resolved moduleId to NPM package:`, moduleId);
68    return { type: 'npm', uri: moduleId };
69  }
70}
71
72/**
73 * Extract a template app to a given file path and clean up any properties left over from npm to
74 * prepare it for usage.
75 */
76export async function extractAndPrepareTemplateAppAsync(
77  projectRoot: string,
78  { npmPackage }: { npmPackage?: string | null }
79) {
80  const projectName = path.basename(projectRoot);
81
82  debug(`Extracting template app (pkg: ${npmPackage}, projectName: ${projectName})`);
83
84  const { type, uri } = resolvePackageModuleId(npmPackage || 'expo-template-blank');
85
86  const resolvedUri = type === 'file' ? uri : getResolvedTemplateName(applyBetaTag(uri));
87
88  await downloadAndExtractNpmModuleAsync(resolvedUri, {
89    cwd: projectRoot,
90    name: projectName,
91    disableCache: type === 'file',
92  });
93
94  await sanitizeTemplateAsync(projectRoot);
95
96  return projectRoot;
97}
98
99/**
100 * Sanitize a template (or example) with expected `package.json` properties and files.
101 */
102export async function sanitizeTemplateAsync(projectRoot: string) {
103  const projectName = path.basename(projectRoot);
104
105  debug(`Sanitizing template or example app (projectName: ${projectName})`);
106
107  const templatePath = path.join(__dirname, '../template/gitignore');
108  const ignorePath = path.join(projectRoot, '.gitignore');
109  if (!fs.existsSync(ignorePath)) {
110    await fs.promises.copyFile(templatePath, ignorePath);
111  }
112
113  const config: Record<string, any> = {
114    expo: {
115      name: projectName,
116      slug: projectName,
117    },
118  };
119
120  const appFile = new JsonFile(path.join(projectRoot, 'app.json'), {
121    default: { expo: {} },
122  });
123  const appJson = deepMerge(await appFile.readAsync(), config);
124  await appFile.writeAsync(appJson);
125
126  debug(`Created app.json:\n%O`, appJson);
127
128  const packageFile = new JsonFile(path.join(projectRoot, 'package.json'));
129  const packageJson = await packageFile.readAsync();
130  // name and version are required for yarn workspaces (monorepos)
131  const inputName = 'name' in config ? config.name : config.expo.name;
132  packageJson.name = applyKnownNpmPackageNameRules(inputName) || 'app';
133  // These are metadata fields related to the template package, let's remove them from the package.json.
134  // A good place to start
135  packageJson.version = '1.0.0';
136  packageJson.private = true;
137  delete packageJson.description;
138  delete packageJson.tags;
139  delete packageJson.repository;
140
141  await packageFile.writeAsync(packageJson);
142}
143
144export function validateName(name?: string): string | true {
145  if (typeof name !== 'string' || name === '') {
146    return 'The project name can not be empty.';
147  }
148  if (!/^[a-z0-9@.\-_]+$/i.test(name)) {
149    return 'The project name can only contain URL-friendly characters.';
150  }
151  return true;
152}
153
154export function logProjectReady({
155  cdPath,
156  packageManager,
157}: {
158  cdPath: string;
159  packageManager: PackageManagerName;
160}) {
161  console.log(chalk.bold(`✅ Your project is ready!`));
162  console.log();
163
164  // empty string if project was created in current directory
165  if (cdPath) {
166    console.log(
167      `To run your project, navigate to the directory and run one of the following ${packageManager} commands.`
168    );
169    console.log();
170    console.log(`- ${chalk.bold('cd ' + cdPath)}`);
171  } else {
172    console.log(`To run your project, run one of the following ${packageManager} commands.`);
173    console.log();
174  }
175
176  console.log(`- ${chalk.bold(formatRunCommand(packageManager, 'android'))}`);
177
178  let macOSComment = '';
179  if (!isMacOS) {
180    macOSComment =
181      ' # you need to use macOS to build the iOS project - use the Expo app if you need to do iOS development without a Mac';
182  }
183  console.log(`- ${chalk.bold(formatRunCommand(packageManager, 'ios'))}${macOSComment}`);
184
185  console.log(`- ${chalk.bold(formatRunCommand(packageManager, 'web'))}`);
186}
187
188export async function installPodsAsync(projectRoot: string) {
189  let step = logNewSection('Installing CocoaPods.');
190  if (process.platform !== 'darwin') {
191    step.succeed('Skipped installing CocoaPods because operating system is not macOS.');
192    return false;
193  }
194  const packageManager = new PackageManager.CocoaPodsPackageManager({
195    cwd: path.join(projectRoot, 'ios'),
196    silent: !env.EXPO_DEBUG,
197  });
198
199  if (!(await packageManager.isCLIInstalledAsync())) {
200    try {
201      step.text = 'CocoaPods CLI not found in your $PATH, installing it now.';
202      step.render();
203      await packageManager.installCLIAsync();
204      step.succeed('Installed CocoaPods CLI');
205      step = logNewSection('Running `pod install` in the `ios` directory.');
206    } catch (e: any) {
207      step.stopAndPersist({
208        symbol: '⚠️ ',
209        text: chalk.red(
210          'Unable to install the CocoaPods CLI. Continuing with initializing the project, you can install CocoaPods afterwards.'
211        ),
212      });
213      if (e.message) {
214        Log.error(`- ${e.message}`);
215      }
216      return false;
217    }
218  }
219
220  try {
221    await packageManager.installAsync();
222    step.succeed('Installed pods and initialized Xcode workspace.');
223    return true;
224  } catch (e: any) {
225    step.stopAndPersist({
226      symbol: '⚠️ ',
227      text: chalk.red(
228        'Something went wrong running `pod install` in the `ios` directory. Continuing with initializing the project, you can debug this afterwards.'
229      ),
230    });
231    if (e.message) {
232      Log.error(`- ${e.message}`);
233    }
234    return false;
235  }
236}
237
238export function logNewSection(title: string) {
239  const disabled = env.CI || env.EXPO_DEBUG;
240  const spinner = ora({
241    text: chalk.bold(title),
242    // Ensure our non-interactive mode emulates CI mode.
243    isEnabled: !disabled,
244    // In non-interactive mode, send the stream to stdout so it prevents looking like an error.
245    stream: disabled ? process.stdout : process.stderr,
246  });
247
248  spinner.start();
249  return spinner;
250}
251