Files

448 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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);
});
});
}
/**
* 读取并解析本地的 .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<string | null> {
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<VersionFile | null> {
// 尝试方式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<VersionFile | null> {
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<VersionInfo> {
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 日志回调
*/
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 || "";
const repoUrl = process.env.REPOSITORY_URL || "https://git.hanhan.ltd/recipe_tool";
const registryImage = process.env.REGISTRY_IMAGE || "recipe_tool:latest";
// 从 REPOSITORY_URL 提取 registry host 和 project path 以拼出完整镜像地址
// 例如: https://git.hanhan.ltd/recipe_tool → host=git.hanhan.ltd, path=recipe_tool
// 完整镜像: git.hanhan.ltd/recipe_tool/recipe_tool:latest
const repoUrlObj = new URL(repoUrl);
const registryHost = repoUrlObj.host;
const projectPath = repoUrlObj.pathname.replace(/^\/|\/$/g, "");
const fullImage = `${registryHost}/${projectPath}/${registryImage}`;
consoleLog("执行", "=== 开始执行更新 ===");
consoleLog("执行", `工作目录: ${cwd}, 镜像: ${fullImage}`);
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 ${registryHost} -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", fullImage], "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}`,
`REGISTRY_IMAGE=${registryImage}`,
`REPOSITORY_URL=${repoUrl}`,
`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 };
}
}