mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 10:36:19 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4d47d670 | ||
|
|
b6974306c9 |
@@ -458,7 +458,8 @@ export const dict = {
|
||||
"dialog.project.edit.color": "Color",
|
||||
"dialog.project.edit.color.select": "Select {{color}} color",
|
||||
"dialog.project.edit.worktree.startup": "Workspace startup script",
|
||||
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
|
||||
"dialog.project.edit.worktree.startup.description":
|
||||
"Runs after creating a new workspace (worktree). Use $OPENCODE_WORKTREE_BASE for the base worktree and $OPENCODE_WORKTREE_PATH for the new worktree.",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "e.g. bun install",
|
||||
|
||||
"dialog.releaseNotes.action.getStarted": "Get started",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -9,7 +8,7 @@ import { type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
|
||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const supported = !props.project.id || props.project.id === "global"
|
||||
const supported = true
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
@@ -72,9 +71,14 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
// TODO: Restore project edits when the V2 client exposes a project update API.
|
||||
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
|
||||
throw new Error(`Project ${props.project.id} cannot be updated`)
|
||||
await serverCtx().sdk.api.project.update({
|
||||
projectID: props.project.id,
|
||||
name,
|
||||
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
|
||||
commands: { start },
|
||||
})
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
serverCtx().sync.project.meta(props.project.worktree, {
|
||||
|
||||
@@ -1291,6 +1291,15 @@ export interface CredentialApi<E = never> {
|
||||
export type ProjectListOutput = ReadonlyArray<Project.Info>
|
||||
export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOutput, E>
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
readonly projectID: Project.ID
|
||||
readonly name?: string | undefined
|
||||
readonly icon?: Project.Icon | undefined
|
||||
readonly commands?: Project.Commands | undefined
|
||||
}
|
||||
export type ProjectUpdateOutput = Project.Info
|
||||
export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => Effect.Effect<ProjectUpdateOutput, E>
|
||||
|
||||
export type ProjectCurrentInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
@@ -1299,6 +1308,7 @@ export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) =
|
||||
|
||||
export interface ProjectApi<E = never> {
|
||||
readonly list: ProjectListOperation<E>
|
||||
readonly update: ProjectUpdateOperation<E>
|
||||
readonly current: ProjectCurrentOperation<E>
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ import type {
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
ProjectUpdateInput,
|
||||
ProjectUpdateOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
FormRequestListInput,
|
||||
@@ -919,6 +921,14 @@ const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
|
||||
const EndpointProjectList = (raw: RawClient["server.project"]) => () =>
|
||||
preserveEffect<ProjectListOutput>()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) =>
|
||||
preserveEffect<ProjectUpdateOutput>()(
|
||||
raw["project.update"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: ProjectCurrentInput) =>
|
||||
preserveEffect<ProjectCurrentOutput>()(
|
||||
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -926,6 +936,7 @@ const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: Pr
|
||||
|
||||
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
|
||||
list: EndpointProjectList(raw),
|
||||
update: EndpointProjectUpdate(raw),
|
||||
current: EndpointProjectCurrent(raw),
|
||||
})
|
||||
|
||||
|
||||
@@ -133,6 +133,8 @@ import type {
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
ProjectUpdateInput,
|
||||
ProjectUpdateOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
FormRequestListInput,
|
||||
@@ -1251,6 +1253,18 @@ export function make(options: ClientOptions) {
|
||||
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectUpdateOutput>(
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/project/${encodeURIComponent(input.projectID)}`,
|
||||
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCurrentOutput>(
|
||||
{
|
||||
|
||||
@@ -2269,6 +2269,14 @@ export type McpServerNotFoundError = {
|
||||
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
|
||||
|
||||
export type ProjectNotFoundError = {
|
||||
readonly _tag: "ProjectNotFoundError"
|
||||
readonly projectID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError"
|
||||
|
||||
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
|
||||
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
|
||||
@@ -4379,6 +4387,27 @@ export type CredentialRemoveOutput = void
|
||||
|
||||
export type ProjectListOutput = Array<Project>
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly name?: {
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["name"]
|
||||
readonly icon?: {
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["icon"]
|
||||
readonly commands?: {
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["commands"]
|
||||
}
|
||||
|
||||
export type ProjectUpdateOutput = Project
|
||||
|
||||
export type ProjectCurrentInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -50,7 +50,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
})
|
||||
|
||||
@@ -81,6 +81,29 @@ test("config.get returns ordered config entries for a location", async () => {
|
||||
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
})
|
||||
|
||||
test("project.update uses the global project contract", async () => {
|
||||
let request: Request | undefined
|
||||
const project = {
|
||||
id: "proj_test",
|
||||
canonical: "/tmp/project",
|
||||
commands: { start: "bun install" },
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
}
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json(project)
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.project.update({ projectID: "proj_test", commands: { start: "bun install" } })).toEqual(project)
|
||||
expect(request?.method).toBe("PATCH")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/project/proj_test")
|
||||
expect(await request?.json()).toEqual({ commands: { start: "bun install" } })
|
||||
})
|
||||
|
||||
test("websearch.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -29,6 +29,13 @@ export type Current = ProjectSchema.Current
|
||||
export const Info = ProjectSchema.Info
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const UpdateInput = ProjectSchema.UpdateInput
|
||||
export type UpdateInput = ProjectSchema.UpdateInput
|
||||
|
||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Project.NotFoundError", {
|
||||
projectID: ID,
|
||||
}) {}
|
||||
|
||||
export interface Resolved {
|
||||
readonly previous?: ID
|
||||
readonly id: ID
|
||||
@@ -47,6 +54,7 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||
}
|
||||
|
||||
@@ -145,6 +153,31 @@ const layer = Layer.effect(
|
||||
return rows.map(fromRow)
|
||||
})
|
||||
|
||||
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
|
||||
const row = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
name: input.name === undefined ? undefined : input.name || null,
|
||||
icon_url_override: input.icon?.override === undefined ? undefined : input.icon.override || null,
|
||||
icon_color: input.icon?.color === undefined ? undefined : input.icon.color || null,
|
||||
commands:
|
||||
input.commands?.start === undefined
|
||||
? undefined
|
||||
: input.commands.start
|
||||
? { start: input.commands.start }
|
||||
: null,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new NotFoundError({ projectID: input.projectID })
|
||||
const project = fromRow(row)
|
||||
yield* bus.publish(ProjectSchema.Event.Updated, project)
|
||||
return project
|
||||
})
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
@@ -258,7 +291,7 @@ const layer = Layer.effect(
|
||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||
})
|
||||
|
||||
return Service.of({ list, resolve })
|
||||
return Service.of({ list, update, resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ export type Current = typeof Current.Type
|
||||
export const Info = Project.Info
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const UpdateInput = Project.UpdateInput
|
||||
export type UpdateInput = typeof UpdateInput.Type
|
||||
|
||||
export const Event = Project.Event
|
||||
|
||||
export const Vcs = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("git"),
|
||||
|
||||
@@ -18,6 +18,8 @@ import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
import { WorktreeGit } from "./worktree/git.js"
|
||||
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
|
||||
import { ProjectTable } from "./project/sql.js"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
|
||||
export { DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
|
||||
@@ -87,6 +89,7 @@ export type Error =
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| AppProcess.AppProcessError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
@@ -147,6 +150,7 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const processService = yield* AppProcess.Service
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
@@ -260,6 +264,30 @@ const layer = Layer.effect(
|
||||
strategy: input.strategy,
|
||||
}),
|
||||
)
|
||||
const project = yield* db
|
||||
.select({ worktree: ProjectTable.worktree, commands: ProjectTable.commands })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const command = project?.commands?.start?.trim()
|
||||
if (command && project) {
|
||||
const shell = process.platform === "win32" ? "cmd" : "bash"
|
||||
const args = process.platform === "win32" ? ["/c", command] : ["-lc", command]
|
||||
yield* processService
|
||||
.run(
|
||||
ChildProcess.make(shell, args, {
|
||||
cwd: result.directory,
|
||||
env: {
|
||||
OPENCODE_WORKTREE_BASE: project.worktree,
|
||||
OPENCODE_WORKTREE_PATH: result.directory,
|
||||
},
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.flatMap(AppProcess.requireSuccess))
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -342,7 +370,7 @@ const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("node build", () => {
|
||||
acquisitions++
|
||||
return Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
update: () => Effect.die("not implemented"),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -5,6 +5,7 @@ export const globalProjectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
update: () => Effect.die("not implemented"),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ const projectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
update: () => Effect.die("not implemented"),
|
||||
resolve: () =>
|
||||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -66,6 +66,55 @@ describe("Project.list", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("Project.update", () => {
|
||||
it.effect("updates and clears project metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const project = yield* Project.Service
|
||||
const id = Project.ID.make("update")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id,
|
||||
worktree: abs("/update"),
|
||||
sandboxes: [],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
|
||||
expect(
|
||||
yield* project.update({
|
||||
projectID: id,
|
||||
name: "Updated",
|
||||
icon: { color: "blue", override: "data:image/png;base64,test" },
|
||||
commands: { start: "bun install" },
|
||||
}),
|
||||
).toMatchObject({
|
||||
id,
|
||||
name: "Updated",
|
||||
icon: { color: "blue", override: "data:image/png;base64,test" },
|
||||
commands: { start: "bun install" },
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* project.update({
|
||||
projectID: id,
|
||||
name: "",
|
||||
icon: { color: "", override: "" },
|
||||
commands: { start: "" },
|
||||
}),
|
||||
).toMatchObject({ id })
|
||||
expect((yield* project.list())[0]).toEqual({
|
||||
id,
|
||||
canonical: abs("/update"),
|
||||
time: { created: 1, updated: expect.any(Number) },
|
||||
sandboxes: [],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
@@ -192,6 +192,43 @@ describe("Worktree", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("runs the project setup script with worktree paths", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-setup"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* input.db
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
commands: {
|
||||
start:
|
||||
"bun -e \"await Bun.write('setup.json', JSON.stringify([process.env.OPENCODE_WORKTREE_BASE, process.env.OPENCODE_WORKTREE_PATH, process.cwd()]))\"",
|
||||
},
|
||||
})
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
})
|
||||
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "setup.json")).json())).toEqual([
|
||||
input.sourceDirectory,
|
||||
created.directory,
|
||||
created.directory,
|
||||
])
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects a missing source directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
|
||||
@@ -62,6 +62,15 @@ export class ProviderNotFoundError extends Schema.TaggedError<ProviderNotFoundEr
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundError>()(
|
||||
"ProjectNotFoundError",
|
||||
{
|
||||
projectID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class AgentNotFoundError extends Schema.TaggedError<AgentNotFoundError>()(
|
||||
"AgentNotFoundError",
|
||||
{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Schema } from "effect"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
import { ProjectNotFoundError } from "../errors.js"
|
||||
|
||||
const root = "/api/project"
|
||||
const UpdatePayload = Schema.Struct(Struct.omit(Project.UpdateInput.fields, ["projectID"]))
|
||||
|
||||
export const ProjectGroup = HttpApiGroup.make("server.project")
|
||||
.add(
|
||||
@@ -17,6 +19,20 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.patch("project.update", `${root}/:projectID`, {
|
||||
params: { projectID: Project.ID },
|
||||
payload: UpdatePayload,
|
||||
success: Project.Info,
|
||||
error: ProjectNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.project.update",
|
||||
summary: "Update project",
|
||||
description: "Update project display metadata and workspace commands.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("project.current", `${root}/current`, {
|
||||
query: LocationQuery,
|
||||
|
||||
@@ -42,7 +42,7 @@ export const WorktreeGroup = HttpApiGroup.make("server.worktree")
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.create",
|
||||
summary: "Create worktree",
|
||||
description: "Create a worktree for a project.",
|
||||
description: "Create a worktree for a project and run its configured setup script.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -46,5 +46,13 @@ export const Info = Schema.Struct({
|
||||
}).annotate({ identifier: "Project" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
projectID: ID,
|
||||
name: optional(Schema.String),
|
||||
icon: optional(Icon),
|
||||
commands: optional(Commands),
|
||||
}).annotate({ identifier: "Project.UpdateInput" })
|
||||
export interface UpdateInput extends Schema.Schema.Type<typeof UpdateInput> {}
|
||||
|
||||
const Updated = ephemeral({ type: "project.updated", schema: Info.fields })
|
||||
export const Event = { Updated, Definitions: inventory(Updated) }
|
||||
|
||||
@@ -3,10 +3,24 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { ProjectNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
|
||||
export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) =>
|
||||
handlers
|
||||
.handle("project.list", () => Project.Service.use((project) => project.list()))
|
||||
.handle("project.update", (ctx) =>
|
||||
Project.Service.use((project) =>
|
||||
project.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new ProjectNotFoundError({
|
||||
projectID: ctx.params.projectID,
|
||||
message: `Project not found: ${ctx.params.projectID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.handle("project.current", () =>
|
||||
Location.Service.use((location) =>
|
||||
Effect.succeed({
|
||||
|
||||
Reference in New Issue
Block a user