Files
aitu/scripts/deploy-hybrid.js
T

461 lines
13 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.
#!/usr/bin/env node
/**
* 统一混合部署脚本
*
* 一键完成:
* 1. 构建项目
* 2. 分离 HTML 和静态资源
* 3. 发布静态资源到 npm CDN
* 4. 部署 HTML 到自有服务器
*
* 用法:
* node scripts/deploy-hybrid.js [options]
*
* 选项:
* --skip-build 跳过构建步骤
* --skip-npm 跳过 npm 发布
* --skip-server 跳过服务器部署
* --dry-run 预览模式,不实际执行
* --otp=123456 npm 2FA 验证码
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// ============================================
// 配置
// ============================================
const CONFIG = {
packageName: 'aitu-app',
distDir: path.resolve(__dirname, '../dist/apps/web'),
outputServer: path.resolve(__dirname, '../dist/deploy/server'),
outputCDN: path.resolve(__dirname, '../dist/deploy/cdn'),
cdnTemplates: {
unpkg: 'https://unpkg.com/aitu-app@{version}',
jsdelivr: 'https://cdn.jsdelivr.net/npm/aitu-app@{version}',
},
// 只在服务器的文件
serverOnlyFiles: [
'index.html',
'sw-debug.html',
'cdn-debug.html',
'batch-image.html',
'versions.html',
'iframe-test.html',
'init.json',
],
// 不上传到 CDN 的模式
excludeFromCDN: [
/\.html$/,
/^init\.json$/,
/\.map$/,
],
};
// ============================================
// 命令行参数
// ============================================
const args = process.argv.slice(2);
const skipBuild = args.includes('--skip-build');
const skipNpm = args.includes('--skip-npm');
const skipServer = args.includes('--skip-server');
const isDryRun = args.includes('--dry-run');
const otpArg = args.find(arg => arg.startsWith('--otp='));
const otp = otpArg ? otpArg.split('=')[1] : null;
const cdnProvider = 'unpkg';
// ============================================
// 工具函数
// ============================================
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
red: '\x1b[31m',
cyan: '\x1b[36m',
gray: '\x1b[90m',
};
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function logStep(step, total, message) {
log(`\n[${'='.repeat(step)}${'-'.repeat(total - step)}] 步骤 ${step}/${total}: ${message}`, 'blue');
}
function logSuccess(message) {
log(` ✓ ${message}`, 'green');
}
function logWarning(message) {
log(` ⚠ ${message}`, 'yellow');
}
function logError(message) {
log(` ✗ ${message}`, 'red');
}
function exec(command, options = {}) {
log(` 执行: ${command.substring(0, 80)}${command.length > 80 ? '...' : ''}`, 'gray');
try {
if (isDryRun) {
log(` [DRY RUN] 跳过执行`, 'yellow');
return true;
}
execSync(command, { stdio: 'inherit', ...options });
return true;
} catch (error) {
return false;
}
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function copyFile(src, dest) {
ensureDir(path.dirname(dest));
fs.copyFileSync(src, dest);
}
function getVersion() {
const versionPath = path.resolve(__dirname, '../apps/web/public/version.json');
if (fs.existsSync(versionPath)) {
return JSON.parse(fs.readFileSync(versionPath, 'utf-8')).version;
}
const pkgPath = path.resolve(__dirname, '../package.json');
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
}
function shouldUploadToCDN(filename) {
return !CONFIG.excludeFromCDN.some(pattern =>
pattern instanceof RegExp ? pattern.test(filename) : filename === pattern
);
}
function shouldKeepOnServer(filename) {
return CONFIG.serverOnlyFiles.some(f => filename === f || filename.endsWith(f));
}
// ============================================
// 加载服务器配置
// ============================================
function loadEnvConfig() {
const envPath = path.join(__dirname, '../.env');
const config = {
DEPLOY_HOST: '',
DEPLOY_USER: '',
DEPLOY_PORT: '22',
DEPLOY_SSH_KEY: '',
DEPLOY_SSH_PASSWORD: '',
DEPLOY_WEB_DIR: '', // 新增:Web 根目录
};
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;
}
// ============================================
// 步骤 1: 构建项目
// ============================================
function stepBuild() {
logStep(1, 5, '构建项目');
if (skipBuild) {
logWarning('跳过构建(使用现有产物)');
return true;
}
if (!exec('pnpm run build:web', { cwd: path.resolve(__dirname, '..') })) {
logError('构建失败');
return false;
}
logSuccess('构建完成');
return true;
}
// ============================================
// 步骤 2: 准备部署文件
// ============================================
function stepSeparateFiles(version, cdnBaseUrl) {
logStep(2, 5, '准备部署文件');
// 检查构建产物
if (!fs.existsSync(CONFIG.distDir)) {
logError(`构建目录不存在: ${CONFIG.distDir}`);
return false;
}
// 清理输出目录
if (fs.existsSync(CONFIG.outputServer)) {
fs.rmSync(CONFIG.outputServer, { recursive: true });
}
if (fs.existsSync(CONFIG.outputCDN)) {
fs.rmSync(CONFIG.outputCDN, { recursive: true });
}
ensureDir(CONFIG.outputServer);
ensureDir(CONFIG.outputCDN);
let serverFileCount = 0;
let cdnFileCount = 0;
// 递归复制目录
function copyDir(src, dest, filter = () => true) {
ensureDir(dest);
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath, filter);
} else if (filter(entry.name)) {
copyFile(srcPath, destPath);
}
}
}
// 服务器:复制全部文件(作为兜底)
log(' 复制全部文件到服务器目录(兜底)...', 'gray');
copyDir(CONFIG.distDir, CONFIG.outputServer, (filename) => {
// 排除 source maps
if (filename.endsWith('.map')) return false;
serverFileCount++;
return true;
});
// CDN:只复制静态资源(不含 HTML)
log(' 复制静态资源到 CDN 目录...', 'gray');
copyDir(CONFIG.distDir, CONFIG.outputCDN, (filename) => {
if (!shouldUploadToCDN(filename)) return false;
cdnFileCount++;
return true;
});
// 添加 CDN 版本注释到 HTML(资源路径保持相对,由 SW 处理 CDN 加载)
const htmlFiles = fs.readdirSync(CONFIG.outputServer).filter(f => f.endsWith('.html'));
for (const htmlFile of htmlFiles) {
const htmlPath = path.join(CONFIG.outputServer, htmlFile);
let content = fs.readFileSync(htmlPath, 'utf-8');
// 只添加注释,不修改资源路径(SW 会自动从 CDN 加载并缓存)
content = content.replace('</head>', ` <!-- CDN: ${cdnProvider} v${version} | SW handles CDN loading -->\n </head>`);
fs.writeFileSync(htmlPath, content);
}
logSuccess(`服务器: ${serverFileCount} 个文件(完整副本,用于兜底)`);
logSuccess(`CDN: ${cdnFileCount} 个静态资源(不含 HTML)`);
logSuccess(`资源加载:SW 优先 CDN,缓存到 Cache Storage,兜底服务器`);
return true;
}
// ============================================
// 步骤 3: 发布到 npm CDN
// ============================================
function stepPublishNpm(version) {
logStep(3, 5, '发布静态资源到 npm CDN');
if (skipNpm) {
logWarning('跳过 npm 发布');
return true;
}
// 生成 package.json
const npmPackage = {
name: CONFIG.packageName,
version: version,
description: 'Aitu static assets for CDN (HTML not included)',
license: 'MIT',
files: ['**/*'],
publishConfig: { access: 'public' },
aituAssets: { type: 'cdn-assets', htmlIncluded: false }
};
fs.writeFileSync(
path.join(CONFIG.outputCDN, 'package.json'),
JSON.stringify(npmPackage, null, 2)
);
// 生成 README
const readme = `# Aitu CDN Assets v${version}\n\n> 静态资源包,不含 HTML 文件\n\n- unpkg: https://unpkg.com/${CONFIG.packageName}@${version}/\n- jsdelivr: https://cdn.jsdelivr.net/npm/${CONFIG.packageName}@${version}/\n`;
fs.writeFileSync(path.join(CONFIG.outputCDN, 'README.md'), readme);
// 发布
let publishCmd = `cd "${CONFIG.outputCDN}" && npm publish --access public --registry https://registry.npmjs.org`;
if (otp) {
publishCmd += ` --otp=${otp}`;
}
if (isDryRun) {
log(` [DRY RUN] 将发布: ${CONFIG.packageName}@${version}`, 'yellow');
return true;
}
if (!exec(publishCmd)) {
logError('npm 发布失败');
if (!otp) {
logWarning('提示:如果启用了 2FA,请使用 --otp=123456 参数');
}
return false;
}
logSuccess(`已发布 ${CONFIG.packageName}@${version}`);
return true;
}
// ============================================
// 步骤 4: 部署到服务器(复用 create-deploy-package.js)
// ============================================
function stepDeployServer(version) {
logStep(4, 5, '打包并部署到服务器');
if (skipServer) {
logWarning('跳过服务器部署');
return true;
}
if (isDryRun) {
log(` [DRY RUN] 将调用 create-deploy-package.js 打包并部署`, 'yellow');
return true;
}
// 调用 create-deploy-package.js 进行打包和部署
log(' 调用 create-deploy-package.js 打包并部署...', 'gray');
try {
execSync('node scripts/create-deploy-package.js', {
cwd: path.resolve(__dirname, '..'),
stdio: 'inherit',
});
logSuccess('打包并部署完成');
return true;
} catch (error) {
logError('打包或部署失败');
return false;
}
}
// ============================================
// 步骤 5: 验证部署
// ============================================
function stepVerify(version) {
logStep(5, 5, '部署完成');
log('\n📋 部署摘要', 'cyan');
log('═'.repeat(50), 'cyan');
log('\n🏗️ 架构说明:', 'cyan');
log(' 用户访问 → 自有服务器(HTML + 静态资源)');
log(' 静态资源 → 优先 CDN,失败兜底服务器');
if (!skipNpm) {
log(`\n🌐 CDN(静态资源,优先加载):`, 'green');
log(` unpkg: https://unpkg.com/${CONFIG.packageName}@${version}/`);
log(` jsdelivr: https://cdn.jsdelivr.net/npm/${CONFIG.packageName}@${version}/`);
log(` ⚠️ CDN 不含 HTML 文件,用户信息安全`);
}
if (!skipServer) {
const config = loadEnvConfig();
log(`\n🖥️ 自有服务器:`, 'green');
if (config.DEPLOY_HOST) {
log(` ${config.DEPLOY_HOST}`);
}
log(` ✓ 通过 create-deploy-package.js 部署`);
log(` ✓ 完整副本(CDN 失败时兜底)`);
}
log('\n🔄 加载顺序:', 'cyan');
log(' 1. Service Worker 缓存(最快)');
log(' 2. CDN unpkg/jsdelivr(节约流量)');
log(' 3. 自有服务器(兜底保障)');
if (isDryRun) {
log('\n⚠️ DRY RUN 模式 - 未实际执行任何操作', 'yellow');
}
return true;
}
// ============================================
// 主流程
// ============================================
async function main() {
log('\n' + '═'.repeat(50), 'cyan');
log('🚀 Aitu 统一混合部署', 'cyan');
log('═'.repeat(50), 'cyan');
if (isDryRun) {
log('\n⚠️ DRY RUN 模式 - 预览执行,不实际操作\n', 'yellow');
}
const version = getVersion();
const cdnBaseUrl = CONFIG.cdnTemplates[cdnProvider].replace('{version}', version);
log(`\n📦 版本: ${version}`, 'cyan');
log(`🌐 CDN: ${cdnProvider}`, 'cyan');
// 执行步骤
const steps = [
() => stepBuild(),
() => stepSeparateFiles(version, cdnBaseUrl),
() => stepPublishNpm(version),
() => stepDeployServer(version),
() => stepVerify(version),
];
for (const step of steps) {
if (!step()) {
log('\n❌ 部署失败\n', 'red');
process.exit(1);
}
}
log('\n✅ 部署完成!\n', 'green');
}
main().catch(error => {
logError(`脚本执行失败: ${error.message}`);
console.error(error);
process.exit(1);
});