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