#!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; const args = parseArgs(process.argv.slice(2)); const projectRoot = path.resolve(args.project || process.cwd()); loadEnvFile(path.join(projectRoot, '.env.local')); const stateRoot = path.resolve( process.env.UPGRADE_STATE_DIR || (process.env.LOCAL_STORAGE_DIR ? path.join(path.dirname(process.env.LOCAL_STORAGE_DIR), 'upgrade') : path.join(projectRoot, 'upgrade-state')), ); const jobId = requireArg(args, 'job-id'); const mode = requireArg(args, 'mode'); const packagePath = path.resolve(requireArg(args, 'package')); const packageName = args['package-name'] || path.basename(packagePath); const jobDir = path.join(stateRoot, 'jobs', jobId); const stateFile = path.join(jobDir, 'state.json'); const extractDir = path.join(jobDir, 'extract'); const sourceBackupFile = path.join(jobDir, `source-before-${jobId}.tar.gz`); const HOT_ALLOWED_PREFIXES = ['public/']; const HOT_ALLOWED_FILES = new Set([ 'manifest.json', 'robots.txt', 'sitemap.xml', 'favicon.ico', 'icon.png', 'apple-icon.png', ]); const COLD_ALLOWED_PREFIXES = ['src/', 'public/', 'scripts/', 'database/', 'docs/']; const COLD_ALLOWED_FILES = new Set([ 'manifest.json', 'package.json', 'pnpm-lock.yaml', 'next.config.js', 'next.config.mjs', 'next.config.ts', 'tsconfig.json', 'postcss.config.mjs', 'components.json', 'ecosystem.config.cjs', ]); const BLOCKED_NAMES = new Set(['.git', 'node_modules', '.next', 'dist', 'backups', 'local-storage', 'upgrade-state']); let state = readState() || { id: jobId, mode, status: 'queued', step: 'queued', message: '升级任务已创建', progress: 0, packageName, startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), logs: [], }; main().catch(error => { log(`fatal: ${error instanceof Error ? error.stack || error.message : String(error)}`); rollbackAfterFailure(error instanceof Error ? error.message : '升级任务异常退出').catch(rollbackError => { updateState({ status: 'rollback_failed', step: 'rollback_failed', progress: 100, message: '升级失败,自动回滚也失败,请立即人工检查', error: `${error instanceof Error ? error.message : String(error)}; rollback: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, finishedAt: new Date().toISOString(), }); }); }); async function main() { ensureDir(jobDir); updateState({ status: 'running', step: 'preflight', progress: 5, message: '正在检查升级包与运行环境', startedAt: state.startedAt || new Date().toISOString(), }); if (mode !== 'hot' && mode !== 'cold') { throw new Error('升级方式无效'); } if (!fs.existsSync(packagePath)) { throw new Error(`升级包不存在: ${packagePath}`); } if (!isAllowedArchive(packageName) && !isAllowedArchive(packagePath)) { throw new Error('仅支持 .tar、.tar.gz、.tgz 升级包'); } run('tar', tarReadArgs('list', packagePath), { cwd: projectRoot, label: '检查升级包结构' }); resetDir(extractDir); run('tar', [...tarReadArgs('extract', packagePath), '-C', extractDir], { cwd: projectRoot, label: '解压升级包' }); const payloadRoot = resolvePayloadRoot(extractDir); const files = listFiles(payloadRoot); if (files.length === 0) { throw new Error('升级包为空'); } const validation = validateFiles(files, mode); updateState({ step: 'validated', progress: 14, message: `升级包校验通过,共 ${files.length} 个文件`, restartRequired: mode === 'cold' || validation.requiresRestart, packageHash: sha256(packagePath), changedFiles: files, }); updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' }); const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], { cwd: projectRoot, label: '创建数据备份', env: { BACKUP_DIR: path.join(stateRoot, 'data-backups'), COZE_WORKSPACE_PATH: projectRoot }, }).trim().split('\n').pop(); if (!backupFile || !fs.existsSync(backupFile)) { throw new Error('数据备份创建失败'); } updateState({ backupFile }); updateState({ step: 'backup_source', progress: 30, message: '正在创建源码快照' }); createSourceBackup(sourceBackupFile); updateState({ sourceBackupFile }); updateState({ step: 'apply', progress: 42, message: '正在应用升级包文件' }); updateState({ preExistingFiles: files.filter(file => fs.existsSync(path.join(projectRoot, file))) }); applyFiles(payloadRoot, files); if (mode === 'hot') { updateState({ step: 'verify_hot', progress: 70, message: '正在验证热更新文件' }); run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' }); updateState({ status: 'succeeded', step: 'completed', progress: 100, message: '热更新成功,平台未重启', finishedAt: new Date().toISOString(), restartRequired: false, }); return; } const dependencyChanged = files.some(file => file === 'package.json' || file === 'pnpm-lock.yaml'); if (dependencyChanged) { updateState({ step: 'install', progress: 54, message: '依赖文件发生变化,正在安装依赖' }); run('pnpm', ['install', '--frozen-lockfile', '--prod=false'], { cwd: projectRoot, label: '安装依赖' }); } updateState({ step: 'ts_check', progress: 64, message: '正在执行 TypeScript 校验' }); run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' }); updateState({ step: 'build', progress: 75, message: '正在构建平台' }); run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '构建平台' }); updateState({ step: 'restart', progress: 86, message: '正在重启平台进程' }); restartPlatform(); updateState({ step: 'health_check', progress: 94, message: '正在检查平台健康状态' }); waitForHealth(); updateState({ status: 'succeeded', step: 'completed', progress: 100, message: '冷更新成功,平台已重启并通过健康检查', finishedAt: new Date().toISOString(), restartRequired: true, }); } async function rollbackAfterFailure(message) { const originalError = message; updateState({ status: 'rolling_back', step: 'rolling_back', progress: 96, message: '升级失败,正在自动回滚到升级前状态', error: originalError, }); if (fs.existsSync(sourceBackupFile)) { restoreSourceBackup(sourceBackupFile); } if (state.backupFile && fs.existsSync(state.backupFile)) { run('bash', ['./scripts/backup-restore.sh', state.backupFile], { cwd: projectRoot, label: '恢复数据备份', env: { COZE_WORKSPACE_PATH: projectRoot }, }); } if (mode === 'cold') { try { run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '回滚后重新构建' }); restartPlatform(); waitForHealth(); } catch (error) { throw new Error(`回滚后平台恢复检查失败: ${error instanceof Error ? error.message : String(error)}`); } } updateState({ status: 'rolled_back', step: 'rolled_back', progress: 100, message: '升级失败,已自动回滚到升级开始前状态', error: originalError, finishedAt: new Date().toISOString(), }); } function parseArgs(argv) { const parsed = {}; for (let index = 0; index < argv.length; index += 1) { const item = argv[index]; if (!item.startsWith('--')) continue; const key = item.slice(2); const next = argv[index + 1]; if (!next || next.startsWith('--')) { parsed[key] = 'true'; } else { parsed[key] = next; index += 1; } } return parsed; } function requireArg(parsed, key) { const value = parsed[key]; if (!value) throw new Error(`missing --${key}`); return value; } function loadEnvFile(file) { if (!fs.existsSync(file)) return; const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue; const index = trimmed.indexOf('='); const key = trimmed.slice(0, index).trim(); let value = trimmed.slice(index + 1).trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } if (!process.env[key]) process.env[key] = value; } } function readState() { try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch { return null; } } function updateState(patch) { state = { ...state, ...patch, updatedAt: new Date().toISOString(), logs: patch.logs || state.logs || [], }; ensureDir(path.dirname(stateFile)); const tempFile = `${stateFile}.tmp`; fs.writeFileSync(tempFile, `${JSON.stringify(state, null, 2)}\n`); fs.renameSync(tempFile, stateFile); } function log(line) { const timestamped = `[${new Date().toISOString()}] ${line}`; const logs = [...(state.logs || []), timestamped].slice(-300); updateState({ logs }); } function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } function resetDir(dir) { fs.rmSync(dir, { recursive: true, force: true }); ensureDir(dir); } function run(command, commandArgs, options = {}) { runCapture(command, commandArgs, options); } function runCapture(command, commandArgs, options = {}) { const label = options.label || command; log(`${label}: ${command} ${commandArgs.join(' ')}`); const result = spawnSync(command, commandArgs, { cwd: options.cwd || projectRoot, env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack', ...(options.env || {}) }, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024, }); const output = `${result.stdout || ''}${result.stderr || ''}`.trim(); if (output) { for (const line of output.split(/\r?\n/).slice(-80)) log(`${label}: ${line}`); } if (result.status !== 0) { throw new Error(`${label}失败,退出码 ${result.status ?? 'unknown'}`); } return result.stdout || ''; } function isAllowedArchive(file) { return file.endsWith('.tar') || file.endsWith('.tar.gz') || file.endsWith('.tgz'); } function resolvePayloadRoot(root) { const entries = fs.readdirSync(root, { withFileTypes: true }).filter(entry => entry.name !== '__MACOSX'); if (entries.length === 1 && entries[0].isDirectory()) { return path.join(root, entries[0].name); } return root; } function listFiles(root) { const files = []; walk(root, ''); return files.sort(); function walk(currentRoot, relativeRoot) { for (const entry of fs.readdirSync(currentRoot, { withFileTypes: true })) { if (entry.name === '.DS_Store') continue; const relative = toPosix(path.join(relativeRoot, entry.name)); const absolute = path.join(currentRoot, entry.name); if (entry.isDirectory()) { walk(absolute, relative); } else if (entry.isFile()) { files.push(relative); } else { throw new Error(`升级包包含不支持的文件类型: ${relative}`); } } } } function validateFiles(files, updateMode) { for (const file of files) { assertSafeRelativePath(file); const parts = file.split('/'); if (parts.some(part => BLOCKED_NAMES.has(part) || part.startsWith('.env'))) { throw new Error(`升级包包含禁止覆盖的路径: ${file}`); } if (updateMode === 'hot' && !isHotAllowed(file)) { throw new Error(`热更新只能包含 public 等无需重启的静态资源;${file} 需要使用冷更新`); } if (updateMode === 'cold' && !isColdAllowed(file)) { throw new Error(`冷更新包包含未授权路径: ${file}`); } } return { requiresRestart: files.some(file => !isHotAllowed(file)) }; } function assertSafeRelativePath(file) { if (!file || file.startsWith('/') || file.startsWith('\\') || file.includes('\\')) { throw new Error(`升级包包含非法路径: ${file}`); } const normalized = path.posix.normalize(file); if (normalized !== file || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) { throw new Error(`升级包包含目录穿越路径: ${file}`); } } function isHotAllowed(file) { return HOT_ALLOWED_FILES.has(file) || HOT_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix)); } function isColdAllowed(file) { return COLD_ALLOWED_FILES.has(file) || COLD_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix)); } function applyFiles(root, files) { for (const file of files) { if (file === 'manifest.json') continue; const source = path.join(root, file); const target = path.join(projectRoot, file); ensureDir(path.dirname(target)); fs.copyFileSync(source, target); } } function createSourceBackup(target) { ensureDir(path.dirname(target)); run('tar', [ '-czf', target, '--exclude=.git', '--exclude=node_modules', '--exclude=.next', '--exclude=dist', '--exclude=backups', '--exclude=local-storage', '--exclude=upgrade-state', '--exclude=tsconfig.tsbuildinfo', '-C', projectRoot, '.', ], { cwd: projectRoot, label: '创建源码快照' }); } function restoreSourceBackup(source) { log(`恢复源码快照: ${source}`); const preExistingFiles = new Set(Array.isArray(state.preExistingFiles) ? state.preExistingFiles : []); const changedFiles = Array.isArray(state.changedFiles) ? state.changedFiles : []; for (const file of changedFiles) { if (file === 'manifest.json' || preExistingFiles.has(file)) continue; const target = path.join(projectRoot, file); if (target.startsWith(projectRoot)) { fs.rmSync(target, { force: true }); } } run('tar', [ '-xzf', source, '--exclude=.git', '--exclude=node_modules', '--exclude=.next', '--exclude=dist', '-C', projectRoot, ], { cwd: projectRoot, label: '恢复源码快照' }); } function restartPlatform() { const restartCommand = process.env.UPGRADE_RESTART_COMMAND || detectRestartCommand(); run('bash', ['-lc', restartCommand], { cwd: projectRoot, label: '重启平台' }); } function detectRestartCommand() { const pm2Names = runCapture('bash', ['-lc', 'command -v pm2 >/dev/null 2>&1 && pm2 jlist || true'], { cwd: projectRoot, label: '检测 PM2 进程', }); if (pm2Names.includes('"name":"miaojing-dev"')) return 'pm2 restart miaojing-dev --update-env'; if (fs.existsSync(path.join(projectRoot, 'ecosystem.config.cjs'))) return 'pm2 startOrReload ecosystem.config.cjs --update-env'; return 'pm2 restart miaojing-dev --update-env'; } function tarReadArgs(action, archivePath) { const flag = action === 'list' ? '-tf' : '-xf'; const gzipFlag = action === 'list' ? '-tzf' : '-xzf'; return archivePath.endsWith('.tar') ? [flag, archivePath] : [gzipFlag, archivePath]; } function waitForHealth() { const healthUrl = process.env.UPGRADE_HEALTH_URL || process.env.APP_HEALTH_URL || 'http://127.0.0.1:5100/api/health'; const timeoutMs = Number(process.env.UPGRADE_HEALTH_TIMEOUT_MS || 90000); const startedAt = Date.now(); let lastError = ''; while (Date.now() - startedAt < timeoutMs) { const result = spawnSync('curl', ['-fsS', healthUrl], { encoding: 'utf8', timeout: 8000 }); if (result.status === 0) { log(`健康检查通过: ${healthUrl}`); return; } lastError = `${result.stderr || result.stdout || `exit ${result.status}`}`.trim(); sleep(3000); } throw new Error(`健康检查超时: ${healthUrl}; ${lastError}`); } function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } function sha256(file) { const hash = createHash('sha256'); hash.update(fs.readFileSync(file)); return hash.digest('hex'); } function toPosix(file) { return file.split(path.sep).join('/'); }