331 lines
8.8 KiB
TypeScript
331 lines
8.8 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 = () => {};
|
|
|
|
/**
|
|
* 读取并解析本地的 .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> {
|
|
const current = readLocalVersionFile();
|
|
const remoteCommit = getRemoteCommitHash();
|
|
|
|
if (!remoteCommit) {
|
|
return {
|
|
current,
|
|
latest: null,
|
|
hasUpdate: false,
|
|
message: "无法连接到远程仓库,请检查网络和 ONEDEV_ACCESS_TOKEN 配置",
|
|
};
|
|
}
|
|
|
|
const remoteVersionFile = getRemoteVersionFile();
|
|
|
|
if (remoteVersionFile) {
|
|
const cmp = compareVersions(remoteVersionFile.version, current.version);
|
|
return {
|
|
current,
|
|
latest: remoteVersionFile,
|
|
hasUpdate: cmp > 0,
|
|
latestCommit: remoteCommit,
|
|
};
|
|
}
|
|
|
|
// 远程能连上但 .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 } {
|
|
try {
|
|
execSync("docker info", {
|
|
encoding: "utf-8",
|
|
timeout: 10000,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
});
|
|
return { ok: true, message: "" };
|
|
} catch {
|
|
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()}`
|
|
);
|
|
|
|
try {
|
|
onLog("开始更新流程...\n");
|
|
|
|
// 0. 检查 Docker 是否可用
|
|
onLog("[0/4] 检查 Docker 环境...\n");
|
|
const dockerCheck = checkDockerAvailable();
|
|
if (!dockerCheck.ok) {
|
|
onLog(`✗ ${dockerCheck.message}\n`);
|
|
return { success: false, message: dockerCheck.message };
|
|
}
|
|
onLog("✓ Docker 环境正常\n");
|
|
|
|
// 1. 拉取最新代码
|
|
const url = getAuthenticatedRemoteUrl();
|
|
onLog("[1/4] 克隆最新代码...\n");
|
|
execSync(`git clone --depth 1 "${url}" "${tmpDir}"`, {
|
|
encoding: "utf-8",
|
|
timeout: 120000,
|
|
windowsHide: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
onLog("✓ 代码拉取完成\n");
|
|
|
|
// 2. 生成 .env 文件
|
|
onLog("[2/4] 准备环境变量...\n");
|
|
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");
|
|
onLog("✓ 环境变量已准备\n");
|
|
|
|
// 3. 构建新镜像
|
|
onLog("[3/4] 构建新 Docker 镜像...\n");
|
|
execSync(`cd "${tmpDir}" && docker compose -p recipe_tool build`, {
|
|
encoding: "utf-8",
|
|
timeout: 600000,
|
|
windowsHide: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
onLog("✓ 镜像构建完成\n");
|
|
|
|
// 4. 重启服务
|
|
onLog("[4/4] 重启服务...\n");
|
|
execSync(`cd "${tmpDir}" && docker compose -p recipe_tool up -d`, {
|
|
encoding: "utf-8",
|
|
timeout: 60000,
|
|
windowsHide: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
onLog("✓ 服务已重启\n");
|
|
|
|
return { success: true, message: "更新完成" };
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : "未知错误";
|
|
onLog(`✗ 更新失败: ${errMsg}\n`);
|
|
return { success: false, message: errMsg };
|
|
} finally {
|
|
try {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
}
|
|
}
|