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('Installing module dependencies', async (step) => {
79    await installDependencies(packageManager, targetDir);
80    step.succeed('Installed module dependencies');
81  });
82
83  await newStep('Compiling TypeScript files', async (step) => {
84    await spawnAsync(packageManager, ['run', 'build'], {
85      cwd: targetDir,
86      stdio: 'ignore',
87    });
88    step.succeed('Compiled TypeScript files');
89  });
90
91  if (!options.source) {
92    // Files in the downloaded tarball are wrapped in `package` dir.
93    // We should remove it after all.
94    await fs.remove(packagePath);
95  }
96  if (!options.withReadme) {
97    await fs.remove(path.join(targetDir, 'README.md'));
98  }
99  if (!options.withChangelog) {
100    await fs.remove(path.join(targetDir, 'CHANGELOG.md'));
101  }
102  if (options.example) {
103    // Create "example" folder
104    await createExampleApp(data, targetDir, packageManager);
105  }
106
107  await newStep('Creating an empty Git repository', async (step) => {
108    try {
109      const result = await createGitRepositoryAsync(targetDir);
110      if (result) {
111        step.succeed('Created an empty Git repository');
112      } else if (result === null) {
113        step.succeed('Skipped creating an empty Git repository, already within a Git repository');
114      } else if (result === false) {
115        step.warn('Could not create an empty Git repository, see debug logs with EXPO_DEBUG=true');
116      }
117    } catch (e: any) {
118      step.fail(e.toString());
119    }
120  });
121
122  console.log();
123  console.log('✅ Successfully created Expo module');
124
125  printFurtherInstructions(targetDir, packageManager, options.example);
126}
127
128/**
129 * Recursively scans for the files within the directory. Returned paths are relative to the `root` path.
130 */
131async function getFilesAsync(root: string, dir: string | null = null): Promise<string[]> {
132  const files: string[] = [];
133  const baseDir = dir ? path.join(root, dir) : root;
134
135  for (const file of await fs.readdir(baseDir)) {
136    const relativePath = dir ? path.join(dir, file) : file;
137
138    if (IGNORES_PATHS.includes(relativePath) || IGNORES_PATHS.includes(file)) {
139      continue;
140    }
141
142    const fullPath = path.join(baseDir, file);
143    const stat = await fs.lstat(fullPath);
144
145    if (stat.isDirectory()) {
146      files.push(...(await getFilesAsync(root, relativePath)));
147    } else {
148      files.push(relativePath);
149    }
150  }
151  return files;
152}
153
154/**
155 * Asks NPM registry for the url to the tarball.
156 */
157async function getNpmTarballUrl(packageName: string, version: string = 'latest'): Promise<string> {
158  debug(`Using module template ${chalk.bold(packageName)}@${chalk.bold(version)}`);
159  const { stdout } = await spawnAsync('npm', ['view', `${packageName}@${version}`, 'dist.tarball']);
160  return stdout.trim();
161}
162
163/**
164 * Downloads the template from NPM registry.
165 */
166async function downloadPackageAsync(targetDir: string): Promise<string> {
167  return await newStep('Downloading module template from npm', async (step) => {
168    const tarballUrl = await getNpmTarballUrl(
169      'expo-module-template',
170      EXPO_BETA ? 'next' : 'latest'
171    );
172
173    await downloadTarball({
174      url: tarballUrl,
175      dir: targetDir,
176    });
177
178    step.succeed('Downloaded module template from npm');
179
180    return path.join(targetDir, 'package');
181  });
182}
183
184function handleSuffix(name: string, suffix: string): string {
185  if (name.endsWith(suffix)) {
186    return name;
187  }
188  return `${name}${suffix}`;
189}
190
191/**
192 * Creates the module based on the `ejs` template (e.g. `expo-module-template` package).
193 */
194async function createModuleFromTemplate(
195  templatePath: string,
196  targetPath: string,
197  data: SubstitutionData
198) {
199  const files = await getFilesAsync(templatePath);
200
201  // Iterate through all template files.
202  for (const file of files) {
203    const renderedRelativePath = ejs.render(file.replace(/^\$/, ''), data, {
204      openDelimiter: '{',
205      closeDelimiter: '}',
206      escape: (value: string) => value.replace(/\./g, path.sep),
207    });
208    const fromPath = path.join(templatePath, file);
209    const toPath = path.join(targetPath, renderedRelativePath);
210    const template = await fs.readFile(fromPath, { encoding: 'utf8' });
211    const renderedContent = ejs.render(template, data);
212
213    await fs.outputFile(toPath, renderedContent, { encoding: 'utf8' });
214  }
215}
216
217async function createGitRepositoryAsync(targetDir: string) {
218  // Check if we are inside a git repository already
219  try {
220    await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], {
221      stdio: 'ignore',
222      cwd: targetDir,
223    });
224    debug(chalk.dim('New project is already inside of a Git repo, skipping git init.'));
225    return null;
226  } catch (e: any) {
227    if (e.errno === 'ENOENT') {
228      debug(chalk.dim('Unable to initialize Git repo. `git` not in $PATH.'));
229      return false;
230    }
231  }
232
233  // Create a new git repository
234  await spawnAsync('git', ['init'], { stdio: 'ignore', cwd: targetDir });
235  await spawnAsync('git', ['add', '-A'], { stdio: 'ignore', cwd: targetDir });
236
237  const commitMsg = `Initial commit\n\nGenerated by ${packageJson.name} ${packageJson.version}.`;
238  await spawnAsync('git', ['commit', '-m', commitMsg], {
239    stdio: 'ignore',
240    cwd: targetDir,
241  });
242
243  debug(chalk.dim('Initialized a Git repository.'));
244  return true;
245}
246
247/**
248 * Asks the user for the package slug (npm package name).
249 */
250async function askForPackageSlugAsync(customTargetPath?: string): Promise<string> {
251  const { slug } = await prompts(getSlugPrompt(customTargetPath), {
252    onCancel: () => process.exit(0),
253  });
254  return slug;
255}
256
257/**
258 * Asks the user for some data necessary to render the template.
259 * Some values may already be provided by command options, the prompt is skipped in that case.
260 */
261async function askForSubstitutionDataAsync(slug: string): Promise<SubstitutionData> {
262  const promptQueries = await getSubstitutionDataPrompts(slug);
263
264  // Stop the process when the user cancels/exits the prompt.
265  const onCancel = () => {
266    process.exit(0);
267  };
268
269  const {
270    name,
271    description,
272    package: projectPackage,
273    authorName,
274    authorEmail,
275    authorUrl,
276    repo,
277  } = await prompts(promptQueries, { onCancel });
278
279  return {
280    project: {
281      slug,
282      name,
283      version: '0.1.0',
284      description,
285      package: projectPackage,
286      moduleName: handleSuffix(name, 'Module'),
287      viewName: handleSuffix(name, 'View'),
288    },
289    author: `${authorName} <${authorEmail}> (${authorUrl})`,
290    license: 'MIT',
291    repo,
292  };
293}
294
295/**
296 * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue.
297 */
298async function confirmTargetDirAsync(targetDir: string): Promise<void> {
299  const files = await fs.readdir(targetDir);
300
301  if (files.length === 0) {
302    return;
303  }
304  const { shouldContinue } = await prompts(
305    {
306      type: 'confirm',
307      name: 'shouldContinue',
308      message: `The target directory ${chalk.magenta(
309        targetDir
310      )} is not empty, do you want to continue anyway?`,
311      initial: true,
312    },
313    {
314      onCancel: () => false,
315    }
316  );
317  if (!shouldContinue) {
318    process.exit(0);
319  }
320}
321
322/**
323 * Prints how the user can follow up once the script finishes creating the module.
324 */
325function printFurtherInstructions(
326  targetDir: string,
327  packageManager: PackageManagerName,
328  includesExample: boolean
329) {
330  if (includesExample) {
331    const commands = [
332      `cd ${path.relative(CWD, targetDir)}`,
333      formatRunCommand(packageManager, 'open:ios'),
334      formatRunCommand(packageManager, 'open:android'),
335    ];
336
337    console.log();
338    console.log(
339      'To start developing your module, navigate to the directory and open iOS and Android projects of the example app'
340    );
341    commands.forEach((command) => console.log(chalk.gray('>'), chalk.bold(command)));
342    console.log();
343  }
344  console.log(`Visit ${chalk.blue.bold(DOCS_URL)} for the documentation on Expo Modules APIs`);
345}
346
347const program = new Command();
348
349program
350  .name(packageJson.name)
351  .version(packageJson.version)
352  .description(packageJson.description)
353  .arguments('[path]')
354  .option(
355    '-s, --source <source_dir>',
356    'Local path to the template. By default it downloads `expo-module-template` from NPM.'
357  )
358  .option('--with-readme', 'Whether to include README.md file.', false)
359  .option('--with-changelog', 'Whether to include CHANGELOG.md file.', false)
360  .option('--no-example', 'Whether to skip creating the example app.', false)
361  .action(main);
362
363program
364  .hook('postAction', async () => {
365    await getTelemetryClient().flush?.();
366  })
367  .parse(process.argv);
368