From 7e3754830ad17c2b28bc9a8a30ce771d0c966068 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 22:13:50 -0400 Subject: [PATCH 1/6] refactor: move project routes onto HttpApi --- packages/opencode/src/project/project.ts | 116 ++++--- .../src/server/instance/httpapi/project.ts | 111 +++++++ .../src/server/instance/httpapi/server.ts | 3 + .../opencode/src/server/instance/index.ts | 2 + .../opencode/src/server/instance/project.ts | 10 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 298 +++++++++--------- packages/sdk/js/src/v2/gen/types.gen.ts | 208 ++++++------ 7 files changed, 452 insertions(+), 296 deletions(-) create mode 100644 packages/opencode/src/server/instance/httpapi/project.ts diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f838d9ab4348..8f1ff128ab67 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -13,41 +13,79 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" const log = Log.create({ service: "project" }) -export const Info = z - .object({ - id: ProjectID.zod, - worktree: z.string(), - vcs: z.literal("git").optional(), - name: z.string().optional(), - icon: z - .object({ - url: z.string().optional(), - override: z.string().optional(), - color: z.string().optional(), - }) - .optional(), - commands: z - .object({ - start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"), - }) - .optional(), - time: z.object({ - created: z.number(), - updated: z.number(), - initialized: z.number().optional(), - }), - sandboxes: z.array(z.string()), - }) - .meta({ - ref: "Project", - }) -export type Info = z.infer +type Mutable = T extends string | number | boolean | bigint | symbol | null | undefined + ? T + : T extends ReadonlyArray + ? Mutable[] + : T extends object + ? { -readonly [K in keyof T]: Mutable } + : T + +const ProjectVcs = Schema.Literal("git") + +const ProjectIcon = Schema.Struct({ + url: Schema.optional(Schema.String), + override: Schema.optional(Schema.String), + color: Schema.optional(Schema.String), +}) + +const ProjectCommands = Schema.Struct({ + start: Schema.optional( + Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), + ), +}) + +const ProjectTime = Schema.Struct({ + created: Schema.Number, + updated: Schema.Number, + initialized: Schema.optional(Schema.Number), +}) + +const UpdateBodyZod = z.object({ + name: z.string().optional(), + icon: z + .object({ + url: z.string().optional(), + override: z.string().optional(), + color: z.string().optional(), + }) + .optional(), + commands: z + .object({ + start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"), + }) + .optional(), +}) + +const _UpdateBody = Schema.Struct({ + name: Schema.optional(Schema.String), + icon: Schema.optional(ProjectIcon), + commands: Schema.optional(ProjectCommands), +}) + +export const UpdateBody = Object.assign(_UpdateBody, { zod: UpdateBodyZod }) +export type UpdateBody = Mutable> + +const _Info = Schema.Struct({ + id: ProjectID, + worktree: Schema.String, + vcs: Schema.optional(ProjectVcs), + name: Schema.optional(Schema.String), + icon: Schema.optional(ProjectIcon), + commands: Schema.optional(ProjectCommands), + time: ProjectTime, + sandboxes: Schema.Array(Schema.String), +}).annotate({ identifier: "Project" }) +export const Info = Object.assign(_Info, { zod: zod(_Info) }) +export type Info = Mutable> export const Event = { - Updated: BusEvent.define("project.updated", Info), + Updated: BusEvent.define("project.updated", Info.zod), } type Row = typeof ProjectTable.$inferSelect @@ -58,7 +96,7 @@ export function fromRow(row: Row): Info { return { id: row.id, worktree: row.worktree, - vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, + vcs: row.vcs ? Schema.decodeUnknownSync(ProjectVcs)(row.vcs) : undefined, name: row.name ?? undefined, icon, time: { @@ -71,13 +109,15 @@ export function fromRow(row: Row): Info { } } -export const UpdateInput = z.object({ - projectID: ProjectID.zod, - name: z.string().optional(), - icon: Info.shape.icon.optional(), - commands: Info.shape.commands.optional(), +const _UpdateInput = Schema.Struct({ + projectID: ProjectID, + name: Schema.optional(Schema.String), + icon: Schema.optional(ProjectIcon), + commands: Schema.optional(ProjectCommands), }) -export type UpdateInput = z.infer +const UpdateInputZod = z.object({ projectID: ProjectID.zod, ...UpdateBodyZod.shape }) +export const UpdateInput = Object.assign(_UpdateInput, { zod: UpdateInputZod }) +export type UpdateInput = Mutable> // --------------------------------------------------------------------------- // Effect service @@ -139,7 +179,7 @@ export const layer: Layer.Layer< }), ) - const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS) + const fakeVcs = Schema.decodeUnknownSync(ProjectVcs)(Flag.OPENCODE_FAKE_VCS) const resolveGitPath = (cwd: string, name: string) => { if (!name) return cwd diff --git a/packages/opencode/src/server/instance/httpapi/project.ts b/packages/opencode/src/server/instance/httpapi/project.ts new file mode 100644 index 000000000000..8a5eb396e9fc --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/project.ts @@ -0,0 +1,111 @@ +import { Instance } from "@/project/instance" +import { Project } from "@/project" +import { ProjectID } from "@/project/schema" +import { InstanceBootstrap } from "@/project/bootstrap" +import { AppRuntime } from "@/effect/app-runtime" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/project" + +export const ProjectApi = HttpApi.make("project") + .add( + HttpApiGroup.make("project") + .add( + HttpApiEndpoint.get("list", root, { + success: Schema.Array(Project.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "project.list", + summary: "List all projects", + description: "Get a list of projects that have been opened with OpenCode.", + }), + ), + HttpApiEndpoint.get("current", `${root}/current`, { + success: Project.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "project.current", + summary: "Get current project", + description: "Retrieve the currently active project that OpenCode is working with.", + }), + ), + HttpApiEndpoint.post("initGit", `${root}/git/init`, { + success: Project.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "project.initGit", + summary: "Initialize git repository", + description: "Create a git repository for the current project and return the refreshed project info.", + }), + ), + HttpApiEndpoint.patch("update", `${root}/:projectID`, { + params: { projectID: ProjectID }, + payload: Project.UpdateBody, + success: Project.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "project.update", + summary: "Update project", + description: "Update project properties such as name, icon, and commands.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "project", + description: "Experimental HttpApi project routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const projectHandlers = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* Project.Service + + const list = Effect.fn("ProjectHttpApi.list")(function* () { + return yield* svc.list() + }) + + const current = Effect.fn("ProjectHttpApi.current")(function* () { + return Instance.project + }) + + const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () { + const dir = Instance.directory + const prev = Instance.project + const next = yield* svc.initGit({ directory: dir, project: prev }) + if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return next + yield* Effect.promise(() => + Instance.reload({ + directory: dir, + worktree: dir, + project: next, + init: () => AppRuntime.runPromise(InstanceBootstrap), + }), + ) + return next + }) + + const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { + params: { projectID: ProjectID } + payload: Project.UpdateBody + }) { + return yield* svc.update({ + projectID: ctx.params.projectID, + ...ctx.payload, + }) + }) + + return HttpApiBuilder.group(ProjectApi, "project", (handlers) => + handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update), + ) + }), +).pipe(Layer.provide(Project.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 64332fd2a08b..b4442d640041 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -12,6 +12,7 @@ import { lazy } from "@/util/lazy" import { Filesystem } from "@/util" import { ConfigApi, configHandlers } from "./config" import { PermissionApi, permissionHandlers } from "./permission" +import { ProjectApi, projectHandlers } from "./project" import { ProviderApi, providerHandlers } from "./provider" import { QuestionApi, questionHandlers } from "./question" @@ -108,11 +109,13 @@ const instance = HttpRouter.middleware()( const QuestionSecured = QuestionApi.middleware(Authorization) const PermissionSecured = PermissionApi.middleware(Authorization) +const ProjectSecured = ProjectApi.middleware(Authorization) const ProviderSecured = ProviderApi.middleware(Authorization) const ConfigSecured = ConfigApi.middleware(Authorization) export const routes = Layer.mergeAll( HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)), + HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)), HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)), HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)), HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)), diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 6a290093c514..40ec6ba84b0b 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -52,6 +52,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.get("/provider/auth", (c) => handler(c.req.raw, context)) app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) + app.get("/project", (c) => handler(c.req.raw, context)) + app.get("/project/current", (c) => handler(c.req.raw, context)) } return app diff --git a/packages/opencode/src/server/instance/project.ts b/packages/opencode/src/server/instance/project.ts index eea741596dd3..3ad59b1d53e8 100644 --- a/packages/opencode/src/server/instance/project.ts +++ b/packages/opencode/src/server/instance/project.ts @@ -23,7 +23,7 @@ export const ProjectRoutes = lazy(() => description: "List of projects", content: { "application/json": { - schema: resolver(Project.Info.array()), + schema: resolver(Project.Info.zod.array()), }, }, }, @@ -45,7 +45,7 @@ export const ProjectRoutes = lazy(() => description: "Current project information", content: { "application/json": { - schema: resolver(Project.Info), + schema: resolver(Project.Info.zod), }, }, }, @@ -66,7 +66,7 @@ export const ProjectRoutes = lazy(() => description: "Project information after git initialization", content: { "application/json": { - schema: resolver(Project.Info), + schema: resolver(Project.Info.zod), }, }, }, @@ -99,7 +99,7 @@ export const ProjectRoutes = lazy(() => description: "Updated project information", content: { "application/json": { - schema: resolver(Project.Info), + schema: resolver(Project.Info.zod), }, }, }, @@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() => }, }), validator("param", z.object({ projectID: ProjectID.zod })), - validator("json", Project.UpdateInput.omit({ projectID: true })), + validator("json", Project.UpdateBody.zod), async (c) => { const projectID = c.req.valid("param").projectID const body = c.req.valid("json") diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index d7bf43f506f8..92e83a47c202 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -510,150 +510,6 @@ export class App extends HeyApiClient { } } -export class Project extends HeyApiClient { - /** - * List all projects - * - * Get a list of projects that have been opened with OpenCode. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/project", - ...options, - ...params, - }) - } - - /** - * Get current project - * - * Retrieve the currently active project that OpenCode is working with. - */ - public current( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/project/current", - ...options, - ...params, - }) - } - - /** - * Initialize git repository - * - * Create a git repository for the current project and return the refreshed project info. - */ - public initGit( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/project/git/init", - ...options, - ...params, - }) - } - - /** - * Update project - * - * Update project properties such as name, icon, and commands. - */ - public update( - parameters: { - projectID: string - directory?: string - workspace?: string - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "projectID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "icon" }, - { in: "body", key: "commands" }, - ], - }, - ], - ) - return (options?.client ?? this.client).patch({ - url: "/project/{projectID}", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - export class Pty extends HeyApiClient { /** * List PTY sessions @@ -2746,6 +2602,150 @@ export class Permission extends HeyApiClient { } } +export class Project extends HeyApiClient { + /** + * List all projects + * + * Get a list of projects that have been opened with OpenCode. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/project", + ...options, + ...params, + }) + } + + /** + * Get current project + * + * Retrieve the currently active project that OpenCode is working with. + */ + public current( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/project/current", + ...options, + ...params, + }) + } + + /** + * Initialize git repository + * + * Create a git repository for the current project and return the refreshed project info. + */ + public initGit( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/project/git/init", + ...options, + ...params, + }) + } + + /** + * Update project + * + * Update project properties such as name, icon, and commands. + */ + public update( + parameters: { + projectID: string + directory?: string + workspace?: string + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "icon" }, + { in: "body", key: "commands" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/project/{projectID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Question extends HeyApiClient { /** * List pending questions @@ -4314,11 +4314,6 @@ export class OpencodeClient extends HeyApiClient { return (this._app ??= new App({ client: this.client })) } - private _project?: Project - get project(): Project { - return (this._project ??= new Project({ client: this.client })) - } - private _pty?: Pty get pty(): Pty { return (this._pty ??= new Pty({ client: this.client })) @@ -4359,6 +4354,11 @@ export class OpencodeClient extends HeyApiClient { return (this._permission ??= new Permission({ client: this.client })) } + private _project?: Project + get project(): Project { + return (this._project ??= new Project({ client: this.client })) + } + private _question?: Question get question(): Question { return (this._question ??= new Question({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 25c3cfa66981..1c3dad20f133 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2394,110 +2394,6 @@ export type AppLogResponses = { export type AppLogResponse = AppLogResponses[keyof AppLogResponses] -export type ProjectListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/project" -} - -export type ProjectListResponses = { - /** - * List of projects - */ - 200: Array -} - -export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses] - -export type ProjectCurrentData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/project/current" -} - -export type ProjectCurrentResponses = { - /** - * Current project information - */ - 200: Project -} - -export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] - -export type ProjectInitGitData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/project/git/init" -} - -export type ProjectInitGitResponses = { - /** - * Project information after git initialization - */ - 200: Project -} - -export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] - -export type ProjectUpdateData = { - body?: { - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - } - path: { - projectID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/project/{projectID}" -} - -export type ProjectUpdateErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors] - -export type ProjectUpdateResponses = { - /** - * Updated project information - */ - 200: Project -} - -export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses] - export type PtyListData = { body?: never path?: never @@ -4280,6 +4176,110 @@ export type PermissionListResponses = { export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] +export type ProjectListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project" +} + +export type ProjectListResponses = { + /** + * List of projects + */ + 200: Array +} + +export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses] + +export type ProjectCurrentData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project/current" +} + +export type ProjectCurrentResponses = { + /** + * Current project information + */ + 200: Project +} + +export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] + +export type ProjectInitGitData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project/git/init" +} + +export type ProjectInitGitResponses = { + /** + * Project information after git initialization + */ + 200: Project +} + +export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] + +export type ProjectUpdateData = { + body?: { + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + } + path: { + projectID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/project/{projectID}" +} + +export type ProjectUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors] + +export type ProjectUpdateResponses = { + /** + * Updated project information + */ + 200: Project +} + +export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses] + export type QuestionListData = { body?: never path?: never From 4db4150f2f331e24063af8ab0681e9e274f00afa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 22:33:39 -0400 Subject: [PATCH 2/6] fix: trim project HttpApi slice to read routes --- packages/opencode/src/project/project.ts | 51 +-- .../src/server/instance/httpapi/project.ts | 51 +-- .../opencode/src/server/instance/project.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 298 +++++++++--------- packages/sdk/js/src/v2/gen/types.gen.ts | 208 ++++++------ 5 files changed, 268 insertions(+), 342 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 8f1ff128ab67..013a5f5d5d03 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -13,8 +13,9 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { Schema } from "effect" import { zod } from "@/util/effect-zod" +import { Schema } from "effect" +import { withStatics } from "@/util/schema" const log = Log.create({ service: "project" }) @@ -46,32 +47,7 @@ const ProjectTime = Schema.Struct({ initialized: Schema.optional(Schema.Number), }) -const UpdateBodyZod = z.object({ - name: z.string().optional(), - icon: z - .object({ - url: z.string().optional(), - override: z.string().optional(), - color: z.string().optional(), - }) - .optional(), - commands: z - .object({ - start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"), - }) - .optional(), -}) - -const _UpdateBody = Schema.Struct({ - name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), -}) - -export const UpdateBody = Object.assign(_UpdateBody, { zod: UpdateBodyZod }) -export type UpdateBody = Mutable> - -const _Info = Schema.Struct({ +export const Info = Schema.Struct({ id: ProjectID, worktree: Schema.String, vcs: Schema.optional(ProjectVcs), @@ -80,9 +56,10 @@ const _Info = Schema.Struct({ commands: Schema.optional(ProjectCommands), time: ProjectTime, sandboxes: Schema.Array(Schema.String), -}).annotate({ identifier: "Project" }) -export const Info = Object.assign(_Info, { zod: zod(_Info) }) -export type Info = Mutable> +}) + .annotate({ identifier: "Project" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Mutable> export const Event = { Updated: BusEvent.define("project.updated", Info.zod), @@ -109,15 +86,13 @@ export function fromRow(row: Row): Info { } } -const _UpdateInput = Schema.Struct({ - projectID: ProjectID, - name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), +export const UpdateInput = z.object({ + projectID: ProjectID.zod, + name: z.string().optional(), + icon: zod(ProjectIcon).optional(), + commands: zod(ProjectCommands).optional(), }) -const UpdateInputZod = z.object({ projectID: ProjectID.zod, ...UpdateBodyZod.shape }) -export const UpdateInput = Object.assign(_UpdateInput, { zod: UpdateInputZod }) -export type UpdateInput = Mutable> +export type UpdateInput = z.infer // --------------------------------------------------------------------------- // Effect service diff --git a/packages/opencode/src/server/instance/httpapi/project.ts b/packages/opencode/src/server/instance/httpapi/project.ts index 8a5eb396e9fc..7d2d8462f075 100644 --- a/packages/opencode/src/server/instance/httpapi/project.ts +++ b/packages/opencode/src/server/instance/httpapi/project.ts @@ -1,8 +1,5 @@ import { Instance } from "@/project/instance" import { Project } from "@/project" -import { ProjectID } from "@/project/schema" -import { InstanceBootstrap } from "@/project/bootstrap" -import { AppRuntime } from "@/effect/app-runtime" import { Effect, Layer, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -30,26 +27,6 @@ export const ProjectApi = HttpApi.make("project") description: "Retrieve the currently active project that OpenCode is working with.", }), ), - HttpApiEndpoint.post("initGit", `${root}/git/init`, { - success: Project.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "project.initGit", - summary: "Initialize git repository", - description: "Create a git repository for the current project and return the refreshed project info.", - }), - ), - HttpApiEndpoint.patch("update", `${root}/:projectID`, { - params: { projectID: ProjectID }, - payload: Project.UpdateBody, - success: Project.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "project.update", - summary: "Update project", - description: "Update project properties such as name, icon, and commands.", - }), - ), ) .annotateMerge( OpenApi.annotations({ @@ -78,34 +55,8 @@ export const projectHandlers = Layer.unwrap( return Instance.project }) - const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () { - const dir = Instance.directory - const prev = Instance.project - const next = yield* svc.initGit({ directory: dir, project: prev }) - if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return next - yield* Effect.promise(() => - Instance.reload({ - directory: dir, - worktree: dir, - project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), - }), - ) - return next - }) - - const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { - params: { projectID: ProjectID } - payload: Project.UpdateBody - }) { - return yield* svc.update({ - projectID: ctx.params.projectID, - ...ctx.payload, - }) - }) - return HttpApiBuilder.group(ProjectApi, "project", (handlers) => - handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update), + handlers.handle("list", list).handle("current", current), ) }), ).pipe(Layer.provide(Project.defaultLayer)) diff --git a/packages/opencode/src/server/instance/project.ts b/packages/opencode/src/server/instance/project.ts index 3ad59b1d53e8..95b5862fd571 100644 --- a/packages/opencode/src/server/instance/project.ts +++ b/packages/opencode/src/server/instance/project.ts @@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() => }, }), validator("param", z.object({ projectID: ProjectID.zod })), - validator("json", Project.UpdateBody.zod), + validator("json", Project.UpdateInput.omit({ projectID: true })), async (c) => { const projectID = c.req.valid("param").projectID const body = c.req.valid("json") diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 92e83a47c202..d7bf43f506f8 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -510,6 +510,150 @@ export class App extends HeyApiClient { } } +export class Project extends HeyApiClient { + /** + * List all projects + * + * Get a list of projects that have been opened with OpenCode. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/project", + ...options, + ...params, + }) + } + + /** + * Get current project + * + * Retrieve the currently active project that OpenCode is working with. + */ + public current( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/project/current", + ...options, + ...params, + }) + } + + /** + * Initialize git repository + * + * Create a git repository for the current project and return the refreshed project info. + */ + public initGit( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/project/git/init", + ...options, + ...params, + }) + } + + /** + * Update project + * + * Update project properties such as name, icon, and commands. + */ + public update( + parameters: { + projectID: string + directory?: string + workspace?: string + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "icon" }, + { in: "body", key: "commands" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/project/{projectID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Pty extends HeyApiClient { /** * List PTY sessions @@ -2602,150 +2746,6 @@ export class Permission extends HeyApiClient { } } -export class Project extends HeyApiClient { - /** - * List all projects - * - * Get a list of projects that have been opened with OpenCode. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/project", - ...options, - ...params, - }) - } - - /** - * Get current project - * - * Retrieve the currently active project that OpenCode is working with. - */ - public current( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/project/current", - ...options, - ...params, - }) - } - - /** - * Initialize git repository - * - * Create a git repository for the current project and return the refreshed project info. - */ - public initGit( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/project/git/init", - ...options, - ...params, - }) - } - - /** - * Update project - * - * Update project properties such as name, icon, and commands. - */ - public update( - parameters: { - projectID: string - directory?: string - workspace?: string - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "projectID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "icon" }, - { in: "body", key: "commands" }, - ], - }, - ], - ) - return (options?.client ?? this.client).patch({ - url: "/project/{projectID}", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - export class Question extends HeyApiClient { /** * List pending questions @@ -4314,6 +4314,11 @@ export class OpencodeClient extends HeyApiClient { return (this._app ??= new App({ client: this.client })) } + private _project?: Project + get project(): Project { + return (this._project ??= new Project({ client: this.client })) + } + private _pty?: Pty get pty(): Pty { return (this._pty ??= new Pty({ client: this.client })) @@ -4354,11 +4359,6 @@ export class OpencodeClient extends HeyApiClient { return (this._permission ??= new Permission({ client: this.client })) } - private _project?: Project - get project(): Project { - return (this._project ??= new Project({ client: this.client })) - } - private _question?: Question get question(): Question { return (this._question ??= new Question({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 1c3dad20f133..25c3cfa66981 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2394,6 +2394,110 @@ export type AppLogResponses = { export type AppLogResponse = AppLogResponses[keyof AppLogResponses] +export type ProjectListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project" +} + +export type ProjectListResponses = { + /** + * List of projects + */ + 200: Array +} + +export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses] + +export type ProjectCurrentData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project/current" +} + +export type ProjectCurrentResponses = { + /** + * Current project information + */ + 200: Project +} + +export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] + +export type ProjectInitGitData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project/git/init" +} + +export type ProjectInitGitResponses = { + /** + * Project information after git initialization + */ + 200: Project +} + +export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] + +export type ProjectUpdateData = { + body?: { + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + } + path: { + projectID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/project/{projectID}" +} + +export type ProjectUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors] + +export type ProjectUpdateResponses = { + /** + * Updated project information + */ + 200: Project +} + +export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses] + export type PtyListData = { body?: never path?: never @@ -4176,110 +4280,6 @@ export type PermissionListResponses = { export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] -export type ProjectListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/project" -} - -export type ProjectListResponses = { - /** - * List of projects - */ - 200: Array -} - -export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses] - -export type ProjectCurrentData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/project/current" -} - -export type ProjectCurrentResponses = { - /** - * Current project information - */ - 200: Project -} - -export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] - -export type ProjectInitGitData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/project/git/init" -} - -export type ProjectInitGitResponses = { - /** - * Project information after git initialization - */ - 200: Project -} - -export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] - -export type ProjectUpdateData = { - body?: { - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - } - path: { - projectID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/project/{projectID}" -} - -export type ProjectUpdateErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors] - -export type ProjectUpdateResponses = { - /** - * Updated project information - */ - 200: Project -} - -export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses] - export type QuestionListData = { body?: never path?: never From 84f2abec2098bea160b628ea2308b981b3305f73 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 22:47:08 -0400 Subject: [PATCH 3/6] refactor: use effect deepmutable in project schema migration --- packages/opencode/src/project/project.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 013a5f5d5d03..fca1c4815168 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -8,7 +8,7 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" import { ProjectID } from "./schema" -import { Effect, Layer, Path, Scope, Context, Stream } from "effect" +import { Effect, Layer, Path, Scope, Context, Stream, Types } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -19,14 +19,6 @@ import { withStatics } from "@/util/schema" const log = Log.create({ service: "project" }) -type Mutable = T extends string | number | boolean | bigint | symbol | null | undefined - ? T - : T extends ReadonlyArray - ? Mutable[] - : T extends object - ? { -readonly [K in keyof T]: Mutable } - : T - const ProjectVcs = Schema.Literal("git") const ProjectIcon = Schema.Struct({ @@ -59,7 +51,7 @@ export const Info = Schema.Struct({ }) .annotate({ identifier: "Project" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type Info = Mutable> +export type Info = Types.DeepMutable> export const Event = { Updated: BusEvent.define("project.updated", Info.zod), From ac194a34452ebfc24e4f615ffe7cd7071cbd3281 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 23:03:10 -0400 Subject: [PATCH 4/6] refactor: simplify project schema imports --- packages/opencode/src/project/project.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index fca1c4815168..658896097df2 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -8,13 +8,12 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" import { ProjectID } from "./schema" -import { Effect, Layer, Path, Scope, Context, Stream, Types } from "effect" +import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { zod } from "@/util/effect-zod" -import { Schema } from "effect" import { withStatics } from "@/util/schema" const log = Log.create({ service: "project" }) From d89e23323870df6832f2f97d545b74a1c687a44b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 23:15:59 -0400 Subject: [PATCH 5/6] fix: register project bridge before route stubs --- packages/opencode/src/server/instance/index.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 40ec6ba84b0b..cfcaffc59693 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -30,14 +30,7 @@ import { WorkspaceRouterMiddleware } from "./middleware" import { AppRuntime } from "@/effect/app-runtime" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { - const app = new Hono() - .use(WorkspaceRouterMiddleware(upgrade)) - .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes(upgrade)) - .route("/config", ConfigRoutes()) - .route("/experimental", ExperimentalRoutes()) - .route("/session", SessionRoutes()) - .route("/permission", PermissionRoutes()) + const app = new Hono().use(WorkspaceRouterMiddleware(upgrade)) if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler @@ -57,6 +50,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { } return app + .route("/project", ProjectRoutes()) + .route("/pty", PtyRoutes(upgrade)) + .route("/config", ConfigRoutes()) + .route("/experimental", ExperimentalRoutes()) + .route("/session", SessionRoutes()) + .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/sync", SyncRoutes()) From 08392113be0a0357da7b504a7474b8904c0b607d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 23:40:59 -0400 Subject: [PATCH 6/6] fix: preserve optional fake vcs decoding --- packages/opencode/src/project/project.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 658896097df2..6a2132274adf 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -145,7 +145,7 @@ export const layer: Layer.Layer< }), ) - const fakeVcs = Schema.decodeUnknownSync(ProjectVcs)(Flag.OPENCODE_FAKE_VCS) + const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.OPENCODE_FAKE_VCS) const resolveGitPath = (cwd: string, name: string) => { if (!name) return cwd