feat(update): WebUI self-update and backend-proxy API routes

This commit is contained in:
m4
2026-08-12 17:07:18 +08:00
parent 49e7dde242
commit caacec90bc
16 changed files with 855 additions and 0 deletions
@@ -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");
});
});
+58
View File
@@ -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");
});
});
+48
View File
@@ -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 });
});
});
+27
View File
@@ -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);
}
}
+86
View File
@@ -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");
});
});
+40
View File
@@ -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"
);
});
});
+29
View File
@@ -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);
}
}
+81
View File
@@ -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;
}
});
});
+68
View File
@@ -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 }
);
}