create-expo-module
Version:
The script to create the Expo module
513 lines (461 loc) • 15.8 kB
text/typescript
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import { Command } from 'commander';
import downloadTarball from 'download-tarball';
import ejs from 'ejs';
import fs from 'fs';
import { boolish } from 'getenv';
import path from 'path';
import prompts from 'prompts';
import { createExampleApp } from './createExampleApp';
import { installDependencies } from './packageManager';
import {
getLocalFolderNamePrompt,
getLocalSubstitutionDataPrompts,
getSlugPrompt,
getSubstitutionDataPrompts,
} from './prompts';
import {
formatRunCommand,
PackageManagerName,
resolvePackageManager,
} from './resolvePackageManager';
import { eventCreateExpoModule, getTelemetryClient, logEventAsync } from './telemetry';
import { CommandOptions, LocalSubstitutionData, SubstitutionData } from './types';
import { newStep } from './utils/ora';
const debug = require('debug')('create-expo-module:main') as typeof console.log;
const packageJson = require('../package.json');
// Opt in to using beta versions
const EXPO_BETA = boolish('EXPO_BETA', false);
// `yarn run` may change the current working dir, then we should use `INIT_CWD` env.
const CWD = process.env.INIT_CWD || process.cwd();
// Ignore some paths. Especially `package.json` as it is rendered
// from `$package.json` file instead of the original one.
const IGNORES_PATHS = [
'.DS_Store',
'build',
'node_modules',
'package.json',
'.npmignore',
'.gitignore',
];
// Url to the documentation on Expo Modules
const DOCS_URL = 'https://docs.expo.dev/modules';
const FYI_LOCAL_DIR = 'https://expo.fyi/expo-module-local-autolinking.md';
async function getCorrectLocalDirectory(targetOrSlug: string) {
let packageJsonPath: string | null = null;
for (let dir = CWD; path.dirname(dir) !== dir; dir = path.dirname(dir)) {
const file = path.resolve(dir, 'package.json');
if (fs.existsSync(file)) {
packageJsonPath = file;
break;
}
}
if (!packageJsonPath) {
console.log(
chalk.red.bold(
'⚠️ This command should be run inside your Expo project when run with the --local flag.'
)
);
console.log(
chalk.red(
'For native modules to autolink correctly, you need to place them in the `modules` directory in the root of the project.'
)
);
return null;
}
return path.join(packageJsonPath, '..', 'modules', targetOrSlug);
}
/**
* The main function of the command.
*
* @param target Path to the directory where to create the module. Defaults to current working dir.
* @param options An options object for `commander`.
*/
async function main(target: string | undefined, options: CommandOptions) {
if (options.local) {
console.log();
console.log(
`${chalk.gray('The local module will be created in the ')}${chalk.gray.bold.italic(
'modules'
)} ${chalk.gray('directory in the root of your project. Learn more: ')}${chalk.gray.bold(
FYI_LOCAL_DIR
)}`
);
console.log();
}
const slug = await askForPackageSlugAsync(target, options.local);
const targetDir = options.local
? await getCorrectLocalDirectory(target || slug)
: path.join(CWD, target || slug);
if (!targetDir) {
return;
}
await fs.promises.mkdir(targetDir, { recursive: true });
await confirmTargetDirAsync(targetDir);
options.target = targetDir;
const data = await askForSubstitutionDataAsync(slug, options.local);
// Make one line break between prompts and progress logs
console.log();
const packageManager = resolvePackageManager();
const packagePath = options.source
? path.join(CWD, options.source)
: await downloadPackageAsync(targetDir, options.local);
await logEventAsync(eventCreateExpoModule(packageManager, options));
await newStep('Creating the module from template files', async (step) => {
await createModuleFromTemplate(packagePath, targetDir, data);
step.succeed('Created the module from template files');
});
if (!options.local) {
await newStep('Installing module dependencies', async (step) => {
await installDependencies(packageManager, targetDir);
step.succeed('Installed module dependencies');
});
await newStep('Compiling TypeScript files', async (step) => {
await spawnAsync(packageManager, ['run', 'build'], {
cwd: targetDir,
stdio: 'ignore',
});
step.succeed('Compiled TypeScript files');
});
}
if (!options.source) {
// Files in the downloaded tarball are wrapped in `package` dir.
// We should remove it after all.
await fs.promises.rm(packagePath, { recursive: true, force: true });
}
if (!options.local && data.type !== 'local') {
if (!options.withReadme) {
await fs.promises.rm(path.join(targetDir, 'README.md'), { force: true });
}
if (!options.withChangelog) {
await fs.promises.rm(path.join(targetDir, 'CHANGELOG.md'), { force: true });
}
if (options.example) {
// Create "example" folder
await createExampleApp(data, targetDir, packageManager);
}
await newStep('Creating an empty Git repository', async (step) => {
try {
const result = await createGitRepositoryAsync(targetDir);
if (result) {
step.succeed('Created an empty Git repository');
} else if (result === null) {
step.succeed('Skipped creating an empty Git repository, already within a Git repository');
} else if (result === false) {
step.warn(
'Could not create an empty Git repository, see debug logs with EXPO_DEBUG=true'
);
}
} catch (error: any) {
step.fail(error.toString());
}
});
}
console.log();
if (options.local) {
console.log(`✅ Successfully created Expo module in ${chalk.bold.italic(`modules/${slug}`)}`);
printFurtherLocalInstructions(slug, data.project.moduleName);
} else {
console.log('✅ Successfully created Expo module');
printFurtherInstructions(targetDir, packageManager, options.example);
}
}
/**
* Recursively scans for the files within the directory. Returned paths are relative to the `root` path.
*/
async function getFilesAsync(root: string, dir: string | null = null): Promise<string[]> {
const files: string[] = [];
const baseDir = dir ? path.join(root, dir) : root;
for (const file of await fs.promises.readdir(baseDir)) {
const relativePath = dir ? path.join(dir, file) : file;
if (IGNORES_PATHS.includes(relativePath) || IGNORES_PATHS.includes(file)) {
continue;
}
const fullPath = path.join(baseDir, file);
const stat = await fs.promises.lstat(fullPath);
if (stat.isDirectory()) {
files.push(...(await getFilesAsync(root, relativePath)));
} else {
files.push(relativePath);
}
}
return files;
}
/**
* Asks NPM registry for the url to the tarball.
*/
async function getNpmTarballUrl(packageName: string, version: string = 'latest'): Promise<string> {
debug(`Using module template ${chalk.bold(packageName)}@${chalk.bold(version)}`);
const { stdout } = await spawnAsync('npm', ['view', `${packageName}@${version}`, 'dist.tarball']);
return stdout.trim();
}
/**
* Gets expo SDK version major from the local package.json.
*/
async function getLocalSdkMajorVersion(): Promise<string | null> {
const path = require.resolve('expo/package.json', { paths: [process.cwd()] });
if (!path) {
return null;
}
const { version } = require(path) ?? {};
return version?.split('.')[0] ?? null;
}
/**
* Selects correct version of the template based on the SDK version for local modules and EXPO_BETA flag.
*/
async function getTemplateVersion(isLocal: boolean) {
if (EXPO_BETA) {
return 'next';
}
if (!isLocal) {
return 'latest';
}
try {
const sdkVersionMajor = await getLocalSdkMajorVersion();
return sdkVersionMajor ? `sdk-${sdkVersionMajor}` : 'latest';
} catch {
console.log();
console.warn(
chalk.yellow(
"Couldn't determine the SDK version from the local project, using `latest` as the template version."
)
);
return 'latest';
}
}
/**
* Downloads the template from NPM registry.
*/
async function downloadPackageAsync(targetDir: string, isLocal = false): Promise<string> {
return await newStep('Downloading module template from npm', async (step) => {
const templateVersion = await getTemplateVersion(isLocal);
const packageName = isLocal ? 'expo-module-template-local' : 'expo-module-template';
try {
await downloadTarball({
url: await getNpmTarballUrl(packageName, templateVersion),
dir: targetDir,
});
} catch {
console.log();
console.warn(
chalk.yellow(
"Couldn't download the versioned template from npm, falling back to the latest version."
)
);
await downloadTarball({
url: await getNpmTarballUrl(packageName, 'latest'),
dir: targetDir,
});
}
step.succeed('Downloaded module template from npm registry.');
return path.join(targetDir, 'package');
});
}
function handleSuffix(name: string, suffix: string): string {
if (name.endsWith(suffix)) {
return name;
}
return `${name}${suffix}`;
}
/**
* Creates the module based on the `ejs` template (e.g. `expo-module-template` package).
*/
async function createModuleFromTemplate(
templatePath: string,
targetPath: string,
data: SubstitutionData | LocalSubstitutionData
) {
const files = await getFilesAsync(templatePath);
// Iterate through all template files.
for (const file of files) {
const renderedRelativePath = ejs.render(file.replace(/^\$/, ''), data, {
openDelimiter: '{',
closeDelimiter: '}',
escape: (value: string) => value.replace(/\./g, path.sep),
});
const fromPath = path.join(templatePath, file);
const toPath = path.join(targetPath, renderedRelativePath);
const template = await fs.promises.readFile(fromPath, 'utf8');
const renderedContent = ejs.render(template, data);
if (!fs.existsSync(path.dirname(toPath))) {
await fs.promises.mkdir(path.dirname(toPath), { recursive: true });
}
await fs.promises.writeFile(toPath, renderedContent, 'utf8');
}
}
async function createGitRepositoryAsync(targetDir: string) {
// Check if we are inside a git repository already
try {
await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], {
stdio: 'ignore',
cwd: targetDir,
});
debug(chalk.dim('New project is already inside of a Git repository, skipping `git init`.'));
return null;
} catch (e: any) {
if (e.errno === 'ENOENT') {
debug(chalk.dim('Unable to initialize Git repo. `git` not in $PATH.'));
return false;
}
}
// Create a new git repository
await spawnAsync('git', ['init'], { stdio: 'ignore', cwd: targetDir });
await spawnAsync('git', ['add', '-A'], { stdio: 'ignore', cwd: targetDir });
const commitMsg = `Initial commit\n\nGenerated by ${packageJson.name} ${packageJson.version}.`;
await spawnAsync('git', ['commit', '-m', commitMsg], {
stdio: 'ignore',
cwd: targetDir,
});
debug(chalk.dim('Initialized a Git repository.'));
return true;
}
/**
* Asks the user for the package slug (npm package name).
*/
async function askForPackageSlugAsync(customTargetPath?: string, isLocal = false): Promise<string> {
const { slug } = await prompts(
(isLocal ? getLocalFolderNamePrompt : getSlugPrompt)(customTargetPath),
{
onCancel: () => process.exit(0),
}
);
return slug;
}
/**
* Asks the user for some data necessary to render the template.
* Some values may already be provided by command options, the prompt is skipped in that case.
*/
async function askForSubstitutionDataAsync(
slug: string,
isLocal = false
): Promise<SubstitutionData | LocalSubstitutionData> {
const promptQueries = await (
isLocal ? getLocalSubstitutionDataPrompts : getSubstitutionDataPrompts
)(slug);
// Stop the process when the user cancels/exits the prompt.
const onCancel = () => {
process.exit(0);
};
const {
name,
description,
package: projectPackage,
authorName,
authorEmail,
authorUrl,
repo,
} = await prompts(promptQueries, { onCancel });
if (isLocal) {
return {
project: {
slug,
name,
package: projectPackage,
moduleName: handleSuffix(name, 'Module'),
viewName: handleSuffix(name, 'View'),
},
type: 'local',
};
}
return {
project: {
slug,
name,
version: '0.1.0',
description,
package: projectPackage,
moduleName: handleSuffix(name, 'Module'),
viewName: handleSuffix(name, 'View'),
},
author: `${authorName} <${authorEmail}> (${authorUrl})`,
license: 'MIT',
repo,
type: 'remote',
};
}
/**
* Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue.
*/
async function confirmTargetDirAsync(targetDir: string): Promise<void> {
const files = await fs.promises.readdir(targetDir);
if (files.length === 0) {
return;
}
const { shouldContinue } = await prompts(
{
type: 'confirm',
name: 'shouldContinue',
message: `The target directory ${chalk.magenta(
targetDir
)} is not empty, do you want to continue anyway?`,
initial: true,
},
{
onCancel: () => false,
}
);
if (!shouldContinue) {
process.exit(0);
}
}
/**
* Prints how the user can follow up once the script finishes creating the module.
*/
function printFurtherInstructions(
targetDir: string,
packageManager: PackageManagerName,
includesExample: boolean
) {
if (includesExample) {
const commands = [
`cd ${path.relative(CWD, targetDir)}`,
formatRunCommand(packageManager, 'open:ios'),
formatRunCommand(packageManager, 'open:android'),
];
console.log();
console.log(
'To start developing your module, navigate to the directory and open Android and iOS projects of the example app'
);
commands.forEach((command) => console.log(chalk.gray('>'), chalk.bold(command)));
console.log();
}
console.log(`Learn more on Expo Modules APIs: ${chalk.blue.bold(DOCS_URL)}`);
}
function printFurtherLocalInstructions(slug: string, name: string) {
console.log();
console.log(`You can now import this module inside your application.`);
console.log(`For example, you can add this line to your App.tsx or App.js file:`);
console.log(`${chalk.gray.italic(`import ${name} from './modules/${slug}';`)}`);
console.log();
console.log(`Learn more on Expo Modules APIs: ${chalk.blue.bold(DOCS_URL)}`);
console.log(
chalk.yellow(
`Remember to re-build your native app (for example, with ${chalk.bold('npx expo run')}) when you make changes to the module. Native code changes are not reloaded with Fast Refresh.`
)
);
}
const program = new Command();
program
.name(packageJson.name)
.version(packageJson.version)
.description(packageJson.description)
.arguments('[path]')
.option(
'-s, --source <source_dir>',
'Local path to the template. By default it downloads `expo-module-template` from NPM.'
)
.option('--with-readme', 'Whether to include README.md file.', false)
.option('--with-changelog', 'Whether to include CHANGELOG.md file.', false)
.option('--no-example', 'Whether to skip creating the example app.', false)
.option(
'--local',
'Whether to create a local module in the current project, skipping installing node_modules and creating the example directory.',
false
)
.action(main);
program
.hook('postAction', async () => {
await getTelemetryClient().flush?.();
})
.parse(process.argv);