Compare commits

..
Author SHA1 Message Date
Brendonovich 4d4d47d670 feat(core): add project update API 2026-08-23 16:26:51 +00:00
Brendonovich b6974306c9 feat(core): run worktree setup scripts 2026-08-23 16:19:44 +00:00
13 changed files with 123 additions and 72 deletions
+2 -1
View File
@@ -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"
@@ -72,16 +71,12 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
const project = await serverCtx().sdk.api.project.update({
await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
})
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
+24 -1
View File
@@ -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({
+15 -11
View File
@@ -30,7 +30,7 @@ export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = ProjectSchema.UpdateInput
export type UpdateInput = typeof UpdateInput.Type
export type UpdateInput = ProjectSchema.UpdateInput
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Project.NotFoundError", {
projectID: ID,
@@ -54,8 +54,8 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -157,11 +157,15 @@ const layer = Layer.effect(
const row = yield* db
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_url_override: input.icon?.override,
icon_color: input.icon?.color,
commands: input.commands,
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))
@@ -169,9 +173,9 @@ const layer = Layer.effect(
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ projectID: input.projectID })
const result = fromRow(row)
yield* bus.publish(ProjectSchema.Event.Updated, result)
return result
const project = fromRow(row)
yield* bus.publish(ProjectSchema.Event.Updated, project)
return project
})
const cached = Effect.fnUntraced(function* (dir: string) {
@@ -287,7 +291,7 @@ const layer = Layer.effect(
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
return Service.of({ list, resolve, update })
return Service.of({ list, update, resolve })
}),
)
+17 -27
View File
@@ -89,6 +89,7 @@ export type Error =
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| AppProcess.AppProcessError
| Git.WorktreeError
export interface Strategy {
@@ -149,7 +150,7 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const proc = yield* AppProcess.Service
const processService = yield* AppProcess.Service
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
@@ -264,39 +265,28 @@ const layer = Layer.effect(
}),
)
const project = yield* db
.select({ commands: ProjectTable.commands })
.select({ worktree: ProjectTable.worktree, commands: ProjectTable.commands })
.from(ProjectTable)
.where(eq(ProjectTable.id, input.projectID))
.get()
.pipe(Effect.orDie)
const script = project?.commands?.start?.trim()
if (script) {
yield* proc
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(
process.platform === "win32" ? "cmd" : "bash",
[process.platform === "win32" ? "/c" : "-lc", script],
{
cwd: result.directory,
extendEnv: true,
stdin: "ignore",
ChildProcess.make(shell, args, {
cwd: result.directory,
env: {
OPENCODE_WORKTREE_BASE: project.worktree,
OPENCODE_WORKTREE_PATH: result.directory,
},
),
)
.pipe(
Effect.flatMap((output) =>
output.exitCode === 0
? Effect.void
: Effect.logError("worktree setup script failed", {
directory: result.directory,
exitCode: output.exitCode,
stderr: output.stderr.toString("utf8"),
}),
),
Effect.catchCause((cause) =>
Effect.logError("worktree setup script failed", { directory: result.directory, cause }),
),
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(Effect.flatMap(AppProcess.requireSuccess))
}
return result
})
@@ -78,8 +78,8 @@ 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 }),
update: () => Effect.die("unused"),
})
}),
)
+1 -1
View File
@@ -5,7 +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 }),
update: () => Effect.die("unused"),
}),
)
+1 -1
View File
@@ -13,7 +13,7 @@ const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("unused"),
update: () => Effect.die("not implemented"),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
+37 -9
View File
@@ -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"
@@ -67,22 +67,50 @@ describe("Project.list", () => {
})
describe("Project.update", () => {
it.effect("updates project metadata", () =>
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("updated")
const id = Project.ID.make("update")
yield* db
.insert(ProjectTable)
.values({ id, worktree: abs("/updated"), sandboxes: [], time_created: 1, time_updated: 1 })
.values({
id,
worktree: abs("/update"),
sandboxes: [],
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
const result = yield* project.update({ projectID: id, name: "Updated", commands: { start: "bun install" } })
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(result.name).toBe("Updated")
expect(result.commands).toEqual({ start: "bun install" })
expect(result.time.updated).toBeGreaterThan(1)
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: [],
})
}),
)
})
+13 -4
View File
@@ -192,18 +192,23 @@ describe("Worktree", () => {
}),
)
it.live("runs the project setup script in a new 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-script"))
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: "echo ready > setup.txt" } })
.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)
@@ -215,7 +220,11 @@ describe("Worktree", () => {
name: "worktree",
})
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "setup.txt")).text())).toContain("ready")
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 })
}),
)
+1 -1
View File
@@ -29,7 +29,7 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
OpenApi.annotations({
identifier: "v2.project.update",
summary: "Update project",
description: "Update project metadata and workspace setup commands.",
description: "Update project display metadata and workspace commands.",
}),
),
)
+1 -1
View File
@@ -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.",
}),
),
)
+8 -7
View File
@@ -10,14 +10,15 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl
.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.catchTag(
"Project.NotFoundError",
(error) => new ProjectNotFoundError({ projectID: error.projectID, message: "Project not found" }),
),
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", () =>