feat(update): WebUI self-update and backend-proxy API routes
This commit is contained in:
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<RollbackVersionsResult>(
|
||||
actor,
|
||||
"/internal/system/rollback-versions"
|
||||
);
|
||||
return NextResponse.json(body, { status, headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return routeErrorResponse(error, actor);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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@<version>",
|
||||
},
|
||||
{ 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<SystemUpdateApplyResult>(actor, path, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
return NextResponse.json(body, { status, headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return routeErrorResponse(error, actor);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>>(
|
||||
actor,
|
||||
"/internal/system/update/status"
|
||||
);
|
||||
return NextResponse.json(body, { status, headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return routeErrorResponse(error, actor);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<NextResponse> {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user