Files
recipe_tool/lib/update.ts
T

411 lines
12 KiB
TypeScript

import fs from "fs";
import path from "path";
import { execSync } 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}`);
}
/**
* 执行命令并记录到控制台
*/
function execSyncAndLog(
command: string,
tag: string,
options: Parameters<typeof execSync>[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;
}
}
/**
* 读取并解析本地的 .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 function getRemoteCommitHash(): string | null {
try {
const url = getAuthenticatedRemoteUrl();
const output = execSync(`git ls-remote "${url}" HEAD`, {
encoding: "utf-8",
timeout: 15000,
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
},
windowsHide: true,
});
const match = output.match(/^([a-f0-9]+)\s+HEAD/m);
return match?.[1] || null;
} catch {
return null;
}
}
/**
* 获取远程 .version 文件内容并解析为 VersionFile
*/
export function getRemoteVersionFile(): VersionFile | null {
// 尝试方式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;
if (parsed.version) return parsed;
} catch {
// fall through
}
// 尝试方式2: 浅克隆
return getRemoteVersionByClone();
}
/**
* 通过浅克隆获取远程 .version 文件(备选方案)
* OneDev 不支持 git archive --remote,改用完整浅克隆
*/
function getRemoteVersionByClone(): VersionFile | null {
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",
timeout: 60000,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
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 = getRemoteCommitHash();
if (!remoteCommit) {
consoleErr("检查", "无法连接到远程仓库");
return {
current,
latest: null,
hasUpdate: false,
message: "无法连接到远程仓库,请检查网络和 ONEDEV_ACCESS_TOKEN 配置",
};
}
consoleLog("检查", `远程 HEAD: ${remoteCommit}`);
const remoteVersionFile = 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 是否可用
*/
function checkDockerAvailable(): { ok: boolean; message: string } {
consoleLog("Docker", "检查 Docker 守护进程...");
try {
execSyncAndLog("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:3000/settings/update",
};
}
}
/**
* 执行 Docker 容器更新
* @param onLog 日志回调
*/
export async function executeUpdate(
onLog: LogCallback = noopLog
): Promise<{ success: boolean; message: string }> {
const tmpDir = path.join(
process.env.TEMP || "/tmp",
`recipe_tool_update_${Date.now()}`
);
consoleLog("执行", "=== 开始执行更新 ===");
consoleLog("执行", `临时目录: ${tmpDir}`);
try {
onLog("开始更新流程");
// 0. 检查 Docker 是否可用
onLog("[0/5] 检查 Docker 环境");
consoleLog("执行", "步骤 0/5 — 检查 Docker 环境");
const dockerCheck = checkDockerAvailable();
if (!dockerCheck.ok) {
onLog(`✗ ${dockerCheck.message}`);
consoleErr("执行", "Docker 检查未通过,更新终止");
return { success: false, message: dockerCheck.message };
}
onLog("✓ Docker 环境正常");
// 1. 拉取最新代码
const url = getAuthenticatedRemoteUrl();
onLog("[1/5] 克隆最新代码");
consoleLog("执行", "步骤 1/5 — 克隆代码");
execSyncAndLog(
`git clone --depth 1 "${url}" "${tmpDir}"`,
"git-clone",
{ timeout: 120000, stdio: ["ignore", "pipe", "pipe"] }
);
onLog("✓ 代码拉取完成");
// 2. 生成 .env 文件
onLog("[2/5] 准备环境变量");
consoleLog("执行", "步骤 2/5 — 准备环境变量");
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=${process.env.ONEDEV_ACCESS_TOKEN || ""}`,
].join("\n");
fs.writeFileSync(path.join(tmpDir, ".env"), envContent, "utf-8");
consoleLog("执行", `.env 已写入 ${envContent.split("\n").length} 行`);
onLog("✓ 环境变量已准备");
// 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"] }
);
onLog("✓ 镜像构建完成");
// 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"] }
);
} catch {
consoleLog("执行", "旧容器停止(可能不存在,忽略)");
}
onLog("✓ 旧容器已停止");
// 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"] }
);
onLog("✓ 新容器已启动");
consoleLog("执行", "=== 更新成功 ===");
return { success: true, message: "更新完成" };
} catch (error) {
const errMsg = error instanceof Error ? error.message : "未知错误";
consoleErr("执行", `=== 更新失败: ${errMsg} ===`);
onLog(`✗ 更新失败: ${errMsg}`);
return { success: false, message: errMsg };
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
consoleLog("执行", `临时目录已清理: ${tmpDir}`);
} catch {
// ignore cleanup errors
}
}
}