import fs from "fs"; import path from "path"; import { spawn } 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 = () => {}; /** 带时间戳的控制台日志 */ function consoleLog(tag: string, msg: string): void { const t = new Date().toISOString().slice(0, 19).replace("T", " "); console.log(`[${t}] [更新:${tag}] ${msg}`); } /** 带时间戳的控制台错误 */ function consoleErr(tag: string, msg: string): void { const t = new Date().toISOString().slice(0, 19).replace("T", " "); console.error(`[${t}] [更新:${tag}] ${msg}`); } /** * 异步执行命令,实时流式输出(不阻塞事件循环) * @param command 可执行文件 * @param args 参数列表 * @param tag 日志标签 * @param options.onLine 每行输出回调(用于 SSE 推送到前端) */ function execAsync( command: string, args: string[], tag: string, options?: { timeout?: number; cwd?: string; onLine?: (line: string) => void; } ): Promise { return new Promise((resolve, reject) => { const desc = `${command} ${args.join(" ")}`; consoleLog(tag, `执行: ${desc}`); const child = spawn(command, args, { cwd: options?.cwd, timeout: options?.timeout, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); let timedOut = false; if (options?.timeout) { const timer = setTimeout(() => { timedOut = true; child.kill(); reject(new Error(`命令超时 (${options.timeout}ms): ${desc}`)); }, options.timeout); child.on("close", () => clearTimeout(timer)); } const handleData = (data: Buffer, isStderr: boolean) => { const str = data.toString(); const lines = str.split("\n").filter(Boolean); for (const raw of lines) { const line = raw.trimEnd(); if (!line) continue; if (isStderr) { consoleErr(tag, ` │ ${line.slice(0, 2000)}`); } else { consoleLog(tag, ` │ ${line.slice(0, 2000)}`); } options?.onLine?.(line); } }; child.stdout?.on("data", (data: Buffer) => handleData(data, false)); child.stderr?.on("data", (data: Buffer) => handleData(data, true)); child.on("close", (code) => { if (timedOut) return; if (code === 0) { consoleLog(tag, `✓ 完成`); resolve(); } else { consoleErr(tag, `失败 (exit=${code})`); reject(new Error(`Command failed with exit code ${code}: ${desc}`)); } }); child.on("error", (err) => { if (timedOut) return; consoleErr(tag, `异常: ${err.message}`); reject(err); }); }); } /** * 读取并解析本地的 .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 async function getRemoteCommitHash(): Promise { try { const url = getAuthenticatedRemoteUrl(); let stdout = ""; await execAsync("git", ["ls-remote", url, "HEAD"], "git-ls-remote", { timeout: 15000, onLine: (line) => { stdout += line + "\n"; }, }); const match = stdout.match(/^([a-f0-9]+)\s+HEAD/m); return match?.[1] || null; } catch { return null; } } /** * 获取远程 .version 文件内容并解析为 VersionFile */ export async function getRemoteVersionFile(): Promise { // 尝试方式1: git archive try { const url = getAuthenticatedRemoteUrl(); let stdout = ""; await execAsync("git", ["archive", `--remote=${url}`, "HEAD:.version"], "git-archive", { timeout: 15000, onLine: (line) => { stdout += line; }, }); const parsed = JSON.parse(stdout.trim()) as VersionFile; if (parsed.version) return parsed; } catch { // fall through } // 尝试方式2: 浅克隆 return getRemoteVersionByClone(); } /** * 通过浅克隆获取远程 .version 文件(备选方案) * OneDev 不支持 git archive --remote,改用完整浅克隆 */ async function getRemoteVersionByClone(): Promise { const tmpDir = path.join( process.env.TEMP || "/tmp", `recipe_tool_version_${Date.now()}` ); try { const url = getAuthenticatedRemoteUrl(); await execAsync("git", ["clone", "--depth", "1", url, tmpDir], "git-clone", { timeout: 60000, }); 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 { consoleLog("检查", "开始检查版本更新"); const current = readLocalVersionFile(); consoleLog("检查", `当前版本: v${current.version} (${current.release_date || "无日期"})`); const remoteCommit = await getRemoteCommitHash(); if (!remoteCommit) { consoleErr("检查", "无法连接到远程仓库"); return { current, latest: null, hasUpdate: false, message: "无法连接到远程仓库,请检查网络和 ONEDEV_ACCESS_TOKEN 配置", }; } consoleLog("检查", `远程 HEAD: ${remoteCommit}`); const remoteVersionFile = await getRemoteVersionFile(); if (remoteVersionFile) { const cmp = compareVersions(remoteVersionFile.version, current.version); consoleLog( "检查", `远程版本: v${remoteVersionFile.version} → ${cmp > 0 ? "有新版本" : "已是最新"}` ); return { current, latest: remoteVersionFile, hasUpdate: cmp > 0, latestCommit: remoteCommit, }; } consoleErr("检查", "远程仓库中未找到 .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 是否可用 */ async function checkDockerAvailable(): Promise<{ ok: boolean; message: string }> { consoleLog("Docker", "检查 Docker 守护进程..."); try { await execAsync("docker", ["info"], "Docker"); consoleLog("Docker", "✓ Docker 正常"); return { ok: true, message: "" }; } catch { consoleErr("Docker", "✗ Docker 不可用"); return { ok: false, message: "Docker 守护进程不可用。\n" + "更新功能需要在 Docker 容器内运行,当前环境不支持。\n" + "如需测试,请使用 Docker 部署后访问容器内的页面:\n" + " docker compose -p recipe_tool up -d\n" + "然后访问 http://localhost:8011/settings/update", }; } } /** * 执行 Docker 容器更新 * @param onLog 日志回调 */ const REGISTRY_IMAGE = "git.hanhan.ltd/recipe_tool/recipe_tool:latest"; export async function executeUpdate( onLog: LogCallback = noopLog ): Promise<{ success: boolean; message: string }> { const cwd = process.cwd(); // /app(容器内工作目录,含 docker-compose.yml) const token = process.env.ONEDEV_ACCESS_TOKEN || ""; consoleLog("执行", "=== 开始执行更新 ==="); consoleLog("执行", `工作目录: ${cwd}, 镜像: ${REGISTRY_IMAGE}`); const logLine = (line: string) => onLog(line); try { onLog("开始更新流程"); // 0. 检查 Docker 是否可用 onLog("[0/4] 检查 Docker 环境"); consoleLog("执行", "步骤 0/4 — 检查 Docker 环境"); const dockerCheck = await checkDockerAvailable(); if (!dockerCheck.ok) { onLog(`✗ ${dockerCheck.message}`); consoleErr("执行", "Docker 检查未通过,更新终止"); return { success: false, message: dockerCheck.message }; } onLog("✓ Docker 环境正常"); // 1. 登录 OneDev 镜像仓库并拉取最新镜像 onLog("[1/4] 登录镜像仓库"); consoleLog("执行", "步骤 1/4 — 登录 OneDev 镜像仓库"); if (token) { await execAsync( "sh", ["-c", `echo "${token}" | docker login git.hanhan.ltd -u access-token --password-stdin`], "docker-login", { timeout: 15000, onLine: logLine } ); } else { onLog("⚠ 未配置 ONEDEV_ACCESS_TOKEN,尝试匿名拉取"); consoleLog("执行", "未配置 access token,尝试匿名拉取"); } onLog("[1/4] 拉取新镜像"); consoleLog("执行", "步骤 1/4 — 拉取镜像"); await execAsync("docker", ["pull", REGISTRY_IMAGE], "docker-pull", { timeout: 300000, onLine: logLine, }); onLog("✓ 镜像拉取完成"); // 2. 生成 .env 配置文件(供 docker-compose 解析 ${VAR}) onLog("[2/4] 准备环境变量"); consoleLog("执行", "步骤 2/4 — 准备环境变量"); 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=${token}`, `GIT_REMOTE_URL=${process.env.GIT_REMOTE_URL || "https://git.hanhan.ltd/recipe_tool.git"}`, ].join("\n"); fs.writeFileSync(path.join(cwd, ".env"), envContent, "utf-8"); consoleLog("执行", `.env 已写入 ${envContent.split("\n").length} 行`); onLog("✓ 环境变量已准备"); // 3. 停止旧容器(释放端口),忽略失败 onLog("[3/4] 停止旧容器"); consoleLog("执行", "步骤 3/4 — 停止旧容器"); try { await execAsync("docker-compose", ["-p", "recipe_tool", "down", "--remove-orphans"], "docker-down", { cwd, timeout: 30000, onLine: logLine, }); } catch { consoleLog("执行", "旧容器停止(可能不存在,忽略)"); } onLog("✓ 旧容器已停止"); // 4. 启动新容器(使用拉取的镜像) onLog("[4/4] 启动新容器"); consoleLog("执行", "步骤 4/4 — 启动新容器"); await execAsync("docker-compose", ["-p", "recipe_tool", "up", "-d"], "docker-up", { cwd, timeout: 60000, onLine: logLine, }); onLog("✓ 新容器已启动"); consoleLog("执行", "=== 更新成功 ==="); return { success: true, message: "更新完成" }; } catch (error) { const errMsg = error instanceof Error ? error.message : "未知错误"; consoleErr("执行", `=== 更新失败: ${errMsg} ===`); onLog(`✗ 更新失败: ${errMsg}`); return { success: false, message: errMsg }; } }