diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 6c80dc65a250..771c8b987d3e 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -412,8 +412,9 @@ Current instance route inventory: - `workspace` - `bridged` best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status` defer create/remove mutations first -- `file` - `later` - good JSON-only candidate set, but larger than the current first-wave slices +- `file` - `bridged` (partial) + bridged endpoints: `GET /file`, `GET /file/content`, `GET /file/status` + defer search endpoints first - `mcp` - `later` has JSON-only endpoints, but interactive OAuth/auth flows make it a worse early fit - `session` - `defer` @@ -449,7 +450,7 @@ Recommended near-term sequence: - [x] port `project` read endpoints (`GET /project`, `GET /project/current`) - [x] port `GET /config` full read endpoint - [x] port `workspace` read endpoints -- [ ] port `file` JSON read endpoints +- [x] port `file` JSON read endpoints - [ ] decide when to remove the flag and make Effect routes the default ## Rule of thumb diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index ca791e412879..05e2ce359acf 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -9,69 +9,63 @@ import { formatPatch, structuredPatch } from "diff" import fuzzysort from "fuzzysort" import ignore from "ignore" import path from "path" -import z from "zod" import { Global } from "../global" import { Instance } from "../project/instance" import { Log } from "../util" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" - -export const Info = z - .object({ - path: z.string(), - added: z.number().int(), - removed: z.number().int(), - status: z.enum(["added", "deleted", "modified"]), - }) - .meta({ - ref: "File", - }) - -export type Info = z.infer - -export const Node = z - .object({ - name: z.string(), - path: z.string(), - absolute: z.string(), - type: z.enum(["file", "directory"]), - ignored: z.boolean(), - }) - .meta({ - ref: "FileNode", - }) -export type Node = z.infer - -export const Content = z - .object({ - type: z.enum(["text", "binary"]), - content: z.string(), - diff: z.string().optional(), - patch: z - .object({ - oldFileName: z.string(), - newFileName: z.string(), - oldHeader: z.string().optional(), - newHeader: z.string().optional(), - hunks: z.array( - z.object({ - oldStart: z.number(), - oldLines: z.number(), - newStart: z.number(), - newLines: z.number(), - lines: z.array(z.string()), - }), - ), - index: z.string().optional(), - }) - .optional(), - encoding: z.literal("base64").optional(), - mimeType: z.string().optional(), - }) - .meta({ - ref: "FileContent", - }) -export type Content = z.infer +import { zod } from "@/util/effect-zod" +import { type DeepMutable, withStatics } from "@/util/schema" + +export const Info = Schema.Struct({ + path: Schema.String, + added: Schema.Int, + removed: Schema.Int, + status: Schema.Literals(["added", "deleted", "modified"]), +}) + .annotate({ identifier: "File" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = DeepMutable> + +export const Node = Schema.Struct({ + name: Schema.String, + path: Schema.String, + absolute: Schema.String, + type: Schema.Literals(["file", "directory"]), + ignored: Schema.Boolean, +}) + .annotate({ identifier: "FileNode" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Node = DeepMutable> + +const Hunk = Schema.Struct({ + oldStart: Schema.Number, + oldLines: Schema.Number, + newStart: Schema.Number, + newLines: Schema.Number, + lines: Schema.Array(Schema.String), +}) + +const Patch = Schema.Struct({ + oldFileName: Schema.String, + newFileName: Schema.String, + oldHeader: Schema.optional(Schema.String), + newHeader: Schema.optional(Schema.String), + hunks: Schema.Array(Hunk), + index: Schema.optional(Schema.String), +}) + +export const Content = Schema.Struct({ + type: Schema.Literals(["text", "binary"]), + content: Schema.String, + diff: Schema.optional(Schema.String), + patch: Schema.optional(Patch), + encoding: Schema.optional(Schema.Literal("base64")), + mimeType: Schema.optional(Schema.String), +}) + .annotate({ identifier: "FileContent" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Content = DeepMutable> export const Event = { Edited: BusEvent.define( diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts index f92fe6e7e5fd..661322127e75 100644 --- a/packages/opencode/src/server/routes/instance/file.ts +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -117,7 +117,7 @@ export const FileRoutes = lazy(() => description: "Files and directories", content: { "application/json": { - schema: resolver(File.Node.array()), + schema: resolver(File.Node.zod.array()), }, }, }, @@ -146,7 +146,7 @@ export const FileRoutes = lazy(() => description: "File content", content: { "application/json": { - schema: resolver(File.Content), + schema: resolver(File.Content.zod), }, }, }, @@ -175,7 +175,7 @@ export const FileRoutes = lazy(() => description: "File status", content: { "application/json": { - schema: resolver(File.Info.array()), + schema: resolver(File.Info.zod.array()), }, }, }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/file.ts b/packages/opencode/src/server/routes/instance/httpapi/file.ts new file mode 100644 index 000000000000..eaf43862ad9a --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/file.ts @@ -0,0 +1,84 @@ +import { File } from "@/file" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const FileQuery = Schema.Struct({ + path: Schema.String, +}) + +export const FilePaths = { + list: "/file", + content: "/file/content", + status: "/file/status", +} as const + +export const FileApi = HttpApi.make("file") + .add( + HttpApiGroup.make("file") + .add( + HttpApiEndpoint.get("list", FilePaths.list, { + query: FileQuery, + success: Schema.Array(File.Node), + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.list", + summary: "List files", + description: "List files and directories in a specified path.", + }), + ), + HttpApiEndpoint.get("content", FilePaths.content, { + query: FileQuery, + success: File.Content, + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.read", + summary: "Read file", + description: "Read the content of a specified file.", + }), + ), + HttpApiEndpoint.get("status", FilePaths.status, { + success: Schema.Array(File.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.status", + summary: "Get file status", + description: "Get the git status of all files in the project.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "file", + description: "Experimental HttpApi file routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const fileHandlers = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* File.Service + + const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) { + return yield* svc.list(ctx.query.path) + }) + + const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { + return yield* svc.read(ctx.query.path) + }) + + const status = Effect.fn("FileHttpApi.status")(function* () { + return yield* svc.status() + }) + + return HttpApiBuilder.group(FileApi, "file", (handlers) => + handlers.handle("list", list).handle("content", content).handle("status", status), + ) + }), +).pipe(Layer.provide(File.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 7b131d400029..c57320c10346 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -10,6 +10,7 @@ import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" import { Filesystem } from "@/util" import { ConfigApi, configHandlers } from "./config" +import { FileApi, fileHandlers } from "./file" import { PermissionApi, permissionHandlers } from "./permission" import { ProjectApi, projectHandlers } from "./project" import { ProviderApi, providerHandlers } from "./provider" @@ -114,9 +115,11 @@ const ProjectSecured = ProjectApi.middleware(Authorization) const ProviderSecured = ProviderApi.middleware(Authorization) const ConfigSecured = ConfigApi.middleware(Authorization) const WorkspaceSecured = WorkspaceApi.middleware(Authorization) +const FileSecured = FileApi.middleware(Authorization) export const routes = Layer.mergeAll( HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)), + HttpApiBuilder.layer(FileSecured).pipe(Layer.provide(fileHandlers)), HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)), HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)), HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index e8a038fabc0a..fba150116c47 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -16,6 +16,7 @@ import { QuestionRoutes } from "./question" import { PermissionRoutes } from "./permission" import { Flag } from "@/flag/flag" import { ExperimentalHttpApiServer } from "./httpapi/server" +import { FilePaths } from "./httpapi/file" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" import { PtyRoutes } from "./pty" @@ -48,6 +49,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { 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)) + app.get(FilePaths.list, (c) => handler(c.req.raw, context)) + app.get(FilePaths.content, (c) => handler(c.req.raw, context)) + app.get(FilePaths.status, (c) => handler(c.req.raw, context)) } return app diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts new file mode 100644 index 000000000000..5a9058cb69b6 --- /dev/null +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Context } from "effect" +import path from "path" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { FilePaths } from "../../src/server/routes/instance/httpapi/file" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const context = Context.empty() as Context.Context + +function request(route: string, directory: string, query?: Record) { + const url = new URL(`http://localhost${route}`) + for (const [key, value] of Object.entries(query ?? {})) { + url.searchParams.set(key, value) + } + return ExperimentalHttpApiServer.webHandler().handler( + new Request(url, { + headers: { + "x-opencode-directory": directory, + }, + }), + context, + ) +} + +afterEach(async () => { + await Instance.disposeAll() + await resetDatabase() +}) + +describe("file HttpApi", () => { + test("serves read endpoints", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, "hello.txt"), "hello") + + const [list, content, status] = await Promise.all([ + request(FilePaths.list, tmp.path, { path: "." }), + request(FilePaths.content, tmp.path, { path: "hello.txt" }), + request(FilePaths.status, tmp.path), + ]) + + expect(list.status).toBe(200) + expect(await list.json()).toContainEqual( + expect.objectContaining({ name: "hello.txt", path: "hello.txt", type: "file" }), + ) + + expect(content.status).toBe(200) + expect(await content.json()).toMatchObject({ type: "text", content: "hello" }) + + expect(status.status).toBe(200) + expect(await status.json()).toContainEqual({ path: "hello.txt", added: 1, removed: 0, status: "added" }) + }) +})