|
@@ -0,0 +1,315 @@
|
|
|
|
|
+// .build/deploy-android-to-server.js - Android APK 部署脚本
|
|
|
|
|
+const { NodeSSH } = require('node-ssh');
|
|
|
|
|
+const path = require('path');
|
|
|
|
|
+const fs = require('fs');
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 部署 Android APK 到远程服务器
|
|
|
|
|
+ * @param {Object} options 部署选项
|
|
|
|
|
+ * @param {string} options.privateKey SSH 私钥内容
|
|
|
|
|
+ * @param {string} options.host 服务器地址
|
|
|
|
|
+ * @param {string} options.username 用户名
|
|
|
|
|
+ * @param {string} options.remotePath H5 部署路径(会自动转换为 android-releases)
|
|
|
|
|
+ * @param {string} options.apkPath 本地 APK 文件路径
|
|
|
|
|
+ * @param {string} options.appVersion 应用版本号(如 1.0.0)
|
|
|
|
|
+ * @param {boolean} options.testOnly 是否仅测试连接
|
|
|
|
|
+ */
|
|
|
|
|
+async function deployAndroidToServer(options) {
|
|
|
|
|
+ const {
|
|
|
|
|
+ privateKey,
|
|
|
|
|
+ host,
|
|
|
|
|
+ username,
|
|
|
|
|
+ remotePath,
|
|
|
|
|
+ apkPath,
|
|
|
|
|
+ appVersion,
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 检查本地 APK 文件是否存在
|
|
|
|
|
+ if (!fs.existsSync(apkPath)) {
|
|
|
|
|
+ throw new Error(`本地 APK 文件不存在: ${apkPath}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 路径转换:/var/www/html/releases -> /var/www/html/android-releases
|
|
|
|
|
+ const androidRemotePath = remotePath.replace(/\/releases$/, '/android-releases');
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`📱 Android APK 部署`);
|
|
|
|
|
+ console.log(` H5 路径: ${remotePath}`);
|
|
|
|
|
+ console.log(` Android 路径: ${androidRemotePath}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 生成时间戳
|
|
|
|
|
+ const timestamp = new Date().toISOString()
|
|
|
|
|
+ .replace(/[-:]/g, '')
|
|
|
|
|
+ .replace('T', '-')
|
|
|
|
|
+ .split('.')[0];
|
|
|
|
|
+
|
|
|
|
|
+ // 生成版本号(格式: v1.1.5-20250129-143005)
|
|
|
|
|
+ const version = `v${appVersion}-${timestamp}`;
|
|
|
|
|
+
|
|
|
|
|
+ const remoteVersionDir = `${androidRemotePath}/${version}`;
|
|
|
|
|
+ const apkFileName = path.basename(apkPath);
|
|
|
|
|
+ const remoteApkPath = `${remoteVersionDir}/${apkFileName}`;
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`📦 部署版本: ${version}`);
|
|
|
|
|
+ console.log(` 本地 APK: ${apkPath}`);
|
|
|
|
|
+ console.log(` 远程路径: ${remoteApkPath}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 创建远程 android-releases 根目录
|
|
|
|
|
+ console.log('📁 创建远程目录结构...');
|
|
|
|
|
+ const mkdirResult = await ssh.execCommand(`mkdir -p ${remoteVersionDir}`);
|
|
|
|
|
+ if (mkdirResult.code !== 0) {
|
|
|
|
|
+ throw new Error(`创建远程目录失败: ${mkdirResult.stderr}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log('✅ 远程目录已创建');
|
|
|
|
|
+
|
|
|
|
|
+ // 上传 APK 文件
|
|
|
|
|
+ console.log('📤 开始上传 APK 文件...');
|
|
|
|
|
+ const fileStats = fs.statSync(apkPath);
|
|
|
|
|
+ const fileSizeMB = (fileStats.size / 1024 / 1024).toFixed(2);
|
|
|
|
|
+ console.log(` 文件大小: ${fileSizeMB} MB`);
|
|
|
|
|
+
|
|
|
|
|
+ await ssh.putFile(apkPath, remoteApkPath);
|
|
|
|
|
+ console.log('✅ APK 文件上传完成!');
|
|
|
|
|
+
|
|
|
|
|
+ // 验证上传的文件
|
|
|
|
|
+ console.log('🔍 验证上传的文件...');
|
|
|
|
|
+ const lsResult = await ssh.execCommand(`ls -lh ${remoteApkPath}`);
|
|
|
|
|
+ if (lsResult.code !== 0) {
|
|
|
|
|
+ throw new Error(`文件验证失败: ${lsResult.stderr}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log(` ${lsResult.stdout}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 更新 latest.apk 符号链接
|
|
|
|
|
+ console.log('🔗 更新 latest.apk 符号链接...');
|
|
|
|
|
+ const latestLinkPath = `${androidRemotePath}/latest.apk`;
|
|
|
|
|
+ const symlinkCmd = `cd ${androidRemotePath} && ln -sfn ${version}/${apkFileName} latest.apk`;
|
|
|
|
|
+ const symlinkResult = await ssh.execCommand(symlinkCmd);
|
|
|
|
|
+
|
|
|
|
|
+ if (symlinkResult.code !== 0) {
|
|
|
|
|
+ throw new Error(`更新符号链接失败: ${symlinkResult.stderr}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 验证符号链接
|
|
|
|
|
+ console.log('🔍 验证符号链接...');
|
|
|
|
|
+ const readlinkResult = await ssh.execCommand(`readlink ${latestLinkPath}`);
|
|
|
|
|
+ const currentTarget = readlinkResult.stdout.trim();
|
|
|
|
|
+ const expectedTarget = `${version}/${apkFileName}`;
|
|
|
|
|
+
|
|
|
|
|
+ if (currentTarget === expectedTarget) {
|
|
|
|
|
+ console.log(`✅ latest.apk 符号链接已正确更新 -> ${expectedTarget}`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.warn(`⚠️ 符号链接验证异常:`);
|
|
|
|
|
+ console.warn(` 期望: ${expectedTarget}`);
|
|
|
|
|
+ console.warn(` 实际: ${currentTarget}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 生成/更新版本清单文件
|
|
|
|
|
+ console.log('📋 更新版本清单文件...');
|
|
|
|
|
+ await updateVersionsManifest(ssh, androidRemotePath, {
|
|
|
|
|
+ version,
|
|
|
|
|
+ appVersion,
|
|
|
|
|
+ timestamp: new Date().toISOString(),
|
|
|
|
|
+ filename: apkFileName,
|
|
|
|
|
+ size: fileStats.size,
|
|
|
|
|
+ downloadUrl: `/android-releases/${version}/${apkFileName}`
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 列出所有版本
|
|
|
|
|
+ console.log('📊 版本列表:');
|
|
|
|
|
+ const versionsResult = await ssh.execCommand(`ls -1 ${androidRemotePath} | grep "^v"`);
|
|
|
|
|
+ const versions = versionsResult.stdout.split('\n').filter(v => v.trim());
|
|
|
|
|
+ console.log(` 总计 ${versions.length} 个版本:`);
|
|
|
|
|
+ versions.forEach((v, i) => {
|
|
|
|
|
+ const marker = v === version ? '← 最新' : '';
|
|
|
|
|
+ console.log(` ${i + 1}. ${v} ${marker}`);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ console.log('\n🎉 Android APK 部署成功!');
|
|
|
|
|
+ console.log(` 版本: ${version}`);
|
|
|
|
|
+ console.log(` 最新版本下载: ${androidRemotePath}/latest.apk`);
|
|
|
|
|
+ console.log(` 版本清单: ${androidRemotePath}/versions.json`);
|
|
|
|
|
+
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 更新版本清单文件
|
|
|
|
|
+ * @param {NodeSSH} ssh SSH 连接实例
|
|
|
|
|
+ * @param {string} remotePath 远程 android-releases 路径
|
|
|
|
|
+ * @param {Object} versionInfo 版本信息
|
|
|
|
|
+ */
|
|
|
|
|
+async function updateVersionsManifest(ssh, remotePath, versionInfo) {
|
|
|
|
|
+ const manifestPath = `${remotePath}/versions.json`;
|
|
|
|
|
+ const tempLocalPath = path.join(__dirname, '.versions-temp.json');
|
|
|
|
|
+
|
|
|
|
|
+ let manifest = {
|
|
|
|
|
+ latest: versionInfo.version,
|
|
|
|
|
+ versions: []
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 尝试下载现有的版本清单文件
|
|
|
|
|
+ console.log(' 下载现有版本清单...');
|
|
|
|
|
+ await ssh.getFile(tempLocalPath, manifestPath);
|
|
|
|
|
+
|
|
|
|
|
+ // 读取并解析
|
|
|
|
|
+ const content = fs.readFileSync(tempLocalPath, 'utf8');
|
|
|
|
|
+ manifest = JSON.parse(content);
|
|
|
|
|
+ console.log(` 已加载现有清单,包含 ${manifest.versions.length} 个版本`);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // 文件不存在,使用默认清单
|
|
|
|
|
+ console.log(' 版本清单文件不存在,创建新清单');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 更新清单
|
|
|
|
|
+ manifest.latest = versionInfo.version;
|
|
|
|
|
+
|
|
|
|
|
+ // 检查版本是否已存在
|
|
|
|
|
+ const existingIndex = manifest.versions.findIndex(v => v.version === versionInfo.version);
|
|
|
|
|
+ if (existingIndex !== -1) {
|
|
|
|
|
+ // 更新现有版本信息
|
|
|
|
|
+ manifest.versions[existingIndex] = versionInfo;
|
|
|
|
|
+ console.log(' 更新现有版本信息');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 添加新版本到数组开头
|
|
|
|
|
+ manifest.versions.unshift(versionInfo);
|
|
|
|
|
+ console.log(` 添加新版本,当前总计 ${manifest.versions.length} 个版本`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 写入本地临时文件
|
|
|
|
|
+ fs.writeFileSync(tempLocalPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
|
|
|
+
|
|
|
|
|
+ // 上传到服务器
|
|
|
|
|
+ console.log(' 上传版本清单到服务器...');
|
|
|
|
|
+ await ssh.putFile(tempLocalPath, manifestPath);
|
|
|
|
|
+
|
|
|
|
|
+ // 清理临时文件
|
|
|
|
|
+ fs.unlinkSync(tempLocalPath);
|
|
|
|
|
+
|
|
|
|
|
+ console.log('✅ 版本清单已更新');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 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;
|
|
|
|
|
+
|
|
|
|
|
+ // APK 文件路径(需要从命令行参数或环境变量获取)
|
|
|
|
|
+ const apkPath = process.env.APK_PATH || args.find(arg => arg.endsWith('.apk'));
|
|
|
|
|
+ const appVersion = process.env.APP_VERSION || '0.0.0';
|
|
|
|
|
+
|
|
|
|
|
+ // 验证必需的环境变量
|
|
|
|
|
+ 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 (!testOnly && !apkPath) missingVars.push('APK_PATH 或命令行参数');
|
|
|
|
|
+
|
|
|
|
|
+ if (missingVars.length > 0) {
|
|
|
|
|
+ console.error('❌ 缺少必需的环境变量/参数:');
|
|
|
|
|
+ missingVars.forEach(varName => {
|
|
|
|
|
+ console.error(` - ${varName}`);
|
|
|
|
|
+ });
|
|
|
|
|
+ console.error('\n请设置这些环境变量后重试。');
|
|
|
|
|
+ console.error('\n使用示例:');
|
|
|
|
|
+ console.error(' node deploy-android-to-server.js /path/to/app.apk');
|
|
|
|
|
+ console.error(' 或');
|
|
|
|
|
+ console.error(' APK_PATH=/path/to/app.apk APP_VERSION=1.0.0 node deploy-android-to-server.js');
|
|
|
|
|
+ process.exit(1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 执行部署
|
|
|
|
|
+ console.log('🚀 Android APK 部署系统启动\n');
|
|
|
|
|
+ if (testOnly) {
|
|
|
|
|
+ console.log('🧪 模式: 连接测试');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log('📦 模式: 完整部署');
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log('='.repeat(50) + '\n');
|
|
|
|
|
+
|
|
|
|
|
+ deployAndroidToServer({
|
|
|
|
|
+ privateKey,
|
|
|
|
|
+ host,
|
|
|
|
|
+ username,
|
|
|
|
|
+ remotePath,
|
|
|
|
|
+ apkPath,
|
|
|
|
|
+ appVersion,
|
|
|
|
|
+ testOnly
|
|
|
|
|
+ })
|
|
|
|
|
+ .then(() => {
|
|
|
|
|
+ console.log('\n' + '='.repeat(50));
|
|
|
|
|
+ process.exit(0);
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(error => {
|
|
|
|
|
+ console.error('\n' + '='.repeat(50));
|
|
|
|
|
+ process.exit(1);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+module.exports = { deployAndroidToServer };
|