deploy-android-to-server.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. // .build/deploy-android-to-server.js - Android APK 部署脚本
  2. const { NodeSSH } = require('node-ssh');
  3. const path = require('path');
  4. const fs = require('fs');
  5. /**
  6. * 部署 Android APK 到远程服务器
  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 H5 部署路径(会自动转换为 android-releases)
  12. * @param {string} options.apkPath 本地 APK 文件路径
  13. * @param {string} options.appVersion 应用版本号(如 1.0.0)
  14. * @param {boolean} options.testOnly 是否仅测试连接
  15. */
  16. async function deployAndroidToServer(options) {
  17. const {
  18. privateKey,
  19. host,
  20. username,
  21. remotePath,
  22. apkPath,
  23. appVersion,
  24. testOnly = false
  25. } = options;
  26. const ssh = new NodeSSH();
  27. try {
  28. console.log('🔐 正在连接到服务器...');
  29. console.log(` 主机: ${username}@${host}`);
  30. // 连接到服务器
  31. await ssh.connect({
  32. host,
  33. username,
  34. privateKey,
  35. // 兼容多种 SSH 密钥算法
  36. algorithms: {
  37. key: [
  38. 'ssh-rsa',
  39. 'ecdsa-sha2-nistp256',
  40. 'ecdsa-sha2-nistp384',
  41. 'ecdsa-sha2-nistp521',
  42. 'ssh-ed25519'
  43. ],
  44. serverHostKey: [
  45. 'ssh-rsa',
  46. 'ecdsa-sha2-nistp256',
  47. 'ecdsa-sha2-nistp384',
  48. 'ecdsa-sha2-nistp521',
  49. 'ssh-ed25519'
  50. ]
  51. }
  52. });
  53. console.log('✅ SSH 连接成功!');
  54. // 如果是测试模式,执行测试命令后返回
  55. if (testOnly) {
  56. console.log('🧪 测试模式:执行测试命令...');
  57. const result = await ssh.execCommand('echo "SSH key works!"');
  58. console.log(` 输出: ${result.stdout}`);
  59. ssh.dispose();
  60. console.log('✅ SSH 连接测试完成');
  61. return;
  62. }
  63. // 检查本地 APK 文件是否存在
  64. if (!fs.existsSync(apkPath)) {
  65. throw new Error(`本地 APK 文件不存在: ${apkPath}`);
  66. }
  67. // 路径转换:/var/www/html/releases -> /var/www/html/android-releases
  68. const androidRemotePath = remotePath.replace(/\/releases$/, '/android-releases');
  69. console.log(`📱 Android APK 部署`);
  70. console.log(` H5 路径: ${remotePath}`);
  71. console.log(` Android 路径: ${androidRemotePath}`);
  72. // 生成时间戳
  73. const timestamp = new Date().toISOString()
  74. .replace(/[-:]/g, '')
  75. .replace('T', '-')
  76. .split('.')[0];
  77. // 生成版本号(格式: v1.1.5-20250129-143005)
  78. const version = `v${appVersion}-${timestamp}`;
  79. const remoteVersionDir = `${androidRemotePath}/${version}`;
  80. const apkFileName = path.basename(apkPath);
  81. const remoteApkPath = `${remoteVersionDir}/${apkFileName}`;
  82. console.log(`📦 部署版本: ${version}`);
  83. console.log(` 本地 APK: ${apkPath}`);
  84. console.log(` 远程路径: ${remoteApkPath}`);
  85. // 创建远程 android-releases 根目录
  86. console.log('📁 创建远程目录结构...');
  87. const mkdirResult = await ssh.execCommand(`mkdir -p ${remoteVersionDir}`);
  88. if (mkdirResult.code !== 0) {
  89. throw new Error(`创建远程目录失败: ${mkdirResult.stderr}`);
  90. }
  91. console.log('✅ 远程目录已创建');
  92. // 上传 APK 文件
  93. console.log('📤 开始上传 APK 文件...');
  94. const fileStats = fs.statSync(apkPath);
  95. const fileSizeMB = (fileStats.size / 1024 / 1024).toFixed(2);
  96. console.log(` 文件大小: ${fileSizeMB} MB`);
  97. await ssh.putFile(apkPath, remoteApkPath);
  98. console.log('✅ APK 文件上传完成!');
  99. // 验证上传的文件
  100. console.log('🔍 验证上传的文件...');
  101. const lsResult = await ssh.execCommand(`ls -lh ${remoteApkPath}`);
  102. if (lsResult.code !== 0) {
  103. throw new Error(`文件验证失败: ${lsResult.stderr}`);
  104. }
  105. console.log(` ${lsResult.stdout}`);
  106. // 更新 latest.apk 符号链接
  107. console.log('🔗 更新 latest.apk 符号链接...');
  108. const latestLinkPath = `${androidRemotePath}/latest.apk`;
  109. const symlinkCmd = `cd ${androidRemotePath} && ln -sfn ${version}/${apkFileName} latest.apk`;
  110. const symlinkResult = await ssh.execCommand(symlinkCmd);
  111. if (symlinkResult.code !== 0) {
  112. throw new Error(`更新符号链接失败: ${symlinkResult.stderr}`);
  113. }
  114. // 验证符号链接
  115. console.log('🔍 验证符号链接...');
  116. const readlinkResult = await ssh.execCommand(`readlink ${latestLinkPath}`);
  117. const currentTarget = readlinkResult.stdout.trim();
  118. const expectedTarget = `${version}/${apkFileName}`;
  119. if (currentTarget === expectedTarget) {
  120. console.log(`✅ latest.apk 符号链接已正确更新 -> ${expectedTarget}`);
  121. } else {
  122. console.warn(`⚠️ 符号链接验证异常:`);
  123. console.warn(` 期望: ${expectedTarget}`);
  124. console.warn(` 实际: ${currentTarget}`);
  125. }
  126. // 生成/更新版本清单文件
  127. console.log('📋 更新版本清单文件...');
  128. await updateVersionsManifest(ssh, androidRemotePath, {
  129. version,
  130. appVersion,
  131. timestamp: new Date().toISOString(),
  132. filename: apkFileName,
  133. size: fileStats.size,
  134. downloadUrl: `/android-releases/${version}/${apkFileName}`
  135. });
  136. // 列出所有版本
  137. console.log('📊 版本列表:');
  138. const versionsResult = await ssh.execCommand(`ls -1 ${androidRemotePath} | grep "^v"`);
  139. const versions = versionsResult.stdout.split('\n').filter(v => v.trim());
  140. console.log(` 总计 ${versions.length} 个版本:`);
  141. versions.forEach((v, i) => {
  142. const marker = v === version ? '← 最新' : '';
  143. console.log(` ${i + 1}. ${v} ${marker}`);
  144. });
  145. console.log('\n🎉 Android APK 部署成功!');
  146. console.log(` 版本: ${version}`);
  147. console.log(` 最新版本下载: ${androidRemotePath}/latest.apk`);
  148. console.log(` 版本清单: ${androidRemotePath}/versions.json`);
  149. ssh.dispose();
  150. return version;
  151. } catch (error) {
  152. console.error('\n❌ 部署失败:', error.message);
  153. // 提供更详细的错误信息
  154. if (error.level === 'client-authentication') {
  155. console.error(' → 认证失败,请检查 SSH 私钥是否正确');
  156. } else if (error.level === 'client-timeout') {
  157. console.error(' → 连接超时,请检查网络和服务器地址');
  158. } else if (error.code === 'ENOENT') {
  159. console.error(' → 文件或目录不存在');
  160. }
  161. ssh.dispose();
  162. throw error;
  163. }
  164. }
  165. /**
  166. * 更新版本清单文件
  167. * @param {NodeSSH} ssh SSH 连接实例
  168. * @param {string} remotePath 远程 android-releases 路径
  169. * @param {Object} versionInfo 版本信息
  170. */
  171. async function updateVersionsManifest(ssh, remotePath, versionInfo) {
  172. const manifestPath = `${remotePath}/versions.json`;
  173. const tempLocalPath = path.join(__dirname, '.versions-temp.json');
  174. let manifest = {
  175. latest: versionInfo.version,
  176. versions: []
  177. };
  178. try {
  179. // 尝试下载现有的版本清单文件
  180. console.log(' 下载现有版本清单...');
  181. await ssh.getFile(tempLocalPath, manifestPath);
  182. // 读取并解析
  183. const content = fs.readFileSync(tempLocalPath, 'utf8');
  184. manifest = JSON.parse(content);
  185. console.log(` 已加载现有清单,包含 ${manifest.versions.length} 个版本`);
  186. } catch (error) {
  187. // 文件不存在,使用默认清单
  188. console.log(' 版本清单文件不存在,创建新清单');
  189. }
  190. // 更新清单
  191. manifest.latest = versionInfo.version;
  192. // 检查版本是否已存在
  193. const existingIndex = manifest.versions.findIndex(v => v.version === versionInfo.version);
  194. if (existingIndex !== -1) {
  195. // 更新现有版本信息
  196. manifest.versions[existingIndex] = versionInfo;
  197. console.log(' 更新现有版本信息');
  198. } else {
  199. // 添加新版本到数组开头
  200. manifest.versions.unshift(versionInfo);
  201. console.log(` 添加新版本,当前总计 ${manifest.versions.length} 个版本`);
  202. }
  203. // 写入本地临时文件
  204. fs.writeFileSync(tempLocalPath, JSON.stringify(manifest, null, 2), 'utf8');
  205. // 上传到服务器
  206. console.log(' 上传版本清单到服务器...');
  207. await ssh.putFile(tempLocalPath, manifestPath);
  208. // 清理临时文件
  209. fs.unlinkSync(tempLocalPath);
  210. console.log('✅ 版本清单已更新');
  211. }
  212. // CLI 入口点
  213. if (require.main === module) {
  214. const args = process.argv.slice(2);
  215. const testOnly = args.includes('--test');
  216. // 从环境变量读取配置
  217. const privateKey = process.env.DEPLOY_KEY;
  218. const host = process.env.DEPLOY_HOST;
  219. const username = process.env.DEPLOY_USER;
  220. const remotePath = process.env.DEPLOY_PATH;
  221. // APK 文件路径(需要从命令行参数或环境变量获取)
  222. const apkPath = process.env.APK_PATH || args.find(arg => arg.endsWith('.apk'));
  223. const appVersion = process.env.APP_VERSION || '0.0.0';
  224. // 验证必需的环境变量
  225. const missingVars = [];
  226. if (!privateKey) missingVars.push('DEPLOY_KEY');
  227. if (!host) missingVars.push('DEPLOY_HOST');
  228. if (!username) missingVars.push('DEPLOY_USER');
  229. if (!remotePath) missingVars.push('DEPLOY_PATH');
  230. if (!testOnly && !apkPath) missingVars.push('APK_PATH 或命令行参数');
  231. if (missingVars.length > 0) {
  232. console.error('❌ 缺少必需的环境变量/参数:');
  233. missingVars.forEach(varName => {
  234. console.error(` - ${varName}`);
  235. });
  236. console.error('\n请设置这些环境变量后重试。');
  237. console.error('\n使用示例:');
  238. console.error(' node deploy-android-to-server.js /path/to/app.apk');
  239. console.error(' 或');
  240. console.error(' APK_PATH=/path/to/app.apk APP_VERSION=1.0.0 node deploy-android-to-server.js');
  241. process.exit(1);
  242. }
  243. // 执行部署
  244. console.log('🚀 Android APK 部署系统启动\n');
  245. if (testOnly) {
  246. console.log('🧪 模式: 连接测试');
  247. } else {
  248. console.log('📦 模式: 完整部署');
  249. }
  250. console.log('='.repeat(50) + '\n');
  251. deployAndroidToServer({
  252. privateKey,
  253. host,
  254. username,
  255. remotePath,
  256. apkPath,
  257. appVersion,
  258. testOnly
  259. })
  260. .then(() => {
  261. console.log('\n' + '='.repeat(50));
  262. process.exit(0);
  263. })
  264. .catch(error => {
  265. console.error('\n' + '='.repeat(50));
  266. process.exit(1);
  267. });
  268. }
  269. module.exports = { deployAndroidToServer };