diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index a75a0a02e78..9a0f603d46f 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -380,6 +380,18 @@ export namespace Project { }, ) + export const remove = fn( + z.object({ + projectID: z.string(), + }), + async ({ projectID }) => { + const existing = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()) + if (!existing) throw new Error(`Project not found: ${projectID}`) + Database.use((db) => db.delete(ProjectTable).where(eq(ProjectTable.id, projectID)).run()) + return true + }, + ) + export async function sandboxes(id: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return [] diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts index 81092284de6..c45a2a1ed2e 100644 --- a/packages/opencode/src/server/routes/project.ts +++ b/packages/opencode/src/server/routes/project.ts @@ -31,6 +31,31 @@ export const ProjectRoutes = lazy(() => return c.json(projects) }, ) + .post( + "/", + describeRoute({ + summary: "Add a project", + description: "Register a new project by providing a directory path. The directory will be analyzed and registered as a project.", + operationId: "project.add", + responses: { + 200: { + description: "Project successfully added", + content: { + "application/json": { + schema: resolver(Project.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("json", z.object({ directory: z.string() })), + async (c) => { + const { directory } = c.req.valid("json") + const { project } = await Project.fromDirectory(directory) + return c.json(project) + }, + ) .get( "/current", describeRoute({ @@ -78,5 +103,30 @@ export const ProjectRoutes = lazy(() => const project = await Project.update({ ...body, projectID }) return c.json(project) }, + ) + .delete( + "/:projectID", + describeRoute({ + summary: "Delete project", + description: "Delete a project by ID.", + operationId: "project.delete", + responses: { + 200: { + description: "Project deleted", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("param", z.object({ projectID: z.string() })), + async (c) => { + const projectID = c.req.valid("param").projectID + await Project.remove({ projectID }) + return c.json(true) + }, ), ) diff --git a/packages/opencode/test/server/project-routes.test.ts b/packages/opencode/test/server/project-routes.test.ts new file mode 100644 index 00000000000..5b7223c5c59 --- /dev/null +++ b/packages/opencode/test/server/project-routes.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import { Log } from "../../src/util/log" +import { Server } from "../../src/server/server" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("project routes", () => { + test("POST /project registers a project from directory", async () => { + await using tmp = await tmpdir({ git: true }) + + const app = Server.App() + const response = await app.request("/project", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ directory: tmp.path }), + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.worktree).toBe(tmp.path) + expect(body.id).toBeDefined() + }) + + test("POST /project returns 400 for invalid payload", async () => { + const app = Server.App() + const response = await app.request("/project", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(400) + }) + + test("DELETE /project/:projectID removes a project", async () => { + await using tmp = await tmpdir({ git: true }) + + const app = Server.App() + const createResponse = await app.request("/project", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ directory: tmp.path }), + }) + expect(createResponse.status).toBe(200) + + const created = await createResponse.json() + const projectID = created.id as string + expect(projectID).toBeDefined() + + const deleteResponse = await app.request(`/project/${projectID}`, { + method: "DELETE", + }) + expect(deleteResponse.status).toBe(200) + expect(await deleteResponse.json()).toBe(true) + + const listResponse = await app.request("/project", { + method: "GET", + }) + expect(listResponse.status).toBe(200) + const projects = (await listResponse.json()) as Array<{ id: string; worktree: string }> + expect(projects.find((p) => p.id === projectID)).toBeUndefined() + expect(projects.find((p) => p.worktree === tmp.path)).toBeUndefined() + }) +})