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