deploy-to-server.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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. // 读取应用版本号
  66. const pkgPath = path.join(__dirname, '..', 'package.json');
  67. let appVersion = '0.0.0'; // 默认版本号
  68. try {
  69. const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
  70. appVersion = pkg.version || '0.0.0';
  71. } catch (error) {
  72. console.warn(`⚠️ 无法读取 package.json 版本号: ${error.message}`);
  73. console.warn(` 使用默认版本号: ${appVersion}`);
  74. }
  75. // 生成时间戳
  76. const timestamp = new Date().toISOString()
  77. .replace(/[-:]/g, '')
  78. .replace('T', '-')
  79. .split('.')[0];
  80. // 生成版本号(格式: v1.1.5-20250129-143005)
  81. const version = `v${appVersion}-${timestamp}`;
  82. const remoteDir = `${remotePath}/${version}`;
  83. console.log(`📦 部署版本: ${version}`);
  84. console.log(` 本地路径: ${localPath}`);
  85. console.log(` 远程路径: ${remoteDir}`);
  86. // 创建远程目录
  87. console.log('📁 创建远程目录...');
  88. const mkdirResult = await ssh.execCommand(`mkdir -p ${remoteDir}`);
  89. if (mkdirResult.code !== 0) {
  90. throw new Error(`创建远程目录失败: ${mkdirResult.stderr}`);
  91. }
  92. console.log('✅ 远程目录已创建');
  93. // 上传文件
  94. console.log('📤 开始上传文件...');
  95. let uploadedCount = 0;
  96. let errorCount = 0;
  97. const uploadResult = await ssh.putDirectory(localPath, remoteDir, {
  98. recursive: true,
  99. concurrency: 10, // 并发上传 10 个文件
  100. tick: (localFilePath, remoteFilePath, error) => {
  101. const fileName = path.basename(localFilePath);
  102. if (error) {
  103. errorCount++;
  104. console.error(` ❌ ${fileName}: ${error.message}`);
  105. } else {
  106. uploadedCount++;
  107. // 每上传 10 个文件显示一次进度
  108. if (uploadedCount % 10 === 0) {
  109. console.log(` ✓ 已上传 ${uploadedCount} 个文件...`);
  110. }
  111. }
  112. }
  113. });
  114. // 基于实际错误计数判断上传是否成功
  115. if (errorCount > 0) {
  116. throw new Error(`部分文件上传失败,成功: ${uploadedCount}, 失败: ${errorCount}`);
  117. }
  118. // node-ssh 的 successful 标志有时会误报,以实际错误计数为准
  119. if (!uploadResult.successful && errorCount === 0) {
  120. console.warn('⚠️ node-ssh 报告 successful=false,但所有文件都成功上传');
  121. }
  122. console.log(`✅ 文件上传完成!总计: ${uploadedCount} 个文件`);
  123. // 更新 latest 符号链接
  124. console.log('🔗 更新 latest 符号链接...');
  125. const symlinkCmd = `cd ${remotePath} && ln -sfn ${version} latest`;
  126. const symlinkResult = await ssh.execCommand(symlinkCmd);
  127. if (symlinkResult.code !== 0) {
  128. throw new Error(`更新符号链接失败: ${symlinkResult.stderr}`);
  129. }
  130. // 验证符号链接是否正确指向新版本
  131. console.log('🔍 验证符号链接...');
  132. const readlinkResult = await ssh.execCommand(`readlink ${remotePath}/latest`);
  133. const currentTarget = readlinkResult.stdout.trim();
  134. if (currentTarget === version) {
  135. console.log(`✅ latest 符号链接已正确更新 -> ${version}`);
  136. } else {
  137. console.warn(`⚠️ 符号链接验证异常:`);
  138. console.warn(` 期望: ${version}`);
  139. console.warn(` 实际: ${currentTarget}`);
  140. }
  141. // 验证部署文件
  142. console.log('🔍 验证部署文件...');
  143. const lsResult = await ssh.execCommand(`ls -la ${remotePath}/latest`);
  144. console.log(` ${lsResult.stdout}`);
  145. // 统计远程文件数量
  146. const countResult = await ssh.execCommand(`find ${remoteDir} -type f | wc -l`);
  147. const remoteFileCount = parseInt(countResult.stdout.trim());
  148. console.log('📊 部署统计:');
  149. console.log(` 上传文件: ${uploadedCount} 个`);
  150. console.log(` 远程文件: ${remoteFileCount} 个`);
  151. console.log(` 失败文件: ${errorCount} 个`);
  152. if (remoteFileCount >= uploadedCount - errorCount) {
  153. console.log(' ✅ 文件数量验证通过');
  154. } else {
  155. console.warn(' ⚠️ 远程文件数量少于预期');
  156. }
  157. console.log('\n🎉 部署成功!');
  158. console.log(` 版本: ${version}`);
  159. console.log(` 访问: ${remotePath}/latest/`);
  160. ssh.dispose();
  161. return version;
  162. } catch (error) {
  163. console.error('\n❌ 部署失败:', error.message);
  164. // 提供更详细的错误信息
  165. if (error.level === 'client-authentication') {
  166. console.error(' → 认证失败,请检查 SSH 私钥是否正确');
  167. } else if (error.level === 'client-timeout') {
  168. console.error(' → 连接超时,请检查网络和服务器地址');
  169. } else if (error.code === 'ENOENT') {
  170. console.error(' → 文件或目录不存在');
  171. }
  172. ssh.dispose();
  173. throw error;
  174. }
  175. }
  176. // CLI 入口点
  177. if (require.main === module) {
  178. const args = process.argv.slice(2);
  179. const testOnly = args.includes('--test');
  180. // 从环境变量读取配置
  181. const privateKey = process.env.DEPLOY_KEY;
  182. const host = process.env.DEPLOY_HOST;
  183. const username = process.env.DEPLOY_USER;
  184. const remotePath = process.env.DEPLOY_PATH;
  185. // 本地构建产物路径
  186. const localPath = path.join(__dirname, '..', 'dist', 'h5');
  187. // 验证必需的环境变量
  188. const missingVars = [];
  189. if (!privateKey) missingVars.push('DEPLOY_KEY');
  190. if (!host) missingVars.push('DEPLOY_HOST');
  191. if (!username) missingVars.push('DEPLOY_USER');
  192. if (!remotePath) missingVars.push('DEPLOY_PATH');
  193. if (missingVars.length > 0) {
  194. console.error('❌ 缺少必需的环境变量:');
  195. missingVars.forEach(varName => {
  196. console.error(` - ${varName}`);
  197. });
  198. console.error('\n请设置这些环境变量后重试。');
  199. process.exit(1);
  200. }
  201. // 执行部署
  202. console.log('🚀 部署系统启动\n');
  203. if (testOnly) {
  204. console.log('🧪 模式: 连接测试');
  205. } else {
  206. console.log('📦 模式: 完整部署');
  207. }
  208. console.log('='.repeat(50) + '\n');
  209. deployToServer({
  210. privateKey,
  211. host,
  212. username,
  213. remotePath,
  214. localPath,
  215. testOnly
  216. })
  217. .then(() => {
  218. console.log('\n' + '='.repeat(50));
  219. process.exit(0);
  220. })
  221. .catch(error => {
  222. console.error('\n' + '='.repeat(50));
  223. process.exit(1);
  224. });
  225. }
  226. module.exports = { deployToServer };