Files
aitu/scripts/upload-and-deploy.js
gongchengtu 51bb033edb feat: 版本日志菜单 + CHANGELOG.md 自动同步到 changelog.json
- 新增 scripts/sync-changelog.js:解析 CHANGELOG.md 生成 changelog.json,
  自动过滤噪音条目(bump version、docs、refactor 等内部描述)
- safe-version-bump.js:version:patch 时自动调用 sync-changelog 并提交 changelog.json
- versions.html:简化为纯 changelog 展示页,去掉版本跳转和 CDN 链接,
  默认显示 30 个版本 + 加载更多按钮,展开详情 max-height 调至 5000px
- app-menu-items.tsx:新增「版本日志」菜单项(打开 versions.html),
  「版本更多」恢复跳转到 release.opentu.ai
- update-version.js:构建时检查 changelog.json 是否包含当前版本
- upload-and-deploy.js:部署时复制 versions.html 和 changelog.json 到 releases 根目录
- i18n.tsx:新增 menu.changelog 翻译 key
2026-02-26 15:01:02 +08:00

609 lines
20 KiB
JavaScript
Raw Permalink 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;
}
}
// 查找最新的打包文件
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 {
// 构建 SSH 命令
let sshCommand = '';
let usePassword = false;
if (config.DEPLOY_SSH_PASSWORD) {
if (!checkSshpassInstalled()) {
return false;
}
usePassword = true;
sshCommand = `sshpass -p "${config.DEPLOY_SSH_PASSWORD}" `;
}
sshCommand += 'ssh';
if (config.DEPLOY_PORT && config.DEPLOY_PORT !== '22') {
sshCommand += ` -p ${config.DEPLOY_PORT}`;
}
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)) {
sshCommand += ` -i "${sshKeyPath}"`;
}
}
sshCommand += ` -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
sshCommand += ` ${config.DEPLOY_USER}@${config.DEPLOY_HOST}`;
sshCommand += ` "test -f ${config.DEPLOY_UPLOAD_DIR}/${tarName} && echo 'exists' || echo 'not_exists'"`;
const result = execSync(sshCommand, { encoding: 'utf8', stdio: 'pipe' });
return result.trim() === '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 {
// 构建 SSH 命令
let sshCommand = '';
let usePassword = false;
if (config.DEPLOY_SSH_PASSWORD) {
if (!checkSshpassInstalled()) {
return null;
}
usePassword = true;
sshCommand = `sshpass -p "${config.DEPLOY_SSH_PASSWORD}" `;
}
sshCommand += 'ssh';
if (config.DEPLOY_PORT && config.DEPLOY_PORT !== '22') {
sshCommand += ` -p ${config.DEPLOY_PORT}`;
}
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)) {
sshCommand += ` -i "${sshKeyPath}"`;
}
}
sshCommand += ` -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
sshCommand += ` ${config.DEPLOY_USER}@${config.DEPLOY_HOST}`;
sshCommand += ` "sha256sum ${config.DEPLOY_UPLOAD_DIR}/${tarName} 2>/dev/null | cut -d' ' -f1 || echo ''"`;
const result = execSync(sshCommand, { encoding: 'utf8', stdio: 'pipe' });
const hash = result.trim();
return hash || null;
} catch (error) {
return null;
}
}
// 上传文件到远程服务器
function uploadToServer(tarPath, tarName, config, localHash = null) {
if (!config.DEPLOY_UPLOAD_DIR) {
console.error(`\n❌ 未配置上传目录`);
console.error(` 请在 .env 文件中配置 DEPLOY_UPLOAD_DIR`);
return false;
}
// 计算本地文件哈希
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(` 远程文件不存在,需要上传`);
}
console.log(`\n🚀 开始上传到远程服务器...`);
console.log(` 服务器: ${config.DEPLOY_USER}@${config.DEPLOY_HOST}:${config.DEPLOY_PORT}`);
console.log(` 目标目录: ${config.DEPLOY_UPLOAD_DIR}`);
console.log(` 文件: ${tarName}`);
try {
// 构建 scp 命令
let scpCommand = '';
let usePassword = false;
// 如果配置了密码,优先使用密码
if (config.DEPLOY_SSH_PASSWORD) {
if (!checkSshpassInstalled()) {
console.error(`\n❌ 未安装 sshpass,无法使用密码认证`);
console.error(`\n💡 安装方法:`);
console.error(` macOS: brew install hudochenkov/sshpass/sshpass`);
console.error(` Linux: apt-get install sshpass 或 yum install sshpass`);
return false;
}
usePassword = true;
scpCommand = `sshpass -p "${config.DEPLOY_SSH_PASSWORD}" `;
}
scpCommand += 'scp';
// 添加端口
if (config.DEPLOY_PORT && config.DEPLOY_PORT !== '22') {
scpCommand += ` -P ${config.DEPLOY_PORT}`;
}
// 添加 SSH 密钥(如果没有使用密码)
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)) {
scpCommand += ` -i "${sshKeyPath}"`;
}
}
// 禁用严格主机密钥检查
scpCommand += ` -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
// 添加源文件和目标
const remotePath = `${config.DEPLOY_USER}@${config.DEPLOY_HOST}:${config.DEPLOY_UPLOAD_DIR}`;
scpCommand += ` "${tarPath}" "${remotePath}/"`;
console.log(`🔄 执行上传命令...`);
if (usePassword) {
console.log(` 使用密码认证`);
} else if (config.DEPLOY_SSH_KEY) {
console.log(` 使用 SSH 密钥认证`);
} else {
console.log(` 使用默认 SSH 认证`);
}
execSync(scpCommand, { stdio: 'inherit' });
console.log(`✅ 上传成功!`);
console.log(`📦 远程路径: ${config.DEPLOY_UPLOAD_DIR}/${tarName}`);
return { success: true, tarName, usePassword };
} catch (error) {
console.error(`❌ 上传失败:`, error.message);
return false;
}
}
// 执行远程解压(只解压,不部署)
function executeRemoteExtract(config, tarName, usePassword = false) {
if (!config.DEPLOY_UPLOAD_DIR) {
console.error(`\n❌ 未配置上传目录`);
console.error(` 请在 .env 文件中配置 DEPLOY_UPLOAD_DIR`);
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(` 上传目录: ${uploadsDir}`);
console.log(` 解压目录: ${releasesDir}`);
try {
// 构建 SSH 命令
let sshCommand = '';
if (usePassword) {
sshCommand = `sshpass -p "${config.DEPLOY_SSH_PASSWORD}" `;
}
sshCommand += 'ssh';
// 添加端口
if (config.DEPLOY_PORT && config.DEPLOY_PORT !== '22') {
sshCommand += ` -p ${config.DEPLOY_PORT}`;
}
// 添加 SSH 密钥(如果没有使用密码)
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)) {
sshCommand += ` -i "${sshKeyPath}"`;
}
}
// 禁用严格主机密钥检查
sshCommand += ` -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
// 构建远程解压命令
// 使用 base64 编码避免引号转义问题
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`;
// 将脚本编码为 base64,避免引号转义问题
const encodedScript = Buffer.from(extractScript).toString('base64');
const remoteCommand = `echo ${encodedScript} | base64 -d | bash`;
sshCommand += ` ${config.DEPLOY_USER}@${config.DEPLOY_HOST} "${remoteCommand}"`;
console.log(`🔄 执行远程解压命令...`);
execSync(sshCommand, { 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❌ 未配置部署脚本路径`);
console.error(` 请在 .env 文件中配置 DEPLOY_SCRIPT_PATH`);
return false;
}
const deployScriptPath = config.DEPLOY_SCRIPT_PATH;
console.log(`\n🚀 开始自动部署到${env === 'test' ? '测试' : '生产'}环境...`);
console.log(` 部署脚本: ${deployScriptPath}`);
console.log(` 包文件: ${tarName}`);
try {
// 构建 SSH 命令
let sshCommand = '';
if (usePassword) {
sshCommand = `sshpass -p "${config.DEPLOY_SSH_PASSWORD}" `;
}
sshCommand += 'ssh';
// 添加端口
if (config.DEPLOY_PORT && config.DEPLOY_PORT !== '22') {
sshCommand += ` -p ${config.DEPLOY_PORT}`;
}
// 添加 SSH 密钥(如果没有使用密码)
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)) {
sshCommand += ` -i "${sshKeyPath}"`;
}
}
// 禁用严格主机密钥检查
sshCommand += ` -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
// 构建远程命令
const remoteCommand = `bash ${deployScriptPath} --${env} ${tarName}`;
sshCommand += ` ${config.DEPLOY_USER}@${config.DEPLOY_HOST} "${remoteCommand}"`;
console.log(`🔄 执行远程部署命令...`);
execSync(sshCommand, { 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 {
// 计算本地文件哈希
console.log(`\n🔐 计算本地文件哈希...`);
const localHash = calculateLocalFileHash(packageFile.path);
if (localHash) {
console.log(` 本地哈希: ${localHash.substring(0, 16)}...`);
}
// 检查文件是否已存在并比较哈希
fileExists = checkRemoteFileExists(packageFile.name, config);
if (fileExists) {
console.log(`\nℹ️ 远程文件已存在: ${config.DEPLOY_UPLOAD_DIR}/${packageFile.name}`);
// 获取远程文件哈希
const remoteHash = getRemoteFileHash(packageFile.name, config);
if (remoteHash && localHash) {
console.log(` 远程哈希: ${remoteHash.substring(0, 16)}...`);
if (remoteHash === localHash) {
console.log(`✅ 远程文件哈希匹配,跳过上传`);
uploadResult = getAuthInfo(config);
uploadResult.success = true;
uploadResult.tarName = packageFile.name;
uploadResult.skipped = true;
uploadResult.hash = localHash;
} else {
console.log(`⚠️ 远程文件哈希不匹配,将重新上传`);
uploadResult = uploadToServer(packageFile.path, packageFile.name, config, localHash);
}
} else {
console.log(` 将重新上传覆盖`);
uploadResult = uploadToServer(packageFile.path, packageFile.name, config, localHash);
}
} else {
// 上传文件
uploadResult = uploadToServer(packageFile.path, packageFile.name, config, localHash);
}
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();