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 path from 'path';
8import prompts from 'prompts';
9import validateNpmPackage from 'validate-npm-package-name';
10
11import { createExampleApp } from './createExampleApp';
12import { installDependencies } from './packageManager';
13import { PackageManagerName, resolvePackageManager } from './resolvePackageManager';
14import { CommandOptions, CustomPromptObject, SubstitutionData } from './types';
15
16const packageJson = require('../package.json');
17
18// `yarn run` may change the current working dir, then we should use `INIT_CWD` env.
19const CWD = process.env.INIT_CWD || process.cwd();
20
21// Ignore some paths. Especially `package.json` as it is rendered
22// from `$package.json` file instead of the original one.
23const IGNORES_PATHS = ['.DS_Store', 'build', 'node_modules', 'package.json'];
24
25/**
26 * The main function of the command.
27 *
28 * @param target Path to the directory where to create the module. Defaults to current working dir.
29 * @param command An object from `commander`.
30 */
31async function main(target: string | undefined, options: CommandOptions) {
32  const targetDir = target ? path.join(CWD, target) : CWD;
33
34  await fs.ensureDir(targetDir);
35  await confirmTargetDirAsync(targetDir);
36
37  options.target = targetDir;
38
39  const data = await askForSubstitutionDataAsync(targetDir, options);
40  const packageManager = await resolvePackageManager();
41  const packagePath = options.source
42    ? path.join(CWD, options.source)
43    : await downloadPackageAsync(targetDir);
44  const files = await getFilesAsync(packagePath);
45
46  console.log('�� Creating Expo module from the template files...');
47
48  // Iterate through all template files.
49  for (const file of files) {
50    const renderedRelativePath = ejs.render(file.replace(/^\$/, ''), data, {
51      openDelimiter: '{',
52      closeDelimiter: '}',
53      escape: (value: string) => value.replace('.', path.sep),
54    });
55    const fromPath = path.join(packagePath, file);
56    const toPath = path.join(targetDir, renderedRelativePath);
57    const template = await fs.readFile(fromPath, { encoding: 'utf8' });
58    const renderedContent = ejs.render(template, data);
59
60    await fs.outputFile(toPath, renderedContent, { encoding: 'utf8' });
61  }
62
63  // Install dependencies and build
64  await postActionsAsync(packageManager, targetDir);
65
66  if (!options.source) {
67    // Files in the downloaded tarball are wrapped in `package` dir.
68    // We should remove it after all.
69    await fs.remove(packagePath);
70  }
71  if (!options.withReadme) {
72    await fs.remove(path.join(targetDir, 'README.md'));
73  }
74  if (!options.withChangelog) {
75    await fs.remove(path.join(targetDir, 'CHANGELOG.md'));
76  }
77  if (options.example) {
78    // Create "example" folder
79    await createExampleApp(data, targetDir, packageManager);
80  }
81
82  console.log('✅ Successfully created Expo module');
83}
84
85/**
86 * Recursively scans for the files within the directory. Returned paths are relative to the `root` path.
87 */
88async function getFilesAsync(root: string, dir: string | null = null): Promise<string[]> {
89  const files: string[] = [];
90  const baseDir = dir ? path.join(root, dir) : root;
91
92  for (const file of await fs.readdir(baseDir)) {
93    const relativePath = dir ? path.join(dir, file) : file;
94
95    if (IGNORES_PATHS.includes(relativePath) || IGNORES_PATHS.includes(file)) {
96      continue;
97    }
98
99    const fullPath = path.join(baseDir, file);
100    const stat = await fs.lstat(fullPath);
101
102    if (stat.isDirectory()) {
103      files.push(...(await getFilesAsync(root, relativePath)));
104    } else {
105      files.push(relativePath);
106    }
107  }
108  return files;
109}
110
111/**
112 * Asks NPM registry for the url to the tarball.
113 */
114async function getNpmTarballUrl(packageName: string, version: string = 'latest'): Promise<string> {
115  const { stdout } = await spawnAsync('npm', ['view', `${packageName}@${version}`, 'dist.tarball']);
116  return stdout.trim();
117}
118
119/**
120 * Gets the username of currently logged in user. Used as a default in the prompt asking for the module author.
121 */
122async function npmWhoamiAsync(targetDir: string): Promise<string | null> {
123  try {
124    const { stdout } = await spawnAsync('npm', ['whoami'], { cwd: targetDir });
125    return stdout.trim();
126  } catch {
127    return null;
128  }
129}
130
131/**
132 * Downloads the template from NPM registry.
133 */
134async function downloadPackageAsync(targetDir: string): Promise<string> {
135  const tarballUrl = await getNpmTarballUrl('expo-module-template');
136
137  console.log('⬇️  Downloading module template from npm...');
138
139  await downloadTarball({
140    url: tarballUrl,
141    dir: targetDir,
142  });
143  return path.join(targetDir, 'package');
144}
145
146/**
147 * Installs dependencies and builds TypeScript files.
148 */
149async function postActionsAsync(packageManager: PackageManagerName, targetDir: string) {
150  console.log('�� Installing module dependencies...');
151  await installDependencies(packageManager, targetDir);
152
153  console.log('��  Compiling TypeScript files...');
154  await spawnAsync(packageManager, ['run', 'build'], {
155    cwd: targetDir,
156    stdio: 'ignore',
157  });
158}
159
160/**
161 * Asks the user for some data necessary to render the template.
162 * Some values may already be provided by command options, the prompt is skipped in that case.
163 */
164async function askForSubstitutionDataAsync(
165  targetDir: string,
166  options: CommandOptions
167): Promise<SubstitutionData> {
168  const defaultPackageSlug = path.basename(targetDir);
169  const useDefaultSlug = options.target && validateNpmPackage(defaultPackageSlug);
170  const defaultProjectName = defaultPackageSlug
171    .replace(/^./, (match) => match.toUpperCase())
172    .replace(/\W+(\w)/g, (_, p1) => p1.toUpperCase());
173
174  const promptQueries: CustomPromptObject[] = [
175    {
176      type: 'text',
177      name: 'slug',
178      message: 'What is the package slug?',
179      initial: defaultPackageSlug,
180      resolvedValue: useDefaultSlug ? defaultPackageSlug : null,
181      validate: (input) =>
182        validateNpmPackage(input).validForNewPackages || 'Must be a valid npm package name',
183    },
184    {
185      type: 'text',
186      name: 'name',
187      message: 'What is the project name?',
188      initial: defaultProjectName,
189    },
190    {
191      type: 'text',
192      name: 'description',
193      message: 'How would you describe the module?',
194      validate: (input) => !!input || 'Cannot be empty',
195    },
196    {
197      type: 'text',
198      name: 'package',
199      message: 'What is the Android package name?',
200      initial: `expo.modules.${defaultPackageSlug.replace(/\W/g, '').toLowerCase()}`,
201    },
202    {
203      type: 'text',
204      name: 'author',
205      message: 'Who is the author?',
206      initial: (await npmWhoamiAsync(targetDir)) ?? '',
207    },
208    {
209      type: 'text',
210      name: 'license',
211      message: 'What is the license?',
212      initial: 'MIT',
213    },
214    {
215      type: 'text',
216      name: 'repo',
217      message: 'What is the repository URL?',
218      validate: (input) => /^https?:\/\//.test(input) || 'Must be a valid URL',
219    },
220  ];
221
222  // Stop the process when the user cancels/exits the prompt.
223  const onCancel = () => {
224    process.exit(0);
225  };
226
227  const answers: Record<string, string> = {};
228  for (const query of promptQueries) {
229    const { name, resolvedValue } = query;
230    answers[name] = resolvedValue ?? options[name] ?? (await prompts(query, { onCancel }))[name];
231  }
232
233  const { slug, name, description, package: projectPackage, author, license, repo } = answers;
234
235  return {
236    project: {
237      slug,
238      name,
239      version: '0.1.0',
240      description,
241      package: projectPackage,
242    },
243    author,
244    license,
245    repo,
246  };
247}
248
249/**
250 * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue.
251 */
252async function confirmTargetDirAsync(targetDir: string): Promise<void> {
253  const files = await fs.readdir(targetDir);
254
255  if (files.length === 0) {
256    return;
257  }
258  const { shouldContinue } = await prompts(
259    {
260      type: 'confirm',
261      name: 'shouldContinue',
262      message: `The target directory ${chalk.magenta(
263        targetDir
264      )} is not empty.\nDo you want to continue anyway?`,
265      initial: true,
266    },
267    {
268      onCancel: () => false,
269    }
270  );
271  if (!shouldContinue) {
272    process.exit(0);
273  }
274}
275
276const program = new Command();
277
278program
279  .name(packageJson.name)
280  .version(packageJson.version)
281  .description(packageJson.description)
282  .arguments('[target_dir]')
283  .option(
284    '-s, --source <source_dir>',
285    'Local path to the template. By default it downloads `expo-module-template` from NPM.'
286  )
287  .option('-n, --name <module_name>', 'Name of the native module.')
288  .option('-d, --description <description>', 'Description of the module.')
289  .option('-p, --package <package>', 'The Android package name.')
290  .option('-a, --author <author>', 'The author name.')
291  .option('-l, --license <license>', 'The license that the module is distributed with.')
292  .option('-r, --repo <repo_url>', 'The URL to the repository.')
293  .option('--with-readme', 'Whether to include README.md file.', false)
294  .option('--with-changelog', 'Whether to include CHANGELOG.md file.', false)
295  .option('--no-example', 'Whether to skip creating the example app.', false)
296  .action(main);
297
298program.parse(process.argv);
299