deploy-to-server.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. // .build/deploy-to-server.js - 跨平台部署脚本
  2. const { NodeSSH } = require('node-ssh');
  3. const path = require('path');
  4. const fs = require('fs');
  5. /**
  6. * 部署到远程服务器
  7. * @param {Object} options 部署选项
  8. * @param {string} options.privateKey SSH 私钥内容
  9. * @param {string} options.host 服务器地址
  10. * @param {string} options.username 用户名
  11. * @param {string} options.remotePath 远程部署路径
  12. * @param {string} options.localPath 本地构建产物路径
  13. * @param {boolean} options.testOnly 是否仅测试连接
  14. */
  15. async function deployToServer(options) {
  16. const {
  17. privateKey,
  18. host,
  19. username,
  20. remotePath,
  21. localPath,
  22. testOnly = false
  23. } = options;
  24. const ssh = new NodeSSH();
  25. try {
  26. console.log('🔐 正在连接到服务器...');
  27. console.log(` 主机: ${username}@${host}`);
  28. // 连接到服务器
  29. await ssh.connect({
  30. host,
  31. username,
  32. privateKey,
  33. // 兼容多种 SSH 密钥算法
  34. algorithms: {
  35. key: [
  36. 'ssh-rsa',
  37. 'ecdsa-sha2-nistp256',
  38. 'ecdsa-sha2-nistp384',
  39. 'ecdsa-sha2-nistp521',
  40. 'ssh-ed25519'
  41. ],
  42. serverHostKey: [
  43. 'ssh-rsa',
  44. 'ecdsa-sha2-nistp256',
  45. 'ecdsa-sha2-nistp384',
  46. 'ecdsa-sha2-nistp521',
  47. 'ssh-ed25519'
  48. ]
  49. }
  50. });
  51. console.log('✅ SSH 连接成功!');
  52. // 如果是测试模式,执行测试命令后返回
  53. if (testOnly) {
  54. console.log('🧪 测试模式:执行测试命令...');
  55. const result = await ssh.execCommand('echo "SSH key works!"');
  56. console.log(` 输出: ${result.stdout}`);
  57. ssh.dispose();
  58. console.log('✅ SSH 连接测试完成');
  59. return;
  60. }
  61. // 检查本地构建产物是否存在
  62. if (!fs.existsSync(localPath)) {
  63. throw new Error(`本地构建产物不存在: ${localPath}`);
  64. }
  65. // 生成版本号(格式: 20250129-143005)
  66. const version = new Date().toISOString()
  67. .replace(/[-:]/g, '')
  68. .replace('T', '-')
  69. .split('.')[0];
  70. const remoteDir = `${remotePath}/${version}`;
  71. console.log(`📦 部署版本: ${version}`);
  72. console.log(` 本地路径: ${localPath}`);
  73. console.log(` 远程路径: ${remoteDir}`);
  74. // 创建远程目录
  75. console.log('📁 创建远程目录...');
  76. const mkdirResult = await ssh.execCommand(`mkdir -p ${remoteDir}`);
  77. if (mkdirResult.code !== 0) {
  78. throw new Error(`创建远程目录失败: ${mkdirResult.stderr}`);
  79. }
  80. console.log('✅ 远程目录已创建');
  81. // 上传文件
  82. console.log('📤 开始上传文件...');
  83. let uploadedCount = 0;
  84. let errorCount = 0;
  85. const uploadResult = await ssh.putDirectory(localPath, remoteDir, {
  86. recursive: true,
  87. concurrency: 10, // 并发上传 10 个文件
  88. tick: (localFilePath, remoteFilePath, error) => {
  89. const fileName = path.basename(localFilePath);
  90. if (error) {
  91. errorCount++;
  92. console.error(` ❌ ${fileName}: ${error.message}`);
  93. } else {
  94. uploadedCount++;
  95. // 每上传 10 个文件显示一次进度
  96. if (uploadedCount % 10 === 0) {
  97. console.log(` ✓ 已上传 ${uploadedCount} 个文件...`);
  98. }
  99. }
  100. }
  101. });
  102. // 基于实际错误计数判断上传是否成功
  103. if (errorCount > 0) {
  104. throw new Error(`部分文件上传失败,成功: ${uploadedCount}, 失败: ${errorCount}`);
  105. }
  106. // node-ssh 的 successful 标志有时会误报,以实际错误计数为准
  107. if (!uploadResult.successful && errorCount === 0) {
  108. console.warn('⚠️ node-ssh 报告 successful=false,但所有文件都成功上传');
  109. }
  110. console.log(`✅ 文件上传完成!总计: ${uploadedCount} 个文件`);
  111. // 更新 latest 符号链接
  112. console.log('🔗 更新 latest 符号链接...');
  113. const symlinkCmd = `cd ${remotePath} && ln -sfn ${version} latest`;
  114. const symlinkResult = await ssh.execCommand(symlinkCmd);
  115. if (symlinkResult.code !== 0) {
  116. throw new Error(`更新符号链接失败: ${symlinkResult.stderr}`);
  117. }
  118. // 验证符号链接是否正确指向新版本
  119. console.log('🔍 验证符号链接...');
  120. const readlinkResult = await ssh.execCommand(`readlink ${remotePath}/latest`);
  121. const currentTarget = readlinkResult.stdout.trim();
  122. if (currentTarget === version) {
  123. console.log(`✅ latest 符号链接已正确更新 -> ${version}`);
  124. } else {
  125. console.warn(`⚠️ 符号链接验证异常:`);
  126. console.warn(` 期望: ${version}`);
  127. console.warn(` 实际: ${currentTarget}`);
  128. }
  129. // 验证部署文件
  130. console.log('🔍 验证部署文件...');
  131. const lsResult = await ssh.execCommand(`ls -la ${remotePath}/latest`);
  132. console.log(` ${lsResult.stdout}`);
  133. // 统计远程文件数量
  134. const countResult = await ssh.execCommand(`find ${remoteDir} -type f | wc -l`);
  135. const remoteFileCount = parseInt(countResult.stdout.trim());
  136. console.log('📊 部署统计:');
  137. console.log(` 上传文件: ${uploadedCount} 个`);
  138. console.log(` 远程文件: ${remoteFileCount} 个`);
  139. console.log(` 失败文件: ${errorCount} 个`);
  140. if (remoteFileCount >= uploadedCount - errorCount) {
  141. console.log(' ✅ 文件数量验证通过');
  142. } else {
  143. console.warn(' ⚠️ 远程文件数量少于预期');
  144. }
  145. console.log('\n🎉 部署成功!');
  146. console.log(` 版本: ${version}`);
  147. console.log(` 访问: ${remotePath}/latest/`);
  148. ssh.dispose();
  149. return version;
  150. } catch (error) {
  151. console.error('\n❌ 部署失败:', error.message);
  152. // 提供更详细的错误信息
  153. if (error.level === 'client-authentication') {
  154. console.error(' → 认证失败,请检查 SSH 私钥是否正确');
  155. } else if (error.level === 'client-timeout') {
  156. console.error(' → 连接超时,请检查网络和服务器地址');
  157. } else if (error.code === 'ENOENT') {
  158. console.error(' → 文件或目录不存在');
  159. }
  160. ssh.dispose();
  161. throw error;
  162. }
  163. }
  164. // CLI 入口点
  165. if (require.main === module) {
  166. const args = process.argv.slice(2);
  167. const testOnly = args.includes('--test');
  168. // 从环境变量读取配置
  169. const privateKey = process.env.DEPLOY_KEY;
  170. const host = process.env.DEPLOY_HOST;
  171. const username = process.env.DEPLOY_USER;
  172. const remotePath = process.env.DEPLOY_PATH;
  173. // 本地构建产物路径
  174. const localPath = path.join(__dirname, '..', 'dist', 'h5');
  175. // 验证必需的环境变量
  176. const missingVars = [];
  177. if (!privateKey) missingVars.push('DEPLOY_KEY');
  178. if (!host) missingVars.push('DEPLOY_HOST');
  179. if (!username) missingVars.push('DEPLOY_USER');
  180. if (!remotePath) missingVars.push('DEPLOY_PATH');
  181. if (missingVars.length > 0) {
  182. console.error('❌ 缺少必需的环境变量:');
  183. missingVars.forEach(varName => {
  184. console.error(` - ${varName}`);
  185. });
  186. console.error('\n请设置这些环境变量后重试。');
  187. process.exit(1);
  188. }
  189. // 执行部署
  190. console.log('🚀 部署系统启动\n');
  191. if (testOnly) {
  192. console.log('🧪 模式: 连接测试');
  193. } else {
  194. console.log('📦 模式: 完整部署');
  195. }
  196. console.log('='.repeat(50) + '\n');
  197. deployToServer({
  198. privateKey,
  199. host,
  200. username,
  201. remotePath,
  202. localPath,
  203. testOnly
  204. })
  205. .then(() => {
  206. console.log('\n' + '='.repeat(50));
  207. process.exit(0);
  208. })
  209. .catch(error => {
  210. console.error('\n' + '='.repeat(50));
  211. process.exit(1);
  212. });
  213. }
  214. module.exports = { deployToServer };