feat(web): add workspace delete API route

This commit is contained in:
kumarabhirup 2026-03-02 18:34:19 -08:00
parent efbeacff54
commit 67812f0de6
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
2 changed files with 335 additions and 0 deletions

View File

@ -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<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
};
kill: ReturnType<typeof vi.fn>;
};
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<typeof import("node:child_process").spawn>;
});
return {
getChild: () => spawnedChild,
};
}
describe("POST /api/workspace/delete", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
async function callDelete(body: Record<string, unknown>) {
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);
});
});

View File

@ -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>): 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<SpawnResult> {
const args = ["--profile", profile, "workspace", "delete"];
return await new Promise<SpawnResult>((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(),
});
}