UNPKG

@luxine/create-vue

Version:

A CLI scaffold for creating Vue projects

574 lines (518 loc) 19 kB
#!/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 初始化完成。'); }