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