1import spawnAsync from '@expo/spawn-async';
2import chalk from 'chalk';
3import { Command } from 'commander';
4import downloadTarball from 'download-tarball';
5import ejs from 'ejs';
6import findUp from 'find-up';
7import fs from 'fs-extra';
8import { boolish } from 'getenv';
9import path from 'path';
10import prompts from 'prompts';
11
12import { createExampleApp } from './createExampleApp';
13import { installDependencies } from './packageManager';
14import {
15  getLocalFolderNamePrompt,
16  getLocalSubstitutionDataPrompts,
17  getSlugPrompt,
18  getSubstitutionDataPrompts,
19} from './prompts';
20import {
21  formatRunCommand,
22  PackageManagerName,
23  resolvePackageManager,
24} from './resolvePackageManager';
25import { eventCreateExpoModule, getTelemetryClient, logEventAsync } from './telemetry';
26import { CommandOptions, LocalSubstitutionData, SubstitutionData } from './types';
27import { newStep } from './utils';
28
29const debug = require('debug')('create-expo-module:main') as typeof console.log;
30const packageJson = require('../package.json');
31
32// Opt in to using beta versions
33const EXPO_BETA = boolish('EXPO_BETA', false);
34
35// `yarn run` may change the current working dir, then we should use `INIT_CWD` env.
36const CWD = process.env.INIT_CWD || process.cwd();
37
38// Ignore some paths. Especially `package.json` as it is rendered
39// from `$package.json` file instead of the original one.
40const IGNORES_PATHS = [
41  '.DS_Store',
42  'build',
43  'node_modules',
44  'package.json',
45  '.npmignore',
46  '.gitignore',
47];
48
49// Url to the documentation on Expo Modules
50const DOCS_URL = 'https://docs.expo.dev/modules';
51
52async function getCorrectLocalDirectory(targetOrSlug: string) {
53  const packageJsonPath = await findUp('package.json', { cwd: CWD });
54  if (!packageJsonPath) {
55    console.log(
56      chalk.red.bold(
57        '⚠️ This command should  be run inside your Expo project when run with the --local flag.'
58      )
59    );
60    console.log(
61      chalk.red(
62        'For native modules to autolink correctly, you need to place them in the `modules` directory in the root of the project.'
63      )
64    );
65    return null;
66  }
67  return path.join(packageJsonPath, '..', 'modules', targetOrSlug);
68}
69
70/**
71 * The main function of the command.
72 *
73 * @param target Path to the directory where to create the module. Defaults to current working dir.
74 * @param command An object from `commander`.
75 */
76async function main(target: string | undefined, options: CommandOptions) {
77  const slug = await askForPackageSlugAsync(target, options.local);
78  const targetDir = options.local
79    ? await getCorrectLocalDirectory(target || slug)
80    : path.join(CWD, target || slug);
81
82  if (!targetDir) {
83    return;
84  }
85  await fs.ensureDir(targetDir);
86  await confirmTargetDirAsync(targetDir);
87
88  options.target = targetDir;
89
90  const data = await askForSubstitutionDataAsync(slug, options.local);
91
92  // Make one line break between prompts and progress logs
93  console.log();
94
95  const packageManager = await resolvePackageManager();
96  const packagePath = options.source
97    ? path.join(CWD, options.source)
98    : await downloadPackageAsync(targetDir, options.local);
99
100  logEventAsync(eventCreateExpoModule(packageManager, options));
101
102  await newStep('Creating the module from template files', async (step) => {
103    await createModuleFromTemplate(packagePath, targetDir, data);
104    step.succeed('Created the module from template files');
105  });
106  if (!options.local) {
107    await newStep('Installing module dependencies', async (step) => {
108      await installDependencies(packageManager, targetDir);
109      step.succeed('Installed module dependencies');
110    });
111    await newStep('Compiling TypeScript files', async (step) => {
112      await spawnAsync(packageManager, ['run', 'build'], {
113        cwd: targetDir,
114        stdio: 'ignore',
115      });
116      step.succeed('Compiled TypeScript files');
117    });
118  }
119
120  if (!options.source) {
121    // Files in the downloaded tarball are wrapped in `package` dir.
122    // We should remove it after all.
123    await fs.remove(packagePath);
124  }
125  if (!options.local && data.type !== 'local') {
126    if (!options.withReadme) {
127      await fs.remove(path.join(targetDir, 'README.md'));
128    }
129    if (!options.withChangelog) {
130      await fs.remove(path.join(targetDir, 'CHANGELOG.md'));
131    }
132    if (options.example) {
133      // Create "example" folder
134      await createExampleApp(data, targetDir, packageManager);
135    }
136
137    await newStep('Creating an empty Git repository', async (step) => {
138      try {
139        const result = await createGitRepositoryAsync(targetDir);
140        if (result) {
141          step.succeed('Created an empty Git repository');
142        } else if (result === null) {
143          step.succeed('Skipped creating an empty Git repository, already within a Git repository');
144        } else if (result === false) {
145          step.warn(
146            'Could not create an empty Git repository, see debug logs with EXPO_DEBUG=true'
147          );
148        }
149      } catch (e: any) {
150        step.fail(e.toString());
151      }
152    });
153  }
154
155  console.log();
156  console.log('✅ Successfully created Expo module');
157  if (options.local) {
158    printFurtherLocalInstructions(slug, data.project.moduleName);
159  } else {
160    printFurtherInstructions(targetDir, packageManager, options.example);
161  }
162}
163
164/**
165 * Recursively scans for the files within the directory. Returned paths are relative to the `root` path.
166 */
167async function getFilesAsync(root: string, dir: string | null = null): Promise<string[]> {
168  const files: string[] = [];
169  const baseDir = dir ? path.join(root, dir) : root;
170
171  for (const file of await fs.readdir(baseDir)) {
172    const relativePath = dir ? path.join(dir, file) : file;
173
174    if (IGNORES_PATHS.includes(relativePath) || IGNORES_PATHS.includes(file)) {
175      continue;
176    }
177
178    const fullPath = path.join(baseDir, file);
179    const stat = await fs.lstat(fullPath);
180
181    if (stat.isDirectory()) {
182      files.push(...(await getFilesAsync(root, relativePath)));
183    } else {
184      files.push(relativePath);
185    }
186  }
187  return files;
188}
189
190/**
191 * Asks NPM registry for the url to the tarball.
192 */
193async function getNpmTarballUrl(packageName: string, version: string = 'latest'): Promise<string> {
194  debug(`Using module template ${chalk.bold(packageName)}@${chalk.bold(version)}`);
195  const { stdout } = await spawnAsync('npm', ['view', `${packageName}@${version}`, 'dist.tarball']);
196  return stdout.trim();
197}
198
199/**
200 * Downloads the template from NPM registry.
201 */
202async function downloadPackageAsync(targetDir: string, isLocal = false): Promise<string> {
203  return await newStep('Downloading module template from npm', async (step) => {
204    const tarballUrl = await getNpmTarballUrl(
205      isLocal ? 'expo-module-template-local' : 'expo-module-template',
206      EXPO_BETA ? 'next' : 'latest'
207    );
208
209    await downloadTarball({
210      url: tarballUrl,
211      dir: targetDir,
212    });
213
214    step.succeed('Downloaded module template from npm');
215
216    return path.join(targetDir, 'package');
217  });
218}
219
220function handleSuffix(name: string, suffix: string): string {
221  if (name.endsWith(suffix)) {
222    return name;
223  }
224  return `${name}${suffix}`;
225}
226
227/**
228 * Creates the module based on the `ejs` template (e.g. `expo-module-template` package).
229 */
230async function createModuleFromTemplate(
231  templatePath: string,
232  targetPath: string,
233  data: SubstitutionData | LocalSubstitutionData
234) {
235  const files = await getFilesAsync(templatePath);
236
237  // Iterate through all template files.
238  for (const file of files) {
239    const renderedRelativePath = ejs.render(file.replace(/^\$/, ''), data, {
240      openDelimiter: '{',
241      closeDelimiter: '}',
242      escape: (value: string) => value.replace(/\./g, path.sep),
243    });
244    const fromPath = path.join(templatePath, file);
245    const toPath = path.join(targetPath, renderedRelativePath);
246    const template = await fs.readFile(fromPath, { encoding: 'utf8' });
247    const renderedContent = ejs.render(template, data);
248
249    await fs.outputFile(toPath, renderedContent, { encoding: 'utf8' });
250  }
251}
252
253async function createGitRepositoryAsync(targetDir: string) {
254  // Check if we are inside a git repository already
255  try {
256    await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], {
257      stdio: 'ignore',
258      cwd: targetDir,
259    });
260    debug(chalk.dim('New project is already inside of a Git repo, skipping git init.'));
261    return null;
262  } catch (e: any) {
263    if (e.errno === 'ENOENT') {
264      debug(chalk.dim('Unable to initialize Git repo. `git` not in $PATH.'));
265      return false;
266    }
267  }
268
269  // Create a new git repository
270  await spawnAsync('git', ['init'], { stdio: 'ignore', cwd: targetDir });
271  await spawnAsync('git', ['add', '-A'], { stdio: 'ignore', cwd: targetDir });
272
273  const commitMsg = `Initial commit\n\nGenerated by ${packageJson.name} ${packageJson.version}.`;
274  await spawnAsync('git', ['commit', '-m', commitMsg], {
275    stdio: 'ignore',
276    cwd: targetDir,
277  });
278
279  debug(chalk.dim('Initialized a Git repository.'));
280  return true;
281}
282
283/**
284 * Asks the user for the package slug (npm package name).
285 */
286async function askForPackageSlugAsync(customTargetPath?: string, isLocal = false): Promise<string> {
287  const { slug } = await prompts(
288    (isLocal ? getLocalFolderNamePrompt : getSlugPrompt)(customTargetPath),
289    {
290      onCancel: () => process.exit(0),
291    }
292  );
293  return slug;
294}
295
296/**
297 * Asks the user for some data necessary to render the template.
298 * Some values may already be provided by command options, the prompt is skipped in that case.
299 */
300async function askForSubstitutionDataAsync(
301  slug: string,
302  isLocal = false
303): Promise<SubstitutionData | LocalSubstitutionData> {
304  const promptQueries = await (isLocal
305    ? getLocalSubstitutionDataPrompts
306    : getSubstitutionDataPrompts)(slug);
307
308  // Stop the process when the user cancels/exits the prompt.
309  const onCancel = () => {
310    process.exit(0);
311  };
312
313  const {
314    name,
315    description,
316    package: projectPackage,
317    authorName,
318    authorEmail,
319    authorUrl,
320    repo,
321  } = await prompts(promptQueries, { onCancel });
322
323  if (isLocal) {
324    return {
325      project: {
326        slug,
327        name,
328        package: projectPackage,
329        moduleName: handleSuffix(name, 'Module'),
330        viewName: handleSuffix(name, 'View'),
331      },
332      type: 'local',
333    };
334  }
335
336  return {
337    project: {
338      slug,
339      name,
340      version: '0.1.0',
341      description,
342      package: projectPackage,
343      moduleName: handleSuffix(name, 'Module'),
344      viewName: handleSuffix(name, 'View'),
345    },
346    author: `${authorName} <${authorEmail}> (${authorUrl})`,
347    license: 'MIT',
348    repo,
349    type: 'remote',
350  };
351}
352
353/**
354 * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue.
355 */
356async function confirmTargetDirAsync(targetDir: string): Promise<void> {
357  const files = await fs.readdir(targetDir);
358
359  if (files.length === 0) {
360    return;
361  }
362  const { shouldContinue } = await prompts(
363    {
364      type: 'confirm',
365      name: 'shouldContinue',
366      message: `The target directory ${chalk.magenta(
367        targetDir
368      )} is not empty, do you want to continue anyway?`,
369      initial: true,
370    },
371    {
372      onCancel: () => false,
373    }
374  );
375  if (!shouldContinue) {
376    process.exit(0);
377  }
378}
379
380/**
381 * Prints how the user can follow up once the script finishes creating the module.
382 */
383function printFurtherInstructions(
384  targetDir: string,
385  packageManager: PackageManagerName,
386  includesExample: boolean
387) {
388  if (includesExample) {
389    const commands = [
390      `cd ${path.relative(CWD, targetDir)}`,
391      formatRunCommand(packageManager, 'open:ios'),
392      formatRunCommand(packageManager, 'open:android'),
393    ];
394
395    console.log();
396    console.log(
397      'To start developing your module, navigate to the directory and open iOS and Android projects of the example app'
398    );
399    commands.forEach((command) => console.log(chalk.gray('>'), chalk.bold(command)));
400    console.log();
401  }
402  console.log(`Visit ${chalk.blue.bold(DOCS_URL)} for the documentation on Expo Modules APIs`);
403}
404
405function printFurtherLocalInstructions(slug: string, name: string) {
406  console.log(`You can now import this module inside your application:`);
407  console.log();
408  console.log(chalk.blue(`import { hello } from '${slug}';`));
409  console.log();
410  console.log(`Visit ${chalk.blue.bold(DOCS_URL)} for the documentation on Expo Modules APIs`);
411  console.log(
412    chalk.yellow(
413      `Remember you need to rebuild your development client or reinstall pods to see the changes.`
414    )
415  );
416}
417
418const program = new Command();
419
420program
421  .name(packageJson.name)
422  .version(packageJson.version)
423  .description(packageJson.description)
424  .arguments('[path]')
425  .option(
426    '-s, --source <source_dir>',
427    'Local path to the template. By default it downloads `expo-module-template` from NPM.'
428  )
429  .option('--with-readme', 'Whether to include README.md file.', false)
430  .option('--with-changelog', 'Whether to include CHANGELOG.md file.', false)
431  .option('--no-example', 'Whether to skip creating the example app.', false)
432  .option(
433    '--local',
434    'Whether to create a local module in the current project, skipping installing node_modules and creating the example directory.',
435    false
436  )
437  .action(main);
438
439program
440  .hook('postAction', async () => {
441    await getTelemetryClient().flush?.();
442  })
443  .parse(process.argv);
444