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