From 58c25db0d05294f5cc55480de3d6a8abe98f62eb Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:19:55 +0000 Subject: [PATCH] feat: run setup scripts for new worktrees --- .../src/settings/workspaces/project-model.ts | 17 ++++++-- packages/client/src/effect/api/api.ts | 10 +++++ .../client/src/effect/generated/client.ts | 11 +++++ .../client/src/promise/generated/client.ts | 14 +++++++ .../client/src/promise/generated/types.ts | 29 ++++++++++++++ packages/core/src/project.ts | 31 +++++++++++++- packages/core/src/project/schema.ts | 5 +++ packages/core/src/worktree.ts | 40 ++++++++++++++++++- .../test/effect/layer-node/node-build.test.ts | 1 + packages/core/test/lib/project.ts | 1 + packages/core/test/location.test.ts | 1 + packages/core/test/project.test.ts | 21 ++++++++++ packages/core/test/worktree.test.ts | 28 +++++++++++++ packages/protocol/src/errors.ts | 9 +++++ packages/protocol/src/groups/project.ts | 18 ++++++++- packages/schema/src/project.ts | 8 ++++ packages/server/src/handlers/project.ts | 13 ++++++ 17 files changed, 250 insertions(+), 7 deletions(-) diff --git a/packages/app/src/settings/workspaces/project-model.ts b/packages/app/src/settings/workspaces/project-model.ts index 9fee0599ad5..60d7af63580 100644 --- a/packages/app/src/settings/workspaces/project-model.ts +++ b/packages/app/src/settings/workspaces/project-model.ts @@ -9,7 +9,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 +72,18 @@ 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`) + const project = await serverCtx().sdk.api.project.update({ + projectID: props.project.id, + name, + 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 } serverCtx().sync.project.meta(props.project.worktree, { diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index a2c002a0f1b..5171c06ebf7 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1291,6 +1291,15 @@ export interface CredentialApi { export type ProjectListOutput = ReadonlyArray export type ProjectListOperation = () => Effect.Effect +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 = (input: ProjectUpdateInput) => Effect.Effect + export type ProjectCurrentInput = { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined } @@ -1299,6 +1308,7 @@ export type ProjectCurrentOperation = (input?: ProjectCurrentInput) = export interface ProjectApi { readonly list: ProjectListOperation + readonly update: ProjectUpdateOperation readonly current: ProjectCurrentOperation } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index c1a4e08065b..937fdb8e08a 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -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()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError))) +const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) => + preserveEffect()( + 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()( 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), }) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 9151f83e52f..4b2fba0076e 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( + { + 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( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index fba66256cbd..d31eb5a4052 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 +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 diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 05285718743..c55c1115a93 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -29,6 +29,13 @@ export type Current = ProjectSchema.Current export const Info = ProjectSchema.Info export interface Info extends Schema.Schema.Type {} +export const UpdateInput = ProjectSchema.UpdateInput +export type UpdateInput = typeof UpdateInput.Type + +export class NotFoundError extends Schema.TaggedError()("Project.NotFoundError", { + projectID: ID, +}) {} + export interface Resolved { readonly previous?: ID readonly id: ID @@ -48,6 +55,7 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i export interface Interface { readonly list: () => Effect.Effect> readonly resolve: (input: AbsolutePath) => Effect.Effect + readonly update: (input: UpdateInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/Project") {} @@ -145,6 +153,27 @@ 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, + icon_url: input.icon?.url, + icon_url_override: input.icon?.override, + icon_color: input.icon?.color, + commands: input.commands, + 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 result = fromRow(row) + yield* bus.publish(ProjectSchema.Event.Updated, result) + return result + }) + const cached = Effect.fnUntraced(function* (dir: string) { return yield* fs.readFileString(path.join(dir, "opencode")).pipe( Effect.map((value) => value.trim()), @@ -258,7 +287,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, resolve, update }) }), ) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 4f5fa90fb3e..f2988a2d00f 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -13,6 +13,11 @@ export type Current = typeof Current.Type export const Info = Project.Info export interface Info extends Schema.Schema.Type {} +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"), diff --git a/packages/core/src/worktree.ts b/packages/core/src/worktree.ts index 3489f1e9fdd..5a10d02eed1 100644 --- a/packages/core/src/worktree.ts +++ b/packages/core/src/worktree.ts @@ -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" @@ -147,6 +149,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 changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) { if (update) yield* bus.publish(Event.Updated, { projectID }) @@ -260,6 +263,41 @@ const layer = Layer.effect( strategy: input.strategy, }), ) + const project = yield* db + .select({ 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 + .run( + ChildProcess.make( + process.platform === "win32" ? "cmd" : "bash", + [process.platform === "win32" ? "/c" : "-lc", script], + { + cwd: result.directory, + extendEnv: true, + stdin: "ignore", + }, + ), + ) + .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 }), + ), + ) + } return result }) @@ -342,7 +380,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({ diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index 5edbece2c6e..fe0ee34e7c6 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -79,6 +79,7 @@ describe("node build", () => { return Project.Service.of({ list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), + update: () => Effect.die("unused"), }) }), ) diff --git a/packages/core/test/lib/project.ts b/packages/core/test/lib/project.ts index 66fceaf1993..d605d5bcb5c 100644 --- a/packages/core/test/lib/project.ts +++ b/packages/core/test/lib/project.ts @@ -6,5 +6,6 @@ export const globalProjectLayer = Layer.succeed( Project.Service.of({ list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), + update: () => Effect.die("unused"), }), ) diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index fdda8566fc7..4da2e834241 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -13,6 +13,7 @@ const projectLayer = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), + update: () => Effect.die("unused"), resolve: () => Effect.succeed({ id: Project.ID.make("project"), diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index d9eedaf26f6..44dde93be19 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -66,6 +66,27 @@ describe("Project.list", () => { ) }) +describe("Project.update", () => { + it.effect("updates project metadata", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const project = yield* Project.Service + const id = Project.ID.make("updated") + yield* db + .insert(ProjectTable) + .values({ id, worktree: abs("/updated"), 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(result.name).toBe("Updated") + expect(result.commands).toEqual({ start: "bun install" }) + expect(result.time.updated).toBeGreaterThan(1) + }), + ) +}) + function remoteID(remote: string) { return Project.ID.make(Hash.fast(`git-remote:${remote}`)) } diff --git a/packages/core/test/worktree.test.ts b/packages/core/test/worktree.test.ts index c8ac96f9f44..39fdd1c84da 100644 --- a/packages/core/test/worktree.test.ts +++ b/packages/core/test/worktree.test.ts @@ -192,6 +192,34 @@ describe("Worktree", () => { }), ) + it.live("runs the project setup script in a new worktree", () => + 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")) + 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" } }) + .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.txt")).text())).toContain("ready") + 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() diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index f3240fa5c54..3825972b124 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -62,6 +62,15 @@ export class ProviderNotFoundError extends Schema.TaggedError()( + "ProjectNotFoundError", + { + projectID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class AgentNotFoundError extends Schema.TaggedError()( "AgentNotFoundError", { diff --git a/packages/protocol/src/groups/project.ts b/packages/protocol/src/groups/project.ts index 3e60a437667..435f8655e23 100644 --- a/packages/protocol/src/groups/project.ts +++ b/packages/protocol/src/groups/project.ts @@ -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 metadata and workspace setup commands.", + }), + ), + ) .add( HttpApiEndpoint.get("project.current", `${root}/current`, { query: LocationQuery, diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index 678f9759780..518eb3219df 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -46,5 +46,13 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Project" }) export interface Info extends Schema.Schema.Type {} +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 {} + const Updated = ephemeral({ type: "project.updated", schema: Info.fields }) export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/server/src/handlers/project.ts b/packages/server/src/handlers/project.ts index 5f9aca37307..49fde3f7940 100644 --- a/packages/server/src/handlers/project.ts +++ b/packages/server/src/handlers/project.ts @@ -3,10 +3,23 @@ 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.catchTag( + "Project.NotFoundError", + (error) => new ProjectNotFoundError({ projectID: error.projectID, message: "Project not found" }), + ), + ), + ), + ) .handle("project.current", () => Location.Service.use((location) => Effect.succeed({