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