Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/opencode/specs/effect/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
110 changes: 52 additions & 58 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Info>

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<typeof Node>

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<typeof Content>
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<Schema.Schema.Type<typeof Info>>

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<Schema.Schema.Type<typeof Node>>

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<Schema.Schema.Type<typeof Content>>

export const Event = {
Edited: BusEvent.define(
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/server/routes/instance/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
},
},
},
Expand Down Expand Up @@ -146,7 +146,7 @@ export const FileRoutes = lazy(() =>
description: "File content",
content: {
"application/json": {
schema: resolver(File.Content),
schema: resolver(File.Content.zod),
},
},
},
Expand Down Expand Up @@ -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()),
},
},
},
Expand Down
84 changes: 84 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/file.ts
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)),
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/server/routes/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions packages/opencode/test/server/httpapi-file.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>

function request(route: string, directory: string, query?: Record<string, string>) {
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" })
})
})
Loading