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