@luxine/create-vue
Version:
A CLI scaffold for creating Vue projects
574 lines (518 loc) • 19 kB
JavaScript
#!/usr/bin/env node
import { Command } from 'commander';
import inquirer from 'inquirer';
import fs from 'fs-extra';
import path, { resolve } from 'node:path';
import chalk from 'chalk';
import ora from 'ora';
import degit from 'degit';
import symbols from 'log-symbols';
import boxen from 'boxen';
import { execa } from 'execa';
// ── 日志工具 ───────────────────────────────────────────────
const log = {
info: (msg) => console.log(symbols.info, chalk.cyan(msg)),
ok: (msg) => console.log(symbols.success, chalk.green(msg)),
warn: (msg) => console.log(symbols.warning, chalk.yellow(msg)),
error: (msg) => console.error(symbols.error, chalk.red(msg)),
step: (title) => console.log('\n' + chalk.bold.magenta('›') + ' ' + chalk.bold(title))
};
const isCommandAvailable = async (cmd) => {
try {
await execa.command(`${cmd} --version`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
const runWithSpinner = async (commandLabel, commandFn) => {
const spinner = ora(commandLabel).start();
try {
const result = await commandFn();
spinner.succeed(chalk.green(commandLabel));
return result;
} catch (err) {
spinner.fail(chalk.red(commandLabel));
throw err;
}
};
// ── CLI 配置 ───────────────────────────────────────────────
const program = new Command();
program
.name('luxine-vue-template')
.description('初始化项目模板,自动安装依赖并启动开发服务')
.option('-n, --name <projectName>', 'project name')
.option('-d, --dir <directory>', 'target directory', process.cwd())
.option('-p, --package-manager <npm|yarn|pnpm>', '优先使用的包管理器')
.option('--no-start', '跳过自动启动开发服务')
.parse(process.argv);
const options = program.opts();
// ── 主逻辑 ────────────────────────────────────────────────
(async () => {
try {
let template_url = 'luxine/Vue-template#main'; // 可以改成参数化
// 1. 项目名
const { projectName: rawName } = await inquirer.prompt([
{
type: 'input',
name: 'projectName',
message: chalk.bold('Project name:'),
default: options.name || 'vue-project',
validate: (v) => {
if (!v || !v.trim()) return '名称不能为空';
return true;
}
}
]);
// 简单清洗
const projectName = rawName.trim().replace(/\s+/g, '-');
const availablePMs = ['pnpm', 'yarn', 'npm'];
let packageManager = null;
if (options.packageManager) {
if (availablePMs.includes(options.packageManager)) {
packageManager = options.packageManager;
} else {
log.warn(`指定的包管理器 "${options.packageManager}" 不可用,自动降级使用可用的:${availablePMs.join(', ')}`);
packageManager = availablePMs[0];
}
} else {
// 交互选择,默认第一个(优先 pnpm)
const { pm } = await inquirer.prompt([
{
type: 'list',
name: 'pm',
message: '选择包管理器:',
choices: availablePMs,
default: availablePMs[0]
}
]);
packageManager = pm;
}
// 交互选择
const { docker } = await inquirer.prompt([
{
type: 'input',
name: 'init dockerfile',
message: '是否初始化 Dockerfile? (y/n)',
default: 'y',
validate: (v) => {
if (v.trim().toLowerCase() === 'y' || v.trim().toLowerCase() === 'n') {
return true;
} else {
'请输入 y 或 n'
return false;
}
},
}
]);
if (docker === 'y') {
template_url = 'luxine/Vue-template#main'
}
const { nginx } = await inquirer.prompt([
{
type: 'input',
name: 'init nginx',
message: '是否初始化 nginx 相关 ( nginx.conf / nssm ) ? (y/n)',
default: 'y',
validate: (v) => {
if (v.trim().toLowerCase() === 'y' || v.trim().toLowerCase() === 'n') {
return true;
} else {
'请输入 y 或 n'
return false;
}
},
}
]);
if (nginx === 'y') {
template_url = 'luxine/Vue-template#main'
}
log.step(`使用包管理器: ${chalk.bold(packageManager)}`);
// 3. 目标目录
const targetDir = resolve(options.dir, projectName);
// 4. 目录存在性检查
if (await fs.pathExists(targetDir)) {
log.error(`目录已存在:${targetDir}`);
process.exit(1);
}
// 5. 拉取远程模板
await runWithSpinner(`初始化模板 → ${targetDir}`, async () => {
const emitter = degit(template_url, { cache: false, force: true, verbose: false });
await emitter.clone(targetDir);
});
log.ok(`模板初始化完成:${chalk.bold(targetDir)}`);
try {
await initGitRepo(targetDir);
} catch (error) {
log.warn(`初始化 Git 仓库失败:${error.message}`);
}
// 6. 安装依赖
const installCmdMap = {
pnpm: ['pnpm', ['install']],
yarn: ['yarn', []], // yarn 默认安装
npm: ['npm', ['install']]
};
const [installBin, installArgs] = installCmdMap[packageManager];
await runWithSpinner(`安装必要的依赖( ${packageManager} )`, async () => {
await execa(installBin, installArgs, {
cwd: path.join(targetDir, 'base'),
stdio: 'inherit'
});
});
log.ok('依赖安装完成');
// 7. 识别 dev/start 脚本
let scriptName = null;
const pkgJsonPath = resolve(path.join(targetDir, 'base'), 'package.json');
if (await fs.pathExists(pkgJsonPath)) {
const pkg = await fs.readJson(pkgJsonPath);
if (pkg.scripts?.dev) scriptName = 'dev';
else if (pkg.scripts?.start) scriptName = 'start';
} else {
log.warn('package.json 不存在,无法自动启动开发服务。');
}
if (!scriptName) {
log.warn('未在 package.json 里发现 dev 或 start 脚本。');
}
// 8. 展示最终说明(如果跳过启动或者找不到脚本)
if (!options.start || !scriptName) {
const fallbackInstructions = `
${chalk.cyan('cd')} ${path.join(targetDir, 'base')}
${chalk.cyan(packageManager)} ${scriptName ? (packageManager === 'npm' ? `run ${scriptName}` : scriptName) : 'dev'}
`;
console.log(
boxen(fallbackInstructions.trim(), {
padding: 1,
margin: 1,
borderStyle: 'round',
borderColor: 'green'
})
);
if (!options.start) {
log.ok('已跳过自动启动开发服务。');
process.exit(0);
}
if (!scriptName) {
process.exit(0);
}
}
// 9. 启动开发服务(阻塞,输出直接透传)
log.step(`启动开发服务:${scriptName}`);
const startCmdMap = {
pnpm: ['pnpm', [scriptName]],
yarn: [scriptName === 'start' ? 'yarn' : 'yarn', [scriptName]], // yarn dev / yarn start
npm: ['npm', ['run', scriptName]]
};
const [startBin, startArgs] = startCmdMap[packageManager];
const devProcess = execa(startBin, startArgs, {
cwd: path.join(targetDir, 'base'),
stdio: 'inherit'
});
// Ctrl+C 传递给子进程
const handleSigint = () => {
if (!devProcess.killed) {
devProcess.kill('SIGINT');
}
process.exit(0);
};
process.on('SIGINT', handleSigint);
process.on('SIGTERM', handleSigint);
await devProcess;
log.ok('开发服务已退出。');
process.exit(0);
} catch (err) {
log.error(typeof err === 'object' ? (err.message || JSON.stringify(err)) : String(err));
process.exit(1);
}
})();
async function initGitRepo(targetDir) {
const WIN = process.platform === 'win32';
// 解析 git 可执行文件路径(where/which + 兜底)
async function resolveExecutable(cmd) {
try {
const { stdout } = await execa.command(WIN ? `where ${cmd}` : `command -v ${cmd}`, {
shell: true,
windowsHide: true,
});
const first = stdout.split(/\r?\n/).find(Boolean);
if (first) return first.trim();
} catch {}
try {
await execa(cmd, ['--version'], { windowsHide: true });
return cmd;
} catch {}
if (WIN) {
const candidates = [
'C:\\Program Files\\Git\\cmd\\git.exe',
'C:\\Program Files\\Git\\bin\\git.exe',
'C:\\Program Files (x86)\\Git\\cmd\\git.exe',
'C:\\Program Files (x86)\\Git\\bin\\git.exe',
];
for (const p of candidates) {
if (await fs.pathExists(p)) return p;
}
}
return null;
}
// 确保目录在 Git 安全白名单,自动修复“dubious ownership”
async function ensureSafeDirectory(gitBin, dir) {
try {
await execa(gitBin, ['status', '--porcelain'], { cwd: dir, windowsHide: true });
return; // OK
} catch (err) {
const msg = String(err?.stderr || err?.stdout || err?.message || '');
if (!/detected dubious ownership/i.test(msg)) throw err; // 非安全性问题
await execa(gitBin, ['config', '--global', '--add', 'safe.directory', dir], {
windowsHide: true,
});
log.info(`已将目录加入 Git 安全白名单:${dir}`);
await execa(gitBin, ['status', '--porcelain'], { cwd: dir, windowsHide: true });
}
}
// 获取当前分支名
async function getCurrentBranch(gitBin, dir) {
const { stdout } = await execa(gitBin, ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: dir,
windowsHide: true,
});
return stdout.trim();
}
// 远端是否存在指定分支
async function remoteBranchExists(gitBin, dir, remote, branch) {
const { stdout } = await execa(gitBin, ['ls-remote', '--heads', remote, branch], {
cwd: dir,
windowsHide: true,
reject: false,
});
return Boolean(stdout.trim());
}
// 推送被拒绝是否因远端已有提交
function isRejectedRemoteAhead(err) {
const msg = String(err?.stderr || err?.stdout || err?.message || '');
// 英文
if (/\[rejected\]/i.test(msg)) return true;
if (/non-fast-forward/i.test(msg)) return true;
if (/Updates were rejected because the remote contains work/i.test(msg)) return true;
if (/fetch first/i.test(msg)) return true;
// 中文常见文案(Git for Windows 中文包)
if (/被拒绝/.test(msg)) return true;
if (/非快进/.test(msg)) return true;
if (/先(拉取|获取)/.test(msg)) return true;
if (/远程.*包含.*(你|本地).*(没有|不包含)/.test(msg)) return true;
return false;
}
const gitBin = await resolveExecutable('git');
if (!gitBin) {
log.warn('未检测到可用的 git,跳过仓库初始化。');
return;
}
const gitDir = path.join(targetDir, '.git');
const repoExists = await fs.pathExists(gitDir);
// 交互
const { shouldInit, branch, remoteUrl, shouldPush } = await inquirer.prompt([
{
type: 'confirm',
name: 'shouldInit',
message: repoExists ? '检测到已存在 Git 仓库,是否继续执行提交/远端配置?' : '是否初始化 Git 仓库?',
default: true,
},
{
type: 'input',
name: 'branch',
message: '默认分支名:',
default: 'main',
when: (ans) => ans.shouldInit && !repoExists,
validate: (v) => (v && v.trim() ? true : '分支名不能为空'),
},
{
type: 'input',
name: 'remoteUrl',
message: '可选:添加远端 origin(留空跳过):',
when: (ans) => ans.shouldInit,
filter: (v) => v.trim(),
},
{
type: 'confirm',
name: 'shouldPush',
message: '是否立即推送到远端?(需远端可写权限)',
default: false,
when: (ans) => ans.shouldInit && !!ans.remoteUrl,
},
]);
if (!shouldInit) return;
// 初始化仓库(如不存在)
if (!repoExists) {
await runWithSpinner('初始化 Git 仓库', async () => {
try {
await execa(gitBin, ['init', '-b', branch], { cwd: targetDir, windowsHide: true });
} catch {
await execa(gitBin, ['init'], { cwd: targetDir, windowsHide: true });
await execa(gitBin, ['checkout', '-b', branch], { cwd: targetDir, windowsHide: true });
}
await ensureSafeDirectory(gitBin, targetDir);
});
} else {
await ensureSafeDirectory(gitBin, targetDir);
}
// 确保 .gitignore 存在(若模板已有则跳过)
const gi = path.join(targetDir, '.gitignore');
if (!(await fs.pathExists(gi))) {
await fs.writeFile(
gi,
[
'node_modules/',
'dist/',
'coverage/',
'.DS_Store',
'*.log',
'.env*',
'pnpm-lock.yaml',
'yarn.lock',
'package-lock.json',
].join('\n') + '\n'
);
log.ok('已生成 .gitignore');
}
// 本地身份信息检查(缺失则提示配置,仅作用于当前仓库)
const needIdentity = async () => {
const name = await execa(gitBin, ['config', '--get', 'user.name'], {
cwd: targetDir,
reject: false,
windowsHide: true,
});
const email = await execa(gitBin, ['config', '--get', 'user.email'], {
cwd: targetDir,
reject: false,
windowsHide: true,
});
return !(name.stdout && email.stdout);
};
if (await needIdentity()) {
const { userName, userEmail } = await inquirer.prompt([
{
type: 'input',
name: 'userName',
message: '未检测到 Git 用户名(user.name),请输入(留空跳过):',
filter: (v) => v.trim(),
},
{
type: 'input',
name: 'userEmail',
message: '未检测到 Git 邮箱(user.email),请输入(留空跳过):',
filter: (v) => v.trim(),
},
]);
if (userName) {
await execa(gitBin, ['config', 'user.name', userName], { cwd: targetDir, windowsHide: true });
}
if (userEmail) {
await execa(gitBin, ['config', 'user.email', userEmail], { cwd: targetDir, windowsHide: true });
}
}
// 有变更才提交(带“dubious ownership”自动修复与一次重试)
const hasChanges = async () => {
const { stdout } = await execa(gitBin, ['status', '--porcelain'], {
cwd: targetDir,
windowsHide: true,
});
return stdout.trim().length > 0;
};
await runWithSpinner('创建首个提交', async () => {
const tryCommit = async () => {
await execa(gitBin, ['add', '.'], { cwd: targetDir, windowsHide: true });
if (await hasChanges()) {
await execa(gitBin, ['commit', '-m', 'chore: scaffold project'], {
cwd: targetDir,
windowsHide: true,
});
} else {
log.info('工作区无变更,跳过提交。');
}
};
try {
await ensureSafeDirectory(gitBin, targetDir);
await tryCommit();
} catch (err) {
const msg = String(err?.stderr || err?.stdout || err?.message || '');
if (/detected dubious ownership/i.test(msg)) {
await execa(gitBin, ['config', '--global', '--add', 'safe.directory', targetDir], {
windowsHide: true,
});
log.info(`已将目录加入 Git 安全白名单并重试:${targetDir}`);
await tryCommit();
} else {
throw err;
}
}
});
// 远端与推送(可选)
if (remoteUrl) {
await runWithSpinner(`添加远端:${remoteUrl}`, async () => {
const remotes = await execa(gitBin, ['remote'], { cwd: targetDir, windowsHide: true });
if (remotes.stdout.split(/\r?\n/).includes('origin')) {
await execa(gitBin, ['remote', 'set-url', 'origin', remoteUrl], {
cwd: targetDir,
windowsHide: true,
});
} else {
await execa(gitBin, ['remote', 'add', 'origin', remoteUrl], {
cwd: targetDir,
windowsHide: true,
});
}
});
if (shouldPush) {
await runWithSpinner('推送到远端(设置上游)', async () => {
await ensureSafeDirectory(gitBin, targetDir);
const curBranch = repoExists ? await getCurrentBranch(gitBin, targetDir) : branch;
// 若远端不存在该分支,直接正常推送
const exists = await remoteBranchExists(gitBin, targetDir, 'origin', curBranch);
const doPush = async (extraArgs = []) => {
await execa(
gitBin,
['push', '-u', 'origin', curBranch.trim(), ...extraArgs],
{ cwd: targetDir, windowsHide: true, stdio: 'inherit' }
);
};
try {
if (!exists) {
await doPush(); // 第一次创建远端分支
} else {
await doPush(); // 远端已有该分支,尝试普通推送
}
} catch (err) {
if (!isRejectedRemoteAhead(err)) throw err;
// 远端存在且领先:交互选择处理策略
const { conflictStrategy } = await inquirer.prompt([
{
type: 'list',
name: 'conflictStrategy',
message: '远端已存在提交,选择处理方式:',
choices: [
{ name: '拉取并合并(保留远端历史)→ 再推送', value: 'merge' },
{ name: '强制推送(用本地覆盖远端)', value: 'force' },
{ name: '跳过(稍后手动处理)', value: 'skip' },
],
default: 'merge',
},
]);
if (conflictStrategy === 'skip') {
log.warn('已跳过推送,请稍后手动处理分支冲突。');
return;
}
if (conflictStrategy === 'force') {
await doPush(['--force']);
return;
}
await execa(gitBin, ['fetch', 'origin'], { cwd: targetDir, windowsHide: true, stdio: 'inherit' });
await execa(
gitBin,
['pull', 'origin', curBranch, '--allow-unrelated-histories'],
{ cwd: targetDir, windowsHide: true, stdio: 'inherit' }
);
await doPush();
}
});
}
}
log.ok('Git 初始化完成。');
}