From c964afa76618d55258cd02e87c3b4ca729e49820 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 16 Aug 2026 16:28:26 +0100 Subject: [PATCH 1/4] feat(schema): define project metadata updates --- packages/schema/src/project.ts | 7 +++++++ packages/schema/test/project.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 packages/schema/test/project.test.ts diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index 678f97597807..32c22c8963b7 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -46,5 +46,12 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Project" }) export interface Info extends Schema.Schema.Type {} +export const UpdateInput = Schema.Struct({ + name: optional(Schema.String), + icon: optional(Icon), + commands: optional(Commands), +}).annotate({ identifier: "Project.UpdateInput" }) +export interface UpdateInput extends Schema.Schema.Type {} + const Updated = ephemeral({ type: "project.updated", schema: Info.fields }) export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/test/project.test.ts b/packages/schema/test/project.test.ts new file mode 100644 index 000000000000..b08c58689fa1 --- /dev/null +++ b/packages/schema/test/project.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test" +import { Project } from "../src/project.js" +import { Schema } from "effect" + +test("project update omits undefined metadata", () => { + expect( + Schema.encodeSync(Project.UpdateInput)({ + name: undefined, + icon: { url: undefined, override: "data:image/png;base64,updated", color: undefined }, + commands: { start: undefined }, + }), + ).toEqual({ + icon: { override: "data:image/png;base64,updated" }, + commands: {}, + }) +}) From c9b2da78e08405eff2b00508a08b223330b5266a Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 16 Aug 2026 16:29:02 +0100 Subject: [PATCH 2/4] feat(core): persist project metadata updates --- packages/core/src/project.ts | 35 ++++- packages/core/src/project/schema.ts | 3 + .../test/effect/layer-node/node-build.test.ts | 1 + packages/core/test/lib/project.ts | 1 + packages/core/test/location.test.ts | 1 + packages/core/test/project.test.ts | 142 +++++++++++++++++- 6 files changed, 181 insertions(+), 2 deletions(-) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 8a9ecd4737ea..0bd4e3fef9a9 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -29,6 +29,13 @@ export type Current = ProjectSchema.Current export const Info = ProjectSchema.Info export interface Info extends Schema.Schema.Type {} +export const UpdateInput = ProjectSchema.UpdateInput +export type UpdateInput = ProjectSchema.UpdateInput + +export class NotFoundError extends Schema.TaggedErrorClass()("Project.NotFoundError", { + projectID: ID, +}) {} + export interface Resolved { readonly previous?: ID readonly id: ID @@ -47,6 +54,7 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i export interface Interface { readonly list: () => Effect.Effect> + readonly update: (projectID: ID, input: UpdateInput) => Effect.Effect readonly resolve: (input: AbsolutePath) => Effect.Effect } @@ -145,6 +153,31 @@ const layer = Layer.effect( return rows.map(fromRow) }) + const update = Effect.fn("Project.update")(function* (projectID: ID, input: UpdateInput) { + const values = { + ...(input.name === undefined ? {} : { name: input.name || null }), + ...(input.icon?.url === undefined ? {} : { icon_url: input.icon.url || null }), + ...(input.icon?.override === undefined ? {} : { icon_url_override: input.icon.override || null }), + ...(input.icon?.color === undefined ? {} : { icon_color: input.icon.color || null }), + ...(input.commands?.start === undefined + ? {} + : { commands: input.commands.start ? { start: input.commands.start } : null }), + } + const changed = Object.keys(values).length > 0 + const row = yield* ( + changed + ? db + .update(ProjectTable) + .set({ ...values, time_updated: Date.now() }) + .where(eq(ProjectTable.id, projectID)) + .returning() + .get() + : db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get() + ).pipe(Effect.orDie) + if (!row) return yield* new NotFoundError({ projectID }) + return fromRow(row) + }) + const cached = Effect.fnUntraced(function* (dir: string) { return yield* fs.readFileString(path.join(dir, "opencode")).pipe( Effect.map((value) => value.trim()), @@ -258,7 +291,7 @@ const layer = Layer.effect( return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined }) }) - return Service.of({ list, resolve }) + return Service.of({ list, update, resolve }) }), ) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 4f5fa90fb3e0..7891e70831d0 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -13,6 +13,9 @@ export type Current = typeof Current.Type export const Info = Project.Info export interface Info extends Schema.Schema.Type {} +export const UpdateInput = Project.UpdateInput +export type UpdateInput = typeof UpdateInput.Type + export const Vcs = Schema.Union([ Schema.Struct({ type: Schema.Literal("git"), diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index 5edbece2c6ec..569230fb3126 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -78,6 +78,7 @@ describe("node build", () => { acquisitions++ return Project.Service.of({ list: () => Effect.succeed([]), + update: (projectID) => Effect.fail(new Project.NotFoundError({ projectID })), resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), }) }), diff --git a/packages/core/test/lib/project.ts b/packages/core/test/lib/project.ts index 66fceaf1993d..51dd164cb554 100644 --- a/packages/core/test/lib/project.ts +++ b/packages/core/test/lib/project.ts @@ -5,6 +5,7 @@ export const globalProjectLayer = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), + update: (projectID) => Effect.fail(new Project.NotFoundError({ projectID })), resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), }), ) diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index fdda8566fc7d..f9bb3f631658 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -13,6 +13,7 @@ const projectLayer = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), + update: (projectID) => Effect.fail(new Project.NotFoundError({ projectID })), resolve: () => Effect.succeed({ id: Project.ID.make("project"), diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index d9eedaf26f67..b6813a1c1229 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -2,7 +2,8 @@ import { describe, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer } from "effect" +import { eq } from "drizzle-orm" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Database } from "@opencode-ai/core/database/database" import { Project } from "@opencode-ai/core/project" @@ -66,6 +67,145 @@ describe("Project.list", () => { ) }) +describe("Project.update", () => { + it.effect("updates metadata", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const project = yield* Project.Service + const id = Project.ID.make("updated") + yield* db + .insert(ProjectTable) + .values({ + id, + worktree: abs("/updated"), + name: "Original", + icon_url: "https://example.com/icon.png", + icon_color: "#000000", + commands: { start: "bun start" }, + sandboxes: [], + time_created: 1, + time_updated: 1, + }) + .run() + + const result = yield* project.update(id, { + name: "Updated", + icon: { override: "data:image/png;base64,updated" }, + commands: { start: "bun dev" }, + }) + + expect(result).toEqual({ + id, + canonical: abs("/updated"), + name: "Updated", + icon: { + url: "https://example.com/icon.png", + override: "data:image/png;base64,updated", + color: "#000000", + }, + commands: { start: "bun dev" }, + time: { created: 1, updated: result.time.updated }, + sandboxes: [], + }) + expect(result.time.updated).toBeGreaterThan(1) + }), + ) + + it.effect("preserves omitted metadata", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const project = yield* Project.Service + const id = Project.ID.make("partial") + yield* db + .insert(ProjectTable) + .values({ + id, + worktree: abs("/partial"), + icon_color: "#123456", + commands: { start: "bun dev" }, + sandboxes: [], + time_created: 1, + time_updated: 1, + }) + .run() + + expect(yield* project.update(id, { name: "Partial" })).toMatchObject({ + name: "Partial", + icon: { color: "#123456" }, + commands: { start: "bun dev" }, + }) + }), + ) + + it.effect("clears metadata with empty strings", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const project = yield* Project.Service + const id = Project.ID.make("cleared") + yield* db + .insert(ProjectTable) + .values({ + id, + worktree: abs("/cleared"), + name: "Cleared", + icon_url: "https://example.com/icon.png", + icon_url_override: "data:image/png;base64,original", + icon_color: "#123456", + commands: { start: "bun dev" }, + sandboxes: [], + time_created: 1, + time_updated: 1, + }) + .run() + + expect( + yield* project.update(id, { + name: "", + icon: { url: "", override: "", color: "" }, + commands: { start: "" }, + }), + ).toEqual({ + id, + canonical: abs("/cleared"), + time: { created: 1, updated: expect.any(Number) }, + sandboxes: [], + }) + expect(yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()).toMatchObject({ + name: null, + icon_url: null, + icon_url_override: null, + icon_color: null, + commands: null, + }) + }), + ) + + it.effect("does not update for an empty patch", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const project = yield* Project.Service + const id = Project.ID.make("unchanged") + yield* db + .insert(ProjectTable) + .values({ id, worktree: abs("/unchanged"), sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + expect(yield* project.update(id, {})).toMatchObject({ time: { updated: 1 } }) + }), + ) + + it.effect("fails when the project does not exist", () => + Effect.gen(function* () { + const project = yield* Project.Service + const id = Project.ID.make("missing") + + const error = yield* project.update(id, { name: "Missing" }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(Project.NotFoundError) + expect(error.projectID).toBe(id) + }), + ) +}) + function remoteID(remote: string) { return Project.ID.make(Hash.fast(`git-remote:${remote}`)) } From 9ca15960799291a72bfbd7b4cdff229c4fb5098d Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 16 Aug 2026 16:29:31 +0100 Subject: [PATCH 3/4] feat(core): publish project metadata events --- packages/core/src/project.ts | 6 +++- packages/core/src/project/schema.ts | 2 ++ packages/core/test/project.test.ts | 32 ++++++++++++++++++--- packages/protocol/test/event.test.ts | 1 + packages/schema/src/event-manifest.ts | 2 +- packages/schema/test/event-manifest.test.ts | 1 + 6 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 0bd4e3fef9a9..8f21a40abf88 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -32,6 +32,8 @@ export interface Info extends Schema.Schema.Type {} export const UpdateInput = ProjectSchema.UpdateInput export type UpdateInput = ProjectSchema.UpdateInput +export const Event = ProjectSchema.Event + export class NotFoundError extends Schema.TaggedErrorClass()("Project.NotFoundError", { projectID: ID, }) {} @@ -175,7 +177,9 @@ const layer = Layer.effect( : db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get() ).pipe(Effect.orDie) if (!row) return yield* new NotFoundError({ projectID }) - return fromRow(row) + const info = fromRow(row) + if (changed) yield* bus.publish(ProjectSchema.Event.Updated, info) + return info }) const cached = Effect.fnUntraced(function* (dir: string) { diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 7891e70831d0..f2988a2d00f9 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -16,6 +16,8 @@ export interface Info extends Schema.Schema.Type {} export const UpdateInput = Project.UpdateInput export type UpdateInput = typeof UpdateInput.Type +export const Event = Project.Event + export const Vcs = Schema.Union([ Schema.Struct({ type: Schema.Literal("git"), diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index b6813a1c1229..93e1751854d2 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -2,9 +2,10 @@ import { describe, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" -import { Effect, Layer } from "effect" +import { Effect, Fiber, Layer, Stream } from "effect" import { eq } from "drizzle-orm" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Bus } from "@opencode-ai/core/bus" import { Database } from "@opencode-ai/core/database/database" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -13,7 +14,13 @@ import { Hash } from "@opencode-ai/util/hash" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const it = testEffect(Layer.merge(AppNodeBuilder.build(Project.node), AppNodeBuilder.build(Database.node))) +const it = testEffect( + Layer.mergeAll( + AppNodeBuilder.build(Project.node), + AppNodeBuilder.build(Database.node), + AppNodeBuilder.build(Bus.node), + ), +) describe("Project.list", () => { it.effect("returns complete projects ordered by recent update", () => @@ -68,10 +75,11 @@ describe("Project.list", () => { }) describe("Project.update", () => { - it.effect("updates metadata", () => + it.effect("updates metadata and publishes the complete project", () => Effect.gen(function* () { const db = (yield* Database.Service).db const project = yield* Project.Service + const bus = yield* Bus.Service const id = Project.ID.make("updated") yield* db .insert(ProjectTable) @@ -88,6 +96,12 @@ describe("Project.update", () => { }) .run() + const event = yield* bus.subscribe(Project.Event.Updated).pipe( + Stream.filter((event) => event.data.id === id), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ) const result = yield* project.update(id, { name: "Updated", icon: { override: "data:image/png;base64,updated" }, @@ -108,6 +122,7 @@ describe("Project.update", () => { sandboxes: [], }) expect(result.time.updated).toBeGreaterThan(1) + expect(Array.from(yield* Fiber.join(event)).map((event) => event.data)).toEqual([result]) }), ) @@ -180,16 +195,25 @@ describe("Project.update", () => { }), ) - it.effect("does not update for an empty patch", () => + it.live("does not update or publish for an empty patch", () => Effect.gen(function* () { const db = (yield* Database.Service).db const project = yield* Project.Service + const bus = yield* Bus.Service const id = Project.ID.make("unchanged") yield* db .insert(ProjectTable) .values({ id, worktree: abs("/unchanged"), sandboxes: [], time_created: 1, time_updated: 1 }) .run() + const event = yield* bus.subscribe(Project.Event.Updated).pipe( + Stream.filter((event) => event.data.id === id), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ) + expect(yield* project.update(id, {})).toMatchObject({ time: { updated: 1 } }) + expect(yield* Fiber.join(event).pipe(Effect.timeoutOption("50 millis"))).toMatchObject({ _tag: "None" }) }), ) diff --git a/packages/protocol/test/event.test.ts b/packages/protocol/test/event.test.ts index 4de4240e0faa..b1d0b176d6bb 100644 --- a/packages/protocol/test/event.test.ts +++ b/packages/protocol/test/event.test.ts @@ -25,6 +25,7 @@ test("classifies public events by type", () => { expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true) + expect(isOpenCodeEvent({ type: "project.updated" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false) }) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 156a3df1b809..1fc6f1bdb14e 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -49,6 +49,7 @@ const featureDefinitions = Event.inventory( ...Reference.Event.Definitions, ...Permission.Event.Definitions, ...Plugin.Event.Definitions, + ...Project.Event.Definitions, ...Worktree.Event.Definitions, ...Command.Event.Definitions, ...Config.Event.Definitions, @@ -83,7 +84,6 @@ export const Definitions = Event.inventory( ...McpEvent.Definitions, ...LegacyEventV1.Definitions, ...FileSystemV1.Event.Definitions, - ...Project.Event.Definitions, ...SessionStatusEvent.Definitions, ...SessionCompactionEvent.Definitions, ...VcsEvent.Definitions, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index d61334c55181..23f290961bf5 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -40,6 +40,7 @@ describe("public event manifest", () => { expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged) expect(EventManifest.Server.get("session.created")).toBe(SessionEvent.Created) expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted) + expect(EventManifest.Server.get("project.updated")).toBe(Project.Event.Updated) expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false) expect(EventManifest.Server.has("question.asked")).toBe(false) expect(EventManifest.Server.has("question.replied")).toBe(false) From c06db2f6501d97b7e184e1bdee2e2821187fb308 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 16 Aug 2026 16:29:45 +0100 Subject: [PATCH 4/4] feat(server): expose project metadata update API --- packages/client/src/effect/api/api.ts | 14 +- .../client/src/effect/generated/client.ts | 18 +- .../client/src/promise/generated/client.ts | 14 ++ .../client/src/promise/generated/types.ts | 48 +++++ packages/protocol/openapi.json | 168 ++++++++++++++++++ packages/protocol/src/errors.ts | 10 ++ packages/protocol/src/groups/project.ts | 15 ++ packages/server/src/handlers/project.ts | 17 ++ packages/server/test/project.test.ts | 126 +++++++++++++ packages/www/openapi.json | 168 ++++++++++++++++++ packages/www/public/openapi.json | 168 ++++++++++++++++++ 11 files changed, 762 insertions(+), 4 deletions(-) create mode 100644 packages/server/test/project.test.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index b8f8753383d7..fd8c9b0f7ab6 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1223,13 +1223,23 @@ export type Endpoint13_0Output = ReadonlyArray export type ProjectListOperation = () => Effect.Effect export type Endpoint13_1Input = { + readonly projectID: Project.ID + readonly name?: string | undefined + readonly icon?: Project.Icon | undefined + readonly commands?: Project.Commands | undefined +} +export type Endpoint13_1Output = Project.Info +export type ProjectUpdateOperation = (input: Endpoint13_1Input) => Effect.Effect + +export type Endpoint13_2Input = { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined } -export type Endpoint13_1Output = Project.Current -export type ProjectCurrentOperation = (input?: Endpoint13_1Input) => Effect.Effect +export type Endpoint13_2Output = Project.Current +export type ProjectCurrentOperation = (input?: Endpoint13_2Input) => Effect.Effect export interface ProjectApi { readonly list: ProjectListOperation + readonly update: ProjectUpdateOperation readonly current: ProjectCurrentOperation } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index eee41f22980b..560ddeecdb24 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -139,6 +139,8 @@ import type { Endpoint13_0Output, Endpoint13_1Input, Endpoint13_1Output, + Endpoint13_2Input, + Endpoint13_2Output, Endpoint14_0Input, Endpoint14_0Output, Endpoint14_1Input, @@ -863,12 +865,24 @@ const adaptGroup12 = (raw: RawClient["server.credential"]) => ({ update: Endpoin const Endpoint13_0 = (raw: RawClient["server.project"]) => () => preserveEffect()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError))) -const Endpoint13_1 = (raw: RawClient["server.project"]) => (input?: Endpoint13_1Input) => +const Endpoint13_1 = (raw: RawClient["server.project"]) => (input: Endpoint13_1Input) => preserveEffect()( + raw["project.update"]({ + params: { projectID: input["projectID"] }, + payload: { name: input["name"], icon: input["icon"], commands: input["commands"] }, + }).pipe(Effect.mapError(mapClientError)), + ) + +const Endpoint13_2 = (raw: RawClient["server.project"]) => (input?: Endpoint13_2Input) => + preserveEffect()( raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), ) -const adaptGroup13 = (raw: RawClient["server.project"]) => ({ list: Endpoint13_0(raw), current: Endpoint13_1(raw) }) +const adaptGroup13 = (raw: RawClient["server.project"]) => ({ + list: Endpoint13_0(raw), + update: Endpoint13_1(raw), + current: Endpoint13_2(raw), +}) const Endpoint14_0 = (raw: RawClient["server.form"]) => (input?: Endpoint14_0Input) => preserveEffect()( diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 9daeb90f2230..c048e0b80527 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -131,6 +131,8 @@ import type { CredentialRemoveInput, CredentialRemoveOutput, ProjectListOutput, + ProjectUpdateInput, + ProjectUpdateOutput, ProjectCurrentInput, ProjectCurrentOutput, FormRequestListInput, @@ -1235,6 +1237,18 @@ export function make(options: ClientOptions) { { method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, requestOptions, ), + update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) => + request( + { + method: "PATCH", + path: `/api/project/${encodeURIComponent(input.projectID)}`, + body: { name: input["name"], icon: input["icon"], commands: input["commands"] }, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 1a080b3432b3..3b71f5cb67d3 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1354,6 +1354,24 @@ export type Project = { sandboxes: Array } +export type ProjectUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "project.updated" + location?: LocationRef + data: { + id: string + canonical: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array + } +} + export type FormAnswer = { [x: string]: FormValue } export type PermissionRequest = { @@ -2059,6 +2077,7 @@ export type V2Event = | PermissionReplied | PluginAdded | PluginUpdated + | ProjectUpdated | WorktreeUpdated | WorktreeResolved | CommandUpdated @@ -2214,6 +2233,14 @@ export type McpServerNotFoundError = { export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError" +export type ProjectNotFoundError = { + readonly _tag: "ProjectNotFoundError" + readonly projectID: string + readonly message: string +} +export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError" + export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string } export const isFormNotFoundError = (value: unknown): value is FormNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError" @@ -4286,6 +4313,27 @@ export type CredentialRemoveOutput = void export type ProjectListOutput = Array +export type ProjectUpdateInput = { + readonly projectID: { readonly projectID: string }["projectID"] + readonly name?: { + readonly name?: string + readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string } + readonly commands?: { readonly start?: string } + }["name"] + readonly icon?: { + readonly name?: string + readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string } + readonly commands?: { readonly start?: string } + }["icon"] + readonly commands?: { + readonly name?: string + readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string } + readonly commands?: { readonly start?: string } + }["commands"] +} + +export type ProjectUpdateOutput = Project + export type ProjectCurrentInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 6f519677a815..791c12692966 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -6866,6 +6866,77 @@ "summary": "List projects" } }, + "/api/project/{projectID}": { + "patch": { + "tags": ["project"], + "operationId": "v2.project.update", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ProjectNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectNotFoundError" + } + } + } + } + }, + "description": "Update project metadata. Omitted fields are preserved; empty string values clear fields.", + "summary": "Update project", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project.UpdateInput" + } + } + }, + "required": true + } + } + }, "/api/project/current": { "get": { "tags": ["project"], @@ -19644,6 +19715,38 @@ "required": ["id", "canonical", "time", "sandboxes"], "additionalProperties": false }, + "Project.UpdateInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/Project.Icon" + }, + "commands": { + "$ref": "#/components/schemas/Project.Commands" + } + }, + "additionalProperties": false + }, + "ProjectNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ProjectNotFoundError"] + }, + "projectID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "projectID", "message"], + "additionalProperties": false + }, "Project.Current": { "type": "object", "properties": { @@ -20799,6 +20902,68 @@ "required": ["id", "created", "type", "data"], "additionalProperties": false }, + "project.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "canonical": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/Project.Vcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/Project.Icon" + }, + "commands": { + "$ref": "#/components/schemas/Project.Commands" + }, + "time": { + "$ref": "#/components/schemas/Project.Time" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "canonical", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "created", "type", "data"], + "additionalProperties": false + }, "worktree.updated": { "type": "object", "properties": { @@ -22802,6 +22967,9 @@ { "$ref": "#/components/schemas/plugin.updated" }, + { + "$ref": "#/components/schemas/project.updated" + }, { "$ref": "#/components/schemas/worktree.updated" }, diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index fea0b498b6d6..7c7a5e170d86 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { Skill } from "@opencode-ai/schema/skill" +import { Project } from "@opencode-ai/schema/project" export class InvalidRequestError extends Schema.TaggedErrorClass()( "InvalidRequestError", @@ -62,6 +63,15 @@ export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "ProjectNotFoundError", + { + projectID: Project.ID, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class AgentNotFoundError extends Schema.TaggedErrorClass()( "AgentNotFoundError", { diff --git a/packages/protocol/src/groups/project.ts b/packages/protocol/src/groups/project.ts index 3e60a437667f..9cdb824fbe44 100644 --- a/packages/protocol/src/groups/project.ts +++ b/packages/protocol/src/groups/project.ts @@ -2,6 +2,7 @@ import { Project } from "@opencode-ai/schema/project" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { LocationQuery, locationQueryOpenApi } from "./location.js" +import { ProjectNotFoundError } from "../errors.js" const root = "/api/project" @@ -17,6 +18,20 @@ export const ProjectGroup = HttpApiGroup.make("server.project") }), ), ) + .add( + HttpApiEndpoint.patch("project.update", `${root}/:projectID`, { + params: { projectID: Project.ID }, + payload: Project.UpdateInput, + success: Project.Info, + error: ProjectNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.project.update", + summary: "Update project", + description: "Update project metadata. Omitted fields are preserved; empty string values clear fields.", + }), + ), + ) .add( HttpApiEndpoint.get("project.current", `${root}/current`, { query: LocationQuery, diff --git a/packages/server/src/handlers/project.ts b/packages/server/src/handlers/project.ts index 5f9aca373078..c226422193f3 100644 --- a/packages/server/src/handlers/project.ts +++ b/packages/server/src/handlers/project.ts @@ -2,11 +2,28 @@ import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" +import { ProjectNotFoundError } from "@opencode-ai/protocol/errors" import { Api } from "../api" export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) => handlers .handle("project.list", () => Project.Service.use((project) => project.list())) + .handle( + "project.update", + Effect.fn(function* (ctx) { + const project = yield* Project.Service + return yield* project.update(ctx.params.projectID, ctx.payload).pipe( + Effect.catchTag( + "Project.NotFoundError", + () => + new ProjectNotFoundError({ + projectID: ctx.params.projectID, + message: `Project not found: ${ctx.params.projectID}`, + }), + ), + ) + }), + ) .handle("project.current", () => Location.Service.use((location) => Effect.succeed({ diff --git a/packages/server/test/project.test.ts b/packages/server/test/project.test.ts new file mode 100644 index 000000000000..2f92d4fc2af8 --- /dev/null +++ b/packages/server/test/project.test.ts @@ -0,0 +1,126 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { $ } from "bun" +import { expect } from "bun:test" +import { Project } from "@opencode-ai/schema/project" +import { Effect, Schema } from "effect" +import { HttpServer } from "effect/unstable/http" +import { tmpdir } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { ServerProcess } from "../src/process" + +it.live("updates project metadata", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir("opencode-project-endpoint-")), + (tmp) => + Effect.gen(function* () { + const directory = path.join(tmp.path, "project") + yield* Effect.promise(() => fs.mkdir(directory, { recursive: true })) + yield* Effect.promise(() => $`git init`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git config user.name Test`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(directory).quiet()) + const server = yield* ServerProcess.start({ + hostname: "127.0.0.1", + port: 0, + password: "secret", + app: { version: "test-version" }, + database: { path: ":memory:" }, + config: { directory: path.join(tmp.path, "config") }, + fs: { filewatcher: false }, + }) + const base = HttpServer.formatAddress(server.address) + const headers = { authorization: `Basic ${btoa("opencode:secret")}` } + const location = new URL("/api/location", base) + location.searchParams.set("location[directory]", directory) + const resolved: unknown = yield* Effect.promise(() => + fetch(location, { headers }).then((response) => response.json()), + ) + if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string") + throw new Error("Expected resolved project") + const projectID = resolved.project.id + + const response = yield* Effect.promise(() => + fetch(new URL(`/api/project/${projectID}`, base), { + method: "PATCH", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + name: "Updated", + icon: { color: "#123456" }, + commands: { start: "bun dev" }, + }), + }), + ) + const project = Schema.decodeUnknownSync(Project.Info)(yield* Effect.promise(() => response.json())) + + expect(response.status).toBe(200) + expect(project).toMatchObject({ + id: projectID, + name: "Updated", + icon: { color: "#123456" }, + commands: { start: "bun dev" }, + }) + + const listed = Schema.decodeUnknownSync(Schema.Array(Project.Info))( + yield* Effect.promise(() => + fetch(new URL("/api/project", base), { headers }).then((result) => result.json()), + ), + ) + expect(listed).toContainEqual(project) + + const clearedResponse = yield* Effect.promise(() => + fetch(new URL(`/api/project/${projectID}`, base), { + method: "PATCH", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ name: "", icon: { color: "" }, commands: { start: "" } }), + }), + ) + const cleared = Schema.decodeUnknownSync(Project.Info)(yield* Effect.promise(() => clearedResponse.json())) + expect(clearedResponse.status).toBe(200) + expect(cleared.name).toBeUndefined() + expect(cleared.icon).toBeUndefined() + expect(cleared.commands).toBeUndefined() + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), +) + +it.live("returns a typed error for an unknown project", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir("opencode-project-endpoint-missing-")), + (tmp) => + Effect.gen(function* () { + const server = yield* ServerProcess.start({ + hostname: "127.0.0.1", + port: 0, + password: "secret", + app: { version: "test-version" }, + database: { path: ":memory:" }, + config: { directory: path.join(tmp.path, "config") }, + fs: { filewatcher: false }, + }) + const response = yield* Effect.promise(() => + fetch(new URL("/api/project/missing", HttpServer.formatAddress(server.address)), { + method: "PATCH", + headers: { + authorization: `Basic ${btoa("opencode:secret")}`, + "content-type": "application/json", + }, + body: JSON.stringify({ name: "Missing" }), + }), + ) + + expect(response.status).toBe(404) + expect(yield* Effect.promise(() => response.json())).toEqual({ + _tag: "ProjectNotFoundError", + projectID: "missing", + message: "Project not found: missing", + }) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), +) + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 6f519677a815..791c12692966 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -6866,6 +6866,77 @@ "summary": "List projects" } }, + "/api/project/{projectID}": { + "patch": { + "tags": ["project"], + "operationId": "v2.project.update", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ProjectNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectNotFoundError" + } + } + } + } + }, + "description": "Update project metadata. Omitted fields are preserved; empty string values clear fields.", + "summary": "Update project", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project.UpdateInput" + } + } + }, + "required": true + } + } + }, "/api/project/current": { "get": { "tags": ["project"], @@ -19644,6 +19715,38 @@ "required": ["id", "canonical", "time", "sandboxes"], "additionalProperties": false }, + "Project.UpdateInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/Project.Icon" + }, + "commands": { + "$ref": "#/components/schemas/Project.Commands" + } + }, + "additionalProperties": false + }, + "ProjectNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ProjectNotFoundError"] + }, + "projectID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "projectID", "message"], + "additionalProperties": false + }, "Project.Current": { "type": "object", "properties": { @@ -20799,6 +20902,68 @@ "required": ["id", "created", "type", "data"], "additionalProperties": false }, + "project.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "canonical": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/Project.Vcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/Project.Icon" + }, + "commands": { + "$ref": "#/components/schemas/Project.Commands" + }, + "time": { + "$ref": "#/components/schemas/Project.Time" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "canonical", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "created", "type", "data"], + "additionalProperties": false + }, "worktree.updated": { "type": "object", "properties": { @@ -22802,6 +22967,9 @@ { "$ref": "#/components/schemas/plugin.updated" }, + { + "$ref": "#/components/schemas/project.updated" + }, { "$ref": "#/components/schemas/worktree.updated" }, diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 6f519677a815..791c12692966 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -6866,6 +6866,77 @@ "summary": "List projects" } }, + "/api/project/{projectID}": { + "patch": { + "tags": ["project"], + "operationId": "v2.project.update", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ProjectNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectNotFoundError" + } + } + } + } + }, + "description": "Update project metadata. Omitted fields are preserved; empty string values clear fields.", + "summary": "Update project", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project.UpdateInput" + } + } + }, + "required": true + } + } + }, "/api/project/current": { "get": { "tags": ["project"], @@ -19644,6 +19715,38 @@ "required": ["id", "canonical", "time", "sandboxes"], "additionalProperties": false }, + "Project.UpdateInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/Project.Icon" + }, + "commands": { + "$ref": "#/components/schemas/Project.Commands" + } + }, + "additionalProperties": false + }, + "ProjectNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ProjectNotFoundError"] + }, + "projectID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "projectID", "message"], + "additionalProperties": false + }, "Project.Current": { "type": "object", "properties": { @@ -20799,6 +20902,68 @@ "required": ["id", "created", "type", "data"], "additionalProperties": false }, + "project.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "canonical": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/Project.Vcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/Project.Icon" + }, + "commands": { + "$ref": "#/components/schemas/Project.Commands" + }, + "time": { + "$ref": "#/components/schemas/Project.Time" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "canonical", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "created", "type", "data"], + "additionalProperties": false + }, "worktree.updated": { "type": "object", "properties": { @@ -22802,6 +22967,9 @@ { "$ref": "#/components/schemas/plugin.updated" }, + { + "$ref": "#/components/schemas/project.updated" + }, { "$ref": "#/components/schemas/worktree.updated" },