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