Browse Source

feat: enhance version management and GitHub integration

- Introduced functions to read local version files and fetch the latest release from GitHub, improving version retrieval.
- Added error handling for GitHub API requests and fallback mechanisms to ensure version information is consistently available.
- Updated the version comparison logic to handle semantic versioning, including support for pre-release identifiers.

These changes enhance the application's ability to manage and display version information effectively.
ding113 2 months ago
parent
commit
42e64ef59d
3 changed files with 275 additions and 41 deletions
  1. 157 33
      src/app/api/version/route.ts
  2. 84 8
      src/lib/version.ts
  3. 34 0
      tests/unit/version.test.ts

+ 157 - 33
src/app/api/version/route.ts

@@ -1,3 +1,5 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
 import { NextResponse } from "next/server";
 import { logger } from "@/lib/logger";
 import { APP_VERSION, compareVersions, GITHUB_REPO } from "@/lib/version";
@@ -5,6 +7,9 @@ import { APP_VERSION, compareVersions, GITHUB_REPO } from "@/lib/version";
 export const runtime = "nodejs";
 export const dynamic = "force-dynamic";
 
+const REVALIDATE_SECONDS = 5 * 60; // 5 分钟
+const USER_AGENT = "claude-code-hub";
+
 interface GitHubRelease {
   tag_name: string;
   name: string;
@@ -12,56 +17,175 @@ interface GitHubRelease {
   published_at: string;
 }
 
+interface LatestVersionInfo {
+  latest: string;
+  releaseUrl?: string;
+  publishedAt?: string;
+}
+
+function normalizeVersionForDisplay(version: string): string {
+  const trimmed = version.trim();
+  if (!trimmed) return trimmed;
+
+  // Normalize leading "V" to lowercase.
+  if (/^v/i.test(trimmed)) {
+    return `v${trimmed.slice(1)}`;
+  }
+
+  // Only add "v" prefix for semver-like strings; keep other values (e.g. "dev") as-is.
+  if (/^\d+(?:\.\d+)*(?:[-+].+)?$/.test(trimmed)) {
+    return `v${trimmed}`;
+  }
+
+  return trimmed;
+}
+
+async function readLocalVersionFile(): Promise<string | null> {
+  try {
+    const content = await readFile(join(process.cwd(), "VERSION"), "utf8");
+    const trimmed = content.trim();
+    return trimmed ? normalizeVersionForDisplay(trimmed) : null;
+  } catch {
+    return null;
+  }
+}
+
+async function getCurrentVersion(): Promise<string> {
+  const fromEnv = process.env.NEXT_PUBLIC_APP_VERSION?.trim();
+  if (fromEnv) return normalizeVersionForDisplay(fromEnv);
+
+  const fromFile = await readLocalVersionFile();
+  if (fromFile) return fromFile;
+
+  return normalizeVersionForDisplay(APP_VERSION);
+}
+
+function getGitHubAuthToken(): string | null {
+  const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
+  return token?.trim() || null;
+}
+
+function buildGitHubHeaders(): Record<string, string> {
+  const headers: Record<string, string> = {
+    Accept: "application/vnd.github.v3+json",
+    "User-Agent": USER_AGENT,
+  };
+
+  const token = getGitHubAuthToken();
+  if (token) {
+    headers.Authorization = `Bearer ${token}`;
+  }
+
+  return headers;
+}
+
+async function fetchLatestRelease(): Promise<GitHubRelease | null> {
+  const response = await fetch(
+    `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/latest`,
+    {
+      headers: buildGitHubHeaders(),
+      next: {
+        revalidate: REVALIDATE_SECONDS,
+      },
+    }
+  );
+
+  if (response.status === 404) {
+    return null;
+  }
+
+  if (!response.ok) {
+    throw new Error(`GitHub API 错误: ${response.status}`);
+  }
+
+  return (await response.json()) as GitHubRelease;
+}
+
+async function fetchLatestVersionFromVersionFile(): Promise<string | null> {
+  const response = await fetch(
+    `https://raw.githubusercontent.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/main/VERSION`,
+    {
+      headers: {
+        "User-Agent": USER_AGENT,
+      },
+      next: {
+        revalidate: REVALIDATE_SECONDS,
+      },
+    }
+  );
+
+  if (!response.ok) {
+    return null;
+  }
+
+  const version = (await response.text()).trim();
+  return version ? normalizeVersionForDisplay(version) : null;
+}
+
+async function getLatestVersionInfo(): Promise<LatestVersionInfo | null> {
+  try {
+    const release = await fetchLatestRelease();
+    if (!release) {
+      const latest = await fetchLatestVersionFromVersionFile();
+      if (!latest) return null;
+
+      return {
+        latest,
+        releaseUrl: `https://github.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/tag/${latest}`,
+      };
+    }
+
+    return {
+      latest: normalizeVersionForDisplay(release.tag_name),
+      releaseUrl: release.html_url,
+      publishedAt: release.published_at,
+    };
+  } catch (error) {
+    // Fallback to VERSION file when GitHub API is rate-limited or blocked.
+    const latest = await fetchLatestVersionFromVersionFile();
+    if (!latest) {
+      throw error;
+    }
+
+    return {
+      latest,
+      releaseUrl: `https://github.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/tag/${latest}`,
+    };
+  }
+}
+
 /**
  * GET /api/version
  * 检查是否有新版本可用
  */
 export async function GET() {
   try {
-    // 获取 GitHub 最新 release
-    const response = await fetch(
-      `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/latest`,
-      {
-        headers: {
-          Accept: "application/vnd.github.v3+json",
-          "User-Agent": "claude-code-hub",
-        },
-        next: {
-          revalidate: 3600, // 缓存 1 小时
-        },
-      }
-    );
+    const current = await getCurrentVersion();
+    const latestInfo = await getLatestVersionInfo();
 
-    if (!response.ok) {
-      if (response.status === 404) {
-        return NextResponse.json({
-          current: APP_VERSION,
-          latest: null,
-          hasUpdate: false,
-          message: "暂无发布版本",
-        });
-      }
-      throw new Error(`GitHub API 错误: ${response.status}`);
+    if (!latestInfo) {
+      return NextResponse.json({
+        current,
+        latest: null,
+        hasUpdate: false,
+        message: "暂无发布版本",
+      });
     }
 
-    const release: GitHubRelease = await response.json();
-    const latestVersion = release.tag_name;
-
-    // 比较版本
-    const hasUpdate = compareVersions(APP_VERSION, latestVersion) === 1;
+    const hasUpdate = compareVersions(current, latestInfo.latest) === 1;
 
     return NextResponse.json({
-      current: APP_VERSION,
-      latest: latestVersion,
+      current,
+      latest: latestInfo.latest,
       hasUpdate,
-      releaseUrl: release.html_url,
-      publishedAt: release.published_at,
+      releaseUrl: latestInfo.releaseUrl,
+      publishedAt: latestInfo.publishedAt,
     });
   } catch (error) {
     logger.error("版本检查失败:", error);
     return NextResponse.json(
       {
-        current: APP_VERSION,
+        current: normalizeVersionForDisplay(APP_VERSION),
         latest: null,
         hasUpdate: false,
         error: "无法获取最新版本信息",

+ 84 - 8
src/lib/version.ts

@@ -15,6 +15,47 @@ export const GITHUB_REPO = {
   repo: "claude-code-hub",
 };
 
+type SemverPrereleaseId =
+  | { kind: "num"; value: number }
+  | { kind: "str"; value: string };
+
+function parseSemverLike(
+  raw: string
+): { numbers: number[]; prerelease: SemverPrereleaseId[] | null } | null {
+  const trimmed = raw.trim();
+  if (!trimmed) return null;
+
+  const withoutPrefix = trimmed.replace(/^v/i, "");
+
+  // Ignore build metadata.
+  const withoutBuild = withoutPrefix.split("+")[0] ?? "";
+  if (!withoutBuild) return null;
+
+  const [core, prereleaseRaw] = withoutBuild.split("-", 2);
+  if (!core) return null;
+
+  const numberParts = core.split(".").map((part) => {
+    const match = part.match(/^\d+/);
+    if (!match) return Number.NaN;
+    return Number.parseInt(match[0], 10);
+  });
+
+  if (numberParts.some((n) => Number.isNaN(n))) {
+    return null;
+  }
+
+  const prerelease = prereleaseRaw
+    ? prereleaseRaw.split(".").map((id) => {
+        if (/^\d+$/.test(id)) {
+          return { kind: "num" as const, value: Number.parseInt(id, 10) };
+        }
+        return { kind: "str" as const, value: id };
+      })
+    : null;
+
+  return { numbers: numberParts, prerelease };
+}
+
 /**
  * 比较两个语义化版本号
  * @param current 当前版本 (如 "v1.2.3")
@@ -31,21 +72,56 @@ export const GITHUB_REPO = {
  * - isVersionEqual(a, b) - 检查 a 和 b 是否相等
  */
 export function compareVersions(current: string, latest: string): number {
-  // 移除 'v' 前缀
-  const cleanCurrent = current.replace(/^v/, "");
-  const cleanLatest = latest.replace(/^v/, "");
+  const currentParsed = parseSemverLike(current);
+  const latestParsed = parseSemverLike(latest);
 
-  const currentParts = cleanCurrent.split(".").map(Number);
-  const latestParts = cleanLatest.split(".").map(Number);
+  // Fail open: 任何无法解析的版本都视为相等,避免误判导致拦截/提示异常
+  if (!currentParsed || !latestParsed) {
+    return 0;
+  }
 
-  for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
-    const curr = currentParts[i] || 0;
-    const lat = latestParts[i] || 0;
+  // 1) Compare core numbers.
+  for (let i = 0; i < Math.max(currentParsed.numbers.length, latestParsed.numbers.length); i++) {
+    const curr = currentParsed.numbers[i] ?? 0;
+    const lat = latestParsed.numbers[i] ?? 0;
 
     if (lat > curr) return 1;
     if (lat < curr) return -1;
   }
 
+  const currPre = currentParsed.prerelease;
+  const latPre = latestParsed.prerelease;
+
+  // 2) Core equal: stable > prerelease.
+  if (!currPre && !latPre) return 0;
+  if (!currPre && latPre) return -1;
+  if (currPre && !latPre) return 1;
+
+  // 3) Both prerelease: compare identifiers (SemVer rules).
+  for (let i = 0; i < Math.max(currPre!.length, latPre!.length); i++) {
+    const currId = currPre![i];
+    const latId = latPre![i];
+
+    if (!currId && latId) return 1;
+    if (currId && !latId) return -1;
+    if (!currId || !latId) return 0;
+
+    if (currId.kind === "num" && latId.kind === "num") {
+      if (latId.value > currId.value) return 1;
+      if (latId.value < currId.value) return -1;
+      continue;
+    }
+
+    // Numeric identifiers have lower precedence than non-numeric identifiers.
+    if (currId.kind === "num" && latId.kind === "str") return 1;
+    if (currId.kind === "str" && latId.kind === "num") return -1;
+
+    if (currId.kind === "str" && latId.kind === "str") {
+      if (latId.value > currId.value) return 1;
+      if (latId.value < currId.value) return -1;
+    }
+  }
+
   return 0;
 }
 

+ 34 - 0
tests/unit/version.test.ts

@@ -0,0 +1,34 @@
+import { describe, expect, test } from "vitest";
+import { compareVersions, isVersionEqual, isVersionGreater, isVersionLess } from "@/lib/version";
+
+describe("版本比较", () => {
+  test("应正确判断是否存在可升级版本(latest > current)", () => {
+    expect(compareVersions("v0.3.0", "v0.3.33")).toBe(1);
+    expect(compareVersions("v0.3.33", "v0.3.0")).toBe(-1);
+    expect(compareVersions("v0.3.33", "v0.3.33")).toBe(0);
+  });
+
+  test("应正确处理预发布版本(stable > prerelease)", () => {
+    expect(compareVersions("v1.2.3-beta.1", "v1.2.3")).toBe(1);
+    expect(compareVersions("v1.2.3", "v1.2.3-beta.1")).toBe(-1);
+  });
+
+  test("应正确比较预发布标识(alpha < beta, alpha.1 < alpha.2)", () => {
+    expect(compareVersions("v1.2.3-alpha", "v1.2.3-beta")).toBe(1);
+    expect(compareVersions("v1.2.3-alpha.1", "v1.2.3-alpha.2")).toBe(1);
+    expect(compareVersions("v1.2.3-alpha.2", "v1.2.3-alpha.10")).toBe(1);
+  });
+
+  test("应忽略构建元数据(+build)", () => {
+    expect(compareVersions("v1.2.3+build.1", "v1.2.3+build.2")).toBe(0);
+    expect(compareVersions("v1.2.3+build.2", "v1.2.3+build.1")).toBe(0);
+  });
+
+  test("无法解析的版本应 Fail Open(视为相等)", () => {
+    expect(compareVersions("dev", "v1.0.0")).toBe(0);
+    expect(isVersionLess("dev", "v1.0.0")).toBe(false);
+    expect(isVersionGreater("dev", "v1.0.0")).toBe(false);
+    expect(isVersionEqual("dev", "v1.0.0")).toBe(true);
+  });
+});
+