Initial TrueGrowth source import

This commit is contained in:
2026-07-07 09:36:36 +08:00
commit 3b6781d695
2283 changed files with 691996 additions and 0 deletions

View File

@@ -0,0 +1,515 @@
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();