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