import fs from "fs"; import path from "path"; import { execSync } from "child_process"; // ============================================================ // 版本更新模块 // 用于检查远程仓库版本并执行 Docker 容器更新 // ============================================================ /** .version JSON 文件结构 */ export interface VersionFile { version: string; release_date: string; changelog: string; } /** 版本信息(API 返回给前端) */ export interface VersionInfo { current: VersionFile; latest: VersionFile | null; hasUpdate: boolean; currentCommit?: string; latestCommit?: string; /** 状态消息,如 "远程仓库未包含 .version 文件" */ message?: string; } /** 默认版本文件 */ const DEFAULT_VERSION: VersionFile = { version: "0.0.0", release_date: "", changelog: "", }; /** 更新配置 */ interface UpdateConfig { remoteUrl: string; accessToken: string; } /** 更新日志回调 */ export type LogCallback = (message: string) => void; /** 空回调 */ const noopLog: LogCallback = () => {}; /** * 读取并解析本地的 .version 文件 */ export function readLocalVersionFile(): VersionFile { try { const versionPath = path.join(process.cwd(), ".version"); const content = fs.readFileSync(versionPath, "utf-8").trim(); return JSON.parse(content) as VersionFile; } catch { return DEFAULT_VERSION; } } /** * 获取更新配置 */ function getUpdateConfig(): UpdateConfig { return { remoteUrl: process.env.GIT_REMOTE_URL || "https://git.hanhan.ltd/recipe_tool.git", accessToken: process.env.ONEDEV_ACCESS_TOKEN || "", }; } /** * 构建带认证的远程 URL */ function getAuthenticatedRemoteUrl(): string { const config = getUpdateConfig(); if (!config.accessToken) return config.remoteUrl; const url = new URL(config.remoteUrl); url.username = "access-token"; url.password = config.accessToken; return url.toString(); } /** * 通过 git ls-remote 获取远程最新 commit hash */ export function getRemoteCommitHash(): string | null { try { const url = getAuthenticatedRemoteUrl(); const output = execSync(`git ls-remote "${url}" HEAD`, { encoding: "utf-8", timeout: 15000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", }, windowsHide: true, }); const match = output.match(/^([a-f0-9]+)\s+HEAD/m); return match?.[1] || null; } catch { return null; } } /** * 获取远程 .version 文件内容并解析为 VersionFile */ export function getRemoteVersionFile(): VersionFile | null { // 尝试方式1: git archive try { const url = getAuthenticatedRemoteUrl(); const output = execSync( `git archive --remote="${url}" HEAD:.version 2>nul`, { encoding: "utf-8", timeout: 15000, windowsHide: true, } ); const parsed = JSON.parse(output.trim()) as VersionFile; if (parsed.version) return parsed; } catch { // fall through } // 尝试方式2: 浅克隆 return getRemoteVersionByClone(); } /** * 通过浅克隆获取远程 .version 文件(备选方案) */ function getRemoteVersionByClone(): VersionFile | null { const tmpDir = path.join( process.env.TEMP || "/tmp", `recipe_tool_version_${Date.now()}` ); try { const url = getAuthenticatedRemoteUrl(); execSync( `git clone --depth 1 --filter=blob:none --sparse "${url}" "${tmpDir}" 2>nul`, { encoding: "utf-8", timeout: 30000, windowsHide: true, } ); execSync(`cd "${tmpDir}" && git sparse-checkout set .version 2>nul`, { encoding: "utf-8", timeout: 10000, windowsHide: true, }); const versionPath = path.join(tmpDir, ".version"); if (fs.existsSync(versionPath)) { const content = fs.readFileSync(versionPath, "utf-8").trim(); return JSON.parse(content) as VersionFile; } return null; } catch { return null; } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // ignore cleanup errors } } } /** * 比较两个语义化版本号 * 返回: 1 = v1 > v2, -1 = v1 < v2, 0 = equal */ export function compareVersions(v1: string, v2: string): number { const parts1 = v1.split(".").map(Number); const parts2 = v2.split(".").map(Number); for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { const p1 = parts1[i] || 0; const p2 = parts2[i] || 0; if (p1 > p2) return 1; if (p1 < p2) return -1; } return 0; } /** * 检查是否有可用更新 */ export async function checkForUpdate(): Promise { const current = readLocalVersionFile(); const remoteCommit = getRemoteCommitHash(); if (!remoteCommit) { return { current, latest: null, hasUpdate: false, message: "无法连接到远程仓库,请检查网络和 ONEDEV_ACCESS_TOKEN 配置", }; } const remoteVersionFile = getRemoteVersionFile(); if (remoteVersionFile) { const cmp = compareVersions(remoteVersionFile.version, current.version); return { current, latest: remoteVersionFile, hasUpdate: cmp > 0, latestCommit: remoteCommit, }; } // 远程能连上但 .version 文件不存在或解析失败 return { current, latest: null, hasUpdate: false, latestCommit: remoteCommit, message: "远程仓库中未找到 .version 文件。\n" + "请确保 .version 文件已提交并推送到远程仓库:\n" + " git add .version && git commit -m \"add version file\" && git push", }; } /** * 执行 Docker 容器更新 * @param onLog 日志回调 */ export async function executeUpdate( onLog: LogCallback = noopLog ): Promise<{ success: boolean; message: string }> { const tmpDir = path.join( process.env.TEMP || "/tmp", `recipe_tool_update_${Date.now()}` ); try { onLog("开始更新流程...\n"); // 1. 拉取最新代码 const url = getAuthenticatedRemoteUrl(); onLog(`[1/4] 克隆最新代码...\n`); execSync(`git clone --depth 1 "${url}" "${tmpDir}"`, { encoding: "utf-8", timeout: 120000, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); onLog(`✓ 代码拉取完成\n`); // 2. 生成 .env 文件 onLog(`[2/4] 准备环境变量...\n`); const envContent = [ `DB_HOST=${process.env.DB_HOST || "127.0.0.1"}`, `DB_PORT=${process.env.DB_PORT || "3306"}`, `DB_USER=${process.env.DB_USER || "root"}`, `DB_PASSWORD=${process.env.DB_PASSWORD || ""}`, `DB_NAME=${process.env.DB_NAME || "recipe_tools"}`, `AI_API_KEY=${process.env.AI_API_KEY || ""}`, `AI_BASE_URL=${process.env.AI_BASE_URL || "https://api.openai.com/v1"}`, `AI_MODEL=${process.env.AI_MODEL || "gpt-3.5-turbo"}`, `ONEDEV_ACCESS_TOKEN=${process.env.ONEDEV_ACCESS_TOKEN || ""}`, ].join("\n"); fs.writeFileSync(path.join(tmpDir, ".env"), envContent, "utf-8"); onLog(`✓ 环境变量已准备\n`); // 3. 构建新镜像 onLog(`[3/4] 构建新 Docker 镜像...\n`); execSync(`cd "${tmpDir}" && docker compose -p recipe_tool build`, { encoding: "utf-8", timeout: 600000, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); onLog(`✓ 镜像构建完成\n`); // 4. 重启服务 onLog(`[4/4] 重启服务...\n`); execSync(`cd "${tmpDir}" && docker compose -p recipe_tool up -d`, { encoding: "utf-8", timeout: 60000, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); onLog(`✓ 服务已重启\n`); return { success: true, message: "更新完成" }; } catch (error) { const errMsg = error instanceof Error ? error.message : "未知错误"; onLog(`✗ 更新失败: ${errMsg}\n`); return { success: false, message: errMsg }; } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // ignore cleanup errors } } }