UNPKG

@agile-team/robot-cli

Version:

🤖 现代化项目脚手架工具,支持多技术栈快速创建项目 - 优先 bun,兼容 npm/pnpm/yarn

334 lines (276 loc) 8.14 kB
// lib/utils.js - 增强版本,添加详细进度展示 import fs from 'fs-extra'; import path from 'path'; import chalk from 'chalk'; import { execSync } from 'child_process'; import fetch from 'node-fetch'; /** * 检测当前使用的包管理器 */ export function detectPackageManager() { try { const managers = []; try { execSync('bun --version', { stdio: 'ignore' }); managers.push('bun'); } catch {} try { execSync('pnpm --version', { stdio: 'ignore' }); managers.push('pnpm'); } catch {} try { execSync('yarn --version', { stdio: 'ignore' }); managers.push('yarn'); } catch {} try { execSync('npm --version', { stdio: 'ignore' }); managers.push('npm'); } catch {} return managers; } catch (error) { return ['npm']; } } /** * 验证项目名称 */ export function validateProjectName(name) { const errors = []; if (!name || typeof name !== 'string') { errors.push('项目名称不能为空'); return { valid: false, errors }; } const trimmedName = name.trim(); if (trimmedName.length === 0) { errors.push('项目名称不能为空'); } if (trimmedName.length > 214) { errors.push('项目名称不能超过214个字符'); } if (trimmedName.toLowerCase() !== trimmedName) { errors.push('项目名称只能包含小写字母'); } if (/^[._]/.test(trimmedName)) { errors.push('项目名称不能以 "." 或 "_" 开头'); } if (!/^[a-z0-9._-]+$/.test(trimmedName)) { errors.push('项目名称只能包含字母、数字、点、下划线和短横线'); } const reservedNames = [ 'node_modules', 'favicon.ico', '.git', '.env', 'package.json', 'npm', 'yarn', 'pnpm', 'bun', 'robot' ]; if (reservedNames.includes(trimmedName)) { errors.push(`"${trimmedName}" 是保留名称,请使用其他名称`); } return { valid: errors.length === 0, errors }; } /** * 统计目录中的文件数量 */ async function countFiles(dirPath) { let count = 0; async function walkDir(currentPath) { const items = await fs.readdir(currentPath); for (const item of items) { const itemPath = path.join(currentPath, item); const stat = await fs.stat(itemPath); if (stat.isDirectory()) { // 跳过不需要的目录 if (!['node_modules', '.git', '.DS_Store'].includes(item)) { await walkDir(itemPath); } } else { count++; } } } await walkDir(dirPath); return count; } /** * 复制模板文件 - 带详细进度展示 */ export async function copyTemplate(sourcePath, targetPath, spinner) { if (!fs.existsSync(sourcePath)) { throw new Error(`源路径不存在: ${sourcePath}`); } await fs.ensureDir(targetPath); // 1. 统计文件数量 if (spinner) { spinner.text = '📊 统计文件数量...'; } const totalFiles = await countFiles(sourcePath); if (spinner) { spinner.text = `📋 开始复制 ${totalFiles} 个文件...`; } let copiedFiles = 0; // 2. 递归复制文件,带进度更新 async function copyWithProgress(srcDir, destDir) { const items = await fs.readdir(srcDir); for (const item of items) { const srcPath = path.join(srcDir, item); const destPath = path.join(destDir, item); const stat = await fs.stat(srcPath); if (stat.isDirectory()) { // 排除不需要的文件夹 if (['node_modules', '.git', '.DS_Store', '.vscode', '.idea'].includes(item)) { continue; } await fs.ensureDir(destPath); await copyWithProgress(srcPath, destPath); } else { // 复制文件 await fs.copy(srcPath, destPath); copiedFiles++; // 更新进度 (每10个文件或重要节点更新一次) if (spinner && (copiedFiles % 10 === 0 || copiedFiles === totalFiles)) { const percentage = Math.round((copiedFiles / totalFiles) * 100); spinner.text = `📋 复制中... ${copiedFiles}/${totalFiles} (${percentage}%)`; } } } } await copyWithProgress(sourcePath, targetPath); if (spinner) { spinner.text = `✅ 文件复制完成 (${copiedFiles} 个文件)`; } } /** * 安装依赖 */ export async function installDependencies(projectPath, spinner, packageManager = 'npm') { const originalCwd = process.cwd(); try { process.chdir(projectPath); const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { if (spinner) { spinner.text = '⚠️ 跳过依赖安装 (无 package.json)'; } return; } const installCommands = { bun: 'bun install', pnpm: 'pnpm install', yarn: 'yarn install', npm: 'npm install' }; const command = installCommands[packageManager] || 'npm install'; if (spinner) { spinner.text = `📦 使用 ${packageManager} 安装依赖...`; } execSync(command, { stdio: 'ignore', timeout: 300000 }); if (spinner) { spinner.text = `✅ 依赖安装完成 (${packageManager})`; } } catch (error) { if (spinner) { spinner.text = `⚠️ 依赖安装失败,请手动安装`; } console.log(); console.log(chalk.yellow('⚠️ 自动安装依赖失败')); console.log(chalk.dim(` 错误: ${error.message}`)); console.log(); console.log(chalk.blue('💡 请手动安装:')); console.log(chalk.cyan(` cd ${path.basename(projectPath)}`)); console.log(chalk.cyan(` ${packageManager} install`)); console.log(); } finally { process.chdir(originalCwd); } } /** * 检查网络连接 */ export async function checkNetworkConnection() { try { const response = await fetch('https://api.github.com', { method: 'HEAD', timeout: 5000 }); return response.ok; } catch (error) { try { const response = await fetch('https://www.npmjs.com', { method: 'HEAD', timeout: 5000 }); return response.ok; } catch (backupError) { return false; } } } /** * 生成项目统计 */ export async function generateProjectStats(projectPath) { try { const stats = { files: 0, directories: 0, size: 0, fileTypes: {} }; async function walkDir(dirPath) { const items = await fs.readdir(dirPath); for (const item of items) { const itemPath = path.join(dirPath, item); const stat = await fs.stat(itemPath); if (stat.isDirectory()) { if (!['node_modules', '.git', '.DS_Store'].includes(item)) { stats.directories++; await walkDir(itemPath); } } else { stats.files++; stats.size += stat.size; const ext = path.extname(item).toLowerCase(); if (ext) { stats.fileTypes[ext] = (stats.fileTypes[ext] || 0) + 1; } } } } await walkDir(projectPath); return stats; } catch (error) { return null; } } /** * 打印项目统计 */ export function printProjectStats(stats) { if (!stats) return; console.log(chalk.blue('📊 项目统计:')); console.log(` 文件数量: ${chalk.cyan(stats.files)} 个`); console.log(` 目录数量: ${chalk.cyan(stats.directories)} 个`); console.log(` 项目大小: ${chalk.cyan(formatBytes(stats.size))}`); const topTypes = Object.entries(stats.fileTypes) .sort(([,a], [,b]) => b - a) .slice(0, 5); if (topTypes.length > 0) { console.log(' 主要文件类型:'); topTypes.forEach(([ext, count]) => { console.log(` ${ext}: ${chalk.cyan(count)} 个`); }); } } /** * 格式化字节大小 */ function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }