fix: execSync → async spawn 解决更新卡死,页面自动检查版本,改进容器停止步骤
This commit is contained in:
@@ -42,6 +42,12 @@ export default function UpdatePage() {
|
|||||||
logEndRef.current?.scrollIntoView({behavior: "smooth"});
|
logEndRef.current?.scrollIntoView({behavior: "smooth"});
|
||||||
}, [logs]);
|
}, [logs]);
|
||||||
|
|
||||||
|
// 页面加载时自动检查更新
|
||||||
|
useEffect(() => {
|
||||||
|
handleCheckUpdate();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 检查更新
|
// 检查更新
|
||||||
const handleCheckUpdate = useCallback(async () => {
|
const handleCheckUpdate = useCallback(async () => {
|
||||||
setCheckState("checking");
|
setCheckState("checking");
|
||||||
|
|||||||
+112
-81
@@ -1,6 +1,6 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
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,
|
command: string,
|
||||||
|
args: string[],
|
||||||
tag: string,
|
tag: string,
|
||||||
options: Parameters<typeof execSync>[1] & { encoding?: "utf-8" } = {}
|
options?: {
|
||||||
): string {
|
timeout?: number;
|
||||||
consoleLog(tag, `执行: ${command}`);
|
cwd?: string;
|
||||||
try {
|
onLine?: (line: string) => void;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
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
|
* 通过 git ls-remote 获取远程最新 commit hash
|
||||||
*/
|
*/
|
||||||
export function getRemoteCommitHash(): string | null {
|
export async function getRemoteCommitHash(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const url = getAuthenticatedRemoteUrl();
|
const url = getAuthenticatedRemoteUrl();
|
||||||
const output = execSync(`git ls-remote "${url}" HEAD`, {
|
let stdout = "";
|
||||||
encoding: "utf-8",
|
await execAsync("git", ["ls-remote", url, "HEAD"], "git-ls-remote", {
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
env: {
|
onLine: (line) => { stdout += line + "\n"; },
|
||||||
...process.env,
|
|
||||||
GIT_TERMINAL_PROMPT: "0",
|
|
||||||
},
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
});
|
||||||
const match = output.match(/^([a-f0-9]+)\s+HEAD/m);
|
const match = stdout.match(/^([a-f0-9]+)\s+HEAD/m);
|
||||||
return match?.[1] || null;
|
return match?.[1] || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -152,19 +188,16 @@ export function getRemoteCommitHash(): string | null {
|
|||||||
/**
|
/**
|
||||||
* 获取远程 .version 文件内容并解析为 VersionFile
|
* 获取远程 .version 文件内容并解析为 VersionFile
|
||||||
*/
|
*/
|
||||||
export function getRemoteVersionFile(): VersionFile | null {
|
export async function getRemoteVersionFile(): Promise<VersionFile | null> {
|
||||||
// 尝试方式1: git archive
|
// 尝试方式1: git archive
|
||||||
try {
|
try {
|
||||||
const url = getAuthenticatedRemoteUrl();
|
const url = getAuthenticatedRemoteUrl();
|
||||||
const output = execSync(
|
let stdout = "";
|
||||||
`git archive --remote="${url}" HEAD:.version 2>nul`,
|
await execAsync("git", ["archive", `--remote=${url}`, "HEAD:.version"], "git-archive", {
|
||||||
{
|
timeout: 15000,
|
||||||
encoding: "utf-8",
|
onLine: (line) => { stdout += line; },
|
||||||
timeout: 15000,
|
});
|
||||||
windowsHide: true,
|
const parsed = JSON.parse(stdout.trim()) as VersionFile;
|
||||||
}
|
|
||||||
);
|
|
||||||
const parsed = JSON.parse(output.trim()) as VersionFile;
|
|
||||||
if (parsed.version) return parsed;
|
if (parsed.version) return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
@@ -178,18 +211,15 @@ export function getRemoteVersionFile(): VersionFile | null {
|
|||||||
* 通过浅克隆获取远程 .version 文件(备选方案)
|
* 通过浅克隆获取远程 .version 文件(备选方案)
|
||||||
* OneDev 不支持 git archive --remote,改用完整浅克隆
|
* OneDev 不支持 git archive --remote,改用完整浅克隆
|
||||||
*/
|
*/
|
||||||
function getRemoteVersionByClone(): VersionFile | null {
|
async function getRemoteVersionByClone(): Promise<VersionFile | null> {
|
||||||
const tmpDir = path.join(
|
const tmpDir = path.join(
|
||||||
process.env.TEMP || "/tmp",
|
process.env.TEMP || "/tmp",
|
||||||
`recipe_tool_version_${Date.now()}`
|
`recipe_tool_version_${Date.now()}`
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const url = getAuthenticatedRemoteUrl();
|
const url = getAuthenticatedRemoteUrl();
|
||||||
execSync(`git clone --depth 1 "${url}" "${tmpDir}"`, {
|
await execAsync("git", ["clone", "--depth", "1", url, tmpDir], "git-clone", {
|
||||||
encoding: "utf-8",
|
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
windowsHide: true,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
});
|
||||||
const versionPath = path.join(tmpDir, ".version");
|
const versionPath = path.join(tmpDir, ".version");
|
||||||
if (fs.existsSync(versionPath)) {
|
if (fs.existsSync(versionPath)) {
|
||||||
@@ -234,7 +264,7 @@ export async function checkForUpdate(): Promise<VersionInfo> {
|
|||||||
const current = readLocalVersionFile();
|
const current = readLocalVersionFile();
|
||||||
consoleLog("检查", `当前版本: v${current.version} (${current.release_date || "无日期"})`);
|
consoleLog("检查", `当前版本: v${current.version} (${current.release_date || "无日期"})`);
|
||||||
|
|
||||||
const remoteCommit = getRemoteCommitHash();
|
const remoteCommit = await getRemoteCommitHash();
|
||||||
|
|
||||||
if (!remoteCommit) {
|
if (!remoteCommit) {
|
||||||
consoleErr("检查", "无法连接到远程仓库");
|
consoleErr("检查", "无法连接到远程仓库");
|
||||||
@@ -247,7 +277,7 @@ export async function checkForUpdate(): Promise<VersionInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
consoleLog("检查", `远程 HEAD: ${remoteCommit}`);
|
consoleLog("检查", `远程 HEAD: ${remoteCommit}`);
|
||||||
const remoteVersionFile = getRemoteVersionFile();
|
const remoteVersionFile = await getRemoteVersionFile();
|
||||||
|
|
||||||
if (remoteVersionFile) {
|
if (remoteVersionFile) {
|
||||||
const cmp = compareVersions(remoteVersionFile.version, current.version);
|
const cmp = compareVersions(remoteVersionFile.version, current.version);
|
||||||
@@ -279,10 +309,10 @@ export async function checkForUpdate(): Promise<VersionInfo> {
|
|||||||
/**
|
/**
|
||||||
* 检查 Docker 是否可用
|
* 检查 Docker 是否可用
|
||||||
*/
|
*/
|
||||||
function checkDockerAvailable(): { ok: boolean; message: string } {
|
async function checkDockerAvailable(): Promise<{ ok: boolean; message: string }> {
|
||||||
consoleLog("Docker", "检查 Docker 守护进程...");
|
consoleLog("Docker", "检查 Docker 守护进程...");
|
||||||
try {
|
try {
|
||||||
execSyncAndLog("docker info", "Docker");
|
await execAsync("docker", ["info"], "Docker");
|
||||||
consoleLog("Docker", "✓ Docker 正常");
|
consoleLog("Docker", "✓ Docker 正常");
|
||||||
return { ok: true, message: "" };
|
return { ok: true, message: "" };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -314,13 +344,16 @@ export async function executeUpdate(
|
|||||||
consoleLog("执行", "=== 开始执行更新 ===");
|
consoleLog("执行", "=== 开始执行更新 ===");
|
||||||
consoleLog("执行", `临时目录: ${tmpDir}`);
|
consoleLog("执行", `临时目录: ${tmpDir}`);
|
||||||
|
|
||||||
|
// 包装 onLog,将输出行实时推送到 SSE
|
||||||
|
const logLine = (line: string) => onLog(line);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
onLog("开始更新流程");
|
onLog("开始更新流程");
|
||||||
|
|
||||||
// 0. 检查 Docker 是否可用
|
// 0. 检查 Docker 是否可用
|
||||||
onLog("[0/5] 检查 Docker 环境");
|
onLog("[0/5] 检查 Docker 环境");
|
||||||
consoleLog("执行", "步骤 0/5 — 检查 Docker 环境");
|
consoleLog("执行", "步骤 0/5 — 检查 Docker 环境");
|
||||||
const dockerCheck = checkDockerAvailable();
|
const dockerCheck = await checkDockerAvailable();
|
||||||
if (!dockerCheck.ok) {
|
if (!dockerCheck.ok) {
|
||||||
onLog(`✗ ${dockerCheck.message}`);
|
onLog(`✗ ${dockerCheck.message}`);
|
||||||
consoleErr("执行", "Docker 检查未通过,更新终止");
|
consoleErr("执行", "Docker 检查未通过,更新终止");
|
||||||
@@ -332,11 +365,10 @@ export async function executeUpdate(
|
|||||||
const url = getAuthenticatedRemoteUrl();
|
const url = getAuthenticatedRemoteUrl();
|
||||||
onLog("[1/5] 克隆最新代码");
|
onLog("[1/5] 克隆最新代码");
|
||||||
consoleLog("执行", "步骤 1/5 — 克隆代码");
|
consoleLog("执行", "步骤 1/5 — 克隆代码");
|
||||||
execSyncAndLog(
|
await execAsync("git", ["clone", "--depth", "1", url, tmpDir], "git-clone", {
|
||||||
`git clone --depth 1 "${url}" "${tmpDir}"`,
|
timeout: 120000,
|
||||||
"git-clone",
|
onLine: logLine,
|
||||||
{ timeout: 120000, stdio: ["ignore", "pipe", "pipe"] }
|
});
|
||||||
);
|
|
||||||
onLog("✓ 代码拉取完成");
|
onLog("✓ 代码拉取完成");
|
||||||
|
|
||||||
// 2. 生成 .env 文件
|
// 2. 生成 .env 文件
|
||||||
@@ -357,26 +389,25 @@ export async function executeUpdate(
|
|||||||
consoleLog("执行", `.env 已写入 ${envContent.split("\n").length} 行`);
|
consoleLog("执行", `.env 已写入 ${envContent.split("\n").length} 行`);
|
||||||
onLog("✓ 环境变量已准备");
|
onLog("✓ 环境变量已准备");
|
||||||
|
|
||||||
// 3. 构建新镜像
|
// 3. 构建新镜像(实时输出构建日志)
|
||||||
onLog("[3/5] 构建新 Docker 镜像");
|
onLog("[3/5] 构建新 Docker 镜像");
|
||||||
consoleLog("执行", "步骤 3/5 — 构建镜像(可能耗时较长)");
|
consoleLog("执行", "步骤 3/5 — 构建镜像(可能耗时较长)");
|
||||||
execSyncAndLog(
|
await execAsync("docker-compose", ["-p", "recipe_tool", "build"], "docker-build", {
|
||||||
`cd "${tmpDir}" && docker-compose -p recipe_tool build`,
|
cwd: tmpDir,
|
||||||
"docker-build",
|
timeout: 600000,
|
||||||
{ timeout: 600000, stdio: ["ignore", "pipe", "pipe"] }
|
onLine: logLine,
|
||||||
);
|
});
|
||||||
onLog("✓ 镜像构建完成");
|
onLog("✓ 镜像构建完成");
|
||||||
|
|
||||||
// 4. 停止旧容器(释放端口)
|
// 4. 停止旧容器(释放端口),忽略失败
|
||||||
onLog("[4/5] 停止旧容器");
|
onLog("[4/5] 停止旧容器");
|
||||||
consoleLog("执行", "步骤 4/5 — 停止旧容器");
|
consoleLog("执行", "步骤 4/5 — 停止旧容器");
|
||||||
// 忽略 down 的错误(首次运行可能没有旧容器)
|
|
||||||
try {
|
try {
|
||||||
execSyncAndLog(
|
await execAsync("docker-compose", ["-p", "recipe_tool", "down", "--remove-orphans"], "docker-down", {
|
||||||
`cd "${tmpDir}" && docker-compose -p recipe_tool down`,
|
cwd: tmpDir,
|
||||||
"docker-down",
|
timeout: 30000,
|
||||||
{ timeout: 30000, stdio: ["ignore", "pipe", "pipe"] }
|
onLine: logLine,
|
||||||
);
|
});
|
||||||
} catch {
|
} catch {
|
||||||
consoleLog("执行", "旧容器停止(可能不存在,忽略)");
|
consoleLog("执行", "旧容器停止(可能不存在,忽略)");
|
||||||
}
|
}
|
||||||
@@ -385,11 +416,11 @@ export async function executeUpdate(
|
|||||||
// 5. 启动新容器
|
// 5. 启动新容器
|
||||||
onLog("[5/5] 启动新容器");
|
onLog("[5/5] 启动新容器");
|
||||||
consoleLog("执行", "步骤 5/5 — 启动新容器");
|
consoleLog("执行", "步骤 5/5 — 启动新容器");
|
||||||
execSyncAndLog(
|
await execAsync("docker-compose", ["-p", "recipe_tool", "up", "-d"], "docker-up", {
|
||||||
`cd "${tmpDir}" && docker-compose -p recipe_tool up -d`,
|
cwd: tmpDir,
|
||||||
"docker-up",
|
timeout: 60000,
|
||||||
{ timeout: 60000, stdio: ["ignore", "pipe", "pipe"] }
|
onLine: logLine,
|
||||||
);
|
});
|
||||||
onLog("✓ 新容器已启动");
|
onLog("✓ 新容器已启动");
|
||||||
|
|
||||||
consoleLog("执行", "=== 更新成功 ===");
|
consoleLog("执行", "=== 更新成功 ===");
|
||||||
|
|||||||
Reference in New Issue
Block a user