#!/usr/bin/env node import sftpClient from 'ssh2-sftp-client' import { readFileSync, existsSync, statSync, readdirSync, unlinkSync, createReadStream, writeFileSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { SingleBar } from 'cli-progress' import { exec, spawn } from 'child_process' import { promisify } from 'util' import { Command } from 'commander' const program = new Command(); program.option('-n --env ', '发布环境', 'dev') program.parse(process.argv); const options = program.opts(); const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const projectRoot = join(__dirname, '..') // 配置 测试环境 : const config = options.env === 'prod' ? { host: '60.205.166.0', username: 'root', password: 'U/N$$XBv', // 运行时输入 remotePath: '/opt/data/airport-web/dist', localPath: join(projectRoot, 'dist'), accessLocation: 'http://airport.samsundot.com:9011' } : { host: '192.168.3.221', username: 'root', password: 'root', // 运行时输入 remotePath: '/opt/data/airport-web/dist', localPath: join(projectRoot, 'dist') } // 颜色输出 const colors = { reset: '\x1b[0m', bright: '\x1b[1m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' } function log (message, color = 'reset') { console.log(`${colors[ color ]}${message}${colors.reset}`) } // 获取密码 async function getPassword () { const readline = await import('readline') const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) return new Promise(resolve => { rl.question('🔐 请输入服务器密码: ', password => { rl.close() resolve(password) }) }) } // 检查并构建 dist 目录 async function checkAndBuildDist () { if (!existsSync(config.localPath)) { log('⚠️ dist 目录不存在,开始自动构建...', 'yellow') // 检查 package.json 是否存在 const packageJsonPath = join(projectRoot, 'package.json') if (!existsSync(packageJsonPath)) { log('❌ package.json 不存在', 'red') process.exit(1) } // 检查是否有 build 脚本 const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) if (!packageJson.scripts || !packageJson.scripts.build) { log('❌ package.json 中没有找到 build 脚本', 'red') process.exit(1) } // 执行构建 log('🔨 正在执行构建命令...', 'cyan') const execAsync = promisify(exec) try { // 优先使用 yarn,如果没有则使用 npm let buildCommand = 'npm run build' let packageManager = 'npm' // 检查是否有 yarn try { await execAsync('yarn --version', { cwd: projectRoot }) buildCommand = 'yarn build' packageManager = 'yarn' log(`使用 ${packageManager} 构建...`, 'cyan') } catch (yarnError) { log('yarn 不可用,使用 npm 构建...', 'yellow') } await execAsync(buildCommand, { cwd: projectRoot }) // 再次检查构建结果 if (!existsSync(config.localPath)) { log('❌ 构建失败,dist 目录仍未生成', 'red') process.exit(1) } log(`✅ 构建完成!(使用 ${packageManager})`, 'green') } catch (error) { log(`❌ 构建失败: ${error.message}`, 'red') process.exit(1) } } else { const stats = statSync(config.localPath) if (!stats.isDirectory()) { log('❌ dist 不是目录', 'red') process.exit(1) } log('✅ dist 目录检查通过', 'green') } } // 计算目录大小 function getDirectorySize (dirPath) { let totalSize = 0 const files = readdirSync(dirPath, { withFileTypes: true }) for (const file of files) { const fullPath = join(dirPath, file.name) if (file.isDirectory()) { totalSize += getDirectorySize(fullPath) } else { totalSize += statSync(fullPath).size } } return totalSize } // 格式化文件大小 function formatSize (bytes) { const units = [ 'B', 'KB', 'MB', 'GB' ] let size = bytes let unitIndex = 0 while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024 unitIndex++ } return `${size.toFixed(1)}${units[ unitIndex ]}` } // 创建压缩包 async function createArchive () { const execAsync = promisify(exec) const archivePath = join(projectRoot, 'dist.tar.gz') log('📦 正在创建压缩包...', 'yellow') try { // 使用 tar 创建压缩包,排除 macOS 扩展属性 await execAsync(`tar --no-xattrs -czf "${archivePath}" -C "${config.localPath}" .`) const stats = statSync(archivePath) log(`✅ 压缩包创建完成,大小: ${formatSize(stats.size)}`, 'green') return archivePath } catch (error) { log(`❌ 创建压缩包失败: ${error.message}`, 'red') throw error } } // 获取用户选择 async function getUserChoice (question) { const readline = await import('readline') const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) return new Promise(resolve => { rl.question(question, answer => { rl.close() resolve(answer.toLowerCase().trim()) }) }) } // 主部署函数 async function deploy () { let archivePath = null try { log('🚀 开始 Node.js 部署...', 'cyan') // 检查并构建 dist 目录 await checkAndBuildDist() if (!config.password) { // 获取密码 config.password = await getPassword() } // 计算总大小 const totalSize = getDirectorySize(config.localPath) log(`📊 总大小: ${formatSize(totalSize)}`, 'blue') // 检查是否已有压缩包,支持断点续传 const existingArchivePath = join(projectRoot, 'dist.tar.gz') if (existsSync(existingArchivePath)) { log('📦 发现已存在的压缩包,跳过打包步骤', 'yellow') archivePath = existingArchivePath } else { // 创建压缩包 archivePath = await createArchive() } // 连接 SFTP log('🔌 正在连接服务器...', 'yellow') const sftp = new sftpClient() await sftp.connect({ host: config.host, username: config.username, password: config.password }) log('✅ SFTP 连接成功', 'green') // 创建远程目录 log('📁 创建远程目录...', 'yellow') await sftp.mkdir(config.remotePath, true) // true 表示递归创建 // 检查服务器是否已存在压缩包 const remoteArchivePath = '/opt/data/airport-web/dist.tar.gz' let needUpload = true try { await sftp.stat(remoteArchivePath) log('📦 服务器已存在压缩包', 'yellow') // 询问用户是否续传 const choice = await getUserChoice('是否续传?(y/n): ') if (choice === 'y' || choice === 'yes') { log('📦 跳过上传步骤,使用现有压缩包', 'green') needUpload = false } else { log('🗑️ 删除服务器压缩包,重新上传...', 'yellow') await sftp.unlink(remoteArchivePath) log('📤 开始上传新压缩包...', 'yellow') } } catch (error) { // 文件不存在,需要上传 log('📤 服务器不存在压缩包,开始上传...', 'yellow') } if (needUpload) { // 上传压缩包 log('📤 正在上传压缩包...', 'yellow') // 显示上传进度 const fileSize = statSync(archivePath).size log(`📊 文件大小: ${formatSize(fileSize)}`, 'blue') // 创建进度条 const progressBar = new SingleBar({ format: '📤 上传进度 |{bar}| {percentage}% | {value}/{total} Bytes | {speed}', barCompleteChar: '█', barIncompleteChar: '░', hideCursor: true }) progressBar.start(fileSize, 0) try { // 使用流的方式上传,获得实时进度 const readStream = createReadStream(archivePath) const writeStream = await sftp.createWriteStream(remoteArchivePath) let uploadedBytes = 0 const startTime = Date.now() // 监听数据流,实时更新进度 readStream.on('data', chunk => { uploadedBytes += chunk.length const elapsedTime = (Date.now() - startTime) / 1000 const speed = elapsedTime > 0 ? (uploadedBytes / elapsedTime).toFixed(2) : '0' progressBar.update(uploadedBytes, { speed: `${speed} B/s` }) }) // 处理流事件 readStream.pipe(writeStream) // 等待上传完成 await new Promise((resolve, reject) => { writeStream.on('close', () => { progressBar.stop() log('✅ 压缩包上传完成', 'green') resolve() }) writeStream.on('error', err => { progressBar.stop() reject(err) }) readStream.on('error', err => { progressBar.stop() reject(err) }) }) } catch (error) { progressBar.stop() throw error } } // 在服务器端解压 log('📦 正在服务器端解压...', 'yellow') // 检测操作系统 const isWindows = process.platform === 'win32' const execAsync = promisify(exec) // 转义密码(关键步骤!) function escapeForExpect (password) { return password .replace(/\\/g, "\\\\") // 转义反斜杠 \ .replace(/\$/g, "\\$"); // 转义 $ } if (isWindows) { // Windows 系统:使用 PowerShell 脚本(单次密码输入) log('🪟 Windows 系统检测到,使用 PowerShell 脚本', 'yellow') const runPowerShellScript = (scriptPath) => { return new Promise((resolve, reject) => { const child = spawn('powershell', [ '-ExecutionPolicy', 'Bypass', '-File', scriptPath ], { stdio: 'inherit' }); child.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`PowerShell 脚本执行失败,退出码: ${code}`)); } }); child.on('error', (error) => { reject(new Error(`无法启动 PowerShell: ${error.message}`)); }); }); }; const powershellScript = ` $username = "${config.username}" $hostname = "${config.host}" $remotePath = "${config.remotePath}" $archivePath = "${remoteArchivePath}" $safePath = [System.Management.Automation.WildcardPattern]::Escape($remotePath) $safeArchive = [System.Management.Automation.WildcardPattern]::Escape($archivePath) # 创建 SSH 命令 $combinedCommand = "cd '$safePath' && tar -xzf '$safeArchive' && rm '$safeArchive' && chmod -R 755 '$safePath'" Write-Host "=== 部署脚本 ===" -ForegroundColor Green Write-Host "目标服务器: $username@$hostname" -ForegroundColor Yellow Write-Host "远程路径: $remotePath" -ForegroundColor Yellow Write-Host "" Write-Host "🔑 请输入 SSH 密码 密码:${config.password}" -ForegroundColor Cyan Write-Host "" try { # 执行合并命令 Write-Host "🚀 正在执行部署操作..." -ForegroundColor Cyan ssh -o StrictHostKeyChecking=no $username@$hostname $combinedCommand Write-Host "✅ 所有操作完成!" -ForegroundColor Green } catch { Write-Host "❌ 执行失败: $_" -ForegroundColor Red Write-Host "" Write-Host "请手动执行以下命令:" -ForegroundColor Yellow Write-Host "ssh $username@$hostname \"cd '$remotePath' && tar -xzf '$archivePath' && rm '$archivePath' && chmod -R 755 '$remotePath'\"" -ForegroundColor Cyan exit 1 }` const psScriptPath = join(projectRoot, 'temp_deploy.ps1'); writeFileSync(psScriptPath, powershellScript); try { // 执行 PowerShell 脚本 log('ℹ️ 即将执行部署命令,请根据提示输入密码...', 'blue'); await runPowerShellScript(psScriptPath) log('✅ 部署完成', 'green'); } catch (error) { log('⚠️ 执行失败,请手动执行以下命令:', 'yellow'); log(`ssh ${config.username}@${config.host} "cd '${config.remotePath}' && tar -xzf '${remoteArchivePath}' && rm '${remoteArchivePath}' && chmod -R 755 '${config.remotePath}'"`, 'cyan'); // 等待用户确认 const readline = await import('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); await new Promise(resolve => { rl.question('执行完成后按回车键继续...', () => { rl.close(); resolve(); }); }); } finally { if (existsSync(psScriptPath)) { unlinkSync(psScriptPath); } } } else { // Unix/Linux/macOS 系统:使用 expect 脚本 const expectScript = `#!/usr/bin/expect -f set timeout 30 set password {${config.password}} spawn ssh -o StrictHostKeyChecking=no ${config.username}@${config.host} "cd ${config.remotePath} && tar -xzf ${remoteArchivePath} && rm ${remoteArchivePath}" expect "password:" send "$password\r" expect eof ` const expectScriptPath = join(projectRoot, 'temp_expect.sh') writeFileSync(expectScriptPath, expectScript) try { await execAsync(`chmod +x ${expectScriptPath}`) await execAsync(expectScriptPath) log('✅ 解压完成', 'green') } finally { if (existsSync(expectScriptPath)) { unlinkSync(expectScriptPath) } } // 设置权限 log('🔧 设置文件权限...', 'yellow') const chmodExpectScript = `#!/usr/bin/expect -f set timeout 30 set password {${config.password}} spawn ssh -o StrictHostKeyChecking=no ${config.username}@${config.host} "chmod -R 755 ${config.remotePath}" expect "password:" send "$password\r" expect eof ` const chmodScriptPath = join(projectRoot, 'temp_chmod.sh') writeFileSync(chmodScriptPath, chmodExpectScript) try { await execAsync(`chmod +x ${chmodScriptPath}`) await execAsync(chmodScriptPath) log('✅ 权限设置完成', 'green') } finally { if (existsSync(chmodScriptPath)) { unlinkSync(chmodScriptPath) } } } // 清理服务器上的压缩包 log('🗑️ 清理服务器压缩包...', 'yellow') try { await sftp.unlink(remoteArchivePath) log('✅ 服务器压缩包已清理', 'green') } catch (error) { log('⚠️ 服务器压缩包清理失败(可能已被删除)', 'yellow') } // 关闭连接 await sftp.end() log('🗑️ 清理本地压缩包...', 'yellow') if (archivePath && existsSync(archivePath)) { unlinkSync(archivePath) } log('🎉 部署成功!', 'green') config.accessLocation ? log(`🌐 访问地址: ${config.accessLocation}`, 'cyan') : log(`🌐 访问地址: http://${config.host}`, 'cyan') } catch (error) { log(`❌ 部署失败: ${error.message}`, 'red') console.error(error) process.exit(1) } } // 运行部署 deploy()