add endpoints

This commit is contained in:
James Long
2026-05-31 15:58:58 -04:00
parent 49f3ceebc2
commit decb166858
9 changed files with 647 additions and 1 deletions
@@ -12,6 +12,7 @@ import { InstanceApi } from "./groups/instance"
import { McpApi } from "./groups/mcp"
import { PermissionApi } from "./groups/permission"
import { ProjectApi } from "./groups/project"
import { ProjectCopyApi } from "./groups/project-copy"
import { ProviderApi } from "./groups/provider"
import { PtyApi, PtyConnectApi } from "./groups/pty"
import { QuestionApi } from "./groups/question"
@@ -52,6 +53,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(InstanceApi)
.addHttpApi(McpApi)
.addHttpApi(ProjectApi)
.addHttpApi(ProjectCopyApi)
.addHttpApi(PtyApi)
.addHttpApi(QuestionApi)
.addHttpApi(PermissionApi)
@@ -0,0 +1,83 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/project/:projectID/copy"
export const CreatePayload = Schema.Struct({
strategy: ProjectCopy.StrategyID,
path: ProjectCopy.CreateInput.fields.path,
})
export const RemovePayload = Schema.Struct({
strategy: ProjectCopy.StrategyID,
path: ProjectCopy.RemoveInput.fields.path,
})
export const RefreshPayload = Schema.Struct({
strategy: Schema.optional(ProjectCopy.StrategyID),
})
export const ProjectCopyApi = HttpApi.make("projectCopy")
.add(
HttpApiGroup.make("projectCopy")
.add(
HttpApiEndpoint.get("strategies", `${root}/strategy`, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
success: described(Schema.Array(ProjectCopy.StrategyInfo), "Project copy strategies"),
}).annotateMerge(
OpenApi.annotations({
identifier: "projectCopy.strategies",
summary: "List project copy strategies",
description: "List mechanisms available for managing local copies of a project.",
}),
),
HttpApiEndpoint.post("create", root, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: CreatePayload,
success: described(ProjectCopy.Copy, "Project copy created"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "projectCopy.create",
summary: "Create project copy",
description: "Create a local physical copy of a project using the selected strategy.",
}),
),
HttpApiEndpoint.delete("remove", root, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: RemovePayload,
success: described(HttpApiSchema.NoContent, "Project copy removed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "projectCopy.remove",
summary: "Remove project copy",
description: "Remove a local physical copy of a project using the selected strategy.",
}),
),
HttpApiEndpoint.post("refresh", `${root}/refresh`, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: RefreshPayload,
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "projectCopy.refresh",
summary: "Refresh project copies",
description: "Discover local project copies using one or all configured strategies.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
@@ -62,6 +62,17 @@ export const ProjectApi = HttpApi.make("project")
description: "Update project properties such as name, icon, and commands.",
}),
),
HttpApiEndpoint.get("paths", `${root}/:projectID/paths`, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
success: described(ProjectV2.Paths, "Project paths"),
}).annotateMerge(
OpenApi.annotations({
identifier: "project.paths",
summary: "List project paths",
description: "List known local absolute paths for a project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
@@ -0,0 +1,47 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, RefreshPayload, RemovePayload } from "../groups/project-copy"
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
return effect.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
}
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
Effect.gen(function* () {
const service = yield* ProjectCopy.Service
const strategies = Effect.fn("ProjectCopyHttpApi.strategies")((ctx: { params: { projectID: ProjectV2.ID } }) =>
service.strategies({ projectID: ctx.params.projectID }),
)
const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof CreatePayload.Type
}) {
return yield* badRequest(service.create({ ...ctx.payload, projectID: ctx.params.projectID }))
})
const remove = Effect.fn("ProjectCopyHttpApi.remove")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof RemovePayload.Type
}) {
yield* badRequest(service.remove({ ...ctx.payload, projectID: ctx.params.projectID }))
})
const refresh = Effect.fn("ProjectCopyHttpApi.refresh")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof RefreshPayload.Type
}) {
yield* badRequest(service.refresh({ ...ctx.payload, projectID: ctx.params.projectID }))
})
return handlers
.handle("strategies", strategies)
.handle("create", create)
.handle("remove", remove)
.handle("refresh", refresh)
}),
)
@@ -10,6 +10,7 @@ import { markInstanceForReload } from "../lifecycle"
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
Effect.gen(function* () {
const svc = yield* Project.Service
const project = yield* ProjectV2.Service
const list = Effect.fn("ProjectHttpApi.list")(function* () {
return yield* svc.list()
@@ -48,6 +49,15 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
)
})
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
const paths = Effect.fn("ProjectHttpApi.paths")((ctx: { params: { projectID: ProjectV2.ID } }) =>
project.paths({ projectID: ctx.params.projectID }),
)
return handlers
.handle("list", list)
.handle("current", current)
.handle("initGit", initGit)
.handle("update", update)
.handle("paths", paths)
}),
)
@@ -28,6 +28,8 @@ import { Installation } from "@/installation"
import { InstanceLayer } from "@/project/instance-layer"
import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
@@ -76,6 +78,7 @@ import { instanceHandlers } from "./handlers/instance"
import { mcpHandlers } from "./handlers/mcp"
import { permissionHandlers } from "./handlers/permission"
import { projectHandlers } from "./handlers/project"
import { projectCopyHandlers } from "./handlers/project-copy"
import { providerHandlers } from "./handlers/provider"
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
import { questionHandlers } from "./handlers/question"
@@ -137,6 +140,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
instanceHandlers,
mcpHandlers,
projectHandlers,
projectCopyHandlers,
ptyHandlers,
questionHandlers,
permissionHandlers,
@@ -208,6 +212,8 @@ export function createRoutes(
Permission.defaultLayer,
Plugin.defaultLayer,
Project.defaultLayer,
ProjectV2.defaultLayer,
ProjectCopy.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
Pty.defaultLayer,
@@ -0,0 +1,93 @@
import { afterEach, describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Snapshot } from "@/snapshot"
import { InstanceBootstrap } from "@/project/bootstrap-service"
import { InstanceStore } from "@/project/instance-store"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer))
function request(directory: string, url: string, init: RequestInit = {}) {
return requestInDirectory(url, directory, init)
}
function json<T>(response: HttpClientResponse.HttpClientResponse) {
return response.json.pipe(Effect.map((value) => value as T))
}
describe("project paths and copies endpoints", () => {
it.instance(
"lists paths and manages git worktree copies",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const current = yield* request(test.directory, "/project/current")
const base = `/project/${(yield* json<{ id: string }>(current)).id}`
const createdPath = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(createdPath, { recursive: true, force: true })).pipe(Effect.ignore),
)
const strategies = yield* request(test.directory, `${base}/copy/strategy`)
expect(strategies.status).toBe(200)
expect(yield* json(strategies)).toEqual([{ id: "git_worktree", name: "Git worktree" }])
const initial = yield* request(test.directory, `${base}/paths`)
expect(initial.status).toBe(200)
expect(yield* json<Array<{ path: string; primary: boolean }>>(initial)).toEqual([
{ path: test.directory, primary: true },
])
const create = yield* request(test.directory, `${base}/copy`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ strategy: "git_worktree", path: createdPath }),
})
expect(create.status).toBe(200)
const created = yield* json<{ path: string }>(create)
expect(created.path).toContain("-http-copy")
const listed = yield* request(test.directory, `${base}/paths`)
expect((yield* json<Array<{ path: string; primary: boolean }>>(listed)).map((item) => item.path)).toContain(
created.path,
)
const remove = yield* request(test.directory, `${base}/copy`, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ strategy: "git_worktree", path: created.path }),
})
expect(remove.status).toBe(204)
const externalPath = path.join(test.directory, "..", path.basename(test.directory) + "-http-refresh")
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(externalPath, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${externalPath} HEAD`.cwd(test.directory).quiet())
const refresh = yield* request(test.directory, `${base}/copy/refresh`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ strategy: "git_worktree" }),
})
expect(refresh.status).toBe(204)
const refreshed = yield* request(test.directory, `${base}/paths`)
expect((yield* json<Array<{ path: string; primary: boolean }>>(refreshed)).length).toBe(2)
}),
{ git: true },
)
})
+206
View File
@@ -117,12 +117,22 @@ import type {
PermissionRespondErrors,
PermissionRespondResponses,
PermissionRuleset,
ProjectCopyCreateErrors,
ProjectCopyCreateResponses,
ProjectCopyRefreshErrors,
ProjectCopyRefreshResponses,
ProjectCopyRemoveErrors,
ProjectCopyRemoveResponses,
ProjectCopyStrategiesErrors,
ProjectCopyStrategiesResponses,
ProjectCurrentErrors,
ProjectCurrentResponses,
ProjectInitGitErrors,
ProjectInitGitResponses,
ProjectListErrors,
ProjectListResponses,
ProjectPathsErrors,
ProjectPathsResponses,
ProjectUpdateErrors,
ProjectUpdateResponses,
Prompt,
@@ -2373,6 +2383,197 @@ export class Project extends HeyApiClient {
},
})
}
/**
* List project paths
*
* List known local absolute paths for a project.
*/
public paths<ThrowOnError extends boolean = false>(
parameters: {
projectID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "projectID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ProjectPathsResponses, ProjectPathsErrors, ThrowOnError>({
url: "/project/{projectID}/paths",
...options,
...params,
})
}
}
export class ProjectCopy extends HeyApiClient {
/**
* List project copy strategies
*
* List mechanisms available for managing local copies of a project.
*/
public strategies<ThrowOnError extends boolean = false>(
parameters: {
projectID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "projectID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<
ProjectCopyStrategiesResponses,
ProjectCopyStrategiesErrors,
ThrowOnError
>({
url: "/project/{projectID}/copy/strategy",
...options,
...params,
})
}
/**
* Remove project copy
*
* Remove a local physical copy of a project using the selected strategy.
*/
public remove<ThrowOnError extends boolean = false>(
parameters: {
projectID: string
directory?: string
workspace?: string
strategy?: "git_worktree"
path?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "projectID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "strategy" },
{ in: "body", key: "path" },
],
},
],
)
return (options?.client ?? this.client).delete<ProjectCopyRemoveResponses, ProjectCopyRemoveErrors, ThrowOnError>({
url: "/project/{projectID}/copy",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Create project copy
*
* Create a local physical copy of a project using the selected strategy.
*/
public create<ThrowOnError extends boolean = false>(
parameters: {
projectID: string
directory?: string
workspace?: string
strategy?: "git_worktree"
path?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "projectID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "strategy" },
{ in: "body", key: "path" },
],
},
],
)
return (options?.client ?? this.client).post<ProjectCopyCreateResponses, ProjectCopyCreateErrors, ThrowOnError>({
url: "/project/{projectID}/copy",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Refresh project copies
*
* Discover local project copies using one or all configured strategies.
*/
public refresh<ThrowOnError extends boolean = false>(
parameters: {
projectID: string
directory?: string
workspace?: string
strategy?: "git_worktree"
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "projectID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "strategy" },
],
},
],
)
return (options?.client ?? this.client).post<ProjectCopyRefreshResponses, ProjectCopyRefreshErrors, ThrowOnError>({
url: "/project/{projectID}/copy/refresh",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Pty extends HeyApiClient {
@@ -5119,6 +5320,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._project ??= new Project({ client: this.client }))
}
private _projectCopy?: ProjectCopy
get projectCopy(): ProjectCopy {
return (this._projectCopy ??= new ProjectCopy({ client: this.client }))
}
private _pty?: Pty
get pty(): Pty {
return (this._pty ??= new Pty({ client: this.client }))
+188
View File
@@ -76,6 +76,7 @@ export type Event =
| EventPtyDeleted
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventProjectPathsUpdated
| EventServerConnected
| EventGlobalDisposed
| EventAccountAdded
@@ -1401,6 +1402,13 @@ export type GlobalEvent = {
version: string
}
}
| {
id: string
type: "project.paths.updated"
properties: {
projectID: string
}
}
| {
id: string
type: "server.connected"
@@ -3335,6 +3343,20 @@ export type ConfigV2ExperimentalPolicy = {
resource: string
}
export type ProjectPaths = Array<{
path: string
primary: boolean
}>
export type ProjectCopyStrategyInfo = {
id: "git_worktree"
name: string
}
export type ProjectCopyCopy = {
path: string
}
export type SessionInfo = {
id: string
parentID?: string
@@ -4457,6 +4479,14 @@ export type EventInstallationUpdateAvailable = {
}
}
export type EventProjectPathsUpdated = {
id: string
type: "project.paths.updated"
properties: {
projectID: string
}
}
export type EventServerConnected = {
id: string
type: "server.connected"
@@ -6153,6 +6183,164 @@ export type ProjectUpdateResponses = {
export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses]
export type ProjectPathsData = {
body?: never
path: {
projectID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/project/{projectID}/paths"
}
export type ProjectPathsErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ProjectPathsError = ProjectPathsErrors[keyof ProjectPathsErrors]
export type ProjectPathsResponses = {
/**
* Project paths
*/
200: ProjectPaths
}
export type ProjectPathsResponse = ProjectPathsResponses[keyof ProjectPathsResponses]
export type ProjectCopyStrategiesData = {
body?: never
path: {
projectID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/project/{projectID}/copy/strategy"
}
export type ProjectCopyStrategiesErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ProjectCopyStrategiesError = ProjectCopyStrategiesErrors[keyof ProjectCopyStrategiesErrors]
export type ProjectCopyStrategiesResponses = {
/**
* Project copy strategies
*/
200: Array<ProjectCopyStrategyInfo>
}
export type ProjectCopyStrategiesResponse = ProjectCopyStrategiesResponses[keyof ProjectCopyStrategiesResponses]
export type ProjectCopyRemoveData = {
body?: {
strategy: "git_worktree"
path: string
}
path: {
projectID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/project/{projectID}/copy"
}
export type ProjectCopyRemoveErrors = {
/**
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
}
export type ProjectCopyRemoveError = ProjectCopyRemoveErrors[keyof ProjectCopyRemoveErrors]
export type ProjectCopyRemoveResponses = {
/**
* Project copy removed
*/
204: void
}
export type ProjectCopyRemoveResponse = ProjectCopyRemoveResponses[keyof ProjectCopyRemoveResponses]
export type ProjectCopyCreateData = {
body?: {
strategy: "git_worktree"
path: string
}
path: {
projectID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/project/{projectID}/copy"
}
export type ProjectCopyCreateErrors = {
/**
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
}
export type ProjectCopyCreateError = ProjectCopyCreateErrors[keyof ProjectCopyCreateErrors]
export type ProjectCopyCreateResponses = {
/**
* Project copy created
*/
200: ProjectCopyCopy
}
export type ProjectCopyCreateResponse = ProjectCopyCreateResponses[keyof ProjectCopyCreateResponses]
export type ProjectCopyRefreshData = {
body?: {
strategy?: "git_worktree"
}
path: {
projectID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/project/{projectID}/copy/refresh"
}
export type ProjectCopyRefreshErrors = {
/**
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
}
export type ProjectCopyRefreshError = ProjectCopyRefreshErrors[keyof ProjectCopyRefreshErrors]
export type ProjectCopyRefreshResponses = {
/**
* Project copies refreshed
*/
204: void
}
export type ProjectCopyRefreshResponse = ProjectCopyRefreshResponses[keyof ProjectCopyRefreshResponses]
export type PtyShellsData = {
body?: never
path?: never