1import spawnAsync from '@expo/spawn-async'; 2import chalk from 'chalk'; 3import { Command } from 'commander'; 4import downloadTarball from 'download-tarball'; 5import ejs from 'ejs'; 6import findUp from 'find-up'; 7import fs from 'fs-extra'; 8import { boolish } from 'getenv'; 9import path from 'path'; 10import prompts from 'prompts'; 11 12import { createExampleApp } from './createExampleApp'; 13import { installDependencies } from './packageManager'; 14import { 15 getLocalFolderNamePrompt, 16 getLocalSubstitutionDataPrompts, 17 getSlugPrompt, 18 getSubstitutionDataPrompts, 19} from './prompts'; 20import { 21 formatRunCommand, 22 PackageManagerName, 23 resolvePackageManager, 24} from './resolvePackageManager'; 25import { eventCreateExpoModule, getTelemetryClient, logEventAsync } from './telemetry'; 26import { CommandOptions, LocalSubstitutionData, SubstitutionData } from './types'; 27import { newStep } from './utils'; 28 29const debug = require('debug')('create-expo-module:main') as typeof console.log; 30const packageJson = require('../package.json'); 31 32// Opt in to using beta versions 33const EXPO_BETA = boolish('EXPO_BETA', false); 34 35// `yarn run` may change the current working dir, then we should use `INIT_CWD` env. 36const CWD = process.env.INIT_CWD || process.cwd(); 37 38// Ignore some paths. Especially `package.json` as it is rendered 39// from `$package.json` file instead of the original one. 40const IGNORES_PATHS = [ 41 '.DS_Store', 42 'build', 43 'node_modules', 44 'package.json', 45 '.npmignore', 46 '.gitignore', 47]; 48 49// Url to the documentation on Expo Modules 50const DOCS_URL = 'https://docs.expo.dev/modules'; 51 52async function getCorrectLocalDirectory(targetOrSlug: string) { 53 const packageJsonPath = await findUp('package.json', { cwd: CWD }); 54 if (!packageJsonPath) { 55 console.log( 56 chalk.red.bold( 57 '⚠️ This command should be run inside your Expo project when run with the --local flag.' 58 ) 59 ); 60 console.log( 61 chalk.red( 62 'For native modules to autolink correctly, you need to place them in the `modules` directory in the root of the project.' 63 ) 64 ); 65 return null; 66 } 67 return path.join(packageJsonPath, '..', 'modules', targetOrSlug); 68} 69 70/** 71 * The main function of the command. 72 * 73 * @param target Path to the directory where to create the module. Defaults to current working dir. 74 * @param command An object from `commander`. 75 */ 76async function main(target: string | undefined, options: CommandOptions) { 77 const slug = await askForPackageSlugAsync(target, options.local); 78 const targetDir = options.local 79 ? await getCorrectLocalDirectory(target || slug) 80 : path.join(CWD, target || slug); 81 82 if (!targetDir) { 83 return; 84 } 85 await fs.ensureDir(targetDir); 86 await confirmTargetDirAsync(targetDir); 87 88 options.target = targetDir; 89 90 const data = await askForSubstitutionDataAsync(slug, options.local); 91 92 // Make one line break between prompts and progress logs 93 console.log(); 94 95 const packageManager = await resolvePackageManager(); 96 const packagePath = options.source 97 ? path.join(CWD, options.source) 98 : await downloadPackageAsync(targetDir, options.local); 99 100 logEventAsync(eventCreateExpoModule(packageManager, options)); 101 102 await newStep('Creating the module from template files', async (step) => { 103 await createModuleFromTemplate(packagePath, targetDir, data); 104 step.succeed('Created the module from template files'); 105 }); 106 if (!options.local) { 107 await newStep('Installing module dependencies', async (step) => { 108 await installDependencies(packageManager, targetDir); 109 step.succeed('Installed module dependencies'); 110 }); 111 await newStep('Compiling TypeScript files', async (step) => { 112 await spawnAsync(packageManager, ['run', 'build'], { 113 cwd: targetDir, 114 stdio: 'ignore', 115 }); 116 step.succeed('Compiled TypeScript files'); 117 }); 118 } 119 120 if (!options.source) { 121 // Files in the downloaded tarball are wrapped in `package` dir. 122 // We should remove it after all. 123 await fs.remove(packagePath); 124 } 125 if (!options.local && data.type !== 'local') { 126 if (!options.withReadme) { 127 await fs.remove(path.join(targetDir, 'README.md')); 128 } 129 if (!options.withChangelog) { 130 await fs.remove(path.join(targetDir, 'CHANGELOG.md')); 131 } 132 if (options.example) { 133 // Create "example" folder 134 await createExampleApp(data, targetDir, packageManager); 135 } 136 137 await newStep('Creating an empty Git repository', async (step) => { 138 try { 139 const result = await createGitRepositoryAsync(targetDir); 140 if (result) { 141 step.succeed('Created an empty Git repository'); 142 } else if (result === null) { 143 step.succeed('Skipped creating an empty Git repository, already within a Git repository'); 144 } else if (result === false) { 145 step.warn( 146 'Could not create an empty Git repository, see debug logs with EXPO_DEBUG=true' 147 ); 148 } 149 } catch (e: any) { 150 step.fail(e.toString()); 151 } 152 }); 153 } 154 155 console.log(); 156 console.log('✅ Successfully created Expo module'); 157 if (options.local) { 158 printFurtherLocalInstructions(slug, data.project.moduleName); 159 } else { 160 printFurtherInstructions(targetDir, packageManager, options.example); 161 } 162} 163 164/** 165 * Recursively scans for the files within the directory. Returned paths are relative to the `root` path. 166 */ 167async function getFilesAsync(root: string, dir: string | null = null): Promise<string[]> { 168 const files: string[] = []; 169 const baseDir = dir ? path.join(root, dir) : root; 170 171 for (const file of await fs.readdir(baseDir)) { 172 const relativePath = dir ? path.join(dir, file) : file; 173 174 if (IGNORES_PATHS.includes(relativePath) || IGNORES_PATHS.includes(file)) { 175 continue; 176 } 177 178 const fullPath = path.join(baseDir, file); 179 const stat = await fs.lstat(fullPath); 180 181 if (stat.isDirectory()) { 182 files.push(...(await getFilesAsync(root, relativePath))); 183 } else { 184 files.push(relativePath); 185 } 186 } 187 return files; 188} 189 190/** 191 * Asks NPM registry for the url to the tarball. 192 */ 193async function getNpmTarballUrl(packageName: string, version: string = 'latest'): Promise<string> { 194 debug(`Using module template ${chalk.bold(packageName)}@${chalk.bold(version)}`); 195 const { stdout } = await spawnAsync('npm', ['view', `${packageName}@${version}`, 'dist.tarball']); 196 return stdout.trim(); 197} 198 199/** 200 * Downloads the template from NPM registry. 201 */ 202async function downloadPackageAsync(targetDir: string, isLocal = false): Promise<string> { 203 return await newStep('Downloading module template from npm', async (step) => { 204 const tarballUrl = await getNpmTarballUrl( 205 isLocal ? 'expo-module-template-local' : 'expo-module-template', 206 EXPO_BETA ? 'next' : 'latest' 207 ); 208 209 await downloadTarball({ 210 url: tarballUrl, 211 dir: targetDir, 212 }); 213 214 step.succeed('Downloaded module template from npm'); 215 216 return path.join(targetDir, 'package'); 217 }); 218} 219 220function handleSuffix(name: string, suffix: string): string { 221 if (name.endsWith(suffix)) { 222 return name; 223 } 224 return `${name}${suffix}`; 225} 226 227/** 228 * Creates the module based on the `ejs` template (e.g. `expo-module-template` package). 229 */ 230async function createModuleFromTemplate( 231 templatePath: string, 232 targetPath: string, 233 data: SubstitutionData | LocalSubstitutionData 234) { 235 const files = await getFilesAsync(templatePath); 236 237 // Iterate through all template files. 238 for (const file of files) { 239 const renderedRelativePath = ejs.render(file.replace(/^\$/, ''), data, { 240 openDelimiter: '{', 241 closeDelimiter: '}', 242 escape: (value: string) => value.replace(/\./g, path.sep), 243 }); 244 const fromPath = path.join(templatePath, file); 245 const toPath = path.join(targetPath, renderedRelativePath); 246 const template = await fs.readFile(fromPath, { encoding: 'utf8' }); 247 const renderedContent = ejs.render(template, data); 248 249 await fs.outputFile(toPath, renderedContent, { encoding: 'utf8' }); 250 } 251} 252 253async function createGitRepositoryAsync(targetDir: string) { 254 // Check if we are inside a git repository already 255 try { 256 await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { 257 stdio: 'ignore', 258 cwd: targetDir, 259 }); 260 debug(chalk.dim('New project is already inside of a Git repo, skipping git init.')); 261 return null; 262 } catch (e: any) { 263 if (e.errno === 'ENOENT') { 264 debug(chalk.dim('Unable to initialize Git repo. `git` not in $PATH.')); 265 return false; 266 } 267 } 268 269 // Create a new git repository 270 await spawnAsync('git', ['init'], { stdio: 'ignore', cwd: targetDir }); 271 await spawnAsync('git', ['add', '-A'], { stdio: 'ignore', cwd: targetDir }); 272 273 const commitMsg = `Initial commit\n\nGenerated by ${packageJson.name} ${packageJson.version}.`; 274 await spawnAsync('git', ['commit', '-m', commitMsg], { 275 stdio: 'ignore', 276 cwd: targetDir, 277 }); 278 279 debug(chalk.dim('Initialized a Git repository.')); 280 return true; 281} 282 283/** 284 * Asks the user for the package slug (npm package name). 285 */ 286async function askForPackageSlugAsync(customTargetPath?: string, isLocal = false): Promise<string> { 287 const { slug } = await prompts( 288 (isLocal ? getLocalFolderNamePrompt : getSlugPrompt)(customTargetPath), 289 { 290 onCancel: () => process.exit(0), 291 } 292 ); 293 return slug; 294} 295 296/** 297 * Asks the user for some data necessary to render the template. 298 * Some values may already be provided by command options, the prompt is skipped in that case. 299 */ 300async function askForSubstitutionDataAsync( 301 slug: string, 302 isLocal = false 303): Promise<SubstitutionData | LocalSubstitutionData> { 304 const promptQueries = await (isLocal 305 ? getLocalSubstitutionDataPrompts 306 : getSubstitutionDataPrompts)(slug); 307 308 // Stop the process when the user cancels/exits the prompt. 309 const onCancel = () => { 310 process.exit(0); 311 }; 312 313 const { 314 name, 315 description, 316 package: projectPackage, 317 authorName, 318 authorEmail, 319 authorUrl, 320 repo, 321 } = await prompts(promptQueries, { onCancel }); 322 323 if (isLocal) { 324 return { 325 project: { 326 slug, 327 name, 328 package: projectPackage, 329 moduleName: handleSuffix(name, 'Module'), 330 viewName: handleSuffix(name, 'View'), 331 }, 332 type: 'local', 333 }; 334 } 335 336 return { 337 project: { 338 slug, 339 name, 340 version: '0.1.0', 341 description, 342 package: projectPackage, 343 moduleName: handleSuffix(name, 'Module'), 344 viewName: handleSuffix(name, 'View'), 345 }, 346 author: `${authorName} <${authorEmail}> (${authorUrl})`, 347 license: 'MIT', 348 repo, 349 type: 'remote', 350 }; 351} 352 353/** 354 * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue. 355 */ 356async function confirmTargetDirAsync(targetDir: string): Promise<void> { 357 const files = await fs.readdir(targetDir); 358 359 if (files.length === 0) { 360 return; 361 } 362 const { shouldContinue } = await prompts( 363 { 364 type: 'confirm', 365 name: 'shouldContinue', 366 message: `The target directory ${chalk.magenta( 367 targetDir 368 )} is not empty, do you want to continue anyway?`, 369 initial: true, 370 }, 371 { 372 onCancel: () => false, 373 } 374 ); 375 if (!shouldContinue) { 376 process.exit(0); 377 } 378} 379 380/** 381 * Prints how the user can follow up once the script finishes creating the module. 382 */ 383function printFurtherInstructions( 384 targetDir: string, 385 packageManager: PackageManagerName, 386 includesExample: boolean 387) { 388 if (includesExample) { 389 const commands = [ 390 `cd ${path.relative(CWD, targetDir)}`, 391 formatRunCommand(packageManager, 'open:ios'), 392 formatRunCommand(packageManager, 'open:android'), 393 ]; 394 395 console.log(); 396 console.log( 397 'To start developing your module, navigate to the directory and open iOS and Android projects of the example app' 398 ); 399 commands.forEach((command) => console.log(chalk.gray('>'), chalk.bold(command))); 400 console.log(); 401 } 402 console.log(`Visit ${chalk.blue.bold(DOCS_URL)} for the documentation on Expo Modules APIs`); 403} 404 405function printFurtherLocalInstructions(slug: string, name: string) { 406 console.log(`You can now import this module inside your application:`); 407 console.log(); 408 console.log(chalk.blue(`import { hello } from '${slug}';`)); 409 console.log(); 410 console.log(`Visit ${chalk.blue.bold(DOCS_URL)} for the documentation on Expo Modules APIs`); 411 console.log( 412 chalk.yellow( 413 `Remember you need to rebuild your development client or reinstall pods to see the changes.` 414 ) 415 ); 416} 417 418const program = new Command(); 419 420program 421 .name(packageJson.name) 422 .version(packageJson.version) 423 .description(packageJson.description) 424 .arguments('[path]') 425 .option( 426 '-s, --source <source_dir>', 427 'Local path to the template. By default it downloads `expo-module-template` from NPM.' 428 ) 429 .option('--with-readme', 'Whether to include README.md file.', false) 430 .option('--with-changelog', 'Whether to include CHANGELOG.md file.', false) 431 .option('--no-example', 'Whether to skip creating the example app.', false) 432 .option( 433 '--local', 434 'Whether to create a local module in the current project, skipping installing node_modules and creating the example directory.', 435 false 436 ) 437 .action(main); 438 439program 440 .hook('postAction', async () => { 441 await getTelemetryClient().flush?.(); 442 }) 443 .parse(process.argv); 444