feat(update): WebUI self-update check/download against shared Gitea release

This commit is contained in:
m4
2026-08-12 16:47:56 +08:00
parent 68179dd46e
commit 496236ccf5
2 changed files with 491 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
const BASE = "https://git.foksai.com";
const REPO = "ouyangbo/EvoScientist";
function release(
tag: string,
assets: { name: string; url: string }[] = [],
extra: object = {}
) {
return {
tag_name: tag,
body: "notes",
prerelease: false,
draft: false,
published_at: "2026-01-01",
html_url: `${BASE}/${REPO}/releases/tag/${tag}`,
assets: assets.map((a) => ({ name: a.name, browser_download_url: a.url })),
...extra,
};
}
function mockFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
for (const [prefix, payload] of Object.entries(map)) {
if (String(url).startsWith(prefix)) {
return new Response(JSON.stringify(payload), { status: 200 });
}
}
return new Response("not found", { status: 404 });
})
);
}
describe("selfUpdate", () => {
beforeEach(() => {
process.env.EVOSCIENTIST_UPDATE_BASE_URL = BASE;
process.env.EVOSCIENTIST_UPDATE_REPO = REPO;
delete process.env.EVOSCIENTIST_UPDATE_TOKEN;
delete process.env.EVOSCIENTIST_UPDATE_CHECK_DISABLED;
});
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
delete process.env.EVOSCIENTIST_UPDATE_BASE_URL;
delete process.env.EVOSCIENTIST_UPDATE_REPO;
delete process.env.EVOSCIENTIST_UPDATE_STAGING_DIR;
});
it("picks max semver over releases+tags and reports update", async () => {
mockFetch({
[`${BASE}/api/v1/repos/${REPO}/releases`]: [release("v0.1.7"), release("v0.1.9")],
[`${BASE}/api/v1/repos/${REPO}/tags`]: [{ name: "v0.1.8" }],
});
const mod = await import("./selfUpdate");
const info = await mod.getSelfUpdateInfo({ force: true, currentVersion: "0.1.8" });
expect(info.latest_version).toBe("0.1.9");
expect(info.has_update).toBe(true);
expect(info.current_version).toBe("0.1.8");
});
it("serves cached info within TTL and warns on failure", async () => {
mockFetch({
[`${BASE}/api/v1/repos/${REPO}/releases`]: [release("v0.1.9")],
[`${BASE}/api/v1/repos/${REPO}/tags`]: [],
});
const mod = await import("./selfUpdate");
const first = await mod.getSelfUpdateInfo({ currentVersion: "0.1.8" });
expect(first.cached).toBe(false);
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("network down");
})
);
// force refetch past the TTL: fails, so stale cache is served with a warning
const second = await mod.getSelfUpdateInfo({ force: true, currentVersion: "0.1.8" });
expect(second.cached).toBe(true);
expect(second.latest_version).toBe("0.1.9");
expect(second.warning).toContain("update check failed");
});
it("downloadSelfUpdate verifies checksum and stages tgz", async () => {
const dir = mkdtempSync(join(tmpdir(), "selfupdate-"));
process.env.EVOSCIENTIST_UPDATE_STAGING_DIR = dir;
const tgz = Buffer.from("fake tgz bytes");
const digest = createHash("sha256").update(tgz).digest("hex");
const tgzName = "evoscientist-webui-0.1.9.tgz";
const rel = release("v0.1.9", [
{ name: tgzName, url: `${BASE}:8443/attachments/${tgzName}` },
{ name: "checksums.txt", url: `${BASE}:8443/attachments/checksums.txt` },
]);
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("checksums.txt"))
return new Response(`${digest} ${tgzName}\n`, { status: 200 });
if (u.includes(tgzName)) return new Response(tgz, { status: 200 });
if (u.includes("/releases")) return new Response(JSON.stringify([rel]), { status: 200 });
if (u.includes("/tags")) return new Response("[]", { status: 200 });
return new Response("not found", { status: 404 });
})
);
const mod = await import("./selfUpdate");
const result = await mod.downloadSelfUpdate();
expect(result.file).toBe(tgzName);
expect(result.path).toBe(join(dir, "v0.1.9", tgzName));
expect(result.version).toBe("0.1.9");
});
it("downloadSelfUpdate rejects checksum mismatch", async () => {
const dir = mkdtempSync(join(tmpdir(), "selfupdate-"));
process.env.EVOSCIENTIST_UPDATE_STAGING_DIR = dir;
const tgzName = "evoscientist-webui-0.1.9.tgz";
const rel = release("v0.1.9", [
{ name: tgzName, url: `${BASE}:8443/attachments/${tgzName}` },
{ name: "checksums.txt", url: `${BASE}:8443/attachments/checksums.txt` },
]);
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("checksums.txt"))
return new Response(`${"0".repeat(64)} ${tgzName}\n`, { status: 200 });
if (u.includes(tgzName)) return new Response(Buffer.from("bytes"), { status: 200 });
if (u.includes("/releases")) return new Response(JSON.stringify([rel]), { status: 200 });
if (u.includes("/tags")) return new Response("[]", { status: 200 });
return new Response("not found", { status: 404 });
})
);
const mod = await import("./selfUpdate");
await expect(mod.downloadSelfUpdate()).rejects.toThrow(/checksum mismatch/);
});
it("rejects asset URLs on foreign hosts", async () => {
const dir = mkdtempSync(join(tmpdir(), "selfupdate-"));
process.env.EVOSCIENTIST_UPDATE_STAGING_DIR = dir;
const tgzName = "evoscientist-webui-0.1.9.tgz";
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/releases"))
return new Response(
JSON.stringify([
release("v0.1.9", [{ name: tgzName, url: `https://evil.example.com/${tgzName}` }]),
]),
{ status: 200 }
);
if (u.includes("/tags")) return new Response("[]", { status: 200 });
return new Response("not found", { status: 404 });
})
);
const mod = await import("./selfUpdate");
await expect(mod.downloadSelfUpdate()).rejects.toThrow(/not allowed/);
});
it("listSelfRollbackVersions filters, orders and truncates at BREAKING-DB", async () => {
mockFetch({
[`${BASE}/api/v1/repos/${REPO}/releases`]: [
release("v0.3.0"),
release("v0.2.9", [], { prerelease: true }),
release("v0.2.8"),
release("v0.2.0", [], { prerelease: true, body: "BREAKING-DB" }),
release("v0.1.9"),
],
[`${BASE}/api/v1/repos/${REPO}/tags`]: [],
});
const mod = await import("./selfUpdate");
const versions = (await mod.listSelfRollbackVersions(3, "0.3.1")).map((v) => v.version);
expect(versions).toEqual(["0.3.0", "0.2.8"]);
});
it("isAllowedSelfRollback accepts listed versions only", async () => {
mockFetch({
[`${BASE}/api/v1/repos/${REPO}/releases`]: [release("v0.3.0")],
[`${BASE}/api/v1/repos/${REPO}/tags`]: [],
});
const mod = await import("./selfUpdate");
expect(await mod.isAllowedSelfRollback("0.3.0", "0.3.1")).toBe(true);
expect(await mod.isAllowedSelfRollback("v0.3.0", "0.3.1")).toBe(true);
expect(await mod.isAllowedSelfRollback("0.2.0", "0.3.1")).toBe(false);
});
});
+299
View File
@@ -0,0 +1,299 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
const CACHE_TTL_MS = 20 * 60 * 1000;
const MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024;
export interface SelfUpdateInfo {
current_version: string;
latest_version: string;
has_update: boolean;
release_url: string | null;
release_notes: string | null;
published_at: string | null;
cached: boolean;
warning: string | null;
}
export interface SelfRollbackVersion {
version: string;
published_at: string | null;
release_url: string | null;
}
interface GiteaRelease {
tag_name: string;
body?: string;
prerelease?: boolean;
draft?: boolean;
published_at?: string;
html_url?: string;
assets?: { name: string; browser_download_url: string }[];
}
let cache: { info: SelfUpdateInfo; fetchedAt: number } | null = null;
function base(): string {
return (process.env.EVOSCIENTIST_UPDATE_BASE_URL ?? "https://git.foksai.com").replace(/\/$/, "");
}
function repo(): string {
return process.env.EVOSCIENTIST_UPDATE_REPO ?? "ouyangbo/EvoScientist";
}
function headers(): Record<string, string> {
const h: Record<string, string> = { "User-Agent": "EvoScientist-WebUI update-check" };
const token = process.env.EVOSCIENTIST_UPDATE_TOKEN;
if (token) h["Authorization"] = `token ${token}`;
return h;
}
async function getJson(url: string): Promise<unknown> {
const res = await fetch(url, { headers: headers() });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Gitea returned ${res.status}`);
return res.json();
}
function semverKey(v: string): [number, number, number] {
const parts = v.trim().replace(/^[vV]/, "").split(".").slice(0, 3);
const out = parts.map((p) => {
const n = parseInt(p, 10);
return Number.isNaN(n) ? 0 : n;
});
while (out.length < 3) out.push(0);
return out as [number, number, number];
}
function cmpSemver(a: string, b: string): number {
const ka = semverKey(a);
const kb = semverKey(b);
for (let i = 0; i < 3; i++) {
if (ka[i] !== kb[i]) return ka[i] - kb[i];
}
return 0;
}
export function getSelfVersion(): string {
// Walk up from the entry script (process.argv[1]) looking for the
// package.json that belongs to @evoscientist/webui.
let dir = dirname(resolve(process.argv[1] ?? process.cwd()));
for (let i = 0; i < 6; i++) {
const candidate = join(dir, "package.json");
if (existsSync(candidate)) {
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as {
name?: string;
version?: string;
};
if (pkg.name === "@evoscientist/webui" && pkg.version) return pkg.version;
} catch {
// keep walking up
}
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return "0.0.0-dev";
}
export function selfStagingDir(): string {
const override = process.env.EVOSCIENTIST_UPDATE_STAGING_DIR?.trim();
if (override) return override;
return join(homedir(), ".evoscientist", "updates");
}
async function fetchLatest(): Promise<{ latest: string; release: GiteaRelease | null }> {
const b = base();
const r = repo();
const releases = ((await getJson(`${b}/api/v1/repos/${r}/releases?limit=20`)) ??
[]) as GiteaRelease[];
const tags = ((await getJson(`${b}/api/v1/repos/${r}/tags?limit=20`)) ?? []) as {
name: string;
}[];
const candidates: { tag: string; release: GiteaRelease | null }[] = [];
for (const rel of releases) {
if (rel.tag_name) candidates.push({ tag: rel.tag_name, release: rel });
}
for (const t of tags) {
if (t.name) candidates.push({ tag: t.name, release: null });
}
if (candidates.length === 0) return { latest: getSelfVersion(), release: null };
candidates.sort((a, b) => cmpSemver(a.tag, b.tag));
const winner = candidates[candidates.length - 1];
const release =
winner.release ?? candidates.find((c) => c.release && c.tag === winner.tag)?.release ?? null;
return { latest: winner.tag.replace(/^[vV]/, ""), release };
}
export async function getSelfUpdateInfo(opts?: {
force?: boolean;
currentVersion?: string;
}): Promise<SelfUpdateInfo> {
const current = opts?.currentVersion ?? getSelfVersion();
const stale = (warning: string | null, cached = false, latest?: string): SelfUpdateInfo => ({
current_version: current,
latest_version: latest ?? current,
has_update: false,
release_url: null,
release_notes: null,
published_at: null,
cached,
warning,
});
if (process.env.EVOSCIENTIST_UPDATE_CHECK_DISABLED === "1") return stale(null);
if (cache && !opts?.force && Date.now() - cache.fetchedAt < CACHE_TTL_MS) {
return { ...cache.info, cached: true, current_version: current };
}
try {
const { latest, release } = await fetchLatest();
const info: SelfUpdateInfo = {
current_version: current,
latest_version: latest,
has_update: cmpSemver(latest, current) > 0,
release_url: release?.html_url ?? null,
release_notes: release?.body ?? null,
published_at: release?.published_at ?? null,
cached: false,
warning: null,
};
cache = { info, fetchedAt: Date.now() };
return info;
} catch (err) {
if (cache) {
return {
...cache.info,
cached: true,
current_version: current,
warning: `update check failed: ${err}`,
};
}
return stale(`update check failed: ${err}`);
}
}
function checkAssetUrl(url: string): void {
const assetHost = new URL(url).hostname;
const baseHost = new URL(base()).hostname;
if (assetHost !== baseHost) {
throw new Error(`download URL host is not allowed: ${url}`);
}
}
async function download(url: string, dest: string): Promise<void> {
const res = await fetch(url, { headers: headers() });
if (!res.ok) throw new Error(`download returned ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
if (buf.byteLength > MAX_DOWNLOAD_BYTES) throw new Error("download exceeds size cap");
writeFileSync(dest, buf);
}
function pickTgzAsset(release: GiteaRelease): { name: string; url: string } {
for (const a of release.assets ?? []) {
if (a.name.startsWith("evoscientist-webui-") && a.name.endsWith(".tgz")) {
return { name: a.name, url: a.browser_download_url };
}
}
throw new Error("release has no evoscientist-webui .tgz asset");
}
function checksumMatches(sumsPath: string, filename: string, filePath: string): boolean {
const lines = readFileSync(sumsPath, "utf-8").split("\n");
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length === 2 && parts[1] === filename) {
const actual = createHash("sha256").update(readFileSync(filePath)).digest("hex");
return parts[0] === actual;
}
}
return true; // no entry for the file — nothing to verify against
}
export async function downloadSelfUpdate(version?: string): Promise<{
version: string;
file: string;
path: string;
suggested_command: string;
}> {
let resolved = version?.trim().replace(/^[vV]/, "") ?? "";
let release: GiteaRelease | null = null;
if (resolved) {
release = (await getJson(
`${base()}/api/v1/repos/${repo()}/releases/tags/v${resolved}`
)) as GiteaRelease | null;
if (!release) throw new Error(`no release found for version ${version}`);
} else {
const latest = await fetchLatest();
resolved = latest.latest;
release = latest.release;
if (!release) throw new Error("no release metadata for latest version");
}
const asset = pickTgzAsset(release);
checkAssetUrl(asset.url);
const dir = join(selfStagingDir(), `v${resolved}`);
mkdirSync(dir, { recursive: true });
const dest = join(dir, asset.name);
const sumsAsset = (release.assets ?? []).find((a) => a.name === "checksums.txt");
const sumsPath = join(dir, "checksums.txt");
const localOk =
existsSync(dest) &&
sumsAsset !== undefined &&
existsSync(sumsPath) &&
checksumMatches(sumsPath, asset.name, dest);
if (!localOk) {
await download(asset.url, dest);
if (sumsAsset) {
checkAssetUrl(sumsAsset.browser_download_url);
await download(sumsAsset.browser_download_url, sumsPath);
if (!checksumMatches(sumsPath, asset.name, dest)) {
throw new Error(`checksum mismatch for ${asset.name}`);
}
}
}
return {
version: resolved,
file: asset.name,
path: dest,
suggested_command: `npm install -g ${dest} # then restart the Web UI`,
};
}
export async function listSelfRollbackVersions(
limit = 3,
currentVersion?: string
): Promise<SelfRollbackVersion[]> {
const current = currentVersion ?? getSelfVersion();
const releases = ((await getJson(`${base()}/api/v1/repos/${repo()}/releases?limit=20`)) ??
[]) as GiteaRelease[];
const ordered = releases
.filter((r) => r.tag_name)
.sort((a, b) => cmpSemver(b.tag_name, a.tag_name));
const out: SelfRollbackVersion[] = [];
const seen = new Set<string>();
for (const r of ordered) {
const body = r.body ?? "";
if (r.draft) continue;
if (r.prerelease) {
if (body.includes("BREAKING-DB")) break;
continue;
}
const v = r.tag_name.replace(/^[vV]/, "");
if (seen.has(v) || cmpSemver(v, current) >= 0) continue;
seen.add(v);
out.push({ version: v, published_at: r.published_at ?? null, release_url: r.html_url ?? null });
if (out.length >= limit) break;
}
return out;
}
export async function isAllowedSelfRollback(
version: string,
currentVersion?: string
): Promise<boolean> {
const target = version.trim().replace(/^[vV]/, "");
return (await listSelfRollbackVersions(3, currentVersion)).some((v) => v.version === target);
}