diff --git a/src/app/api/system/rollback-versions/route.test.ts b/src/app/api/system/rollback-versions/route.test.ts new file mode 100644 index 0000000..e3ae714 --- /dev/null +++ b/src/app/api/system/rollback-versions/route.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })), + configApiFetch: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + ActorError: class ActorError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } + }, +})); +vi.mock("@/lib/server/evoscientistConfigClient", () => ({ + configApiFetch: mocks.configApiFetch, + ConfigApiError: class ConfigApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null = null, + readonly details: unknown[] = [] + ) { + super(message); + } + }, +})); + +const { GET } = await import("./route"); + +describe("GET /api/system/rollback-versions", () => { + it("proxies the backend rollback version list", async () => { + mocks.configApiFetch.mockResolvedValue({ + status: 200, + body: { versions: [{ version: "0.2.9", published_at: "p", release_url: "u" }] }, + }); + const res = await GET(new NextRequest(new URL("http://localhost/api/system/rollback-versions"))); + expect(res.status).toBe(200); + expect((await res.json()).versions[0].version).toBe("0.2.9"); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ sub: "tester" }), + "/internal/system/rollback-versions" + ); + }); +}); diff --git a/src/app/api/system/rollback-versions/route.ts b/src/app/api/system/rollback-versions/route.ts new file mode 100644 index 0000000..6bb45aa --- /dev/null +++ b/src/app/api/system/rollback-versions/route.ts @@ -0,0 +1,33 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, type Actor } from "@/lib/server/actor"; +import { configApiFetch } from "@/lib/server/evoscientistConfigClient"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export interface RollbackVersionsResult { + versions: { version: string; published_at: string | null; release_url: string | null }[]; +} + +/** Proxy the backend rollback version list. */ +export async function GET(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + const { status, body } = await configApiFetch( + actor, + "/internal/system/rollback-versions" + ); + return NextResponse.json(body, { status, headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/app/api/system/self-rollback/route.test.ts b/src/app/api/system/self-rollback/route.test.ts new file mode 100644 index 0000000..1da86cf --- /dev/null +++ b/src/app/api/system/self-rollback/route.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })), + isAllowedSelfRollback: vi.fn(), + startSelfApply: vi.fn(), + isNpxCacheInstall: vi.fn(() => false), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + ActorError: class ActorError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } + }, +})); +vi.mock("@/lib/server/selfUpdate", () => ({ + isAllowedSelfRollback: mocks.isAllowedSelfRollback, +})); +vi.mock("@/lib/server/selfApply", () => ({ + startSelfApply: mocks.startSelfApply, + isNpxCacheInstall: mocks.isNpxCacheInstall, +})); + +const { POST } = await import("./route"); + +function req(body: unknown): NextRequest { + return new NextRequest(new URL("http://localhost/api/system/self-rollback"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/system/self-rollback", () => { + it("rejects a missing version", async () => { + const res = await POST(req({})); + expect(res.status).toBe(400); + }); + + it("rejects versions outside the rollback list", async () => { + mocks.isAllowedSelfRollback.mockResolvedValue(false); + const res = await POST(req({ version: "0.0.1" })); + expect(res.status).toBe(400); + expect((await res.json()).code).toBe("ROLLBACK_VERSION_NOT_ALLOWED"); + }); + + it("starts the apply pipeline for allowed versions", async () => { + mocks.isAllowedSelfRollback.mockResolvedValue(true); + mocks.startSelfApply.mockResolvedValue( + NextResponse.json({ status: "applying" }, { status: 202 }) + ); + const res = await POST(req({ version: "0.1.7" })); + expect(res.status).toBe(202); + expect(mocks.startSelfApply).toHaveBeenCalledWith("0.1.7"); + }); +}); diff --git a/src/app/api/system/self-rollback/route.ts b/src/app/api/system/self-rollback/route.ts new file mode 100644 index 0000000..7909026 --- /dev/null +++ b/src/app/api/system/self-rollback/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, type Actor } from "@/lib/server/actor"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; +import { isAllowedSelfRollback } from "@/lib/server/selfUpdate"; +import { isNpxCacheInstall, startSelfApply } from "@/lib/server/selfApply"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** Roll the WebUI package back to an allowed older version. */ +export async function POST(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + if (isNpxCacheInstall()) { + return NextResponse.json( + { + status: "manual", + guidance: + "This Web UI runs from an npx cache and cannot be rolled back in place; " + + "run: npx @evoscientist/webui@", + }, + { headers: NO_STORE } + ); + } + const body: unknown = await request.json().catch(() => null); + const version = + body && typeof body === "object" && "version" in body + ? (body as { version: unknown }).version + : undefined; + if (typeof version !== "string" || !version.trim()) { + return NextResponse.json( + { code: "VERSION_REQUIRED", message: 'body must be {"version": "x.y.z"}' }, + { status: 400, headers: NO_STORE } + ); + } + const target = version.trim().replace(/^[vV]/, ""); + if (!(await isAllowedSelfRollback(target))) { + return NextResponse.json( + { + code: "ROLLBACK_VERSION_NOT_ALLOWED", + message: `${target} is not in the rollback list`, + }, + { status: 400, headers: NO_STORE } + ); + } + return await startSelfApply(target); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/app/api/system/self-update/route.test.ts b/src/app/api/system/self-update/route.test.ts new file mode 100644 index 0000000..09fd99d --- /dev/null +++ b/src/app/api/system/self-update/route.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })), + getSelfUpdateInfo: vi.fn(), + startSelfApply: vi.fn(), + isNpxCacheInstall: vi.fn(() => false), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + ActorError: class ActorError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } + }, +})); +vi.mock("@/lib/server/selfUpdate", () => ({ + getSelfUpdateInfo: mocks.getSelfUpdateInfo, +})); +vi.mock("@/lib/server/selfApply", () => ({ + startSelfApply: mocks.startSelfApply, + isNpxCacheInstall: mocks.isNpxCacheInstall, +})); + +const { POST } = await import("./route"); + +function req(): NextRequest { + return new NextRequest(new URL("http://localhost/api/system/self-update"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); +} + +describe("POST /api/system/self-update", () => { + it("returns 409 when already up to date", async () => { + mocks.getSelfUpdateInfo.mockResolvedValue({ + current_version: "0.1.9", + latest_version: "0.1.9", + has_update: false, + }); + const res = await POST(req()); + expect(res.status).toBe(409); + expect((await res.json()).code).toBe("ALREADY_UP_TO_DATE"); + }); + + it("returns manual guidance for npx cache installs", async () => { + mocks.isNpxCacheInstall.mockReturnValueOnce(true); + const res = await POST(req()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("manual"); + expect(body.guidance).toContain("npx"); + }); + + it("starts the apply pipeline when an update exists", async () => { + mocks.getSelfUpdateInfo.mockResolvedValue({ + current_version: "0.1.8", + latest_version: "0.1.9", + has_update: true, + }); + mocks.startSelfApply.mockResolvedValue( + NextResponse.json({ status: "applying" }, { status: 202 }) + ); + const res = await POST(req()); + expect(res.status).toBe(202); + expect(mocks.startSelfApply).toHaveBeenCalledWith("0.1.9"); + }); +}); diff --git a/src/app/api/system/self-update/route.ts b/src/app/api/system/self-update/route.ts new file mode 100644 index 0000000..b3ce5d7 --- /dev/null +++ b/src/app/api/system/self-update/route.ts @@ -0,0 +1,48 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, type Actor } from "@/lib/server/actor"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; +import { getSelfUpdateInfo } from "@/lib/server/selfUpdate"; +import { isNpxCacheInstall, startSelfApply } from "@/lib/server/selfApply"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** One-click in-place update of the WebUI package itself. */ +export async function POST(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + if (isNpxCacheInstall()) { + return NextResponse.json( + { + status: "manual", + guidance: + "This Web UI runs from an npx cache and cannot be updated in place; " + + "run: npx @evoscientist/webui@latest", + }, + { headers: NO_STORE } + ); + } + const info = await getSelfUpdateInfo({ force: true }); + if (!info.has_update) { + return NextResponse.json( + { + code: "ALREADY_UP_TO_DATE", + current_version: info.current_version, + latest_version: info.latest_version, + }, + { status: 409, headers: NO_STORE } + ); + } + return await startSelfApply(info.latest_version); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/app/api/system/self-version/route.test.ts b/src/app/api/system/self-version/route.test.ts new file mode 100644 index 0000000..ddbb19d --- /dev/null +++ b/src/app/api/system/self-version/route.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })), + getSelfUpdateInfo: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + ActorError: class ActorError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } + }, +})); +vi.mock("@/lib/server/selfUpdate", () => ({ + getSelfUpdateInfo: mocks.getSelfUpdateInfo, +})); + +const { GET } = await import("./route"); + +const INFO = { + current_version: "0.1.8", + latest_version: "0.1.9", + has_update: true, + release_url: "https://example/rel", + release_notes: "notes", + published_at: "2026-01-01", + cached: false, + warning: null, +}; + +describe("GET /api/system/self-version", () => { + it("returns the WebUI's own update info", async () => { + mocks.getSelfUpdateInfo.mockResolvedValue(INFO); + const res = await GET(new NextRequest(new URL("http://localhost/api/system/self-version"))); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(INFO); + }); + + it("passes force=true through", async () => { + mocks.getSelfUpdateInfo.mockResolvedValue(INFO); + await GET(new NextRequest(new URL("http://localhost/api/system/self-version?force=true"))); + expect(mocks.getSelfUpdateInfo).toHaveBeenCalledWith({ force: true }); + }); +}); diff --git a/src/app/api/system/self-version/route.ts b/src/app/api/system/self-version/route.ts new file mode 100644 index 0000000..b538e87 --- /dev/null +++ b/src/app/api/system/self-version/route.ts @@ -0,0 +1,27 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, type Actor } from "@/lib/server/actor"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; +import { getSelfUpdateInfo } from "@/lib/server/selfUpdate"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** The WebUI's own version check against the shared Gitea release. */ +export async function GET(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + const force = request.nextUrl.searchParams.get("force") === "true"; + const info = await getSelfUpdateInfo({ force }); + return NextResponse.json(info, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/app/api/system/update/rollback/route.test.ts b/src/app/api/system/update/rollback/route.test.ts new file mode 100644 index 0000000..2ffed4d --- /dev/null +++ b/src/app/api/system/update/rollback/route.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })), + configApiFetch: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + ActorError: class ActorError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } + }, +})); +vi.mock("@/lib/server/evoscientistConfigClient", () => ({ + configApiFetch: mocks.configApiFetch, + ConfigApiError: class ConfigApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null = null, + readonly details: unknown[] = [] + ) { + super(message); + } + }, +})); + +const { POST } = await import("./route"); +const { ConfigApiError } = await import("@/lib/server/evoscientistConfigClient"); + +function req(body: unknown): NextRequest { + return new NextRequest(new URL("http://localhost/api/system/update/rollback"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/system/update/rollback", () => { + beforeEach(() => vi.clearAllMocks()); + + it("forwards the target version to the backend", async () => { + mocks.configApiFetch.mockResolvedValue({ status: 202, body: { status: "applying" } }); + const res = await POST(req({ version: "0.2.9" })); + expect(res.status).toBe(202); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ sub: "tester" }), + "/internal/system/update/rollback", + expect.objectContaining({ method: "POST", body: { version: "0.2.9" } }) + ); + }); + + it("rejects a missing version before proxying", async () => { + const res = await POST(req({})); + expect(res.status).toBe(400); + expect(mocks.configApiFetch).not.toHaveBeenCalled(); + }); + + it("forwards backend 400 for disallowed versions", async () => { + mocks.configApiFetch.mockRejectedValue( + new ConfigApiError("not allowed", 400, "ROLLBACK_VERSION_NOT_ALLOWED") + ); + const res = await POST(req({ version: "0.0.1" })); + expect(res.status).toBe(400); + expect((await res.json()).code).toBe("ROLLBACK_VERSION_NOT_ALLOWED"); + }); +}); diff --git a/src/app/api/system/update/rollback/route.ts b/src/app/api/system/update/rollback/route.ts new file mode 100644 index 0000000..9586988 --- /dev/null +++ b/src/app/api/system/update/rollback/route.ts @@ -0,0 +1,41 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, type Actor } from "@/lib/server/actor"; +import { configApiFetch } from "@/lib/server/evoscientistConfigClient"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** Proxy a backend rollback request. */ +export async function POST(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + const body: unknown = await request.json().catch(() => null); + const version = + body && typeof body === "object" && "version" in body + ? (body as { version: unknown }).version + : undefined; + if (typeof version !== "string" || !version.trim()) { + return NextResponse.json( + { code: "VERSION_REQUIRED", message: 'body must be {"version": "x.y.z"}' }, + { status: 400, headers: NO_STORE } + ); + } + const { status, body: result } = await configApiFetch>( + actor, + "/internal/system/update/rollback", + { method: "POST", body: { version: version.trim() } } + ); + return NextResponse.json(result, { status, headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/app/api/system/update/route.test.ts b/src/app/api/system/update/route.test.ts new file mode 100644 index 0000000..bfdd9a9 --- /dev/null +++ b/src/app/api/system/update/route.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })), + configApiFetch: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + ActorError: class ActorError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } + }, +})); +vi.mock("@/lib/server/evoscientistConfigClient", () => ({ + configApiFetch: mocks.configApiFetch, + ConfigApiError: class ConfigApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null = null, + readonly details: unknown[] = [] + ) { + super(message); + } + }, +})); + +const { POST } = await import("./route"); +const { ConfigApiError } = await import("@/lib/server/evoscientistConfigClient"); + +function req(url = "http://localhost/api/system/update"): NextRequest { + return new NextRequest(new URL(url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); +} + +describe("POST /api/system/update", () => { + it("proxies to the backend and returns the 202 payload", async () => { + mocks.configApiFetch.mockResolvedValue({ + status: 202, + body: { operation_id: "upd-0.3.0", status: "applying", need_restart: true }, + }); + const res = await POST(req()); + expect(res.status).toBe(202); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ sub: "tester" }), + "/internal/system/update", + expect.objectContaining({ method: "POST" }) + ); + }); + + it("forwards confirm_breaking query param", async () => { + mocks.configApiFetch.mockResolvedValue({ status: 202, body: { status: "applying" } }); + await POST(req("http://localhost/api/system/update?confirm_breaking=true")); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + expect.anything(), + "/internal/system/update?confirm_breaking=true", + expect.objectContaining({ method: "POST" }) + ); + }); + + it("forwards backend 409 codes", async () => { + mocks.configApiFetch.mockRejectedValue( + new ConfigApiError("no update", 409, "ALREADY_UP_TO_DATE") + ); + const res = await POST(req()); + expect(res.status).toBe(409); + expect((await res.json()).code).toBe("ALREADY_UP_TO_DATE"); + }); + + it("returns manual guidance for docker deployments", async () => { + mocks.configApiFetch.mockResolvedValue({ + status: 200, + body: { status: "manual", guidance: "docker compose pull && docker compose up -d" }, + }); + const res = await POST(req()); + expect(res.status).toBe(200); + expect((await res.json()).status).toBe("manual"); + }); +}); diff --git a/src/app/api/system/update/route.ts b/src/app/api/system/update/route.ts new file mode 100644 index 0000000..9b06423 --- /dev/null +++ b/src/app/api/system/update/route.ts @@ -0,0 +1,40 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, type Actor } from "@/lib/server/actor"; +import { configApiFetch } from "@/lib/server/evoscientistConfigClient"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export interface SystemUpdateApplyResult { + operation_id?: string; + status: "applying" | "manual"; + need_restart?: boolean; + guidance?: string; +} + +/** Proxy the backend one-click update. */ +export async function POST(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + const confirmBreaking = request.nextUrl.searchParams.get("confirm_breaking") === "true"; + const path = confirmBreaking + ? "/internal/system/update?confirm_breaking=true" + : "/internal/system/update"; + const { status, body } = await configApiFetch(actor, path, { + method: "POST", + body: {}, + }); + return NextResponse.json(body, { status, headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/app/api/system/update/status/route.test.ts b/src/app/api/system/update/status/route.test.ts new file mode 100644 index 0000000..26bf6fc --- /dev/null +++ b/src/app/api/system/update/status/route.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })), + configApiFetch: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + ActorError: class ActorError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } + }, +})); +vi.mock("@/lib/server/evoscientistConfigClient", () => ({ + configApiFetch: mocks.configApiFetch, + ConfigApiError: class ConfigApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null = null, + readonly details: unknown[] = [] + ) { + super(message); + } + }, +})); + +const { GET } = await import("./route"); + +describe("GET /api/system/update/status", () => { + it("proxies the backend status endpoint", async () => { + mocks.configApiFetch.mockResolvedValue({ status: 200, body: { status: "none" } }); + const res = await GET(new NextRequest(new URL("http://localhost/api/system/update/status"))); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "none" }); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ sub: "tester" }), + "/internal/system/update/status" + ); + }); +}); diff --git a/src/app/api/system/update/status/route.ts b/src/app/api/system/update/status/route.ts new file mode 100644 index 0000000..3c5140d --- /dev/null +++ b/src/app/api/system/update/status/route.ts @@ -0,0 +1,29 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, type Actor } from "@/lib/server/actor"; +import { configApiFetch } from "@/lib/server/evoscientistConfigClient"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** Proxy the backend update-status endpoint. */ +export async function GET(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + const { status, body } = await configApiFetch>( + actor, + "/internal/system/update/status" + ); + return NextResponse.json(body, { status, headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/lib/server/selfApply.test.ts b/src/lib/server/selfApply.test.ts new file mode 100644 index 0000000..319b4e0 --- /dev/null +++ b/src/lib/server/selfApply.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; + +const mocks = vi.hoisted(() => ({ + downloadSelfUpdate: vi.fn(), + selfStagingDir: vi.fn(), + spawn: vi.fn(() => ({ unref: () => {} })), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/selfUpdate", () => ({ + downloadSelfUpdate: mocks.downloadSelfUpdate, + selfStagingDir: mocks.selfStagingDir, +})); +vi.mock("node:child_process", () => ({ spawn: mocks.spawn })); + +const { startSelfApply, isNpxCacheInstall } = await import("./selfApply"); + +describe("startSelfApply", () => { + afterEach(() => vi.clearAllMocks()); + + it("writes plan.json, copies the updater, spawns it and schedules exit", async () => { + const dir = mkdtempSync(join(tmpdir(), "selfapply-")); + mocks.selfStagingDir.mockReturnValue(dir); + mocks.downloadSelfUpdate.mockResolvedValue({ + version: "0.1.9", + file: "evoscientist-webui-0.1.9.tgz", + path: join(dir, "v0.1.9", "evoscientist-webui-0.1.9.tgz"), + suggested_command: "npm install -g ...", + }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + vi.useFakeTimers(); + try { + const res = await startSelfApply("0.1.9"); + expect(res.status).toBe(202); + + const planPath = join(dir, "v0.1.9", "plan.json"); + const plan = JSON.parse(readFileSync(planPath, "utf-8")); + expect(plan.version).toBe("0.1.9"); + expect(plan.install_command).toEqual([ + "npm", + "install", + "-g", + join(dir, "v0.1.9", "evoscientist-webui-0.1.9.tgz"), + ]); + expect(plan.respawn_command[0]).toBe(process.execPath); + + const updaterCopy = join(dir, "v0.1.9", "updater.mjs"); + expect(existsSync(updaterCopy)).toBe(true); + + expect(mocks.spawn).toHaveBeenCalledWith( + process.execPath, + [updaterCopy, "--plan", planPath, "--parent-pid", String(process.pid)], + expect.objectContaining({ detached: true }) + ); + + expect(exitSpy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(600); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + vi.useRealTimers(); + exitSpy.mockRestore(); + } + }); +}); + +describe("isNpxCacheInstall", () => { + it("detects the npm _npx cache path", () => { + const original = process.argv[1]; + try { + process.argv[1] = `/home/u/.npm/_npx/abc123${sep}node_modules${sep}.bin${sep}webui`; + expect(isNpxCacheInstall()).toBe(true); + process.argv[1] = "/usr/local/lib/node_modules/@evoscientist/webui/dist/server.js"; + expect(isNpxCacheInstall()).toBe(false); + } finally { + process.argv[1] = original; + } + }); +}); diff --git a/src/lib/server/selfApply.ts b/src/lib/server/selfApply.ts new file mode 100644 index 0000000..420aa2a --- /dev/null +++ b/src/lib/server/selfApply.ts @@ -0,0 +1,68 @@ +import "server-only"; + +import { spawn } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve, sep } from "node:path"; +import { NextResponse } from "next/server"; +import { downloadSelfUpdate, selfStagingDir } from "./selfUpdate"; +import { NO_STORE } from "./routeErrors"; + +export function isNpxCacheInstall(): boolean { + const entry = process.argv[1] ?? ""; + return entry.includes(`${sep}_npx${sep}`); +} + +function findPackageRoot(): string { + // Walk up from the entry script to the @evoscientist/webui package root, + // which ships bin/updater.mjs (works both in the repo and installed). + let dir = dirname(resolve(process.argv[1] ?? process.cwd())); + for (let i = 0; i < 8; i++) { + const candidate = join(dir, "package.json"); + if (existsSync(candidate)) { + try { + const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { name?: string }; + if (pkg.name === "@evoscientist/webui") return dir; + } catch { + // keep walking up + } + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error("could not locate the @evoscientist/webui package root"); +} + +export async function startSelfApply(version: string): Promise { + const staged = await downloadSelfUpdate(version); + const dir = join(selfStagingDir(), `v${staged.version}`); + mkdirSync(dir, { recursive: true }); + const plan = { + version: staged.version, + artifact: staged.path, + install_command: ["npm", "install", "-g", staged.path], + respawn_command: [process.execPath, process.argv[1], ...process.argv.slice(2)], + cwd: process.cwd(), + previous_version: process.env.npm_package_version ?? "", + result_path: join(dir, "update-result.json"), + log_path: join(dir, "updater.log"), + }; + const planPath = join(dir, "plan.json"); + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + // Run the updater from a copy: the install replaces the package on disk. + const updaterCopy = join(dir, "updater.mjs"); + copyFileSync(join(findPackageRoot(), "bin", "updater.mjs"), updaterCopy); + const child = spawn( + process.execPath, + [updaterCopy, "--plan", planPath, "--parent-pid", String(process.pid)], + { detached: true, stdio: "ignore" } + ); + child.unref(); + // Next.js lazy-loads chunks from disk, so the install must happen after + // this process is gone; give the 202 response time to flush first. + setTimeout(() => process.exit(0), 500).unref(); + return NextResponse.json( + { operation_id: `upd-${staged.version}`, status: "applying", need_restart: true }, + { status: 202, headers: NO_STORE } + ); +}