1import { Command } from '@expo/commander'; 2import spawnAsync from '@expo/spawn-async'; 3import fs from 'fs-extra'; 4import path from 'path'; 5 6import { EXPO_DIR, PACKAGES_DIR } from '../Constants'; 7import { runExpoCliAsync, runCreateExpoAppAsync } from '../ExpoCLI'; 8import { GitDirectory } from '../Git'; 9 10export type GenerateBareAppOptions = { 11 name?: string; 12 template?: string; 13 clean?: boolean; 14 useLocalTemplate?: boolean; 15 outDir?: string; 16 rnVersion?: string; 17}; 18 19export async function action( 20 packageNames: string[], 21 { 22 name: appName = 'my-generated-bare-app', 23 outDir = 'bare-apps', 24 template = 'expo-template-bare-minimum', 25 useLocalTemplate = false, 26 clean = false, 27 rnVersion, 28 }: GenerateBareAppOptions 29) { 30 // TODO: 31 // if appName === '' 32 // if packageNames.length === 0 33 34 const { workspaceDir, projectDir } = getDirectories({ name: appName, outDir }); 35 36 const packagesToSymlink = await getPackagesToSymlink({ packageNames, workspaceDir }); 37 38 await cleanIfNeeded({ clean, projectDir, workspaceDir }); 39 await createProjectDirectory({ workspaceDir, appName, template, useLocalTemplate }); 40 await modifyPackageJson({ packagesToSymlink, projectDir }); 41 await modifyAppJson({ projectDir, appName }); 42 await yarnInstall({ projectDir }); 43 await symlinkPackages({ packagesToSymlink, projectDir }); 44 await runExpoPrebuild({ projectDir, useLocalTemplate }); 45 if (rnVersion != null) { 46 await updateRNVersion({ rnVersion, projectDir }); 47 } 48 await createMetroConfig({ projectRoot: projectDir }); 49 await createScripts({ projectDir }); 50 51 // reestablish symlinks - some might be wiped out from prebuild 52 await symlinkPackages({ projectDir, packagesToSymlink }); 53 await stageAndCommitInitialChanges({ projectDir }); 54 55 console.log(`Project created in ${projectDir}!`); 56} 57 58export function getDirectories({ 59 name: appName = 'my-generated-bare-app', 60 outDir = 'bare-apps', 61}: GenerateBareAppOptions) { 62 const workspaceDir = path.resolve(process.cwd(), outDir); 63 const projectDir = path.resolve(process.cwd(), workspaceDir, appName); 64 65 return { 66 workspaceDir, 67 projectDir, 68 }; 69} 70 71async function cleanIfNeeded({ workspaceDir, projectDir, clean }) { 72 console.log('Creating project'); 73 74 await fs.mkdirs(workspaceDir); 75 76 if (clean) { 77 await fs.remove(projectDir); 78 } 79} 80 81async function createProjectDirectory({ 82 workspaceDir, 83 appName, 84 template, 85 useLocalTemplate, 86}: { 87 workspaceDir: string; 88 appName: string; 89 template: string; 90 useLocalTemplate: boolean; 91}) { 92 if (useLocalTemplate) { 93 // If useLocalTemplate is selected, find the path to the local copy of the template and use that 94 const pathToLocalTemplate = path.resolve(EXPO_DIR, 'templates', template); 95 return await runCreateExpoAppAsync( 96 appName, 97 ['--no-install', '--template', pathToLocalTemplate], 98 { 99 cwd: workspaceDir, 100 stdio: 'inherit', 101 } 102 ); 103 } 104 105 return await runCreateExpoAppAsync(appName, ['--no-install', '--template', template], { 106 cwd: workspaceDir, 107 stdio: 'ignore', 108 }); 109} 110 111function getDefaultPackagesToSymlink({ workspaceDir }: { workspaceDir: string }) { 112 const defaultPackagesToSymlink: string[] = ['expo']; 113 114 const isInExpoRepo = workspaceDir.startsWith(EXPO_DIR); 115 116 if (isInExpoRepo) { 117 // these packages are picked up by prebuild since they are symlinks in the mono repo 118 // config plugins are applied so we include these packages to be safe 119 defaultPackagesToSymlink.concat([ 120 'expo-asset', 121 'expo-application', 122 'expo-constants', 123 'expo-file-system', 124 'expo-font', 125 'expo-keep-awake', 126 'expo-splash-screen', 127 'expo-updates', 128 'expo-manifests', 129 'expo-updates-interface', 130 'expo-dev-client', 131 'expo-dev-launcher', 132 'expo-dev-menu', 133 'expo-dev-menu-interface', 134 ]); 135 } 136 137 return defaultPackagesToSymlink; 138} 139 140export async function getPackagesToSymlink({ 141 packageNames, 142 workspaceDir, 143}: { 144 packageNames: string[]; 145 workspaceDir: string; 146}) { 147 const packagesToSymlink = new Set<string>(); 148 149 const defaultPackages = getDefaultPackagesToSymlink({ workspaceDir }); 150 defaultPackages.forEach((packageName) => packagesToSymlink.add(packageName)); 151 152 for (const packageName of packageNames) { 153 const deps = getPackageDependencies(packageName); 154 deps.forEach((dep) => packagesToSymlink.add(dep)); 155 } 156 157 return Array.from(packagesToSymlink); 158} 159 160function getPackageDependencies(packageName: string) { 161 const packagePath = path.resolve(PACKAGES_DIR, packageName, 'package.json'); 162 163 if (!fs.existsSync(packagePath)) { 164 return []; 165 } 166 167 const dependencies = new Set<string>(); 168 dependencies.add(packageName); 169 170 const pkg = require(packagePath); 171 172 if (pkg.dependencies) { 173 Object.keys(pkg.dependencies).forEach((dependency) => { 174 const deps = getPackageDependencies(dependency); 175 deps.forEach((dep) => dependencies.add(dep)); 176 }); 177 } 178 179 return Array.from(dependencies); 180} 181 182async function modifyPackageJson({ 183 packagesToSymlink, 184 projectDir, 185}: { 186 packagesToSymlink: string[]; 187 projectDir: string; 188}) { 189 const pkgPath = path.resolve(projectDir, 'package.json'); 190 const pkg = await fs.readJSON(pkgPath); 191 192 pkg.expo = pkg.expo ?? {}; 193 pkg.expo.symlinks = pkg.expo.symlinks ?? []; 194 195 packagesToSymlink.forEach((packageName) => { 196 const packageJson = require(path.resolve(PACKAGES_DIR, packageName, 'package.json')); 197 pkg.dependencies[packageName] = packageJson.version ?? '*'; 198 pkg.expo.symlinks.push(packageName); 199 }); 200 201 await fs.outputJson(path.resolve(projectDir, 'package.json'), pkg, { spaces: 2 }); 202} 203 204async function yarnInstall({ projectDir }: { projectDir: string }) { 205 console.log('Yarning'); 206 return await spawnAsync('yarn', [], { cwd: projectDir, stdio: 'ignore' }); 207} 208 209export async function symlinkPackages({ 210 packagesToSymlink, 211 projectDir, 212}: { 213 packagesToSymlink: string[]; 214 projectDir: string; 215}) { 216 for (const packageName of packagesToSymlink) { 217 const projectPackagePath = path.resolve(projectDir, 'node_modules', packageName); 218 const expoPackagePath = path.resolve(PACKAGES_DIR, packageName); 219 220 if (fs.existsSync(projectPackagePath)) { 221 fs.rmSync(projectPackagePath, { recursive: true }); 222 } 223 224 fs.symlinkSync(expoPackagePath, projectPackagePath); 225 } 226} 227 228async function updateRNVersion({ 229 projectDir, 230 rnVersion, 231}: { 232 projectDir: string; 233 rnVersion?: string; 234}) { 235 const reactNativeVersion = rnVersion || getLocalReactNativeVersion(); 236 237 const pkgPath = path.resolve(projectDir, 'package.json'); 238 const pkg = await fs.readJSON(pkgPath); 239 pkg.dependencies['react-native'] = reactNativeVersion; 240 241 await fs.outputJson(path.resolve(projectDir, 'package.json'), pkg, { spaces: 2 }); 242 await spawnAsync('yarn', [], { cwd: projectDir }); 243} 244 245function getLocalReactNativeVersion() { 246 const mainPkg = require(path.resolve(EXPO_DIR, 'package.json')); 247 return mainPkg.resolutions?.['react-native']; 248} 249 250async function runExpoPrebuild({ 251 projectDir, 252 useLocalTemplate, 253}: { 254 projectDir: string; 255 useLocalTemplate: boolean; 256}) { 257 console.log('Applying config plugins'); 258 if (useLocalTemplate) { 259 const pathToBareTemplate = path.resolve(EXPO_DIR, 'templates', 'expo-template-bare-minimum'); 260 const templateVersion = require(path.join(pathToBareTemplate, 'package.json')).version; 261 await spawnAsync('npm', ['pack', '--pack-destination', projectDir], { 262 cwd: pathToBareTemplate, 263 stdio: 'ignore', 264 }); 265 const tarFilePath = path.resolve( 266 projectDir, 267 `expo-template-bare-minimum-${templateVersion}.tgz` 268 ); 269 await runExpoCliAsync('prebuild', ['--no-install', '--template', tarFilePath], { 270 cwd: projectDir, 271 }); 272 return await fs.rm(tarFilePath); 273 } 274 return await runExpoCliAsync('prebuild', ['--no-install'], { cwd: projectDir }); 275} 276 277async function createMetroConfig({ projectRoot }: { projectRoot: string }) { 278 console.log('Adding metro.config.js for project'); 279 280 const template = `// Learn more https://docs.expo.io/guides/customizing-metro 281const { getDefaultConfig } = require('expo/metro-config'); 282const path = require('path'); 283 284const config = getDefaultConfig('${projectRoot}'); 285 286// 1. Watch expo packages within the monorepo 287config.watchFolders = ['${PACKAGES_DIR}']; 288 289// 2. Let Metro know where to resolve packages, and in what order 290config.resolver.nodeModulesPaths = [ 291 path.resolve('${projectRoot}', 'node_modules'), 292 path.resolve('${PACKAGES_DIR}'), 293]; 294 295// Use Node-style module resolution instead of Haste everywhere 296config.resolver.providesModuleNodeModules = []; 297 298// Ignore test files and JS files in the native Android and Xcode projects 299config.resolver.blockList = [ 300 /\\/__tests__\\/.*/, 301 /.*\\/android\\/React(Android|Common)\\/.*/, 302 /.*\\/versioned-react-native\\/.*/, 303]; 304 305module.exports = config; 306`; 307 308 return await fs.writeFile(path.resolve(projectRoot, 'metro.config.js'), template, { 309 encoding: 'utf-8', 310 }); 311} 312 313async function createScripts({ projectDir }) { 314 const scriptsDir = path.resolve(projectDir, 'scripts'); 315 await fs.mkdir(scriptsDir); 316 317 const scriptsToCopy = path.resolve(EXPO_DIR, 'template-files/generate-bare-app/scripts'); 318 await fs.copy(scriptsToCopy, scriptsDir, { recursive: true }); 319 320 const pkgJsonPath = path.resolve(projectDir, 'package.json'); 321 const pkgJson = await fs.readJSON(pkgJsonPath); 322 pkgJson.scripts['package:add'] = `node scripts/addPackages.js ${EXPO_DIR} ${projectDir}`; 323 pkgJson.scripts['package:remove'] = `node scripts/removePackages.js ${EXPO_DIR} ${projectDir}`; 324 pkgJson.scripts['clean'] = 325 'watchman watch-del-all && rm -fr $TMPDIR/metro-cache && rm $TMPDIR/haste-map-*'; 326 pkgJson.scripts['ios'] = 'expo run:ios'; 327 pkgJson.scripts['android'] = 'expo run:android'; 328 329 await fs.writeJSON(pkgJsonPath, pkgJson, { spaces: 2 }); 330 331 console.log('Added package scripts!'); 332} 333 334async function stageAndCommitInitialChanges({ projectDir }) { 335 const gitDirectory = new GitDirectory(projectDir); 336 await gitDirectory.initAsync(); 337 await gitDirectory.addFilesAsync(['.']); 338 await gitDirectory.commitAsync({ title: 'Initialized bare app!' }); 339} 340 341async function modifyAppJson({ projectDir, appName }: { projectDir: string; appName: string }) { 342 const pathToAppJson = path.resolve(projectDir, 'app.json'); 343 const json = await fs.readJson(pathToAppJson); 344 345 const strippedAppName = appName.replaceAll('-', ''); 346 json.expo.android = { package: `com.${strippedAppName}` }; 347 json.expo.ios = { bundleIdentifier: `com.${strippedAppName}` }; 348 349 await fs.writeJSON(pathToAppJson, json, { spaces: 2 }); 350} 351 352export default (program: Command) => { 353 program 354 .command('generate-bare-app [packageNames...]') 355 .alias('gba') 356 .option('-n, --name <string>', 'Specifies the name of the project') 357 .option('-c, --clean', 'Rebuilds the project from scratch') 358 .option('--rnVersion <string>', 'Version of react-native to include') 359 .option('-o, --outDir <string>', 'Specifies the directory to build the project in') 360 .option( 361 '-t, --template <string>', 362 'Specify the expo template to use as the project starter', 363 'expo-template-bare-minimum' 364 ) 365 .option( 366 '--useLocalTemplate', 367 'If true, use the local copy of the template instead of the published template in NPM', 368 false 369 ) 370 .description(`Generates a bare app with the specified packages symlinked`) 371 .asyncAction(action); 372}; 373