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 {
15  formatRunCommand,
16  PackageManagerName,
17  resolvePackageManager,
18} from './resolvePackageManager';
19import { eventCreateExpoModule, getTelemetryClient, logEventAsync } from './telemetry';
20import { CommandOptions, SubstitutionData } from './types';
21import { newStep } from './utils';
22
23const debug = require('debug')('create-expo-module:main') as typeof console.log;
24const packageJson = require('../package.json');
25
26// Opt in to using beta versions
27const EXPO_BETA = boolish('EXPO_BETA', false);
28
29// `yarn run` may change the current working dir, then we should use `INIT_CWD` env.
30const CWD = process.env.INIT_CWD || process.cwd();
31
32// Ignore some paths. Especially `package.json` as it is rendered
33// from `$package.json` file instead of the original one.
34const IGNORES_PATHS = [
35  '.DS_Store',
36  'build',
37  'node_modules',
38  'package.json',
39  '.npmignore',
40  '.gitignore',
41];
42
43// Url to the documentation on Expo Modules
44const DOCS_URL = 'https://docs.expo.dev/modules';
45
46/**
47 * The main function of the command.
48 *
49 * @param target Path to the directory where to create the module. Defaults to current working dir.
50 * @param command An object from `commander`.
51 */
52async function main(target: string | undefined, options: CommandOptions) {
53  const slug = await askForPackageSlugAsync(target);
54  const targetDir = path.join(CWD, target || slug);
55
56  await fs.ensureDir(targetDir);
57  await confirmTargetDirAsync(targetDir);
58
59  options.target = targetDir;
60
61  const data = await askForSubstitutionDataAsync(slug);
62
63  // Make one line break between prompts and progress logs
64  console.log();
65
66  const packageManager = await resolvePackageManager();
67  const packagePath = options.source
68    ? path.join(CWD, options.source)
69    : await downloadPackageAsync(targetDir);
70
71  logEventAsync(eventCreateExpoModule(packageManager, options));
72
73  await newStep('Creating the module from template files', async (step) => {
74    await createModuleFromTemplate(packagePath, targetDir, data);
75    step.succeed('Created the module from template files');
76  });
77
78  await newStep('Creating an empty Git repository', async (step) => {
79    try {
80      await spawnAsync('git', ['init'], {
81        cwd: targetDir,
82        stdio: 'ignore',
83      });
84      step.succeed('Created an empty Git repository');
85    } catch (e: any) {
86      step.fail(e.toString());
87    }
88  });
89
90  await newStep('Installing module dependencies', async (step) => {
91    await installDependencies(packageManager, targetDir);
92    step.succeed('Installed module dependencies');
93  });
94
95  await newStep('Compiling TypeScript files', async (step) => {
96    await spawnAsync(packageManager, ['run', 'build'], {
97      cwd: targetDir,
98      stdio: 'ignore',
99    });
100    step.succeed('Compiled TypeScript files');
101  });
102
103  if (!options.source) {
104    // Files in the downloaded tarball are wrapped in `package` dir.
105    // We should remove it after all.
106    await fs.remove(packagePath);
107  }
108  if (!options.withReadme) {
109    await fs.remove(path.join(targetDir, 'README.md'));
110  }
111  if (!options.withChangelog) {
112    await fs.remove(path.join(targetDir, 'CHANGELOG.md'));
113  }
114  if (options.example) {
115    // Create "example" folder
116    await createExampleApp(data, targetDir, packageManager);
117  }
118
119  console.log();
120  console.log('✅ Successfully created Expo module');
121
122  printFurtherInstructions(targetDir, packageManager, options.example);
123}
124
125/**
126 * Recursively scans for the files within the directory. Returned paths are relative to the `root` path.
127 */
128async function getFilesAsync(root: string, dir: string | null = null): Promise<string[]> {
129  const files: string[] = [];
130  const baseDir = dir ? path.join(root, dir) : root;
131
132  for (const file of await fs.readdir(baseDir)) {
133    const relativePath = dir ? path.join(dir, file) : file;
134
135    if (IGNORES_PATHS.includes(relativePath) || IGNORES_PATHS.includes(file)) {
136      continue;
137    }
138
139    const fullPath = path.join(baseDir, file);
140    const stat = await fs.lstat(fullPath);
141
142    if (stat.isDirectory()) {
143      files.push(...(await getFilesAsync(root, relativePath)));
144    } else {
145      files.push(relativePath);
146    }
147  }
148  return files;
149}
150
151/**
152 * Asks NPM registry for the url to the tarball.
153 */
154async function getNpmTarballUrl(packageName: string, version: string = 'latest'): Promise<string> {
155  debug(`Using module template ${chalk.bold(packageName)}@${chalk.bold(version)}`);
156  const { stdout } = await spawnAsync('npm', ['view', `${packageName}@${version}`, 'dist.tarball']);
157  return stdout.trim();
158}
159
160/**
161 * Downloads the template from NPM registry.
162 */
163async function downloadPackageAsync(targetDir: string): Promise<string> {
164  return await newStep('Downloading module template from npm', async (step) => {
165    const tarballUrl = await getNpmTarballUrl(
166      'expo-module-template',
167      EXPO_BETA ? 'next' : 'latest'
168    );
169
170    await downloadTarball({
171      url: tarballUrl,
172      dir: targetDir,
173    });
174
175    step.succeed('Downloaded module template from npm');
176
177    return path.join(targetDir, 'package');
178  });
179}
180
181function handleSuffix(name: string, suffix: string): string {
182  if (name.endsWith(suffix)) {
183    return name;
184  }
185  return `${name}${suffix}`;
186}
187
188/**
189 * Creates the module based on the `ejs` template (e.g. `expo-module-template` package).
190 */
191async function createModuleFromTemplate(
192  templatePath: string,
193  targetPath: string,
194  data: SubstitutionData
195) {
196  const files = await getFilesAsync(templatePath);
197
198  // Iterate through all template files.
199  for (const file of files) {
200    const renderedRelativePath = ejs.render(file.replace(/^\$/, ''), data, {
201      openDelimiter: '{',
202      closeDelimiter: '}',
203      escape: (value: string) => value.replace(/\./g, path.sep),
204    });
205    const fromPath = path.join(templatePath, file);
206    const toPath = path.join(targetPath, renderedRelativePath);
207    const template = await fs.readFile(fromPath, { encoding: 'utf8' });
208    const renderedContent = ejs.render(template, data);
209
210    await fs.outputFile(toPath, renderedContent, { encoding: 'utf8' });
211  }
212}
213
214/**
215 * Asks the user for the package slug (npm package name).
216 */
217async function askForPackageSlugAsync(customTargetPath?: string): Promise<string> {
218  const { slug } = await prompts(getSlugPrompt(customTargetPath), {
219    onCancel: () => process.exit(0),
220  });
221  return slug;
222}
223
224/**
225 * Asks the user for some data necessary to render the template.
226 * Some values may already be provided by command options, the prompt is skipped in that case.
227 */
228async function askForSubstitutionDataAsync(slug: string): Promise<SubstitutionData> {
229  const promptQueries = await getSubstitutionDataPrompts(slug);
230
231  // Stop the process when the user cancels/exits the prompt.
232  const onCancel = () => {
233    process.exit(0);
234  };
235
236  const {
237    name,
238    description,
239    package: projectPackage,
240    authorName,
241    authorEmail,
242    authorUrl,
243    repo,
244  } = await prompts(promptQueries, { onCancel });
245
246  return {
247    project: {
248      slug,
249      name,
250      version: '0.1.0',
251      description,
252      package: projectPackage,
253      moduleName: handleSuffix(name, 'Module'),
254      viewName: handleSuffix(name, 'View'),
255    },
256    author: `${authorName} <${authorEmail}> (${authorUrl})`,
257    license: 'MIT',
258    repo,
259  };
260}
261
262/**
263 * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue.
264 */
265async function confirmTargetDirAsync(targetDir: string): Promise<void> {
266  const files = await fs.readdir(targetDir);
267
268  if (files.length === 0) {
269    return;
270  }
271  const { shouldContinue } = await prompts(
272    {
273      type: 'confirm',
274      name: 'shouldContinue',
275      message: `The target directory ${chalk.magenta(
276        targetDir
277      )} is not empty, do you want to continue anyway?`,
278      initial: true,
279    },
280    {
281      onCancel: () => false,
282    }
283  );
284  if (!shouldContinue) {
285    process.exit(0);
286  }
287}
288
289/**
290 * Prints how the user can follow up once the script finishes creating the module.
291 */
292function printFurtherInstructions(
293  targetDir: string,
294  packageManager: PackageManagerName,
295  includesExample: boolean
296) {
297  if (includesExample) {
298    const commands = [
299      `cd ${path.relative(CWD, targetDir)}`,
300      formatRunCommand(packageManager, 'open:ios'),
301      formatRunCommand(packageManager, 'open:android'),
302    ];
303
304    console.log();
305    console.log(
306      'To start developing your module, navigate to the directory and open iOS and Android projects of the example app'
307    );
308    commands.forEach((command) => console.log(chalk.gray('>'), chalk.bold(command)));
309    console.log();
310  }
311  console.log(`Visit ${chalk.blue.bold(DOCS_URL)} for the documentation on Expo Modules APIs`);
312}
313
314const program = new Command();
315
316program
317  .name(packageJson.name)
318  .version(packageJson.version)
319  .description(packageJson.description)
320  .arguments('[path]')
321  .option(
322    '-s, --source <source_dir>',
323    'Local path to the template. By default it downloads `expo-module-template` from NPM.'
324  )
325  .option('--with-readme', 'Whether to include README.md file.', false)
326  .option('--with-changelog', 'Whether to include CHANGELOG.md file.', false)
327  .option('--no-example', 'Whether to skip creating the example app.', false)
328  .action(main);
329
330program
331  .hook('postAction', async () => {
332    await getTelemetryClient().flush?.();
333  })
334  .parse(process.argv);
335