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