// .build/deploy-to-server.js - 跨平台部署脚本 const { NodeSSH } = require('node-ssh'); const path = require('path'); const fs = require('fs'); /** * 部署到远程服务器 * @param {Object} options 部署选项 * @param {string} options.privateKey SSH 私钥内容 * @param {string} options.host 服务器地址 * @param {string} options.username 用户名 * @param {string} options.remotePath 远程部署路径 * @param {string} options.localPath 本地构建产物路径 * @param {boolean} options.testOnly 是否仅测试连接 */ async function deployToServer(options) { const { privateKey, host, username, remotePath, localPath, testOnly = false } = options; const ssh = new NodeSSH(); try { console.log('🔐 正在连接到服务器...'); console.log(` 主机: ${username}@${host}`); // 连接到服务器 await ssh.connect({ host, username, privateKey, // 兼容多种 SSH 密钥算法 algorithms: { key: [ 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519' ], serverHostKey: [ 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519' ] } }); console.log('✅ SSH 连接成功!'); // 如果是测试模式,执行测试命令后返回 if (testOnly) { console.log('🧪 测试模式:执行测试命令...'); const result = await ssh.execCommand('echo "SSH key works!"'); console.log(` 输出: ${result.stdout}`); ssh.dispose(); console.log('✅ SSH 连接测试完成'); return; } // 检查本地构建产物是否存在 if (!fs.existsSync(localPath)) { throw new Error(`本地构建产物不存在: ${localPath}`); } // 读取应用版本号 const pkgPath = path.join(__dirname, '..', 'package.json'); let appVersion = '0.0.0'; // 默认版本号 try { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); appVersion = pkg.version || '0.0.0'; } catch (error) { console.warn(`⚠️ 无法读取 package.json 版本号: ${error.message}`); console.warn(` 使用默认版本号: ${appVersion}`); } // 生成时间戳 const timestamp = new Date().toISOString() .replace(/[-:]/g, '') .replace('T', '-') .split('.')[0]; // 生成版本号(格式: v1.1.5-20250129-143005) const version = `v${appVersion}-${timestamp}`; const remoteDir = `${remotePath}/${version}`; console.log(`📦 部署版本: ${version}`); console.log(` 本地路径: ${localPath}`); console.log(` 远程路径: ${remoteDir}`); // 创建远程目录 console.log('📁 创建远程目录...'); const mkdirResult = await ssh.execCommand(`mkdir -p ${remoteDir}`); if (mkdirResult.code !== 0) { throw new Error(`创建远程目录失败: ${mkdirResult.stderr}`); } console.log('✅ 远程目录已创建'); // 上传文件 console.log('📤 开始上传文件...'); let uploadedCount = 0; let errorCount = 0; const uploadResult = await ssh.putDirectory(localPath, remoteDir, { recursive: true, concurrency: 10, // 并发上传 10 个文件 tick: (localFilePath, remoteFilePath, error) => { const fileName = path.basename(localFilePath); if (error) { errorCount++; console.error(` ❌ ${fileName}: ${error.message}`); } else { uploadedCount++; // 每上传 10 个文件显示一次进度 if (uploadedCount % 10 === 0) { console.log(` ✓ 已上传 ${uploadedCount} 个文件...`); } } } }); // 基于实际错误计数判断上传是否成功 if (errorCount > 0) { throw new Error(`部分文件上传失败,成功: ${uploadedCount}, 失败: ${errorCount}`); } // node-ssh 的 successful 标志有时会误报,以实际错误计数为准 if (!uploadResult.successful && errorCount === 0) { console.warn('⚠️ node-ssh 报告 successful=false,但所有文件都成功上传'); } console.log(`✅ 文件上传完成!总计: ${uploadedCount} 个文件`); // 更新 latest 符号链接 console.log('🔗 更新 latest 符号链接...'); const symlinkCmd = `cd ${remotePath} && ln -sfn ${version} latest`; const symlinkResult = await ssh.execCommand(symlinkCmd); if (symlinkResult.code !== 0) { throw new Error(`更新符号链接失败: ${symlinkResult.stderr}`); } // 验证符号链接是否正确指向新版本 console.log('🔍 验证符号链接...'); const readlinkResult = await ssh.execCommand(`readlink ${remotePath}/latest`); const currentTarget = readlinkResult.stdout.trim(); if (currentTarget === version) { console.log(`✅ latest 符号链接已正确更新 -> ${version}`); } else { console.warn(`⚠️ 符号链接验证异常:`); console.warn(` 期望: ${version}`); console.warn(` 实际: ${currentTarget}`); } // 验证部署文件 console.log('🔍 验证部署文件...'); const lsResult = await ssh.execCommand(`ls -la ${remotePath}/latest`); console.log(` ${lsResult.stdout}`); // 统计远程文件数量 const countResult = await ssh.execCommand(`find ${remoteDir} -type f | wc -l`); const remoteFileCount = parseInt(countResult.stdout.trim()); console.log('📊 部署统计:'); console.log(` 上传文件: ${uploadedCount} 个`); console.log(` 远程文件: ${remoteFileCount} 个`); console.log(` 失败文件: ${errorCount} 个`); if (remoteFileCount >= uploadedCount - errorCount) { console.log(' ✅ 文件数量验证通过'); } else { console.warn(' ⚠️ 远程文件数量少于预期'); } console.log('\n🎉 部署成功!'); console.log(` 版本: ${version}`); console.log(` 访问: ${remotePath}/latest/`); ssh.dispose(); return version; } catch (error) { console.error('\n❌ 部署失败:', error.message); // 提供更详细的错误信息 if (error.level === 'client-authentication') { console.error(' → 认证失败,请检查 SSH 私钥是否正确'); } else if (error.level === 'client-timeout') { console.error(' → 连接超时,请检查网络和服务器地址'); } else if (error.code === 'ENOENT') { console.error(' → 文件或目录不存在'); } ssh.dispose(); throw error; } } // CLI 入口点 if (require.main === module) { const args = process.argv.slice(2); const testOnly = args.includes('--test'); // 从环境变量读取配置 const privateKey = process.env.DEPLOY_KEY; const host = process.env.DEPLOY_HOST; const username = process.env.DEPLOY_USER; const remotePath = process.env.DEPLOY_PATH; // 本地构建产物路径 const localPath = path.join(__dirname, '..', 'dist', 'h5'); // 验证必需的环境变量 const missingVars = []; if (!privateKey) missingVars.push('DEPLOY_KEY'); if (!host) missingVars.push('DEPLOY_HOST'); if (!username) missingVars.push('DEPLOY_USER'); if (!remotePath) missingVars.push('DEPLOY_PATH'); if (missingVars.length > 0) { console.error('❌ 缺少必需的环境变量:'); missingVars.forEach(varName => { console.error(` - ${varName}`); }); console.error('\n请设置这些环境变量后重试。'); process.exit(1); } // 执行部署 console.log('🚀 部署系统启动\n'); if (testOnly) { console.log('🧪 模式: 连接测试'); } else { console.log('📦 模式: 完整部署'); } console.log('='.repeat(50) + '\n'); deployToServer({ privateKey, host, username, remotePath, localPath, testOnly }) .then(() => { console.log('\n' + '='.repeat(50)); process.exit(0); }) .catch(error => { console.error('\n' + '='.repeat(50)); process.exit(1); }); } module.exports = { deployToServer };