448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
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 };
|
||
}
|
||
}
|