const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { execSync } = require('child_process'); // 加载 .env 配置文件 function loadEnvConfig() { const envPath = path.join(__dirname, '../.env'); const config = { DEPLOY_HOST: '', DEPLOY_USER: '', DEPLOY_PORT: '22', DEPLOY_SSH_KEY: '', DEPLOY_SSH_PASSWORD: '', DEPLOY_UPLOAD_DIR: '', DEPLOY_RELEASES_DIR: '', // releases 目录,如果不设置则从 UPLOAD_DIR 推导 DEPLOY_SCRIPT_PATH: '', DEPLOY_AUTO_DEPLOY: 'test' // 默认部署到测试环境 }; if (fs.existsSync(envPath)) { const envContent = fs.readFileSync(envPath, 'utf8'); envContent.split('\n').forEach(line => { line = line.trim(); if (line && !line.startsWith('#')) { const match = line.match(/^([^=]+)=(.*)$/); if (match) { const key = match[1].trim(); let value = match[2].trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } if (config.hasOwnProperty(key)) { config[key] = value; } } } }); } return config; } // 检查 sshpass 是否安装 function checkSshpassInstalled() { try { execSync('which sshpass', { stdio: 'ignore' }); return true; } catch (e) { return false; } } // 检查 rsync 是否安装 function checkRsyncInstalled() { try { execSync('which rsync', { stdio: 'ignore' }); return true; } catch (e) { return false; } } // 构建 SSH 公共参数(消除重复逻辑) function buildSSHAuth(config) { let prefix = ''; let usePassword = false; const args = []; if (config.DEPLOY_SSH_PASSWORD) { if (checkSshpassInstalled()) { usePassword = true; prefix = `sshpass -p "${config.DEPLOY_SSH_PASSWORD}" `; } } if (config.DEPLOY_SSH_KEY && !usePassword) { const sshKeyPath = config.DEPLOY_SSH_KEY.startsWith('/') ? config.DEPLOY_SSH_KEY : path.join(process.env.HOME || '', config.DEPLOY_SSH_KEY.replace(/^~/, '')); if (fs.existsSync(sshKeyPath)) { args.push(`-i "${sshKeyPath}"`); } } args.push('-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'); return { prefix, usePassword, args }; } // 构建 SSH 命令 function buildSSHCommand(config, remoteCmd) { const { prefix, usePassword, args } = buildSSHAuth(config); const portArg = config.DEPLOY_PORT && config.DEPLOY_PORT !== '22' ? `-p ${config.DEPLOY_PORT}` : ''; return { command: `${prefix}ssh ${portArg} ${args.join(' ')} ${config.DEPLOY_USER}@${config.DEPLOY_HOST} "${remoteCmd}"`, usePassword, }; } // 执行远程 SSH 命令(返回 stdout) function execRemoteCommand(config, remoteCmd, options = {}) { const { command } = buildSSHCommand(config, remoteCmd); return execSync(command, { encoding: 'utf8', stdio: 'pipe', ...options }).trim(); } // 预检远程目录:确保存在且可写 function ensureRemoteDir(config) { const dir = config.DEPLOY_UPLOAD_DIR; if (!dir) return { ok: false, reason: '未配置 DEPLOY_UPLOAD_DIR' }; try { const result = execRemoteCommand(config, `mkdir -p ${dir} 2>/dev/null; test -w ${dir} && echo 'writable' || echo 'not_writable'` ); if (result === 'writable') return { ok: true }; // 尝试修复权限(如果用户有 sudo) try { execRemoteCommand(config, `sudo chown -R $(whoami) ${dir} 2>/dev/null && echo ok`); const retry = execRemoteCommand(config, `test -w ${dir} && echo 'writable' || echo 'not_writable'`); if (retry === 'writable') { console.log(`🔧 已自动修复目录权限`); return { ok: true }; } } catch (_) { /* sudo 不可用,忽略 */ } return { ok: false, reason: `目录 ${dir} 不可写`, fix: `ssh root@${config.DEPLOY_HOST} "chown -R ${config.DEPLOY_USER}:${config.DEPLOY_USER} ${dir} && chmod 755 ${dir}"`, }; } catch (error) { return { ok: false, reason: `SSH 连接失败: ${error.message}` }; } } // 查找最新的打包文件 function findLatestPackage() { const distPath = path.join(__dirname, '../dist/apps'); if (!fs.existsSync(distPath)) { console.error(`❌ 构建目录不存在: ${distPath}`); console.error(` 请先运行 npm run deploy:package 打包`); process.exit(1); } // 查找所有 tar.gz 文件 const files = fs.readdirSync(distPath) .filter(file => file.startsWith('web-') && file.endsWith('.tar.gz')) .map(file => { const filePath = path.join(distPath, file); const stats = fs.statSync(filePath); return { name: file, path: filePath, mtime: stats.mtime }; }) .sort((a, b) => b.mtime - a.mtime); // 按修改时间排序,最新的在前 if (files.length === 0) { console.error(`❌ 未找到打包文件`); console.error(` 请先运行 npm run deploy:package 打包`); process.exit(1); } return files[0]; } // 检查远程文件是否存在 function checkRemoteFileExists(tarName, config) { if (!config.DEPLOY_UPLOAD_DIR) return false; try { const result = execRemoteCommand(config, `test -f ${config.DEPLOY_UPLOAD_DIR}/${tarName} && echo 'exists' || echo 'not_exists'` ); return result === 'exists'; } catch (error) { return false; } } // 计算本地文件的哈希 function calculateLocalFileHash(filePath) { try { const fileBuffer = fs.readFileSync(filePath); const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex'); return hash; } catch (error) { return null; } } // 获取远程文件的哈希 function getRemoteFileHash(tarName, config) { if (!config.DEPLOY_UPLOAD_DIR) return null; try { const hash = execRemoteCommand(config, `sha256sum ${config.DEPLOY_UPLOAD_DIR}/${tarName} 2>/dev/null | cut -d' ' -f1 || echo ''` ); return hash || null; } catch (error) { return null; } } // 上传文件到远程服务器(rsync 优先,scp 降级) function uploadToServer(tarPath, tarName, config, localHash = null) { if (!config.DEPLOY_UPLOAD_DIR) { console.error(`\n❌ 未配置上传目录`); console.error(` 请在 .env 文件中配置 DEPLOY_UPLOAD_DIR`); return false; } // 预检远程目录 console.log(`\n🔍 预检远程目录...`); const dirCheck = ensureRemoteDir(config); if (!dirCheck.ok) { console.error(`\n❌ ${dirCheck.reason}`); if (dirCheck.fix) { console.error(`\n💡 修复命令:\n ${dirCheck.fix}`); } return false; } console.log(` 目录可写 ✓`); // 计算本地文件哈希 if (!localHash) { console.log(`\n🔐 计算本地文件哈希...`); localHash = calculateLocalFileHash(tarPath); if (localHash) { console.log(` 本地哈希: ${localHash.substring(0, 16)}...`); } } // 检查远程文件是否存在并比较哈希 console.log(`\n🔍 检查远程文件...`); const remoteHash = getRemoteFileHash(tarName, config); if (remoteHash) { console.log(` 远程哈希: ${remoteHash.substring(0, 16)}...`); if (localHash && remoteHash === localHash) { console.log(`✅ 远程文件已存在且哈希匹配,跳过上传`); return { success: true, tarName, usePassword: false, skipped: true, hash: localHash }; } else { console.log(`⚠️ 远程文件存在但哈希不匹配,将重新上传`); } } else { console.log(` 远程文件不存在,需要上传`); } const fileSizeMB = (fs.statSync(tarPath).size / 1024 / 1024).toFixed(2); console.log(`\n🚀 开始上传到远程服务器...`); console.log(` 服务器: ${config.DEPLOY_USER}@${config.DEPLOY_HOST}:${config.DEPLOY_PORT}`); console.log(` 目标目录: ${config.DEPLOY_UPLOAD_DIR}`); console.log(` 文件: ${tarName} (${fileSizeMB} MB)`); const { prefix, usePassword, args } = buildSSHAuth(config); const remoteDest = `${config.DEPLOY_USER}@${config.DEPLOY_HOST}:${config.DEPLOY_UPLOAD_DIR}/`; // 优先尝试 rsync(进度显示 + 压缩 + 断点续传) if (checkRsyncInstalled()) { try { console.log(`🔄 使用 rsync 上传(支持断点续传)...`); const portArg = config.DEPLOY_PORT && config.DEPLOY_PORT !== '22' ? config.DEPLOY_PORT : '22'; const sshOpts = `ssh -p ${portArg} ${args.join(' ')}`; const rsyncCmd = `${prefix}rsync -avz --progress --partial -e '${sshOpts}' "${tarPath}" "${remoteDest}"`; execSync(rsyncCmd, { stdio: 'inherit' }); console.log(`✅ rsync 上传成功!`); console.log(`📦 远程路径: ${config.DEPLOY_UPLOAD_DIR}/${tarName}`); return { success: true, tarName, usePassword }; } catch (error) { console.log(`⚠️ rsync 失败,降级到 scp...`); } } // scp 降级 try { console.log(`🔄 使用 scp 上传...`); let scpCmd = `${prefix}scp`; if (config.DEPLOY_PORT && config.DEPLOY_PORT !== '22') { scpCmd += ` -P ${config.DEPLOY_PORT}`; } scpCmd += ` ${args.join(' ')} "${tarPath}" "${remoteDest}"`; execSync(scpCmd, { stdio: 'inherit' }); console.log(`✅ scp 上传成功!`); console.log(`📦 远程路径: ${config.DEPLOY_UPLOAD_DIR}/${tarName}`); return { success: true, tarName, usePassword }; } catch (error) { console.error(`❌ 上传失败:`, error.message); if (error.message.includes('Permission denied')) { console.error(`\n💡 权限不足,请在服务器执行:`); console.error(` chown -R ${config.DEPLOY_USER}:${config.DEPLOY_USER} ${config.DEPLOY_UPLOAD_DIR}`); } return false; } } // 执行远程解压(只解压,不部署) function executeRemoteExtract(config, tarName, usePassword = false) { if (!config.DEPLOY_UPLOAD_DIR) { console.error(`\n❌ 未配置上传目录`); return false; } const uploadsDir = config.DEPLOY_UPLOAD_DIR; const releasesDir = config.DEPLOY_RELEASES_DIR || uploadsDir.replace('/uploads', '/releases'); console.log(`\n📦 开始远程解压...`); console.log(` 包文件: ${tarName}`); console.log(` 解压目录: ${releasesDir}`); try { const extractScript = `VERSION=$(tar -xzf ${uploadsDir}/${tarName} -O web/version.json 2>/dev/null | grep '"version"' | sed 's/.*"version": "\\([^"]*\\)".*/\\1/') if [ -z "$VERSION" ]; then echo "无法读取版本号" exit 1 fi echo "版本: $VERSION" if [ -d "${releasesDir}/$VERSION" ]; then echo "删除旧版本目录..." rm -rf "${releasesDir}/$VERSION" fi mkdir -p "${releasesDir}/$VERSION" echo "开始解压..." tar -xzf ${uploadsDir}/${tarName} -C "${releasesDir}/$VERSION" --strip-components=1 echo "解压完成: ${releasesDir}/$VERSION" if [ -f "${releasesDir}/$VERSION/version.json" ] && [ -d "${releasesDir}/$VERSION/assets" ]; then FILE_COUNT=$(find "${releasesDir}/$VERSION" -type f | wc -l) ASSETS_JS_COUNT=$(find "${releasesDir}/$VERSION/assets" -type f -name "*.js" | wc -l) echo "解压验证: $FILE_COUNT 个文件,$ASSETS_JS_COUNT 个 JS 文件" if [ "$ASSETS_JS_COUNT" -lt 50 ]; then echo "警告: JS 文件数量较少,可能不完整" fi else echo "解压验证失败" exit 1 fi cp "${releasesDir}/$VERSION/versions.html" "${releasesDir}/versions.html" 2>/dev/null || true cp "${releasesDir}/$VERSION/changelog.json" "${releasesDir}/changelog.json" 2>/dev/null || true`; const encodedScript = Buffer.from(extractScript).toString('base64'); const { command } = buildSSHCommand(config, `echo ${encodedScript} | base64 -d | bash`); console.log(`🔄 执行远程解压命令...`); execSync(command, { stdio: 'inherit' }); console.log(`✅ 解压成功!`); return true; } catch (error) { console.error(`❌ 解压失败:`, error.message); console.error(`\n💡 请检查:`); console.error(` 1. 包文件是否存在: ${uploadsDir}/${tarName}`); console.error(` 2. 服务器目录权限是否正确`); console.error(` 3. 磁盘空间是否充足`); return false; } } // 执行远程部署脚本 function executeRemoteDeploy(config, tarName, env = 'test', usePassword = false) { if (!config.DEPLOY_SCRIPT_PATH) { console.error(`\n❌ 未配置部署脚本路径`); return false; } const deployScriptPath = config.DEPLOY_SCRIPT_PATH; console.log(`\n🚀 开始自动部署到${env === 'test' ? '测试' : '生产'}环境...`); console.log(` 部署脚本: ${deployScriptPath}`); console.log(` 包文件: ${tarName}`); try { const remoteCommand = `bash ${deployScriptPath} --${env} ${tarName}`; const { command } = buildSSHCommand(config, remoteCommand); console.log(`🔄 执行远程部署命令...`); execSync(command, { stdio: 'inherit' }); console.log(`✅ 部署成功!`); return true; } catch (error) { console.error(`❌ 部署失败:`, error.message); console.error(`\n💡 请检查:`); console.error(` 1. 部署脚本路径是否正确: ${deployScriptPath}`); console.error(` 2. 脚本是否有执行权限`); console.error(` 3. 服务器目录权限是否正确`); return false; } } // 获取认证方式(用于后续的部署命令) function getAuthInfo(config) { let usePassword = false; if (config.DEPLOY_SSH_PASSWORD) { if (checkSshpassInstalled()) { usePassword = true; } } return { usePassword }; } // 主函数 function main() { // 解析命令行参数 const args = process.argv.slice(2); const env = args.includes('--prod') ? 'prod' : (args.includes('--test') ? 'test' : 'test'); const skipDeploy = args.includes('--no-deploy'); const deployOnly = args.includes('--deploy-only') || args.includes('--only-deploy'); console.log(`🚀 上传并部署工具`); console.log(`⏰ 时间: ${new Date().toLocaleString()}`); console.log(`───────────────────────────────────`); // 加载配置 const config = loadEnvConfig(); // 检查配置 if (!config.DEPLOY_HOST || !config.DEPLOY_USER) { console.error(`❌ 未配置服务器信息`); console.error(` 请在 .env 文件中配置 DEPLOY_HOST 和 DEPLOY_USER`); process.exit(1); } // 查找最新的打包文件 console.log(`\n📦 查找最新的打包文件...`); const packageFile = findLatestPackage(); console.log(`✅ 找到文件: ${packageFile.name}`); console.log(` 路径: ${packageFile.path}`); console.log(` 大小: ${(fs.statSync(packageFile.path).size / 1024 / 1024).toFixed(2)} MB`); console.log(` 修改时间: ${packageFile.mtime.toLocaleString()}`); let uploadResult = null; let fileExists = false; // 如果使用 --deploy-only,检查远程文件是否存在 if (deployOnly) { console.log(`\n🔍 检查远程文件是否存在...`); fileExists = checkRemoteFileExists(packageFile.name, config); if (fileExists) { console.log(`✅ 远程文件已存在: ${config.DEPLOY_UPLOAD_DIR}/${packageFile.name}`); console.log(` 跳过上传,直接部署`); uploadResult = getAuthInfo(config); uploadResult.success = true; uploadResult.tarName = packageFile.name; } else { console.error(`❌ 远程文件不存在: ${config.DEPLOY_UPLOAD_DIR}/${packageFile.name}`); console.error(` 请先上传文件或移除 --deploy-only 参数`); process.exit(1); } } else { // 上传文件(内部已处理哈希比对和跳过逻辑) uploadResult = uploadToServer(packageFile.path, packageFile.name, config); if (!uploadResult || !uploadResult.success) { console.error(`\n❌ 上传失败,终止部署`); process.exit(1); } } // 执行部署或解压 if (!skipDeploy) { // 如果使用 --prod,只解压不部署 if (env === 'prod') { console.log(`\n📦 生产环境模式:只解压,不部署`); const extractSuccess = executeRemoteExtract( config, packageFile.name, uploadResult.usePassword ); if (!extractSuccess) { console.error(`\n❌ 解压失败`); process.exit(1); } } else { // 测试环境或其他环境,执行完整部署 const deployEnv = config.DEPLOY_AUTO_DEPLOY === 'prod' ? 'prod' : env; const deploySuccess = executeRemoteDeploy( config, packageFile.name, deployEnv, uploadResult.usePassword ); if (!deploySuccess) { console.error(`\n❌ 部署失败`); process.exit(1); } } } else { console.log(`\n💡 已跳过自动部署(使用 --no-deploy 参数)`); console.log(` 可以在服务器上手动运行:`); if (config.DEPLOY_SCRIPT_PATH) { console.log(` ${config.DEPLOY_SCRIPT_PATH} --${env} ${packageFile.name}`); } else { console.log(` 部署脚本 --${env} ${packageFile.name}`); } } console.log(`───────────────────────────────────`); console.log(`🎊 完成!`); console.log(`\n💡 使用方法:`); console.log(` npm run deploy:upload # 上传并部署到测试环境`); console.log(` npm run deploy:upload -- --prod # 上传并解压到生产环境(不部署)`); console.log(` npm run deploy:upload -- --test # 上传并部署到测试环境`); console.log(` npm run deploy:upload -- --no-deploy # 只上传,不解压也不部署`); console.log(` npm run deploy:upload -- --deploy-only # 只部署,不上传(文件需已存在)`); } main();