Files
TrueGrowth/scripts/upload-and-deploy.js

516 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();