diff --git a/app/settings/update/page.tsx b/app/settings/update/page.tsx index 2c2ec29..aeacf97 100644 --- a/app/settings/update/page.tsx +++ b/app/settings/update/page.tsx @@ -42,6 +42,12 @@ export default function UpdatePage() { logEndRef.current?.scrollIntoView({behavior: "smooth"}); }, [logs]); + // 页面加载时自动检查更新 + useEffect(() => { + handleCheckUpdate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // 检查更新 const handleCheckUpdate = useCallback(async () => { setCheckState("checking"); diff --git a/lib/update.ts b/lib/update.ts index 86217ee..b18f6a8 100644 --- a/lib/update.ts +++ b/lib/update.ts @@ -1,6 +1,6 @@ import fs from "fs"; import path from "path"; -import { execSync } from "child_process"; +import { spawn } from "child_process"; // ============================================================ // 版本更新模块 @@ -57,38 +57,78 @@ function consoleErr(tag: string, msg: string): void { } /** - * 执行命令并记录到控制台 + * 异步执行命令,实时流式输出(不阻塞事件循环) + * @param command 可执行文件 + * @param args 参数列表 + * @param tag 日志标签 + * @param options.onLine 每行输出回调(用于 SSE 推送到前端) */ -function execSyncAndLog( +function execAsync( command: string, + args: string[], tag: string, - options: Parameters[1] & { encoding?: "utf-8" } = {} -): string { - consoleLog(tag, `执行: ${command}`); - try { - const stdout = execSync(command, { - encoding: "utf-8", - windowsHide: true, - ...options, - }); - const out = stdout?.trim(); - if (out) { - const lines = out.split("\n").filter(Boolean); - // 最多打印 5 行,避免刷屏 - lines.slice(0, 5).forEach((l) => consoleLog(tag, ` │ ${l}`)); - if (lines.length > 5) consoleLog(tag, ` │ ... 共 ${lines.length} 行`); - } - consoleLog(tag, "✓ 完成"); - return stdout || ""; - } catch (err) { - const e = err as Error & { stderr?: Buffer }; - const stderr = e.stderr?.toString().trim(); - if (stderr) { - stderr.split("\n").filter(Boolean).forEach((l) => consoleErr(tag, ` │ ${l}`)); - } - consoleErr(tag, `失败: ${e.message}`); - throw err; + 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); + }); + }); } /** @@ -130,19 +170,15 @@ function getAuthenticatedRemoteUrl(): string { /** * 通过 git ls-remote 获取远程最新 commit hash */ -export function getRemoteCommitHash(): string | null { +export async function getRemoteCommitHash(): Promise { try { const url = getAuthenticatedRemoteUrl(); - const output = execSync(`git ls-remote "${url}" HEAD`, { - encoding: "utf-8", + let stdout = ""; + await execAsync("git", ["ls-remote", url, "HEAD"], "git-ls-remote", { timeout: 15000, - env: { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - }, - windowsHide: true, + onLine: (line) => { stdout += line + "\n"; }, }); - const match = output.match(/^([a-f0-9]+)\s+HEAD/m); + const match = stdout.match(/^([a-f0-9]+)\s+HEAD/m); return match?.[1] || null; } catch { return null; @@ -152,19 +188,16 @@ export function getRemoteCommitHash(): string | null { /** * 获取远程 .version 文件内容并解析为 VersionFile */ -export function getRemoteVersionFile(): VersionFile | null { +export async function getRemoteVersionFile(): Promise { // 尝试方式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; + 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 @@ -178,18 +211,15 @@ export function getRemoteVersionFile(): VersionFile | null { * 通过浅克隆获取远程 .version 文件(备选方案) * OneDev 不支持 git archive --remote,改用完整浅克隆 */ -function getRemoteVersionByClone(): VersionFile | null { +async function getRemoteVersionByClone(): Promise { const tmpDir = path.join( process.env.TEMP || "/tmp", `recipe_tool_version_${Date.now()}` ); try { const url = getAuthenticatedRemoteUrl(); - execSync(`git clone --depth 1 "${url}" "${tmpDir}"`, { - encoding: "utf-8", + await execAsync("git", ["clone", "--depth", "1", url, tmpDir], "git-clone", { timeout: 60000, - windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], }); const versionPath = path.join(tmpDir, ".version"); if (fs.existsSync(versionPath)) { @@ -234,7 +264,7 @@ export async function checkForUpdate(): Promise { const current = readLocalVersionFile(); consoleLog("检查", `当前版本: v${current.version} (${current.release_date || "无日期"})`); - const remoteCommit = getRemoteCommitHash(); + const remoteCommit = await getRemoteCommitHash(); if (!remoteCommit) { consoleErr("检查", "无法连接到远程仓库"); @@ -247,7 +277,7 @@ export async function checkForUpdate(): Promise { } consoleLog("检查", `远程 HEAD: ${remoteCommit}`); - const remoteVersionFile = getRemoteVersionFile(); + const remoteVersionFile = await getRemoteVersionFile(); if (remoteVersionFile) { const cmp = compareVersions(remoteVersionFile.version, current.version); @@ -279,10 +309,10 @@ export async function checkForUpdate(): Promise { /** * 检查 Docker 是否可用 */ -function checkDockerAvailable(): { ok: boolean; message: string } { +async function checkDockerAvailable(): Promise<{ ok: boolean; message: string }> { consoleLog("Docker", "检查 Docker 守护进程..."); try { - execSyncAndLog("docker info", "Docker"); + await execAsync("docker", ["info"], "Docker"); consoleLog("Docker", "✓ Docker 正常"); return { ok: true, message: "" }; } catch { @@ -314,13 +344,16 @@ export async function executeUpdate( consoleLog("执行", "=== 开始执行更新 ==="); consoleLog("执行", `临时目录: ${tmpDir}`); + // 包装 onLog,将输出行实时推送到 SSE + const logLine = (line: string) => onLog(line); + try { onLog("开始更新流程"); // 0. 检查 Docker 是否可用 onLog("[0/5] 检查 Docker 环境"); consoleLog("执行", "步骤 0/5 — 检查 Docker 环境"); - const dockerCheck = checkDockerAvailable(); + const dockerCheck = await checkDockerAvailable(); if (!dockerCheck.ok) { onLog(`✗ ${dockerCheck.message}`); consoleErr("执行", "Docker 检查未通过,更新终止"); @@ -332,11 +365,10 @@ export async function executeUpdate( const url = getAuthenticatedRemoteUrl(); onLog("[1/5] 克隆最新代码"); consoleLog("执行", "步骤 1/5 — 克隆代码"); - execSyncAndLog( - `git clone --depth 1 "${url}" "${tmpDir}"`, - "git-clone", - { timeout: 120000, stdio: ["ignore", "pipe", "pipe"] } - ); + await execAsync("git", ["clone", "--depth", "1", url, tmpDir], "git-clone", { + timeout: 120000, + onLine: logLine, + }); onLog("✓ 代码拉取完成"); // 2. 生成 .env 文件 @@ -357,26 +389,25 @@ export async function executeUpdate( consoleLog("执行", `.env 已写入 ${envContent.split("\n").length} 行`); onLog("✓ 环境变量已准备"); - // 3. 构建新镜像 + // 3. 构建新镜像(实时输出构建日志) onLog("[3/5] 构建新 Docker 镜像"); consoleLog("执行", "步骤 3/5 — 构建镜像(可能耗时较长)"); - execSyncAndLog( - `cd "${tmpDir}" && docker-compose -p recipe_tool build`, - "docker-build", - { timeout: 600000, stdio: ["ignore", "pipe", "pipe"] } - ); + await execAsync("docker-compose", ["-p", "recipe_tool", "build"], "docker-build", { + cwd: tmpDir, + timeout: 600000, + onLine: logLine, + }); onLog("✓ 镜像构建完成"); - // 4. 停止旧容器(释放端口) + // 4. 停止旧容器(释放端口),忽略失败 onLog("[4/5] 停止旧容器"); consoleLog("执行", "步骤 4/5 — 停止旧容器"); - // 忽略 down 的错误(首次运行可能没有旧容器) try { - execSyncAndLog( - `cd "${tmpDir}" && docker-compose -p recipe_tool down`, - "docker-down", - { timeout: 30000, stdio: ["ignore", "pipe", "pipe"] } - ); + await execAsync("docker-compose", ["-p", "recipe_tool", "down", "--remove-orphans"], "docker-down", { + cwd: tmpDir, + timeout: 30000, + onLine: logLine, + }); } catch { consoleLog("执行", "旧容器停止(可能不存在,忽略)"); } @@ -385,11 +416,11 @@ export async function executeUpdate( // 5. 启动新容器 onLog("[5/5] 启动新容器"); consoleLog("执行", "步骤 5/5 — 启动新容器"); - execSyncAndLog( - `cd "${tmpDir}" && docker-compose -p recipe_tool up -d`, - "docker-up", - { timeout: 60000, stdio: ["ignore", "pipe", "pipe"] } - ); + await execAsync("docker-compose", ["-p", "recipe_tool", "up", "-d"], "docker-up", { + cwd: tmpDir, + timeout: 60000, + onLine: logLine, + }); onLog("✓ 新容器已启动"); consoleLog("执行", "=== 更新成功 ===");