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 } from '../ExpoCLI'; 8import { GitDirectory } from '../Git'; 9 10export type GenerateBareAppOptions = { 11 name?: string; 12 template?: string; 13 clean?: boolean; 14 localTemplate?: 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 localTemplate = 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, localTemplate }); 40 await modifyPackageJson({ packagesToSymlink, projectDir }); 41 await modifyAppJson({ projectDir, appName }); 42 await yarnInstall({ projectDir }); 43 await symlinkPackages({ packagesToSymlink, projectDir }); 44 await runExpoPrebuild({ projectDir }); 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 localTemplate, 86}: { 87 workspaceDir: string; 88 appName: string; 89 template: string; 90 localTemplate: boolean; 91}) { 92 if (localTemplate) { 93 const pathToBareTemplate = path.resolve(EXPO_DIR, 'templates', 'expo-template-bare-minimum'); 94 const pathToWorkspace = path.resolve(workspaceDir, appName); 95 return fs.copy(pathToBareTemplate, pathToWorkspace, { recursive: true }); 96 } 97 98 return await runExpoCliAsync('init', [appName, '--no-install', '--template', template], { 99 cwd: workspaceDir, 100 stdio: 'ignore', 101 }); 102} 103 104function getDefaultPackagesToSymlink({ workspaceDir }: { workspaceDir: string }) { 105 const defaultPackagesToSymlink: string[] = ['expo']; 106 107 const isInExpoRepo = workspaceDir.startsWith(EXPO_DIR); 108 109 if (isInExpoRepo) { 110 // these packages are picked up by prebuild since they are symlinks in the mono repo 111 // config plugins are applied so we include these packages to be safe 112 defaultPackagesToSymlink.concat([ 113 'expo-asset', 114 'expo-application', 115 'expo-constants', 116 'expo-file-system', 117 'expo-font', 118 'expo-keep-awake', 119 'expo-error-recovery', 120 'expo-splash-screen', 121 'expo-updates', 122 'expo-manifests', 123 'expo-updates-interface', 124 'expo-dev-client', 125 'expo-dev-launcher', 126 'expo-dev-menu', 127 'expo-dev-menu-interface', 128 ]); 129 } 130 131 return defaultPackagesToSymlink; 132} 133 134export async function getPackagesToSymlink({ 135 packageNames, 136 workspaceDir, 137}: { 138 packageNames: string[]; 139 workspaceDir: string; 140}) { 141 const packagesToSymlink = new Set<string>(); 142 143 const defaultPackages = getDefaultPackagesToSymlink({ workspaceDir }); 144 defaultPackages.forEach((packageName) => packagesToSymlink.add(packageName)); 145 146 for (const packageName of packageNames) { 147 const deps = getPackageDependencies(packageName); 148 deps.forEach((dep) => packagesToSymlink.add(dep)); 149 } 150 151 return Array.from(packagesToSymlink); 152} 153 154function getPackageDependencies(packageName: string) { 155 const packagePath = path.resolve(PACKAGES_DIR, packageName, 'package.json'); 156 157 if (!fs.existsSync(packagePath)) { 158 return []; 159 } 160 161 const dependencies = new Set<string>(); 162 dependencies.add(packageName); 163 164 const pkg = require(packagePath); 165 166 if (pkg.dependencies) { 167 Object.keys(pkg.dependencies).forEach((dependency) => { 168 const deps = getPackageDependencies(dependency); 169 deps.forEach((dep) => dependencies.add(dep)); 170 }); 171 } 172 173 return Array.from(dependencies); 174} 175 176async function modifyPackageJson({ 177 packagesToSymlink, 178 projectDir, 179}: { 180 packagesToSymlink: string[]; 181 projectDir: string; 182}) { 183 const pkgPath = path.resolve(projectDir, 'package.json'); 184 const pkg = await fs.readJSON(pkgPath); 185 186 pkg.expo = pkg.expo ?? {}; 187 pkg.expo.symlinks = pkg.expo.symlinks ?? []; 188 189 packagesToSymlink.forEach((packageName) => { 190 const packageJson = require(path.resolve(PACKAGES_DIR, packageName, 'package.json')); 191 pkg.dependencies[packageName] = packageJson.version ?? '*'; 192 pkg.expo.symlinks.push(packageName); 193 }); 194 195 await fs.outputJson(path.resolve(projectDir, 'package.json'), pkg, { spaces: 2 }); 196} 197 198async function yarnInstall({ projectDir }: { projectDir: string }) { 199 console.log('Yarning'); 200 return await spawnAsync('yarn', [], { cwd: projectDir, stdio: 'ignore' }); 201} 202 203export async function symlinkPackages({ 204 packagesToSymlink, 205 projectDir, 206}: { 207 packagesToSymlink: string[]; 208 projectDir: string; 209}) { 210 for (const packageName of packagesToSymlink) { 211 const projectPackagePath = path.resolve(projectDir, 'node_modules', packageName); 212 const expoPackagePath = path.resolve(PACKAGES_DIR, packageName); 213 214 if (fs.existsSync(projectPackagePath)) { 215 fs.rmSync(projectPackagePath, { recursive: true }); 216 } 217 218 fs.symlinkSync(expoPackagePath, projectPackagePath); 219 } 220} 221 222async function updateRNVersion({ 223 projectDir, 224 rnVersion, 225}: { 226 projectDir: string; 227 rnVersion?: string; 228}) { 229 const reactNativeVersion = rnVersion || getLocalReactNativeVersion(); 230 231 const pkgPath = path.resolve(projectDir, 'package.json'); 232 const pkg = await fs.readJSON(pkgPath); 233 pkg.dependencies['react-native'] = reactNativeVersion; 234 235 await fs.outputJson(path.resolve(projectDir, 'package.json'), pkg, { spaces: 2 }); 236 await spawnAsync('yarn', [], { cwd: projectDir }); 237} 238 239function getLocalReactNativeVersion() { 240 const mainPkg = require(path.resolve(EXPO_DIR, 'package.json')); 241 return mainPkg.resolutions?.['react-native']; 242} 243 244async function runExpoPrebuild({ projectDir }: { projectDir: string }) { 245 console.log('Applying config plugins'); 246 return await runExpoCliAsync('prebuild', ['--no-install'], { cwd: projectDir }); 247} 248 249async function createMetroConfig({ projectRoot }: { projectRoot: string }) { 250 console.log('Adding metro.config.js for project'); 251 252 const template = `// Learn more https://docs.expo.io/guides/customizing-metro 253const { getDefaultConfig } = require('expo/metro-config'); 254const path = require('path'); 255 256const config = getDefaultConfig('${projectRoot}'); 257 258// 1. Watch expo packages within the monorepo 259config.watchFolders = ['${PACKAGES_DIR}']; 260 261// 2. Let Metro know where to resolve packages, and in what order 262config.resolver.nodeModulesPaths = [ 263 path.resolve('${projectRoot}', 'node_modules'), 264 path.resolve('${PACKAGES_DIR}'), 265]; 266 267// Use Node-style module resolution instead of Haste everywhere 268config.resolver.providesModuleNodeModules = []; 269 270// Ignore test files and JS files in the native Android and Xcode projects 271config.resolver.blockList = [ 272 /\\/__tests__\\/.*/, 273 /.*\\/android\\/React(Android|Common)\\/.*/, 274 /.*\\/versioned-react-native\\/.*/, 275]; 276 277module.exports = config; 278`; 279 280 return await fs.writeFile(path.resolve(projectRoot, 'metro.config.js'), template, { 281 encoding: 'utf-8', 282 }); 283} 284 285async function createScripts({ projectDir }) { 286 const scriptsDir = path.resolve(projectDir, 'scripts'); 287 await fs.mkdir(scriptsDir); 288 289 const scriptsToCopy = path.resolve(EXPO_DIR, 'template-files/generate-bare-app/scripts'); 290 await fs.copy(scriptsToCopy, scriptsDir, { recursive: true }); 291 292 const pkgJsonPath = path.resolve(projectDir, 'package.json'); 293 const pkgJson = await fs.readJSON(pkgJsonPath); 294 pkgJson.scripts['package:add'] = `node scripts/addPackages.js ${EXPO_DIR} ${projectDir}`; 295 pkgJson.scripts['package:remove'] = `node scripts/removePackages.js ${EXPO_DIR} ${projectDir}`; 296 pkgJson.scripts['clean'] = 297 'watchman watch-del-all && rm -fr $TMPDIR/metro-cache && rm $TMPDIR/haste-map-*'; 298 pkgJson.scripts['ios'] = 'expo run:ios'; 299 pkgJson.scripts['android'] = 'expo run:android'; 300 301 await fs.writeJSON(pkgJsonPath, pkgJson, { spaces: 2 }); 302 303 console.log('Added package scripts!'); 304} 305 306async function stageAndCommitInitialChanges({ projectDir }) { 307 const gitDirectory = new GitDirectory(projectDir); 308 await gitDirectory.initAsync(); 309 await gitDirectory.addFilesAsync(['.']); 310 await gitDirectory.commitAsync({ title: 'Initialized bare app!' }); 311} 312 313async function modifyAppJson({ projectDir, appName }: { projectDir: string; appName: string }) { 314 const pathToAppJson = path.resolve(projectDir, 'app.json'); 315 const json = await fs.readJson(pathToAppJson); 316 317 const strippedAppName = appName.replaceAll('-', ''); 318 json.expo.android = { package: `com.${strippedAppName}` }; 319 json.expo.ios = { bundleIdentifier: `com.${strippedAppName}` }; 320 321 await fs.writeJSON(pathToAppJson, json, { spaces: 2 }); 322} 323 324export default (program: Command) => { 325 program 326 .command('generate-bare-app [packageNames...]') 327 .alias('gba') 328 .option('-n, --name <string>', 'Specifies the name of the project') 329 .option('-c, --clean', 'Rebuilds the project from scratch') 330 .option('--rnVersion <string>', 'Version of react-native to include') 331 .option('-o, --outDir <string>', 'Specifies the directory to build the project in') 332 .option('-t, --template <string>', 'Specify the expo template to use as the project starter') 333 .option( 334 '--localTemplate', 335 'Copy the localTemplate expo-template-bare-minimum from the expo repo', 336 false 337 ) 338 .description(`Generates a bare app with the specified packages symlinked`) 339 .asyncAction(action); 340}; 341