feat(update): WebUI self-update check/download against shared Gitea release
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user