xref: /expo/tools/src/commands/GenerateBareApp.ts (revision 75e080d0)
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