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'; 8 9type GenerateBareAppOptions = { 10 name?: string; 11 template?: string; 12 clean?: boolean; 13 outDir?: string; 14}; 15 16async function action( 17 packageNames: string[], 18 { 19 name: appName = 'my-generated-bare-app', 20 outDir = 'bare-apps', 21 template = 'expo-template-bare-minimum', 22 clean = false, 23 }: GenerateBareAppOptions 24) { 25 // TODO: 26 // if appName === '' 27 // if packageNames.length === 0 28 29 const workspaceDir = path.resolve(process.cwd(), outDir); 30 const projectDir = path.resolve(process.cwd(), workspaceDir, appName); 31 const packagesToSymlink = await getPackagesToSymlink({ packageNames, workspaceDir }); 32 33 await createProjectDirectory({ clean, projectDir, workspaceDir, appName, template }); 34 await modifyPackageJson({ packagesToSymlink, projectDir }); 35 await yarnInstall({ projectDir }); 36 await symlinkPackages({ packagesToSymlink, projectDir }); 37 await runExpoPrebuild({ projectDir }); 38 await updateRNVersion({ projectDir }); 39 await createMetroConfig({ workspaceRoot: EXPO_DIR, projectRoot: projectDir }); 40 await applyGradleFlipperFixtures({ projectDir }); 41 // reestablish symlinks - some might be wiped out from prebuild 42 await symlinkPackages({ projectDir, packagesToSymlink }); 43 44 console.log(`Project created in ${projectDir}!`); 45} 46 47async function createProjectDirectory({ 48 clean, 49 workspaceDir, 50 projectDir, 51 appName, 52 template, 53}: { 54 clean: boolean; 55 workspaceDir: string; 56 projectDir: string; 57 appName: string; 58 template: string; 59}) { 60 console.log('Creating project'); 61 62 if (!fs.existsSync(workspaceDir)) { 63 fs.mkdirSync(workspaceDir); 64 } 65 66 if (clean) { 67 await fs.remove(projectDir); 68 } 69 70 return await runExpoCliAsync('init', [appName, '--no-install', '--template', template], { 71 cwd: workspaceDir, 72 stdio: 'ignore', 73 }); 74} 75 76function getDefaultPackagesToSymlink({ workspaceDir }: { workspaceDir: string }) { 77 const defaultPackagesToSymlink: string[] = ['expo', 'expo-modules-autolinking']; 78 79 const isInExpoRepo = workspaceDir.startsWith(EXPO_DIR); 80 81 if (isInExpoRepo) { 82 // these packages are picked up by prebuild since they are symlinks in the mono repo 83 // config plugins are applied so we include these packages to be safe 84 defaultPackagesToSymlink.concat([ 85 'expo-asset', 86 'expo-application', 87 'expo-constants', 88 'expo-file-system', 89 'expo-font', 90 'expo-keep-awake', 91 'expo-error-recovery', 92 'expo-splash-screen', 93 'expo-updates', 94 'expo-manifests', 95 'expo-updates-interface', 96 'expo-dev-client', 97 'expo-dev-launcher', 98 'expo-dev-menu', 99 'expo-dev-menu-interface', 100 ]); 101 } 102 103 return defaultPackagesToSymlink; 104} 105 106async function getPackagesToSymlink({ 107 packageNames, 108 workspaceDir, 109}: { 110 packageNames: string[]; 111 workspaceDir: string; 112}) { 113 const packagesToSymlink = new Set<string>(); 114 115 const defaultPackages = getDefaultPackagesToSymlink({ workspaceDir }); 116 defaultPackages.forEach((packageName) => packagesToSymlink.add(packageName)); 117 118 for (const packageName of packageNames) { 119 const deps = getPackageDependencies(packageName); 120 deps.forEach((dep) => packagesToSymlink.add(dep)); 121 } 122 123 return Array.from(packagesToSymlink); 124} 125 126function getPackageDependencies(packageName: string) { 127 const packagePath = path.resolve(PACKAGES_DIR, packageName, 'package.json'); 128 129 if (!fs.existsSync(packagePath)) { 130 return []; 131 } 132 133 const dependencies = new Set<string>(); 134 dependencies.add(packageName); 135 136 const pkg = require(packagePath); 137 138 if (pkg.dependencies) { 139 Object.keys(pkg.dependencies).forEach((dependency) => { 140 const deps = getPackageDependencies(dependency); 141 deps.forEach((dep) => dependencies.add(dep)); 142 }); 143 } 144 145 return Array.from(dependencies); 146} 147 148async function modifyPackageJson({ 149 packagesToSymlink, 150 projectDir, 151}: { 152 packagesToSymlink: string[]; 153 projectDir: string; 154}) { 155 const pkgPath = path.resolve(projectDir, 'package.json'); 156 const pkg = await fs.readJSON(pkgPath); 157 158 packagesToSymlink.forEach((packageName) => { 159 const packageJson = require(path.resolve(PACKAGES_DIR, packageName, 'package.json')); 160 pkg.dependencies[packageName] = packageJson.version ?? '*'; 161 }); 162 163 await fs.outputJson(path.resolve(projectDir, 'package.json'), pkg, { spaces: 2 }); 164} 165 166async function yarnInstall({ projectDir }: { projectDir: string }) { 167 console.log('Yarning'); 168 return await spawnAsync('yarn', [], { cwd: projectDir, stdio: 'ignore' }); 169} 170 171async function symlinkPackages({ 172 packagesToSymlink, 173 projectDir, 174}: { 175 packagesToSymlink: string[]; 176 projectDir: string; 177}) { 178 for (const packageName of packagesToSymlink) { 179 const projectPackagePath = path.resolve(projectDir, 'node_modules', packageName); 180 const expoPackagePath = path.resolve(PACKAGES_DIR, packageName); 181 182 if (fs.existsSync(projectPackagePath)) { 183 fs.rmdirSync(projectPackagePath, { recursive: true }); 184 } 185 186 fs.symlinkSync(expoPackagePath, projectPackagePath); 187 } 188} 189 190async function updateRNVersion({ projectDir }: { projectDir: string }) { 191 const pkgPath = path.resolve(projectDir, 'package.json'); 192 const pkg = await fs.readJSON(pkgPath); 193 194 const mainPkg = require(path.resolve(EXPO_DIR, 'package.json')); 195 const currentRNVersion = mainPkg.resolutions['react-native']; 196 pkg.dependencies['react-native'] = currentRNVersion; 197 198 await fs.outputJson(path.resolve(projectDir, 'package.json'), pkg, { spaces: 2 }); 199 await spawnAsync('yarn', [], { cwd: projectDir }); 200} 201 202async function runExpoPrebuild({ projectDir }: { projectDir: string }) { 203 console.log('Applying config plugins'); 204 return await runExpoCliAsync('prebuild', ['--no-install'], { cwd: projectDir }); 205} 206 207async function createMetroConfig({ 208 workspaceRoot, 209 projectRoot, 210}: { 211 workspaceRoot: string; 212 projectRoot: string; 213}) { 214 console.log('Adding metro.config.js for project'); 215 216 const template = `// Learn more https://docs.expo.io/guides/customizing-metro 217const { getDefaultConfig } = require('expo/metro-config'); 218const path = require('path'); 219 220const config = getDefaultConfig('${projectRoot}'); 221 222// 1. Watch all files within the monorepo 223config.watchFolders = ['${workspaceRoot}']; 224 225// 2. Let Metro know where to resolve packages, and in what order 226config.resolver.nodeModulesPaths = [ 227 path.resolve('${projectRoot}', 'node_modules'), 228 path.resolve('${workspaceRoot}', 'packages'), 229]; 230 231// Use Node-style module resolution instead of Haste everywhere 232config.resolver.providesModuleNodeModules = []; 233 234// Ignore test files and JS files in the native Android and Xcode projects 235config.resolver.blockList = [ 236 /\\/__tests__\\/.*/, 237 /.*\\/android\\/React(Android|Common)\\/.*/, 238 /.*\\/versioned-react-native\\/.*/, 239]; 240 241module.exports = config; 242`; 243 244 return await fs.writeFile(path.resolve(projectRoot, 'metro.config.js'), template, { 245 encoding: 'utf-8', 246 }); 247} 248 249async function applyGradleFlipperFixtures({ projectDir }: { projectDir: string }) { 250 // prebuild is updating the gradle.properties FLIPPER_VERSION which causes SoLoader crash on launch 251 const gradlePropertiesPath = path.resolve(projectDir, 'android', 'gradle.properties'); 252 const gradleProperties = await fs.readFile(gradlePropertiesPath, { encoding: 'utf-8' }); 253 const updatedGradleProperies = gradleProperties.replace( 254 `FLIPPER_VERSION=0.54.0`, 255 `FLIPPER_VERSION=0.99.0` 256 ); 257 console.log(`Overriding the gradle.properties to FLIPPER_VERSION=0.99.0`); 258 await fs.outputFile(gradlePropertiesPath, updatedGradleProperies); 259} 260 261export default (program: Command) => { 262 program 263 .command('generate-bare-app [packageNames...]') 264 .alias('gba') 265 .option('-n, --name <string>', 'Specifies the name of the project') 266 .option('-c, --clean', 'Rebuilds the project from scratch') 267 .option('-o, --outDir <string>', 'Specifies the directory to build the project in') 268 .option('-t, --template <string>', 'Specify the expo template to use as the project starter') 269 .description(`Generates a bare app with the specified packages symlinked`) 270 .asyncAction(action); 271}; 272