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