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