1import spawnAsync from '@expo/spawn-async';
2import fs from 'fs-extra';
3import getenv from 'getenv';
4import os from 'os';
5import path from 'path';
6
7import { installDependencies } from './packageManager';
8import { PackageManagerName } from './resolvePackageManager';
9import { SubstitutionData } from './types';
10import { newStep } from './utils';
11
12const debug = require('debug')('create-expo-module:createExampleApp') as typeof console.log;
13
14// These dependencies will be removed from the example app (`expo init` adds them)
15const DEPENDENCIES_TO_REMOVE = ['expo-status-bar', 'expo-splash-screen'];
16const EXPO_BETA = getenv.boolish('EXPO_BETA', false);
17
18/**
19 * Initializes a new Expo project as an example app.
20 */
21export async function createExampleApp(
22  data: SubstitutionData,
23  targetDir: string,
24  packageManager: PackageManagerName
25): Promise<void> {
26  // Package name for the example app
27  const exampleProjectSlug = `${data.project.slug}-example`;
28
29  // `expo init` creates a new folder with the same name as the project slug
30  const appTmpPath = path.join(targetDir, exampleProjectSlug);
31
32  // Path to the target example dir
33  const appTargetPath = path.join(targetDir, 'example');
34
35  if (!(await fs.pathExists(appTargetPath))) {
36    // The template doesn't include the example app, so just skip this phase
37    return;
38  }
39
40  await newStep('Initializing the example app', async (step) => {
41    const templateVersion = EXPO_BETA ? 'next' : 'latest';
42    const template = `expo-template-blank-typescript@${templateVersion}`;
43    debug(`Using example template: ${template}`);
44    await spawnAsync(
45      packageManager,
46      ['create', 'expo-app', '--', exampleProjectSlug, '--template', template, '--yes'],
47      {
48        cwd: targetDir,
49        stdio: 'ignore',
50      }
51    );
52    step.succeed('Initialized the example app');
53  });
54
55  await newStep('Configuring the example app', async (step) => {
56    // "example" folder already exists and contains template files,
57    // that should replace these created by `expo init`.
58    await moveFiles(appTargetPath, appTmpPath);
59
60    // Cleanup the "example" dir
61    await fs.rmdir(appTargetPath);
62
63    // Clean up the ".git" from example app
64    // note, this directory has contents, rmdir will throw
65    await fs.remove(path.join(appTmpPath, '.git'));
66
67    // Move the temporary example app to "example" dir
68    await fs.rename(appTmpPath, appTargetPath);
69
70    await addMissingAppConfigFields(appTargetPath, data);
71
72    step.succeed('Configured the example app');
73  });
74
75  await prebuildExampleApp(appTargetPath);
76
77  await modifyPackageJson(appTargetPath);
78
79  await newStep('Installing dependencies in the example app', async (step) => {
80    await installDependencies(packageManager, appTargetPath);
81    if (os.platform() === 'darwin') {
82      await podInstall(appTargetPath);
83      step.succeed('Installed dependencies in the example app');
84    } else {
85      step.succeed('Installed dependencies in the example app (skipped installing CocoaPods)');
86    }
87  });
88}
89
90/**
91 * Copies files from one directory to another.
92 */
93async function moveFiles(fromPath: string, toPath: string): Promise<void> {
94  for (const file of await fs.readdir(fromPath)) {
95    await fs.move(path.join(fromPath, file), path.join(toPath, file), {
96      overwrite: true,
97    });
98  }
99}
100
101/**
102 * Adds missing configuration that are required to run `expo prebuild`.
103 */
104async function addMissingAppConfigFields(appPath: string, data: SubstitutionData): Promise<void> {
105  const appConfigPath = path.join(appPath, 'app.json');
106  const appConfig = await fs.readJson(appConfigPath);
107  const appId = `${data.project.package}.example`;
108
109  // Android package name needs to be added to app.json
110  if (!appConfig.expo.android) {
111    appConfig.expo.android = {};
112  }
113  appConfig.expo.android.package = appId;
114
115  // Specify iOS bundle identifier
116  if (!appConfig.expo.ios) {
117    appConfig.expo.ios = {};
118  }
119  appConfig.expo.ios.bundleIdentifier = appId;
120
121  await fs.writeJson(appConfigPath, appConfig, {
122    spaces: 2,
123  });
124}
125
126/**
127 * Applies necessary changes to **package.json** of the example app.
128 * It means setting the autolinking config and removing unnecessary dependencies.
129 */
130async function modifyPackageJson(appPath: string): Promise<void> {
131  const packageJsonPath = path.join(appPath, 'package.json');
132  const packageJson = await fs.readJson(packageJsonPath);
133
134  if (!packageJson.expo) {
135    packageJson.expo = {};
136  }
137
138  // Set the native modules dir to the root folder,
139  // so that the autolinking can detect and link the module.
140  packageJson.expo.autolinking = {
141    nativeModulesDir: '..',
142  };
143
144  // Remove unnecessary dependencies
145  for (const dependencyToRemove of DEPENDENCIES_TO_REMOVE) {
146    delete packageJson.dependencies[dependencyToRemove];
147  }
148
149  await fs.writeJson(packageJsonPath, packageJson, {
150    spaces: 2,
151  });
152}
153
154/**
155 * Runs `npx expo prebuild` in the example app.
156 */
157async function prebuildExampleApp(exampleAppPath: string): Promise<void> {
158  await newStep('Prebuilding the example app', async (step) => {
159    await spawnAsync('npx', ['expo', 'prebuild', '--no-install'], {
160      cwd: exampleAppPath,
161      stdio: ['ignore', 'ignore', 'pipe'],
162    });
163    step.succeed('Prebuilt the example app');
164  });
165}
166
167/**
168 * Runs `pod install` in the iOS project at the given path.
169 */
170async function podInstall(appPath: string): Promise<void> {
171  await spawnAsync('pod', ['install'], {
172    cwd: path.join(appPath, 'ios'),
173    stdio: ['ignore', 'ignore', 'pipe'],
174  });
175}
176