diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f838d9ab43..6a2132274a 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -8,46 +8,52 @@ 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, 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 { withStatics } from "@/util/schema" 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 +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), +}) + +export 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" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Types.DeepMutable> export const Event = { - Updated: BusEvent.define("project.updated", Info), + Updated: BusEvent.define("project.updated", Info.zod), } type Row = typeof ProjectTable.$inferSelect @@ -58,7 +64,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: { @@ -74,8 +80,8 @@ 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(), + icon: zod(ProjectIcon).optional(), + commands: zod(ProjectCommands).optional(), }) export type UpdateInput = z.infer @@ -139,7 +145,7 @@ export const layer: Layer.Layer< }), ) - const fakeVcs = Info.shape.vcs.parse(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 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 0000000000..7d2d8462f0 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/project.ts @@ -0,0 +1,62 @@ +import { Instance } from "@/project/instance" +import { Project } from "@/project" +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.", + }), + ), + ) + .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 + }) + + return HttpApiBuilder.group(ProjectApi, "project", (handlers) => + handlers.handle("list", list).handle("current", current), + ) + }), +).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 64332fd2a0..b4442d6400 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 6a290093c5..cfcaffc596 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 @@ -52,9 +45,17 @@ 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 + .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()) diff --git a/packages/opencode/src/server/instance/project.ts b/packages/opencode/src/server/instance/project.ts index eea741596d..95b5862fd5 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), }, }, },