vue3-quickstart-cli
Version:
一个用于快速创建 Vue3 项目的脚手架工具。
555 lines (554 loc) • 23.7 kB
JavaScript
#!/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 });
}
})();