1import spawnAsync from '@expo/spawn-async';
2import chalk from 'chalk';
3import { Command } from 'commander';
4import downloadTarball from 'download-tarball';
5import ejs from 'ejs';
6import fs from 'fs-extra';
7import { boolish } from 'getenv';
8import path from 'path';
9import prompts from 'prompts';
10
11import { createExampleApp } from './createExampleApp';
12import { installDependencies } from './packageManager';
13import { getSlugPrompt, getSubstitutionDataPrompts } from './prompts';
14import { resolvePackageManager } from './resolvePackageManager';
15import { CommandOptions, SubstitutionData } from './types';
16import { newStep } from './utils';
17
18const debug = require('debug')('create-expo-module:main') as typeof console.log;
19const packageJson = require('../package.json');
20
21// Opt in to using beta versions
22const EXPO_BETA = boolish('EXPO_BETA', false);
23
24// `yarn run` may change the current working dir, then we should use `INIT_CWD` env.
25const CWD = process.env.INIT_CWD || process.cwd();
26
27// Ignore some paths. Especially `package.json` as it is rendered
28// from `$package.json` file instead of the original one.
29const IGNORES_PATHS = ['.DS_Store', 'build', 'node_modules', 'package.json'];
30
31/**
32 * The main function of the command.
33 *
34 * @param target Path to the directory where to create the module. Defaults to current working dir.
35 * @param command An object from `commander`.
36 */
37async function main(target: string | undefined, options: CommandOptions) {
38  const slug = await askForPackageSlugAsync(target);
39  const targetDir = path.join(CWD, target || slug);
40
41  await fs.ensureDir(targetDir);
42  await confirmTargetDirAsync(targetDir);
43
44  options.target = targetDir;
45
46  const data = await askForSubstitutionDataAsync(slug);
47
48  // Make one line break between prompts and progress logs
49  console.log();
50
51  const packageManager = await resolvePackageManager();
52  const packagePath = options.source
53    ? path.join(CWD, options.source)
54    : await downloadPackageAsync(targetDir);
55
56  await newStep('Creating the module from template files', async (step) => {
57    await createModuleFromTemplate(packagePath, targetDir, data);
58    step.succeed('Created the module from template files');
59  });
60
61  await newStep('Installing module dependencies', async (step) => {
62    await installDependencies(packageManager, targetDir);
63    step.succeed('Installed module dependencies');
64  });
65
66  await newStep('Compiling TypeScript files', async (step) => {
67    await spawnAsync(packageManager, ['run', 'build'], {
68      cwd: targetDir,
69      stdio: 'ignore',
70    });
71    step.succeed('Compiled TypeScript files');
72  });
73
74  if (!options.source) {
75    // Files in the downloaded tarball are wrapped in `package` dir.
76    // We should remove it after all.
77    await fs.remove(packagePath);
78  }
79  if (!options.withReadme) {
80    await fs.remove(path.join(targetDir, 'README.md'));
81  }
82  if (!options.withChangelog) {
83    await fs.remove(path.join(targetDir, 'CHANGELOG.md'));
84  }
85  if (options.example) {
86    // Create "example" folder
87    await createExampleApp(data, targetDir, packageManager);
88  }
89
90  console.log();
91  console.log('✅ Successfully created Expo module');
92}
93
94/**
95 * Recursively scans for the files within the directory. Returned paths are relative to the `root` path.
96 */
97async function getFilesAsync(root: string, dir: string | null = null): Promise<string[]> {
98  const files: string[] = [];
99  const baseDir = dir ? path.join(root, dir) : root;
100
101  for (const file of await fs.readdir(baseDir)) {
102    const relativePath = dir ? path.join(dir, file) : file;
103
104    if (IGNORES_PATHS.includes(relativePath) || IGNORES_PATHS.includes(file)) {
105      continue;
106    }
107
108    const fullPath = path.join(baseDir, file);
109    const stat = await fs.lstat(fullPath);
110
111    if (stat.isDirectory()) {
112      files.push(...(await getFilesAsync(root, relativePath)));
113    } else {
114      files.push(relativePath);
115    }
116  }
117  return files;
118}
119
120/**
121 * Asks NPM registry for the url to the tarball.
122 */
123async function getNpmTarballUrl(packageName: string, version: string = 'latest'): Promise<string> {
124  debug(`Using module template ${chalk.bold(packageName)}@${chalk.bold(version)}`);
125  const { stdout } = await spawnAsync('npm', ['view', `${packageName}@${version}`, 'dist.tarball']);
126  return stdout.trim();
127}
128
129/**
130 * Downloads the template from NPM registry.
131 */
132async function downloadPackageAsync(targetDir: string): Promise<string> {
133  return await newStep('Downloading module template from npm', async (step) => {
134    const tarballUrl = await getNpmTarballUrl(
135      'expo-module-template',
136      EXPO_BETA ? 'next' : 'latest'
137    );
138
139    await downloadTarball({
140      url: tarballUrl,
141      dir: targetDir,
142    });
143
144    step.succeed('Downloaded module template from npm');
145
146    return path.join(targetDir, 'package');
147  });
148}
149
150/**
151 * Creates the module based on the `ejs` template (e.g. `expo-module-template` package).
152 */
153async function createModuleFromTemplate(
154  templatePath: string,
155  targetPath: string,
156  data: SubstitutionData
157) {
158  const files = await getFilesAsync(templatePath);
159
160  // Iterate through all template files.
161  for (const file of files) {
162    const renderedRelativePath = ejs.render(file.replace(/^\$/, ''), data, {
163      openDelimiter: '{',
164      closeDelimiter: '}',
165      escape: (value: string) => value.replace('.', path.sep),
166    });
167    const fromPath = path.join(templatePath, file);
168    const toPath = path.join(targetPath, renderedRelativePath);
169    const template = await fs.readFile(fromPath, { encoding: 'utf8' });
170    const renderedContent = ejs.render(template, data);
171
172    await fs.outputFile(toPath, renderedContent, { encoding: 'utf8' });
173  }
174}
175
176/**
177 * Asks the user for the package slug (npm package name).
178 */
179async function askForPackageSlugAsync(customTargetPath?: string): Promise<string> {
180  const { slug } = await prompts(getSlugPrompt(customTargetPath), {
181    onCancel: () => process.exit(0),
182  });
183  return slug;
184}
185
186/**
187 * Asks the user for some data necessary to render the template.
188 * Some values may already be provided by command options, the prompt is skipped in that case.
189 */
190async function askForSubstitutionDataAsync(slug: string): Promise<SubstitutionData> {
191  const promptQueries = await getSubstitutionDataPrompts(slug);
192
193  // Stop the process when the user cancels/exits the prompt.
194  const onCancel = () => {
195    process.exit(0);
196  };
197
198  const {
199    name,
200    description,
201    package: projectPackage,
202    authorName,
203    authorEmail,
204    authorUrl,
205    repo,
206  } = await prompts(promptQueries, { onCancel });
207
208  return {
209    project: {
210      slug,
211      name,
212      version: '0.1.0',
213      description,
214      package: projectPackage,
215    },
216    author: `${authorName} <${authorEmail}> (${authorUrl})`,
217    license: 'MIT',
218    repo,
219  };
220}
221
222/**
223 * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue.
224 */
225async function confirmTargetDirAsync(targetDir: string): Promise<void> {
226  const files = await fs.readdir(targetDir);
227
228  if (files.length === 0) {
229    return;
230  }
231  const { shouldContinue } = await prompts(
232    {
233      type: 'confirm',
234      name: 'shouldContinue',
235      message: `The target directory ${chalk.magenta(
236        targetDir
237      )} is not empty, do you want to continue anyway?`,
238      initial: true,
239    },
240    {
241      onCancel: () => false,
242    }
243  );
244  if (!shouldContinue) {
245    process.exit(0);
246  }
247}
248
249const program = new Command();
250
251program
252  .name(packageJson.name)
253  .version(packageJson.version)
254  .description(packageJson.description)
255  .arguments('[path]')
256  .option(
257    '-s, --source <source_dir>',
258    'Local path to the template. By default it downloads `expo-module-template` from NPM.'
259  )
260  .option('--with-readme', 'Whether to include README.md file.', false)
261  .option('--with-changelog', 'Whether to include CHANGELOG.md file.', false)
262  .option('--no-example', 'Whether to skip creating the example app.', false)
263  .action(main);
264
265program.parse(process.argv);
266