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 { boolish } from 'getenv'; 8import path from 'path'; 9import prompts from 'prompts'; 10 11import { createExampleApp } from './createExampleApp'; 12import { installDependencies } from './packageManager'; 13import { getSlugPrompt, getSubstitutionDataPrompts } from './prompts'; 14import { resolvePackageManager } from './resolvePackageManager'; 15import { CommandOptions, SubstitutionData } from './types'; 16import { newStep } from './utils'; 17 18const debug = require('debug')('create-expo-module:main') as typeof console.log; 19const packageJson = require('../package.json'); 20 21// Opt in to using beta versions 22const EXPO_BETA = boolish('EXPO_BETA', false); 23 24// `yarn run` may change the current working dir, then we should use `INIT_CWD` env. 25const CWD = process.env.INIT_CWD || process.cwd(); 26 27// Ignore some paths. Especially `package.json` as it is rendered 28// from `$package.json` file instead of the original one. 29const IGNORES_PATHS = ['.DS_Store', 'build', 'node_modules', 'package.json']; 30 31/** 32 * The main function of the command. 33 * 34 * @param target Path to the directory where to create the module. Defaults to current working dir. 35 * @param command An object from `commander`. 36 */ 37async function main(target: string | undefined, options: CommandOptions) { 38 const slug = await askForPackageSlugAsync(target); 39 const targetDir = path.join(CWD, target || slug); 40 41 await fs.ensureDir(targetDir); 42 await confirmTargetDirAsync(targetDir); 43 44 options.target = targetDir; 45 46 const data = await askForSubstitutionDataAsync(slug); 47 48 // Make one line break between prompts and progress logs 49 console.log(); 50 51 const packageManager = await resolvePackageManager(); 52 const packagePath = options.source 53 ? path.join(CWD, options.source) 54 : await downloadPackageAsync(targetDir); 55 56 await newStep('Creating the module from template files', async (step) => { 57 await createModuleFromTemplate(packagePath, targetDir, data); 58 step.succeed('Created the module from template files'); 59 }); 60 61 await newStep('Installing module dependencies', async (step) => { 62 await installDependencies(packageManager, targetDir); 63 step.succeed('Installed module dependencies'); 64 }); 65 66 await newStep('Compiling TypeScript files', async (step) => { 67 await spawnAsync(packageManager, ['run', 'build'], { 68 cwd: targetDir, 69 stdio: 'ignore', 70 }); 71 step.succeed('Compiled TypeScript files'); 72 }); 73 74 if (!options.source) { 75 // Files in the downloaded tarball are wrapped in `package` dir. 76 // We should remove it after all. 77 await fs.remove(packagePath); 78 } 79 if (!options.withReadme) { 80 await fs.remove(path.join(targetDir, 'README.md')); 81 } 82 if (!options.withChangelog) { 83 await fs.remove(path.join(targetDir, 'CHANGELOG.md')); 84 } 85 if (options.example) { 86 // Create "example" folder 87 await createExampleApp(data, targetDir, packageManager); 88 } 89 90 console.log(); 91 console.log('✅ Successfully created Expo module'); 92} 93 94/** 95 * Recursively scans for the files within the directory. Returned paths are relative to the `root` path. 96 */ 97async function getFilesAsync(root: string, dir: string | null = null): Promise<string[]> { 98 const files: string[] = []; 99 const baseDir = dir ? path.join(root, dir) : root; 100 101 for (const file of await fs.readdir(baseDir)) { 102 const relativePath = dir ? path.join(dir, file) : file; 103 104 if (IGNORES_PATHS.includes(relativePath) || IGNORES_PATHS.includes(file)) { 105 continue; 106 } 107 108 const fullPath = path.join(baseDir, file); 109 const stat = await fs.lstat(fullPath); 110 111 if (stat.isDirectory()) { 112 files.push(...(await getFilesAsync(root, relativePath))); 113 } else { 114 files.push(relativePath); 115 } 116 } 117 return files; 118} 119 120/** 121 * Asks NPM registry for the url to the tarball. 122 */ 123async function getNpmTarballUrl(packageName: string, version: string = 'latest'): Promise<string> { 124 debug(`Using module template ${chalk.bold(packageName)}@${chalk.bold(version)}`); 125 const { stdout } = await spawnAsync('npm', ['view', `${packageName}@${version}`, 'dist.tarball']); 126 return stdout.trim(); 127} 128 129/** 130 * Downloads the template from NPM registry. 131 */ 132async function downloadPackageAsync(targetDir: string): Promise<string> { 133 return await newStep('Downloading module template from npm', async (step) => { 134 const tarballUrl = await getNpmTarballUrl( 135 'expo-module-template', 136 EXPO_BETA ? 'next' : 'latest' 137 ); 138 139 await downloadTarball({ 140 url: tarballUrl, 141 dir: targetDir, 142 }); 143 144 step.succeed('Downloaded module template from npm'); 145 146 return path.join(targetDir, 'package'); 147 }); 148} 149 150/** 151 * Creates the module based on the `ejs` template (e.g. `expo-module-template` package). 152 */ 153async function createModuleFromTemplate( 154 templatePath: string, 155 targetPath: string, 156 data: SubstitutionData 157) { 158 const files = await getFilesAsync(templatePath); 159 160 // Iterate through all template files. 161 for (const file of files) { 162 const renderedRelativePath = ejs.render(file.replace(/^\$/, ''), data, { 163 openDelimiter: '{', 164 closeDelimiter: '}', 165 escape: (value: string) => value.replace('.', path.sep), 166 }); 167 const fromPath = path.join(templatePath, file); 168 const toPath = path.join(targetPath, renderedRelativePath); 169 const template = await fs.readFile(fromPath, { encoding: 'utf8' }); 170 const renderedContent = ejs.render(template, data); 171 172 await fs.outputFile(toPath, renderedContent, { encoding: 'utf8' }); 173 } 174} 175 176/** 177 * Asks the user for the package slug (npm package name). 178 */ 179async function askForPackageSlugAsync(customTargetPath?: string): Promise<string> { 180 const { slug } = await prompts(getSlugPrompt(customTargetPath), { 181 onCancel: () => process.exit(0), 182 }); 183 return slug; 184} 185 186/** 187 * Asks the user for some data necessary to render the template. 188 * Some values may already be provided by command options, the prompt is skipped in that case. 189 */ 190async function askForSubstitutionDataAsync(slug: string): Promise<SubstitutionData> { 191 const promptQueries = await getSubstitutionDataPrompts(slug); 192 193 // Stop the process when the user cancels/exits the prompt. 194 const onCancel = () => { 195 process.exit(0); 196 }; 197 198 const { 199 name, 200 description, 201 package: projectPackage, 202 authorName, 203 authorEmail, 204 authorUrl, 205 repo, 206 } = await prompts(promptQueries, { onCancel }); 207 208 return { 209 project: { 210 slug, 211 name, 212 version: '0.1.0', 213 description, 214 package: projectPackage, 215 }, 216 author: `${authorName} <${authorEmail}> (${authorUrl})`, 217 license: 'MIT', 218 repo, 219 }; 220} 221 222/** 223 * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue. 224 */ 225async function confirmTargetDirAsync(targetDir: string): Promise<void> { 226 const files = await fs.readdir(targetDir); 227 228 if (files.length === 0) { 229 return; 230 } 231 const { shouldContinue } = await prompts( 232 { 233 type: 'confirm', 234 name: 'shouldContinue', 235 message: `The target directory ${chalk.magenta( 236 targetDir 237 )} is not empty, do you want to continue anyway?`, 238 initial: true, 239 }, 240 { 241 onCancel: () => false, 242 } 243 ); 244 if (!shouldContinue) { 245 process.exit(0); 246 } 247} 248 249const program = new Command(); 250 251program 252 .name(packageJson.name) 253 .version(packageJson.version) 254 .description(packageJson.description) 255 .arguments('[path]') 256 .option( 257 '-s, --source <source_dir>', 258 'Local path to the template. By default it downloads `expo-module-template` from NPM.' 259 ) 260 .option('--with-readme', 'Whether to include README.md file.', false) 261 .option('--with-changelog', 'Whether to include CHANGELOG.md file.', false) 262 .option('--no-example', 'Whether to skip creating the example app.', false) 263 .action(main); 264 265program.parse(process.argv); 266