diff --git a/src/lib/server/selfUpdate.test.ts b/src/lib/server/selfUpdate.test.ts new file mode 100644 index 0000000..456ac56 --- /dev/null +++ b/src/lib/server/selfUpdate.test.ts @@ -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) { + 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); + }); +}); diff --git a/src/lib/server/selfUpdate.ts b/src/lib/server/selfUpdate.ts new file mode 100644 index 0000000..d628128 --- /dev/null +++ b/src/lib/server/selfUpdate.ts @@ -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 { + const h: Record = { "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 { + 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 { + 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 { + 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 { + 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(); + 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 { + const target = version.trim().replace(/^[vV]/, ""); + return (await listSelfRollbackVersions(3, currentVersion)).some((v) => v.version === target); +}