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