From 67812f0de6e0aefb701b6f34965673d0a8e05e50 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Mon, 2 Mar 2026 18:34:19 -0800 Subject: [PATCH] feat(web): add workspace delete API route --- .../app/api/workspace/delete/route.test.ts | 168 ++++++++++++++++++ apps/web/app/api/workspace/delete/route.ts | 167 +++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 apps/web/app/api/workspace/delete/route.test.ts create mode 100644 apps/web/app/api/workspace/delete/route.ts diff --git a/apps/web/app/api/workspace/delete/route.test.ts b/apps/web/app/api/workspace/delete/route.test.ts new file mode 100644 index 00000000000..f881735dc8c --- /dev/null +++ b/apps/web/app/api/workspace/delete/route.test.ts @@ -0,0 +1,168 @@ +import { EventEmitter } from "node:events"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(), +})); + +vi.mock("@/lib/workspace", () => ({ + discoverProfiles: vi.fn(() => []), + getEffectiveProfile: vi.fn(() => "default"), + resolveWorkspaceRoot: vi.fn(() => null), +})); + +type MockSpawnChild = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + stdin: { + write: ReturnType; + end: ReturnType; + }; + kill: ReturnType; +}; + +function mockSpawnResult( + spawnMock: { + mockImplementation: ( + implementation: (...args: unknown[]) => unknown, + ) => unknown; + }, + params: { + code?: number; + stdout?: string; + stderr?: string; + emitError?: Error | null; + }, +): { getChild: () => MockSpawnChild | null } { + let spawnedChild: MockSpawnChild | null = null; + spawnMock.mockImplementation(() => { + const child = new EventEmitter() as MockSpawnChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { + write: vi.fn(), + end: vi.fn(), + }; + child.kill = vi.fn(); + spawnedChild = child; + + queueMicrotask(() => { + if (params.stdout) { + child.stdout.emit("data", Buffer.from(params.stdout)); + } + if (params.stderr) { + child.stderr.emit("data", Buffer.from(params.stderr)); + } + if (params.emitError) { + child.emit("error", params.emitError); + return; + } + child.emit("close", params.code ?? 0); + }); + + return child as unknown as ReturnType; + }); + return { + getChild: () => spawnedChild, + }; +} + +describe("POST /api/workspace/delete", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + async function callDelete(body: Record) { + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/workspace/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return POST(req); + } + + it("returns 400 for invalid profile names", async () => { + const response = await callDelete({ profile: "../bad" }); + expect(response.status).toBe(400); + }); + + it("returns 404 when profile does not exist", async () => { + const workspace = await import("@/lib/workspace"); + vi.mocked(workspace.discoverProfiles).mockReturnValue([]); + const response = await callDelete({ profile: "work" }); + expect(response.status).toBe(404); + }); + + it("returns 409 when profile has no workspace directory", async () => { + const workspace = await import("@/lib/workspace"); + vi.mocked(workspace.discoverProfiles).mockReturnValue([ + { + name: "work", + stateDir: "/home/testuser/.openclaw-work", + workspaceDir: null, + isActive: false, + hasConfig: true, + }, + ]); + const response = await callDelete({ profile: "work" }); + expect(response.status).toBe(409); + }); + + it("runs openclaw workspace delete for the selected profile", async () => { + const workspace = await import("@/lib/workspace"); + const { spawn } = await import("node:child_process"); + vi.mocked(workspace.discoverProfiles).mockReturnValue([ + { + name: "work", + stateDir: "/home/testuser/.openclaw-work", + workspaceDir: "/home/testuser/.openclaw-work/workspace", + isActive: true, + hasConfig: true, + }, + ]); + vi.mocked(workspace.getEffectiveProfile).mockReturnValue("work"); + vi.mocked(workspace.resolveWorkspaceRoot).mockReturnValue("/home/testuser/.openclaw-work/workspace"); + const spawnResult = mockSpawnResult(vi.mocked(spawn), { code: 0 }); + + const response = await callDelete({ profile: "work" }); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.deleted).toBe(true); + expect(json.profile).toBe("work"); + + expect(spawn).toHaveBeenCalledWith( + "openclaw", + ["--profile", "work", "workspace", "delete"], + expect.objectContaining({ stdio: ["pipe", "pipe", "pipe"] }), + ); + const child = spawnResult.getChild(); + expect(child).toBeTruthy(); + expect(child?.stdin.write).toHaveBeenCalledWith("y\n"); + expect(child?.stdin.write).toHaveBeenCalledWith("yes\n"); + expect(child?.stdin.end).toHaveBeenCalled(); + }); + + it("returns 501 when workspace delete command is unavailable", async () => { + const workspace = await import("@/lib/workspace"); + const { spawn } = await import("node:child_process"); + vi.mocked(workspace.discoverProfiles).mockReturnValue([ + { + name: "work", + stateDir: "/home/testuser/.openclaw-work", + workspaceDir: "/home/testuser/.openclaw-work/workspace", + isActive: false, + hasConfig: true, + }, + ]); + mockSpawnResult(vi.mocked(spawn), { + code: 0, + stdout: + "Usage: openclaw [options] [command]\nHint: commands suffixed with * have subcommands", + }); + + const response = await callDelete({ profile: "work" }); + expect(response.status).toBe(501); + }); +}); diff --git a/apps/web/app/api/workspace/delete/route.ts b/apps/web/app/api/workspace/delete/route.ts new file mode 100644 index 00000000000..58e1966ab10 --- /dev/null +++ b/apps/web/app/api/workspace/delete/route.ts @@ -0,0 +1,167 @@ +import { spawn } from "node:child_process"; +import { discoverProfiles, getEffectiveProfile, resolveWorkspaceRoot } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; +const DELETE_TIMEOUT_MS = 2 * 60_000; + +type SpawnResult = { + code: number; + stdout: string; + stderr: string; +}; + +function normalizeProfileName(raw: unknown): string | null { + if (typeof raw !== "string") { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + if (trimmed.toLowerCase() === "default") { + return "default"; + } + if (!PROFILE_NAME_RE.test(trimmed)) { + return null; + } + return trimmed; +} + +function resolveCommandForPlatform(command: string): string { + if (process.platform === "win32" && !command.toLowerCase().endsWith(".cmd")) { + return `${command}.cmd`; + } + return command; +} + +function firstNonEmptyLine(...values: Array): string | undefined { + for (const value of values) { + const first = value + ?.split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (first) { + return first; + } + } + return undefined; +} + +async function runWorkspaceDelete(profile: string): Promise { + const args = ["--profile", profile, "workspace", "delete"]; + return await new Promise((resolveResult, reject) => { + const child = spawn(resolveCommandForPlatform("openclaw"), args, { + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + + const timer = setTimeout(() => { + if (settled) { + return; + } + child.kill("SIGKILL"); + }, DELETE_TIMEOUT_MS); + + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + child.once("error", (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + }); + child.once("close", (code) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolveResult({ + code: typeof code === "number" ? code : 1, + stdout, + stderr, + }); + }); + + // For commands that prompt for confirmation. + child.stdin?.write("y\n"); + child.stdin?.write("yes\n"); + child.stdin?.end(); + }); +} + +function looksLikeUnknownCommandOutput(stdout: string, stderr: string): boolean { + const text = `${stdout}\n${stderr}`; + return ( + text.includes("Usage: openclaw [options] [command]") && + text.includes("Hint: commands suffixed with * have subcommands") + ); +} + +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as { profile?: unknown }; + const profile = normalizeProfileName(body.profile); + if (!profile) { + return Response.json( + { error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." }, + { status: 400 }, + ); + } + + const availableProfile = discoverProfiles().find((candidate) => candidate.name === profile); + if (!availableProfile) { + return Response.json( + { error: `Profile '${profile}' was not found.` }, + { status: 404 }, + ); + } + if (!availableProfile.workspaceDir) { + return Response.json( + { error: `Profile '${profile}' does not have a workspace to delete.` }, + { status: 409 }, + ); + } + + try { + const result = await runWorkspaceDelete(profile); + if (looksLikeUnknownCommandOutput(result.stdout, result.stderr)) { + return Response.json( + { error: "This OpenClaw installation does not support `workspace delete`." }, + { status: 501 }, + ); + } + if (result.code !== 0) { + const detail = firstNonEmptyLine(result.stderr, result.stdout); + return Response.json( + { + error: detail + ? `Workspace delete failed: ${detail}` + : "Workspace delete command failed.", + }, + { status: 500 }, + ); + } + } catch (error) { + const message = (error as Error).message || "Workspace delete command failed."; + return Response.json({ error: message }, { status: 500 }); + } + + return Response.json({ + deleted: true, + profile, + activeProfile: getEffectiveProfile() ?? "default", + workspaceRoot: resolveWorkspaceRoot(), + }); +}