UNPKG

@micro-cli/create

Version:

A Cli to quickly create modern Vite-based web app.

539 lines (523 loc) 15.3 kB
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 };