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