create-new-express-app
Version:
A CLI tool to create a TypeScript Express application with best practices
382 lines (347 loc) • 10.8 kB
text/typescript
import { Command } from "commander";
import { basename, resolve } from "node:path";
import { existsSync } from "node:fs";
import { cyan, green, red, bold, yellow, blue } from "picocolors";
import Conf from "conf";
import updateCheck from "update-check";
import ciInfo from "ci-info";
import prompts, { type InitialReturnValue } from "prompts";
import packageJson from "../package.json";
import { getPkgManager, type PackageManager } from "./get-pkg-manager";
import { validateNpmName } from "./validate-pkg";
import { isFolderEmpty } from "./is-folder-empty";
import { createApp, DownloadError } from "./create-app";
let projectPath: string = "";
const handleSigTerm = () => process.exit(0);
process.on("SIGINT", handleSigTerm);
process.on("SIGTERM", handleSigTerm);
const onPromptState = (state: {
value: InitialReturnValue;
aborted: boolean;
exited: boolean;
}) => {
if (state.aborted) {
// If we don't re-enable the terminal cursor before exiting
// the program, the cursor will remain hidden
process.stdout.write("\x1B[?25h");
process.stdout.write("\n");
process.exit(1);
}
};
const program = new Command(packageJson.name)
.description(packageJson.description)
.version(
packageJson.version,
"-v, --version",
"Output the current version of create-new-express-app"
)
.argument("[directory]")
.usage("[directory] [options]")
.helpOption("-h, --help", "Display this help message.")
.action((name) => {
// Commander does not implicitly support negated options. When they are used
// by the user they will be interpreted as the positional argument (name) in
// the action handler. See https://github.com/tj/commander.js/pull/1355
if (name && !name.startsWith("--no-")) {
projectPath = name;
}
})
.allowUnknownOption()
.parse(process.argv);
const opts = program.opts();
const { args } = program;
const packageManager: PackageManager = !!opts.useNpm
? "npm"
: !!opts.usePnpm
? "pnpm"
: !!opts.useYarn
? "yarn"
: !!opts.useBun
? "bun"
: getPkgManager();
async function run(): Promise<void> {
const conf = new Conf({ projectName: "create-new-express-app" });
if (opts.resetPreferences) {
const { resetPreferences } = await prompts({
onState: onPromptState,
type: "toggle",
name: "resetPreferences",
message: "Would you like to reset the saved preferences?",
initial: false,
active: "Yes",
inactive: "No",
});
if (resetPreferences) {
conf.clear();
console.log("The preferences have been reset successfully!");
}
process.exit(0);
}
if (typeof projectPath === "string") {
projectPath = projectPath.trim();
}
if (!projectPath) {
const res = await prompts({
onState: onPromptState,
type: "text",
name: "path",
message: "What is your project named?",
initial: "my-app",
validate: (name) => {
const validation = validateNpmName(basename(resolve(name)));
if (validation.valid) {
return true;
}
return "Invalid project name: " + validation.problems[0];
},
});
if (typeof res.path === "string") {
projectPath = res.path.trim();
}
}
if (!projectPath) {
console.log(
"\nPlease specify the project directory:\n" +
` ${cyan(opts.name())} ${green("<project-directory>")}\n` +
"For example:\n" +
` ${cyan(opts.name())} ${green("my-express-app")}\n\n` +
`Run ${cyan(`${opts.name()} --help`)} to see all options.`
);
process.exit(1);
}
const appPath = resolve(projectPath);
const appName = basename(appPath);
const validation = validateNpmName(appName);
if (!validation.valid) {
console.error(
`Could not create a project called ${red(
`"${appName}"`
)} because of npm naming restrictions:`
);
validation.problems.forEach((p) =>
console.error(` ${red(bold("*"))} ${p}`)
);
process.exit(1);
}
if (existsSync(appPath) && !isFolderEmpty(appPath, appName)) {
process.exit(1);
}
const preferences = (conf.get("preferences") || {}) as Record<
string,
boolean | string
>;
/**
* If the user does not provide the necessary flags, prompt them for their
* preferences, unless `--yes` option was specified, or when running in CI.
*/
const skipPrompt = ciInfo.isCI || opts.yes;
const defaults: typeof preferences = {
typescript: true,
eslint: true,
srcDir: true,
importAlias: "@/*",
customizeImportAlias: false,
};
const getPrefOrDefault = (field: string) =>
preferences[field] ?? defaults[field];
if (!opts.typescript && !opts.javascript) {
if (skipPrompt) {
// default to TypeScript in CI as we can't prompt to
// prevent breaking setup flows
opts.typescript = getPrefOrDefault("typescript");
} else {
const styledTypeScript = blue("TypeScript");
const { typescript } = await prompts(
{
type: "toggle",
name: "typescript",
message: `Would you like to use ${styledTypeScript}?`,
initial: getPrefOrDefault("typescript"),
active: "Yes",
inactive: "No",
},
{
/**
* User inputs Ctrl+C or Ctrl+D to exit the prompt. We should close the
* process and not write to the file system.
*/
onCancel: () => {
console.error("Exiting.");
process.exit(1);
},
}
);
/**
* Depending on the prompt response, set the appropriate program flags.
*/
opts.typescript = Boolean(typescript);
opts.javascript = !Boolean(typescript);
preferences.typescript = Boolean(typescript);
}
}
if (!opts.eslint && !args.includes("--no-eslint")) {
if (skipPrompt) {
opts.eslint = getPrefOrDefault("eslint");
} else {
const styledEslint = blue("ESLint");
const { eslint } = await prompts({
onState: onPromptState,
type: "toggle",
name: "eslint",
message: `Would you like to use ${styledEslint}?`,
initial: getPrefOrDefault("eslint"),
active: "Yes",
inactive: "No",
});
opts.eslint = Boolean(eslint);
preferences.eslint = Boolean(eslint);
}
}
if (!opts.srcDir && !args.includes("--no-src-dir")) {
if (skipPrompt) {
opts.srcDir = getPrefOrDefault("srcDir");
} else {
const styledSrcDir = blue("`src/` directory");
const { srcDir } = await prompts({
onState: onPromptState,
type: "toggle",
name: "srcDir",
message: `Would you like your code inside a ${styledSrcDir}?`,
initial: getPrefOrDefault("srcDir"),
active: "Yes",
inactive: "No",
});
opts.srcDir = Boolean(srcDir);
preferences.srcDir = Boolean(srcDir);
}
}
const importAliasPattern = /^[^*"]+\/\*\s*$/;
if (
typeof opts.importAlias !== "string" ||
!importAliasPattern.test(opts.importAlias)
) {
if (skipPrompt) {
// We don't use preferences here because the default value is @/* regardless of existing preferences
opts.importAlias = defaults.importAlias;
} else if (args.includes("--no-import-alias")) {
opts.importAlias = defaults.importAlias;
} else {
const styledImportAlias = blue("import alias");
const { customizeImportAlias } = await prompts({
onState: onPromptState,
type: "toggle",
name: "customizeImportAlias",
message: `Would you like to customize the ${styledImportAlias} (${defaults.importAlias} by default)?`,
initial: getPrefOrDefault("customizeImportAlias"),
active: "Yes",
inactive: "No",
});
if (!customizeImportAlias) {
// We don't use preferences here because the default value is @/* regardless of existing preferences
opts.importAlias = defaults.importAlias;
} else {
const { importAlias } = await prompts({
onState: onPromptState,
type: "text",
name: "importAlias",
message: `What ${styledImportAlias} would you like configured?`,
initial: getPrefOrDefault("importAlias"),
validate: (value) =>
importAliasPattern.test(value)
? true
: "Import alias must follow the pattern <prefix>/*",
});
opts.importAlias = importAlias;
preferences.importAlias = importAlias;
}
}
}
try {
await createApp({
appPath,
packageManager,
typescript: opts.typescript,
eslint: opts.eslint,
tailwind: opts.tailwind,
app: opts.app,
srcDir: opts.srcDir,
importAlias: opts.importAlias,
skipInstall: opts.skipInstall,
empty: opts.empty,
turbo: opts.turbo,
});
} catch (reason) {
if (!(reason instanceof DownloadError)) {
throw reason;
}
const res = await prompts({
onState: onPromptState,
type: "confirm",
name: "builtin",
message:
`Could not download github because of a connectivity issue between your machine and GitHub.\n` +
`Do you want to use the default template instead?`,
initial: true,
});
if (!res.builtin) {
throw reason;
}
await createApp({
appPath,
packageManager,
typescript: opts.typescript,
eslint: opts.eslint,
tailwind: opts.tailwind,
app: opts.app,
srcDir: opts.srcDir,
importAlias: opts.importAlias,
skipInstall: opts.skipInstall,
empty: opts.empty,
turbo: opts.turbo,
});
}
conf.set("preferences", preferences);
}
const update = updateCheck(packageJson).catch(() => null);
async function notifyUpdate(): Promise<void> {
try {
if ((await update)?.latest) {
const global = {
npm: "npm i -g",
yarn: "yarn global add",
pnpm: "pnpm add -g",
bun: "bun add -g",
};
const updateMessage = `${global[packageManager]} create-new-express-app`;
console.log(
yellow(
bold("A new version of `create-new-express-app` is available!")
) +
"\n" +
"You can update by running: " +
cyan(updateMessage) +
"\n"
);
}
process.exit(0);
} catch {
// ignore error
}
}
async function exit(reason: { command?: string }) {
console.log();
console.log("Aborting installation.");
if (reason.command) {
console.log(` ${cyan(reason.command)} has failed.`);
} else {
console.log(
red("Unexpected error. Please report it as a bug:") + "\n",
reason
);
}
console.log();
await notifyUpdate();
process.exit(1);
}
run().then(notifyUpdate).catch(exit);