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-error-recovery', 127 'expo-splash-screen', 128 'expo-updates', 129 'expo-manifests', 130 'expo-updates-interface', 131 'expo-dev-client', 132 'expo-dev-launcher', 133 'expo-dev-menu', 134 'expo-dev-menu-interface', 135 ]); 136 } 137 138 return defaultPackagesToSymlink; 139} 140 141export async function getPackagesToSymlink({ 142 packageNames, 143 workspaceDir, 144}: { 145 packageNames: string[]; 146 workspaceDir: string; 147}) { 148 const packagesToSymlink = new Set<string>(); 149 150 const defaultPackages = getDefaultPackagesToSymlink({ workspaceDir }); 151 defaultPackages.forEach((packageName) => packagesToSymlink.add(packageName)); 152 153 for (const packageName of packageNames) { 154 const deps = getPackageDependencies(packageName); 155 deps.forEach((dep) => packagesToSymlink.add(dep)); 156 } 157 158 return Array.from(packagesToSymlink); 159} 160 161function getPackageDependencies(packageName: string) { 162 const packagePath = path.resolve(PACKAGES_DIR, packageName, 'package.json'); 163 164 if (!fs.existsSync(packagePath)) { 165 return []; 166 } 167 168 const dependencies = new Set<string>(); 169 dependencies.add(packageName); 170 171 const pkg = require(packagePath); 172 173 if (pkg.dependencies) { 174 Object.keys(pkg.dependencies).forEach((dependency) => { 175 const deps = getPackageDependencies(dependency); 176 deps.forEach((dep) => dependencies.add(dep)); 177 }); 178 } 179 180 return Array.from(dependencies); 181} 182 183async function modifyPackageJson({ 184 packagesToSymlink, 185 projectDir, 186}: { 187 packagesToSymlink: string[]; 188 projectDir: string; 189}) { 190 const pkgPath = path.resolve(projectDir, 'package.json'); 191 const pkg = await fs.readJSON(pkgPath); 192 193 pkg.expo = pkg.expo ?? {}; 194 pkg.expo.symlinks = pkg.expo.symlinks ?? []; 195 196 packagesToSymlink.forEach((packageName) => { 197 const packageJson = require(path.resolve(PACKAGES_DIR, packageName, 'package.json')); 198 pkg.dependencies[packageName] = packageJson.version ?? '*'; 199 pkg.expo.symlinks.push(packageName); 200 }); 201 202 await fs.outputJson(path.resolve(projectDir, 'package.json'), pkg, { spaces: 2 }); 203} 204 205async function yarnInstall({ projectDir }: { projectDir: string }) { 206 console.log('Yarning'); 207 return await spawnAsync('yarn', [], { cwd: projectDir, stdio: 'ignore' }); 208} 209 210export async function symlinkPackages({ 211 packagesToSymlink, 212 projectDir, 213}: { 214 packagesToSymlink: string[]; 215 projectDir: string; 216}) { 217 for (const packageName of packagesToSymlink) { 218 const projectPackagePath = path.resolve(projectDir, 'node_modules', packageName); 219 const expoPackagePath = path.resolve(PACKAGES_DIR, packageName); 220 221 if (fs.existsSync(projectPackagePath)) { 222 fs.rmSync(projectPackagePath, { recursive: true }); 223 } 224 225 fs.symlinkSync(expoPackagePath, projectPackagePath); 226 } 227} 228 229async function updateRNVersion({ 230 projectDir, 231 rnVersion, 232}: { 233 projectDir: string; 234 rnVersion?: string; 235}) { 236 const reactNativeVersion = rnVersion || getLocalReactNativeVersion(); 237 238 const pkgPath = path.resolve(projectDir, 'package.json'); 239 const pkg = await fs.readJSON(pkgPath); 240 pkg.dependencies['react-native'] = reactNativeVersion; 241 242 await fs.outputJson(path.resolve(projectDir, 'package.json'), pkg, { spaces: 2 }); 243 await spawnAsync('yarn', [], { cwd: projectDir }); 244} 245 246function getLocalReactNativeVersion() { 247 const mainPkg = require(path.resolve(EXPO_DIR, 'package.json')); 248 return mainPkg.resolutions?.['react-native']; 249} 250 251async function runExpoPrebuild({ 252 projectDir, 253 useLocalTemplate, 254}: { 255 projectDir: string; 256 useLocalTemplate: boolean; 257}) { 258 console.log('Applying config plugins'); 259 if (useLocalTemplate) { 260 const pathToBareTemplate = path.resolve(EXPO_DIR, 'templates', 'expo-template-bare-minimum'); 261 const templateVersion = require(path.join(pathToBareTemplate, 'package.json')).version; 262 await spawnAsync('npm', ['pack', '--pack-destination', projectDir], { 263 cwd: pathToBareTemplate, 264 stdio: 'ignore', 265 }); 266 const tarFilePath = path.resolve( 267 projectDir, 268 `expo-template-bare-minimum-${templateVersion}.tgz` 269 ); 270 await runExpoCliAsync('prebuild', ['--no-install', '--template', tarFilePath], { 271 cwd: projectDir, 272 }); 273 return await fs.rm(tarFilePath); 274 } 275 return await runExpoCliAsync('prebuild', ['--no-install'], { cwd: projectDir }); 276} 277 278async function createMetroConfig({ projectRoot }: { projectRoot: string }) { 279 console.log('Adding metro.config.js for project'); 280 281 const template = `// Learn more https://docs.expo.io/guides/customizing-metro 282const { getDefaultConfig } = require('expo/metro-config'); 283const path = require('path'); 284 285const config = getDefaultConfig('${projectRoot}'); 286 287// 1. Watch expo packages within the monorepo 288config.watchFolders = ['${PACKAGES_DIR}']; 289 290// 2. Let Metro know where to resolve packages, and in what order 291config.resolver.nodeModulesPaths = [ 292 path.resolve('${projectRoot}', 'node_modules'), 293 path.resolve('${PACKAGES_DIR}'), 294]; 295 296// Use Node-style module resolution instead of Haste everywhere 297config.resolver.providesModuleNodeModules = []; 298 299// Ignore test files and JS files in the native Android and Xcode projects 300config.resolver.blockList = [ 301 /\\/__tests__\\/.*/, 302 /.*\\/android\\/React(Android|Common)\\/.*/, 303 /.*\\/versioned-react-native\\/.*/, 304]; 305 306module.exports = config; 307`; 308 309 return await fs.writeFile(path.resolve(projectRoot, 'metro.config.js'), template, { 310 encoding: 'utf-8', 311 }); 312} 313 314async function createScripts({ projectDir }) { 315 const scriptsDir = path.resolve(projectDir, 'scripts'); 316 await fs.mkdir(scriptsDir); 317 318 const scriptsToCopy = path.resolve(EXPO_DIR, 'template-files/generate-bare-app/scripts'); 319 await fs.copy(scriptsToCopy, scriptsDir, { recursive: true }); 320 321 const pkgJsonPath = path.resolve(projectDir, 'package.json'); 322 const pkgJson = await fs.readJSON(pkgJsonPath); 323 pkgJson.scripts['package:add'] = `node scripts/addPackages.js ${EXPO_DIR} ${projectDir}`; 324 pkgJson.scripts['package:remove'] = `node scripts/removePackages.js ${EXPO_DIR} ${projectDir}`; 325 pkgJson.scripts['clean'] = 326 'watchman watch-del-all && rm -fr $TMPDIR/metro-cache && rm $TMPDIR/haste-map-*'; 327 pkgJson.scripts['ios'] = 'expo run:ios'; 328 pkgJson.scripts['android'] = 'expo run:android'; 329 330 await fs.writeJSON(pkgJsonPath, pkgJson, { spaces: 2 }); 331 332 console.log('Added package scripts!'); 333} 334 335async function stageAndCommitInitialChanges({ projectDir }) { 336 const gitDirectory = new GitDirectory(projectDir); 337 await gitDirectory.initAsync(); 338 await gitDirectory.addFilesAsync(['.']); 339 await gitDirectory.commitAsync({ title: 'Initialized bare app!' }); 340} 341 342async function modifyAppJson({ projectDir, appName }: { projectDir: string; appName: string }) { 343 const pathToAppJson = path.resolve(projectDir, 'app.json'); 344 const json = await fs.readJson(pathToAppJson); 345 346 const strippedAppName = appName.replaceAll('-', ''); 347 json.expo.android = { package: `com.${strippedAppName}` }; 348 json.expo.ios = { bundleIdentifier: `com.${strippedAppName}` }; 349 350 await fs.writeJSON(pathToAppJson, json, { spaces: 2 }); 351} 352 353export default (program: Command) => { 354 program 355 .command('generate-bare-app [packageNames...]') 356 .alias('gba') 357 .option('-n, --name <string>', 'Specifies the name of the project') 358 .option('-c, --clean', 'Rebuilds the project from scratch') 359 .option('--rnVersion <string>', 'Version of react-native to include') 360 .option('-o, --outDir <string>', 'Specifies the directory to build the project in') 361 .option( 362 '-t, --template <string>', 363 'Specify the expo template to use as the project starter', 364 'expo-template-bare-minimum' 365 ) 366 .option( 367 '--useLocalTemplate', 368 'If true, use the local copy of the template instead of the published template in NPM', 369 false 370 ) 371 .description(`Generates a bare app with the specified packages symlinked`) 372 .asyncAction(action); 373}; 374