@micro-cli/create
Version:
A Cli to quickly create modern Vite-based web app.
539 lines (523 loc) • 15.3 kB
JavaScript
import path from 'node:path';
import fs from 'fs-extra';
import figlet from 'figlet';
import inquirer from 'inquirer';
import validateProjectName from 'validate-npm-package-name';
import { chalk, injectImportsToFile, hasPnpm3OrLater, hasYarn, resolvePkg, hasPnpmVersionOrLater, execa, wrapLoading, commandSpawn, loadModule, hasGit, hasProjectGit, exit, clear } from '@micro-cli/shared-utils';
import fs$1 from 'node:fs';
import ejs from 'ejs';
import { globby } from 'globby';
class PromptModuleAPI {
constructor(creator) {
this.creator = creator;
}
injectFeature(feature) {
this.creator.featurePrompt.choices?.push(feature);
}
injectPrompt(prompt) {
this.creator.injectedPrompts.push(prompt);
}
onPromptComplete(cb) {
this.creator.promptCompleteCbs.push(cb);
}
}
const cssPreprocessors = (cli) => {
cli.injectFeature({
name: "css-preprocessor",
value: "css-preprocessor",
description: "Add support for CSS pre-processors like Sass, Less"
});
cli.injectPrompt({
name: "cssPreprocessor",
when: (answers) => answers.features?.includes("css-preprocessor"),
type: "list",
message: `Pick a CSS pre-processor`,
choices: [
{
name: "Sass/SCSS (with dart-sass)",
value: "sass"
},
{
name: "Less",
value: "less"
},
{
name: "Stylus",
value: "stylus"
}
]
});
cli.onPromptComplete((answers, options) => {
if (answers.cssPreprocessor) {
options.plugins["@micro-cli/cli-plugin-css-preprocessor"] = {};
}
});
};
const linter = (cli) => {
cli.injectFeature({
name: "Linter / Formatter",
value: "linter",
description: "Check and enforce code quality with ESLint or Prettier",
plugins: ["eslint"],
checked: true
});
cli.injectPrompt({
name: "eslintConfig",
when: (answers) => answers.features?.includes("linter") && answers.features?.includes("TypeScript"),
type: "list",
message: "Pick a linter / formatter config:",
description: "Checking code errors and enforcing an homogeoneous code style is recommended.",
choices: [
{
name: "ESLint with error prevention only",
value: "base",
short: "Basic"
},
{
name: "ESLint + Standard config",
value: "standard",
short: "Standard"
},
{
name: "ESLint + XO config",
value: "xo",
short: "Standard"
}
]
});
cli.injectPrompt({
name: "eslintConfig",
when: (answers) => answers.features?.includes("linter") && !answers.features?.includes("TypeScript"),
type: "list",
message: "Pick a linter / formatter config:",
description: "Checking code errors and enforcing an homogeoneous code style is recommended.",
choices: [
{
name: "ESLint with error prevention only",
value: "base",
when: (answers) => answers.features?.includes("TypeScript"),
short: "Basic"
},
{
name: "ESLint + Airbnb config",
value: "airbnb",
short: "Airbnb"
},
{
name: "ESLint + Standard config",
value: "standard",
short: "Standard"
},
{
name: "ESLint + XO config",
value: "xo",
short: "XO"
}
]
});
cli.onPromptComplete((answers, options) => {
if (answers.features?.includes("linter")) {
options.plugins["@micro-cli/cli-plugin-eslint"] = {
config: answers.eslintConfig
};
}
});
};
const typescript = (cli) => {
cli.injectFeature({
name: "TypeScript",
value: "TypeScript",
description: "Add support for the TypeScript language"
});
cli.onPromptComplete((answers, options) => {
if (answers.features?.includes("TypeScript")) {
options.plugins["@micro-cli/cli-plugin-typescript"] = {};
}
});
};
const gitHooks = (cli) => {
cli.injectFeature({
name: "gitHooks",
value: "gitHooks",
description: "Review the code and ensure compliance with the submission"
});
cli.onPromptComplete((answers, options) => {
if (answers.features?.includes("gitHooks")) {
options.plugins["@micro-cli/cli-plugin-git-hooks"] = {};
}
});
};
const router = (cli) => {
cli.injectFeature({
name: "Router",
value: "router",
description: "Structure the app with dynamic pages"
});
cli.injectPrompt({
name: "historyMode",
when: (answers) => answers.features?.includes("router"),
type: "confirm",
message: `Use history mode for router? ${chalk.yellow(
`(Requires proper server setup for index fallback in production)`
)}`,
description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`
});
cli.onPromptComplete((answers, options) => {
if (answers.features?.includes("router")) {
options.plugins["@micro-cli/cli-plugin-router"] = {};
}
});
};
const promptModules = [cssPreprocessors, linter, typescript, gitHooks, router];
function writeFileTree(dir, files) {
Object.keys(files).forEach((name) => {
const filePath = path.join(dir, name);
fs.ensureDirSync(path.dirname(filePath));
fs.writeFileSync(filePath, files[name]);
});
}
function sortObject(obj, keyOrder, dontSortByUnicode) {
const res = {};
if (keyOrder) {
keyOrder.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
res[key] = obj[key];
delete obj[key];
}
});
}
const keys = Object.keys(obj);
!dontSortByUnicode && keys.sort();
keys.forEach((key) => {
res[key] = obj[key];
});
return res;
}
function extractCallDir(source, projectName, plugin) {
const cwd = process.cwd();
return path.join(
cwd,
`./${projectName}/node_modules/@micro-cli/${plugin}/generator/${source}`
);
}
async function renderFile(name, data) {
const template = fs$1.readFileSync(name, "utf-8");
return ejs.render(template, { data });
}
class GeneratorAPI {
constructor(id, generator, options, answers) {
this.id = id;
this.generator = generator;
this.options = options;
this.answers = answers;
this.entryFile = this.getEntryFile();
}
render(source, options) {
const { plugin, data } = options;
const { projectName } = this.options;
const baseDir = extractCallDir(source, projectName, plugin);
this.injectFileMiddleware(async (files) => {
const allFiles = await globby(["**/*"], { cwd: baseDir, dot: true });
for (const rawPath of allFiles) {
const sourcePath = path.resolve(baseDir, rawPath);
const content = await renderFile(
sourcePath,
data
);
files[rawPath] = content;
}
});
}
injectFileMiddleware(middleware) {
this.generator.fileMiddlewares.push(middleware);
}
extendPackage(fields) {
for (const key in fields) {
this.generator.pkg[key] = {
...this.generator.pkg[key],
...fields[key]
};
}
}
injectImports(file, imports) {
this.generator.imports[file] = imports;
}
injectModifyCodeSnippetCb(file, cb) {
if (!this.generator.modifyCodeSnippetCbs[file])
this.generator.modifyCodeSnippetCbs[file] = [];
this.generator.modifyCodeSnippetCbs[file].push(cb);
}
getEntryFile() {
const files = {
React: ["main.jsx", "main.tsx"],
Vue: ["main.js", "main.ts"]
};
const hasTypeScript = Number(
this.answers.features?.includes("TypeScript") || 0
);
const framework = this.answers.preset;
return files[framework][hasTypeScript];
}
}
class Generator {
constructor(targetDir, {
pkg = {},
plugins = [],
answers = { preset: "React" }
} = {}) {
this.imports = {};
this.modifyCodeSnippetCbs = {};
this.targetDir = targetDir;
this.originalPkg = pkg;
this.pkg = { ...pkg };
this.plugins = plugins;
this.fileMiddlewares = [];
this.files = {};
this.answers = answers;
}
async initPlugins() {
this.plugins.forEach((plugin) => {
const { id, apply, options, answers } = plugin;
const api = new GeneratorAPI(id, this, options, this.answers);
if (typeof apply === "function")
apply(api, options, answers);
else
apply.default(api, options, answers);
});
}
async generate() {
await this.initPlugins();
await this.resolveFiles();
this.files["package.json"] = `${JSON.stringify(this.pkg, null, 2)}
`;
writeFileTree(this.targetDir, this.files);
}
async resolveFiles() {
const { files } = this;
for (const middleware of this.fileMiddlewares) {
await middleware(files);
}
Object.keys(files).forEach((file) => {
const imports = this.imports[file];
if (imports && Object.keys(imports).length > 0) {
files[file] = injectImportsToFile(files[file], imports) || files[file];
}
});
Object.keys(this.modifyCodeSnippetCbs).forEach((file) => {
this.modifyCodeSnippetCbs[file].forEach((cb) => {
files[file] = cb(files[file]);
});
});
}
}
class Creator extends EventTarget {
constructor(name, targetDir) {
super();
this.name = name;
this.targetDir = targetDir;
const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();
this.presetPrompt = presetPrompt;
this.featurePrompt = featurePrompt;
this.injectedPrompts = [];
this.promptCompleteCbs = [];
this.answers = { preset: "React" };
const promptAPI = new PromptModuleAPI(this);
promptModules.forEach((m) => m(promptAPI));
}
async create(cliOptions = {}) {
const preset = await this.promptAndResolvePreset();
preset.plugins["@micro-cli/cli-service"] = {
projectName: this.name,
...preset
};
const packageManager = hasPnpm3OrLater() ? "pnpm" : hasYarn() ? "yarn" : "npm";
console.log(`\u2728 Creating project in ${chalk.yellow(this.targetDir)}.`);
const pkg = {
name: this.name,
version: "0.1.0",
private: true,
devDependencies: {},
...resolvePkg(this.targetDir)
};
const deps = Object.keys(preset.plugins);
deps.forEach((dep) => {
pkg.devDependencies[dep] = `^1.0.0`;
});
writeFileTree(this.targetDir, {
"package.json": JSON.stringify(pkg, null, 2)
});
if (packageManager === "pnpm") {
const pnpmConfig = hasPnpmVersionOrLater("4.0.0") ? "shamefully-hoist=true\nstrict-peer-dependencies=false\n" : "shamefully-flatten=true\n";
writeFileTree(this.targetDir, {
".npmrc": pnpmConfig
});
}
const shouldInitGit = this.shouldInitGit(cliOptions);
if (shouldInitGit) {
console.log(`\u{1F5C3} Initializing git repository...`);
await execa("git init", { cwd: this.targetDir });
}
console.log();
await wrapLoading(
() => commandSpawn(packageManager, ["install"], { cwd: this.targetDir }),
`\u2699\uFE0F `
);
console.log(`\u{1F680} Invoking generators...`);
const plugins = await this.resolvePlugins(
preset.plugins
);
const generator = new Generator(this.targetDir, {
pkg,
plugins,
answers: this.answers
});
await generator.generate();
await wrapLoading(
() => commandSpawn(packageManager, ["install"], { cwd: this.targetDir }),
`\u{1F4E6} `
);
console.log(`\u{1F389} Successfully created project ${chalk.yellow(this.name)}.`);
console.log();
console.log(
`\u{1F449} Get started with the following commands:
${this.targetDir === process.cwd() ? `` : chalk.cyan(` ${chalk.gray("$")} cd ${this.name}
`)}${chalk.cyan(
` ${chalk.gray("$")} ${packageManager === "yarn" ? "yarn run dev" : packageManager === "pnpm" ? "pnpm run dev" : "npm run dev"}`
)}`
);
}
async resolvePlugins(rawPlugins) {
rawPlugins = sortObject(rawPlugins, ["@micro-cli/cli-service"], true);
const plugins = [];
for (const id of Object.keys(rawPlugins)) {
const apply = await loadModule(`${id}`, this.targetDir) || (() => {
});
const options = { ...rawPlugins[id], projectName: this.name };
plugins.push({ id, apply, options, answers: this.answers });
}
return plugins;
}
async promptAndResolvePreset() {
const answers = await inquirer.prompt(this.getFinalPrompts());
this.answers = answers;
const preset = {
useConfigFiles: true,
plugins: {}
};
answers.features = answers.features || [];
this.promptCompleteCbs.forEach((cb) => cb(answers, preset));
return preset;
}
getFinalPrompts() {
return [this.presetPrompt, this.featurePrompt, ...this.injectedPrompts];
}
resolveIntroPrompts() {
const presetPrompt = {
name: "preset",
type: "list",
message: `Please pick a frameWork:`,
choices: [
{
name: "React",
value: "React"
},
{
name: "Vue",
value: "Vue"
}
]
};
const featurePrompt = {
name: "features",
type: "checkbox",
message: "Check the features needed for your project:",
choices: [],
pageSize: 10
};
return {
featurePrompt,
presetPrompt
};
}
shouldInitGit(cliOptions) {
if (!hasGit()) {
return false;
}
if (cliOptions.git) {
return true;
}
return !hasProjectGit(this.targetDir);
}
}
async function create(projectName, options) {
const cwd = process.cwd();
const inCurrent = projectName === ".";
const name = inCurrent ? path.relative("../", cwd) : projectName;
const targetDir = path.resolve(cwd, projectName || ".");
const result = validateProjectName(name);
if (!result.validForNewPackages) {
console.error(chalk.red(`Invalid project name: "${name}"`));
result.errors?.forEach((err) => {
console.error(chalk.red.dim(`Error: ${err}`));
});
result.warnings?.forEach((warn) => {
console.error(chalk.red.dim(`Warning: ${warn}`));
});
exit(1);
}
if (fs.existsSync(targetDir)) {
if (options.force) {
await fs.remove(targetDir);
} else if (inCurrent) {
const { ok } = await inquirer.prompt([
{
name: "ok",
type: "confirm",
message: `Generate project in current directory?`
}
]);
if (!ok) {
return;
}
} else {
const { action } = await inquirer.prompt([
{
name: "action",
type: "list",
message: `Target directory ${chalk.cyan(
targetDir
)} already exists. Pick an action:`,
choices: [
{ name: "Overwrite", value: "overwrite" },
{ name: "Cancel", value: false }
]
}
]);
if (action === "overwrite") {
console.log(`
Removing ${chalk.cyan(targetDir)}...`);
await fs.remove(targetDir);
} else {
return;
}
}
}
if (!inCurrent) {
await fs.mkdir(targetDir);
}
clear();
console.log(
chalk.yellow(
figlet.textSync("M-CLI", {
horizontalLayout: "full",
font: "3D-ASCII",
verticalLayout: "default",
width: 150,
whitespaceBreak: true
})
)
);
const creator = new Creator(name, targetDir);
await creator.create(options);
}
export { create as default };