feat(web): add workspace delete API route
This commit is contained in:
parent
efbeacff54
commit
67812f0de6
168
apps/web/app/api/workspace/delete/route.test.ts
Normal file
168
apps/web/app/api/workspace/delete/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
167
apps/web/app/api/workspace/delete/route.ts
Normal file
167
apps/web/app/api/workspace/delete/route.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user