UNPKG

vue3-quickstart-cli

Version:

一个用于快速创建 Vue3 项目的脚手架工具。

555 lines (554 loc) 23.7 kB
#!/usr/bin/env node import path from 'path'; import fs from 'fs-extra'; import os from 'os'; import minimist from 'minimist'; import { fileURLToPath } from 'url'; import inquirer from 'inquirer'; import plugins from './plugins/index.js'; import messages from './messages.js'; import { getAvailablePm, checkPm } from './utils.js'; import { execSync } from 'child_process'; import open from 'open'; import chalk from 'chalk'; import https from 'https'; import { pipeline } from 'stream'; import { promisify } from 'util'; import extract from 'extract-zip'; import ejs from 'ejs'; import ProgressBar from 'progress'; const streamPipeline = promisify(pipeline); console.log(messages.zh.welcome); (async () => { let isRemote; let tmpDir; try { const argv = minimist(process.argv.slice(2)); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const pkgLocal = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf-8')); const PRESET_PATH = path.join(os.homedir(), '.my-vue3-cli-presets.json'); const FEATURE_LIST = [ { name: 'Vue Router', value: 'router' }, { name: 'Pinia', value: 'pinia' }, { name: 'ESLint', value: 'eslint' }, { name: '单元测试', value: 'unitTest' }, { name: 'axios', value: 'axios' }, { name: 'vueuse', value: 'vueuse' }, { name: 'scss', value: 'scss' }, { name: 'git提交规范(Commitizen)', value: 'commitizen' }, { name: 'husky', value: 'husky' }, { name: 'element-plus', value: 'elementPlus' }, { name: 'TypeScript', value: 'typescript' }, { name: '国际化(vue-i18n)', value: 'i18n' }, ]; // 解析命令行参数 // --template --features=a,b,c --pm=pnpm --lang=zh --name=xxx --preset=xxx const cliArgs = { template: argv.template, features: argv.features ? String(argv.features).split(',') : undefined, pm: argv.pm, lang: argv.lang, name: argv.name, preset: argv.preset, }; // 读取预设 let presets = {}; if (fs.existsSync(PRESET_PATH)) { try { presets = fs.readJsonSync(PRESET_PATH); } catch { } } // 语言选择 let lang = cliArgs.lang; if (!lang) { const langSelect = await inquirer.prompt([ { type: 'list', name: 'lang', message: messages.zh.langSelect, choices: [ { name: messages.zh.langZh, value: 'zh' }, { name: messages.zh.langEn, value: 'en' } ], default: 'zh', } ]); lang = langSelect.lang; } const t = messages[lang]; // 预设选择 let usePreset = false; let presetName = ''; let projectName = ''; let features = []; if (cliArgs.preset && presets[cliArgs.preset]) { usePreset = true; presetName = cliArgs.preset; projectName = cliArgs.name || (await inquirer.prompt([ { type: 'input', name: 'projectName', message: t.inputProjectName, validate: (input) => input ? true : t.projectNameRequired, } ])).projectName; features = presets[presetName]; } else if (Object.keys(presets).length > 0 && !cliArgs.features) { const presetNames = Object.keys(presets); const presetAns = await inquirer.prompt([ { type: 'list', name: 'preset', message: t.savePreset, choices: [ ...presetNames.map(name => ({ name, value: name })), { name: t.no, value: '' } ], default: '' } ]); if (presetAns.preset) { usePreset = true; presetName = presetAns.preset; projectName = await inquirer.prompt([ { type: 'input', name: 'projectName', message: t.inputProjectName, validate: (input) => input ? true : t.projectNameRequired, } ]).then(ans => ans.projectName); features = presets[presetName]; } } if (!usePreset) { if (cliArgs.name) { projectName = cliArgs.name; } else { const projectNameAns = await inquirer.prompt([ { type: 'input', name: 'projectName', message: t.inputProjectName, validate: (input) => input ? true : t.projectNameRequired, } ]); projectName = projectNameAns.projectName; } if (cliArgs.features) { features = cliArgs.features; } else { const featuresAns = await inquirer.prompt([ { type: 'checkbox', name: 'features', message: t.selectFeatures, choices: FEATURE_LIST.map(f => ({ name: f.name, value: f.value })), } ]); features = featuresAns.features; } // 选择后询问是否保存为预设 const savePresetAns = await inquirer.prompt([ { type: 'confirm', name: 'savePreset', message: t.savePreset, default: false, } ]); if (savePresetAns.savePreset) { const presetNameAns = await inquirer.prompt([ { type: 'input', name: 'presetName', message: t.inputPresetName, validate: (input) => input ? true : t.presetNameRequired, } ]); presets[presetNameAns.presetName] = features; fs.writeJsonSync(PRESET_PATH, presets, { spaces: 2 }); } } // 模板选择参数直达 let templateDir = path.resolve(__dirname, '../../template'); if (cliArgs.template) { // 支持 github:xxx、gitee:xxx、zip、本地 if (/^github:|^gitee:|\.zip$/.test(cliArgs.template)) { isRemote = true; // 复用远程模板逻辑 const remoteUrl = cliArgs.template.trim(); tmpDir = path.join(os.tmpdir(), 'vue3-quickstart-tmp-template-' + Date.now()); fs.mkdirSync(tmpDir, { recursive: true }); let zipUrl = ''; if (/^github:/.test(remoteUrl)) { const match = remoteUrl.match(/^github:([^/]+)\/([^#]+)(#(.+))?$/); if (!match) throw new Error('GitHub 模板地址格式错误'); const user = match[1], repo = match[2], branch = match[4] || 'main'; zipUrl = `https://github.com/${user}/${repo}/archive/refs/heads/${branch}.zip`; } else if (/^gitee:/.test(remoteUrl)) { const match = remoteUrl.match(/^gitee:([^/]+)\/([^#]+)(#(.+))?$/); if (!match) throw new Error('Gitee 模板地址格式错误'); const user = match[1], repo = match[2], branch = match[4] || 'master'; zipUrl = `https://gitee.com/${user}/${repo}/repository/archive/${branch}.zip`; } else if (/\.zip$/.test(remoteUrl)) { zipUrl = remoteUrl; } // 下载 zip(带进度条) const zipPath = path.join(tmpDir, 'template.zip'); console.log('正在下载模板:', zipUrl); await new Promise((resolve, reject) => { https.get(zipUrl, res => { if (res.statusCode !== 200) return reject(new Error('下载失败: ' + res.statusCode)); const total = parseInt(res.headers['content-length'] || '0', 10); const bar = total ? new ProgressBar(' 下载中 [:bar] :percent :etas', { complete: '=', incomplete: ' ', width: 30, total }) : null; const file = fs.createWriteStream(zipPath); res.on('data', chunk => { bar && bar.tick(chunk.length); }); res.pipe(file); file.on('finish', () => file.close(resolve)); file.on('error', reject); }).on('error', reject); }); await extract(zipPath, { dir: tmpDir }); const files = fs.readdirSync(tmpDir).filter((f) => f !== 'template.zip'); templateDir = path.join(tmpDir, files[0]); } else { // 本地模板 const customDir1 = path.resolve(__dirname, `../../template-${cliArgs.template}`); const customDir2 = path.resolve(__dirname, `../../template/${cliArgs.template}`); if (fs.existsSync(customDir1)) { templateDir = customDir1; } else if (fs.existsSync(customDir2)) { templateDir = customDir2; } else { console.log('未找到模板:', cliArgs.template); process.exit(1); } } } else { // 交互式模板选择 const templateTypeAns = await inquirer.prompt([ { type: 'list', name: 'templateType', message: t.selectTemplate, choices: [ { name: '本地模板', value: 'local' }, { name: '远程模板(GitHub/Gitee/zip)', value: 'remote' } ], default: 'local', } ]); if (templateTypeAns.templateType === 'remote') { isRemote = true; const remoteAns = await inquirer.prompt([ { type: 'input', name: 'remoteUrl', message: '请输入远程模板地址(支持 github:user/repo[#branch]、gitee:user/repo、zip 链接):', } ]); const remoteUrl = remoteAns.remoteUrl.trim(); tmpDir = path.join(os.tmpdir(), 'vue3-quickstart-tmp-template-' + Date.now()); fs.mkdirSync(tmpDir, { recursive: true }); let zipUrl = ''; if (/^github:/.test(remoteUrl)) { // github:user/repo[#branch] const match = remoteUrl.match(/^github:([^/]+)\/([^#]+)(#(.+))?$/); if (!match) throw new Error('GitHub 模板地址格式错误'); const user = match[1], repo = match[2], branch = match[4] || 'main'; zipUrl = `https://github.com/${user}/${repo}/archive/refs/heads/${branch}.zip`; } else if (/^gitee:/.test(remoteUrl)) { // gitee:user/repo[#branch] const match = remoteUrl.match(/^gitee:([^/]+)\/([^#]+)(#(.+))?$/); if (!match) throw new Error('Gitee 模板地址格式错误'); const user = match[1], repo = match[2], branch = match[4] || 'master'; zipUrl = `https://gitee.com/${user}/${repo}/repository/archive/${branch}.zip`; } else if (/\.zip$/.test(remoteUrl)) { zipUrl = remoteUrl; } else { throw new Error('暂不支持该远程模板格式'); } // 下载 zip(带进度条) const zipPath = path.join(tmpDir, 'template.zip'); console.log('正在下载模板:', zipUrl); await new Promise((resolve, reject) => { https.get(zipUrl, res => { if (res.statusCode !== 200) return reject(new Error('下载失败: ' + res.statusCode)); const total = parseInt(res.headers['content-length'] || '0', 10); const bar = total ? new ProgressBar(' 下载中 [:bar] :percent :etas', { complete: '=', incomplete: ' ', width: 30, total }) : null; const file = fs.createWriteStream(zipPath); res.on('data', chunk => { bar && bar.tick(chunk.length); }); res.pipe(file); file.on('finish', () => file.close(resolve)); file.on('error', reject); }).on('error', reject); }); // 解压 await extract(zipPath, { dir: tmpDir }); // 取解压后的第一个目录为模板目录 const files = fs.readdirSync(tmpDir).filter((f) => f !== 'template.zip'); templateDir = path.join(tmpDir, files[0]); } else { // 本地模板选择 const templateAns = await inquirer.prompt([ { type: 'input', name: 'templateName', message: t.inputTemplate, } ]); const templateName = templateAns.templateName; if (templateName) { const customDir1 = path.resolve(__dirname, `../../template-${templateName}`); const customDir2 = path.resolve(__dirname, `../../template/${templateName}`); if (fs.existsSync(customDir1)) { templateDir = customDir1; } else if (fs.existsSync(customDir2)) { templateDir = customDir2; } else { console.log('未找到模板:', templateName); process.exit(1); } } } } // 包管理器参数直达 let pm = cliArgs.pm; if (!pm) { const detectedPm = getAvailablePm(); const pmAns = await inquirer.prompt([ { type: 'list', name: 'pm', message: t.selectPm, choices: [ ...(checkPm('pnpm') ? [{ name: 'pnpm', value: 'pnpm' }] : []), ...(checkPm('yarn') ? [{ name: 'yarn', value: 'yarn' }] : []), { name: 'npm', value: 'npm' }, ], default: detectedPm, } ]); pm = pmAns.pm; } const targetDir = path.resolve(process.cwd(), projectName); if (fs.existsSync(targetDir)) { console.log(t.overwriteDir); process.exit(1); } // 自动扫描用户插件 function loadUserPlugins(projectRoot) { const plugins = []; // 1. 扫描 node_modules/my-vue3-plugin-* const nm = path.resolve(projectRoot, 'node_modules'); if (fs.existsSync(nm)) { for (const dir of fs.readdirSync(nm)) { if (/^my-vue3-plugin-/.test(dir)) { try { const mod = require(path.join(nm, dir)); if (mod && (mod.pre || mod.post)) plugins.push({ name: dir, ...mod }); } catch { } } } } // 2. 扫描项目根目录 plugins 目录 const localPluginsDir = path.resolve(projectRoot, 'plugins'); if (fs.existsSync(localPluginsDir)) { for (const file of fs.readdirSync(localPluginsDir)) { if (/\.(js|ts)$/i.test(file)) { try { const mod = require(path.join(localPluginsDir, file)); if (mod && (mod.pre || mod.post)) plugins.push({ name: file, ...mod }); } catch { } } } } return plugins; } // 变量注入上下文 const context = { projectName, author: os.userInfo().username, features, year: new Date().getFullYear(), lang, targetDir, templateDir, pm, }; // 加载并执行用户插件 pre 钩子 const userPlugins = loadUserPlugins(process.cwd()); for (const plugin of userPlugins) { if (plugin.pre) await plugin.pre(context); } // 自动检测并删除模板 node_modules const templateNodeModules = path.join(templateDir, 'node_modules'); if (fs.existsSync(templateNodeModules)) { fs.rmSync(templateNodeModules, { recursive: true, force: true }); console.log('已自动删除模板目录下的 node_modules'); } // 递归复制并渲染模板 async function copyTemplateWithEjs(src, dest) { fs.mkdirSync(dest, { recursive: true }); for (const file of fs.readdirSync(src)) { // 跳过 node_modules、.git、dist 等目录 if (["node_modules", ".git", "dist"].includes(file)) continue; const srcPath = path.join(src, file); const destPath = path.join(dest, file); const stat = fs.statSync(srcPath); if (stat.isDirectory()) { await copyTemplateWithEjs(srcPath, destPath); } else { // 只渲染文本文件,二进制文件直接复制 const isText = /\.(js|ts|json|vue|md|html|css|scss|env|gitignore|cjs|mjs|txt)$/i.test(file); if (isText) { const tpl = fs.readFileSync(srcPath, 'utf-8'); const rendered = ejs.render(tpl, context, { filename: srcPath }); fs.writeFileSync(destPath, rendered); } else { fs.copyFileSync(srcPath, destPath); } } } } await copyTemplateWithEjs(templateDir, targetDir); // 执行用户插件 post 钩子 for (const plugin of userPlugins) { if (plugin.post) await plugin.post(context); } console.log(t.copyTemplate, templateDir, '->', targetDir); // 集成特性插件 const pkgPath = path.join(targetDir, 'package.json'); const pkg = fs.readJsonSync(pkgPath); for (const feature of features) { if (plugins[feature]) { plugins[feature].apply(targetDir, pkg); } } fs.writeJsonSync(pkgPath, pkg, { spaces: 2 }); console.log(t.featuresTitle + ':', features); // 自动安装依赖 const autoInstallAns = await inquirer.prompt([ { type: 'confirm', name: 'autoInstall', message: t.autoInstall, default: true, } ]); if (autoInstallAns.autoInstall) { try { execSync(`${pm} install`, { cwd: targetDir, stdio: 'inherit' }); } catch (e) { console.log('依赖安装失败,请手动安装'); } } // 自动 git init const gitInitAns = await inquirer.prompt([ { type: 'confirm', name: 'gitInit', message: t.initGit, default: true, } ]); if (gitInitAns.gitInit) { try { execSync('git init', { cwd: targetDir, stdio: 'ignore' }); execSync('git add .', { cwd: targetDir, stdio: 'ignore' }); execSync('git commit -m "init"', { cwd: targetDir, stdio: 'ignore' }); console.log(chalk.green(t.autoCommitSuccess)); } catch (e) { console.log(chalk.yellow(t.autoCommitFail)); } } // 自动打开项目目录 const openDirAns = await inquirer.prompt([ { type: 'confirm', name: 'openDir', message: t.openDir, default: false, } ]); if (openDirAns.openDir) { try { await open(targetDir); console.log(chalk.green(t.openDirSuccess)); } catch { console.log(chalk.yellow(t.openDirFail)); } } // 自动生成 README.md const readmePath = path.join(targetDir, 'README.md'); const readmeContent = `# ${projectName}\n\n${t.readmeTitle}\n\n## ${t.featuresTitle}\n${features.map(f => `- ${f}`).join('\n')}\n`; fs.writeFileSync(readmePath, readmeContent); console.log(chalk.green(t.readmeGen)); // 输出下一步指引 console.log(chalk.cyan(t.nextStep)); console.log(chalk.cyan(' cd', projectName)); console.log(chalk.cyan(t.installTip)); console.log(chalk.cyan(t.devTip)); console.log(t.enjoy); // CLI 版本检测 try { const pkgJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf-8')); const res = await fetch('https://registry.npmjs.org/vue3-quickstart-cli/latest'); const latest = (await res.json()).version; if (latest && latest !== pkgJson.version) { console.log(chalk.yellow(`\n发现新版本: ${latest},当前版本: ${pkgJson.version}\n建议升级: npm i -g vue3-quickstart-cli\n`)); } } catch { } // 结束 console.log(t.projectSuccess); } catch (e) { console.error(chalk.red('CLI 捕获到异常:'), e, e?.stack); process.exit(1); } // 清理远程模板临时目录 if (typeof isRemote !== 'undefined' && isRemote && tmpDir && fs.existsSync(tmpDir)) { fs.rmSync(tmpDir, { recursive: true, force: true }); } })();