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('Installing module dependencies', async (step) => { 79 await installDependencies(packageManager, targetDir); 80 step.succeed('Installed module dependencies'); 81 }); 82 83 await newStep('Compiling TypeScript files', async (step) => { 84 await spawnAsync(packageManager, ['run', 'build'], { 85 cwd: targetDir, 86 stdio: 'ignore', 87 }); 88 step.succeed('Compiled TypeScript files'); 89 }); 90 91 if (!options.source) { 92 // Files in the downloaded tarball are wrapped in `package` dir. 93 // We should remove it after all. 94 await fs.remove(packagePath); 95 } 96 if (!options.withReadme) { 97 await fs.remove(path.join(targetDir, 'README.md')); 98 } 99 if (!options.withChangelog) { 100 await fs.remove(path.join(targetDir, 'CHANGELOG.md')); 101 } 102 if (options.example) { 103 // Create "example" folder 104 await createExampleApp(data, targetDir, packageManager); 105 } 106 107 await newStep('Creating an empty Git repository', async (step) => { 108 try { 109 const result = await createGitRepositoryAsync(targetDir); 110 if (result) { 111 step.succeed('Created an empty Git repository'); 112 } else if (result === null) { 113 step.succeed('Skipped creating an empty Git repository, already within a Git repository'); 114 } else if (result === false) { 115 step.warn('Could not create an empty Git repository, see debug logs with EXPO_DEBUG=true'); 116 } 117 } catch (e: any) { 118 step.fail(e.toString()); 119 } 120 }); 121 122 console.log(); 123 console.log('✅ Successfully created Expo module'); 124 125 printFurtherInstructions(targetDir, packageManager, options.example); 126} 127 128/** 129 * Recursively scans for the files within the directory. Returned paths are relative to the `root` path. 130 */ 131async function getFilesAsync(root: string, dir: string | null = null): Promise<string[]> { 132 const files: string[] = []; 133 const baseDir = dir ? path.join(root, dir) : root; 134 135 for (const file of await fs.readdir(baseDir)) { 136 const relativePath = dir ? path.join(dir, file) : file; 137 138 if (IGNORES_PATHS.includes(relativePath) || IGNORES_PATHS.includes(file)) { 139 continue; 140 } 141 142 const fullPath = path.join(baseDir, file); 143 const stat = await fs.lstat(fullPath); 144 145 if (stat.isDirectory()) { 146 files.push(...(await getFilesAsync(root, relativePath))); 147 } else { 148 files.push(relativePath); 149 } 150 } 151 return files; 152} 153 154/** 155 * Asks NPM registry for the url to the tarball. 156 */ 157async function getNpmTarballUrl(packageName: string, version: string = 'latest'): Promise<string> { 158 debug(`Using module template ${chalk.bold(packageName)}@${chalk.bold(version)}`); 159 const { stdout } = await spawnAsync('npm', ['view', `${packageName}@${version}`, 'dist.tarball']); 160 return stdout.trim(); 161} 162 163/** 164 * Downloads the template from NPM registry. 165 */ 166async function downloadPackageAsync(targetDir: string): Promise<string> { 167 return await newStep('Downloading module template from npm', async (step) => { 168 const tarballUrl = await getNpmTarballUrl( 169 'expo-module-template', 170 EXPO_BETA ? 'next' : 'latest' 171 ); 172 173 await downloadTarball({ 174 url: tarballUrl, 175 dir: targetDir, 176 }); 177 178 step.succeed('Downloaded module template from npm'); 179 180 return path.join(targetDir, 'package'); 181 }); 182} 183 184function handleSuffix(name: string, suffix: string): string { 185 if (name.endsWith(suffix)) { 186 return name; 187 } 188 return `${name}${suffix}`; 189} 190 191/** 192 * Creates the module based on the `ejs` template (e.g. `expo-module-template` package). 193 */ 194async function createModuleFromTemplate( 195 templatePath: string, 196 targetPath: string, 197 data: SubstitutionData 198) { 199 const files = await getFilesAsync(templatePath); 200 201 // Iterate through all template files. 202 for (const file of files) { 203 const renderedRelativePath = ejs.render(file.replace(/^\$/, ''), data, { 204 openDelimiter: '{', 205 closeDelimiter: '}', 206 escape: (value: string) => value.replace(/\./g, path.sep), 207 }); 208 const fromPath = path.join(templatePath, file); 209 const toPath = path.join(targetPath, renderedRelativePath); 210 const template = await fs.readFile(fromPath, { encoding: 'utf8' }); 211 const renderedContent = ejs.render(template, data); 212 213 await fs.outputFile(toPath, renderedContent, { encoding: 'utf8' }); 214 } 215} 216 217async function createGitRepositoryAsync(targetDir: string) { 218 // Check if we are inside a git repository already 219 try { 220 await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { 221 stdio: 'ignore', 222 cwd: targetDir, 223 }); 224 debug(chalk.dim('New project is already inside of a Git repo, skipping git init.')); 225 return null; 226 } catch (e: any) { 227 if (e.errno === 'ENOENT') { 228 debug(chalk.dim('Unable to initialize Git repo. `git` not in $PATH.')); 229 return false; 230 } 231 } 232 233 // Create a new git repository 234 await spawnAsync('git', ['init'], { stdio: 'ignore', cwd: targetDir }); 235 await spawnAsync('git', ['add', '-A'], { stdio: 'ignore', cwd: targetDir }); 236 237 const commitMsg = `Initial commit\n\nGenerated by ${packageJson.name} ${packageJson.version}.`; 238 await spawnAsync('git', ['commit', '-m', commitMsg], { 239 stdio: 'ignore', 240 cwd: targetDir, 241 }); 242 243 debug(chalk.dim('Initialized a Git repository.')); 244 return true; 245} 246 247/** 248 * Asks the user for the package slug (npm package name). 249 */ 250async function askForPackageSlugAsync(customTargetPath?: string): Promise<string> { 251 const { slug } = await prompts(getSlugPrompt(customTargetPath), { 252 onCancel: () => process.exit(0), 253 }); 254 return slug; 255} 256 257/** 258 * Asks the user for some data necessary to render the template. 259 * Some values may already be provided by command options, the prompt is skipped in that case. 260 */ 261async function askForSubstitutionDataAsync(slug: string): Promise<SubstitutionData> { 262 const promptQueries = await getSubstitutionDataPrompts(slug); 263 264 // Stop the process when the user cancels/exits the prompt. 265 const onCancel = () => { 266 process.exit(0); 267 }; 268 269 const { 270 name, 271 description, 272 package: projectPackage, 273 authorName, 274 authorEmail, 275 authorUrl, 276 repo, 277 } = await prompts(promptQueries, { onCancel }); 278 279 return { 280 project: { 281 slug, 282 name, 283 version: '0.1.0', 284 description, 285 package: projectPackage, 286 moduleName: handleSuffix(name, 'Module'), 287 viewName: handleSuffix(name, 'View'), 288 }, 289 author: `${authorName} <${authorEmail}> (${authorUrl})`, 290 license: 'MIT', 291 repo, 292 }; 293} 294 295/** 296 * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue. 297 */ 298async function confirmTargetDirAsync(targetDir: string): Promise<void> { 299 const files = await fs.readdir(targetDir); 300 301 if (files.length === 0) { 302 return; 303 } 304 const { shouldContinue } = await prompts( 305 { 306 type: 'confirm', 307 name: 'shouldContinue', 308 message: `The target directory ${chalk.magenta( 309 targetDir 310 )} is not empty, do you want to continue anyway?`, 311 initial: true, 312 }, 313 { 314 onCancel: () => false, 315 } 316 ); 317 if (!shouldContinue) { 318 process.exit(0); 319 } 320} 321 322/** 323 * Prints how the user can follow up once the script finishes creating the module. 324 */ 325function printFurtherInstructions( 326 targetDir: string, 327 packageManager: PackageManagerName, 328 includesExample: boolean 329) { 330 if (includesExample) { 331 const commands = [ 332 `cd ${path.relative(CWD, targetDir)}`, 333 formatRunCommand(packageManager, 'open:ios'), 334 formatRunCommand(packageManager, 'open:android'), 335 ]; 336 337 console.log(); 338 console.log( 339 'To start developing your module, navigate to the directory and open iOS and Android projects of the example app' 340 ); 341 commands.forEach((command) => console.log(chalk.gray('>'), chalk.bold(command))); 342 console.log(); 343 } 344 console.log(`Visit ${chalk.blue.bold(DOCS_URL)} for the documentation on Expo Modules APIs`); 345} 346 347const program = new Command(); 348 349program 350 .name(packageJson.name) 351 .version(packageJson.version) 352 .description(packageJson.description) 353 .arguments('[path]') 354 .option( 355 '-s, --source <source_dir>', 356 'Local path to the template. By default it downloads `expo-module-template` from NPM.' 357 ) 358 .option('--with-readme', 'Whether to include README.md file.', false) 359 .option('--with-changelog', 'Whether to include CHANGELOG.md file.', false) 360 .option('--no-example', 'Whether to skip creating the example app.', false) 361 .action(main); 362 363program 364 .hook('postAction', async () => { 365 await getTelemetryClient().flush?.(); 366 }) 367 .parse(process.argv); 368