diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index db82ea4c72..95d8c4b124 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -6,7 +6,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { expect } from "vitest"; -import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts"; +import type { + GitActionProgressEvent, + GitPreparePullRequestThreadInput, + ModelSelection, + ThreadId, +} from "@t3tools/contracts"; import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; import { type GitManagerShape } from "../Services/GitManager.ts"; @@ -21,6 +26,11 @@ import { GitCore } from "../Services/GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerInput, + type ProjectSetupScriptRunnerShape, +} from "../../projectScripts/Services/ProjectSetupScriptRunner.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -496,7 +506,7 @@ function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; refe function preparePullRequestThread( manager: GitManagerShape, - input: { cwd: string; reference: string; mode: "local" | "worktree" }, + input: GitPreparePullRequestThreadInput, ) { return manager.preparePullRequestThread(input); } @@ -504,6 +514,7 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; + setupScriptRunner?: ProjectSetupScriptRunnerShape; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -521,6 +532,12 @@ function makeManager(input?: { const managerLayer = Layer.mergeAll( Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), + Layer.succeed( + ProjectSetupScriptRunner, + input?.setupScriptRunner ?? { + runForThread: () => Effect.succeed({ status: "no-script" as const }), + }, + ), gitCoreLayer, serverSettingsLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); @@ -531,6 +548,8 @@ function makeManager(input?: { ); } +const asThreadId = (threadId: string) => threadId as ThreadId; + const GitManagerTestLayer = GitCoreLive.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), Layer.provideMerge(NodeServices.layer), @@ -1560,6 +1579,59 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("launches setup only when creating a new PR worktree", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); + fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); + yield* runGit(repoDir, ["add", "setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 177, + title: "Worktree setup PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/177", + baseRefName: "main", + headRefName: "feature/pr-worktree-setup", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "177", + mode: "worktree", + threadId: asThreadId("thread-pr-setup"), + }); + + expect(result.worktreePath).not.toBeNull(); + expect(setupCalls).toHaveLength(1); + expect(setupCalls[0]).toEqual({ + threadId: "thread-pr-setup", + projectCwd: repoDir, + worktreePath: result.worktreePath as string, + }); + }), + ); + it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1744,6 +1816,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); + const setupCalls: ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { @@ -1755,18 +1828,27 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }, }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "78", mode: "worktree", + threadId: asThreadId("thread-pr-existing-worktree"), }); expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( fs.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-existing-worktree"); + expect(setupCalls).toHaveLength(0); }), ); @@ -1946,6 +2028,50 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("does not fail PR worktree prep when setup terminal startup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); + fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n"); + yield* runGit(repoDir, ["add", "setup-failure.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 184, + title: "Setup failure PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/184", + baseRefName: "main", + headRefName: "feature/pr-setup-failure", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: () => Effect.fail(new Error("terminal start failed")), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "184", + mode: "worktree", + threadId: asThreadId("thread-pr-setup-failure"), + }); + + expect(result.branch).toBe("feature/pr-setup-failure"); + expect(result.worktreePath).not.toBeNull(); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); + }), + ); + it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index dc082674b7..2125edc7c8 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -24,6 +24,7 @@ import { import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { ProjectSetupScriptRunner } from "../../projectScripts/Services/ProjectSetupScriptRunner.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { GitManagerServiceError } from "../Errors.ts"; @@ -365,6 +366,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( @@ -993,6 +995,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { + const maybeRunSetupScript = (worktreePath: string) => { + if (!input.threadId) { + return Effect.void; + } + return projectSetupScriptRunner + .runForThread({ + threadId: input.threadId, + projectCwd: input.cwd, + worktreePath, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning( + `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, + ).pipe(Effect.asVoid), + ), + ); + }; + const normalizedReference = normalizePullRequestReference(input.reference); const rootWorktreePath = canonicalizeExistingPath(input.cwd); const pullRequestSummary = yield* gitHubCli.getPullRequest({ @@ -1124,6 +1145,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { path: null, }); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + yield* maybeRunSetupScript(worktree.worktree.path); return { pullRequest, diff --git a/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts new file mode 100644 index 0000000000..7d8026eec3 --- /dev/null +++ b/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts @@ -0,0 +1,164 @@ +import { Effect, Layer, Stream } from "effect"; +import { describe, expect, it, vi } from "vitest"; +import type { OrchestrationReadModel } from "@t3tools/contracts"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; +import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; + +const emptySnapshot = ( + scripts: OrchestrationReadModel["projects"][number]["scripts"], +): OrchestrationReadModel => + ({ + snapshotSequence: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + projects: [ + { + id: "project-1", + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: null, + scripts, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }, + ], + threads: [], + providerSessions: [], + providerStatuses: [], + pendingApprovals: [], + latestTurnByThreadId: {}, + }) as unknown as OrchestrationReadModel; + +describe("ProjectSetupScriptRunner", () => { + it("returns no-script when no setup script exists", async () => { + const open = vi.fn(); + const write = vi.fn(); + const runner = await Effect.runPromise( + Effect.service(ProjectSetupScriptRunner).pipe( + Effect.provide( + ProjectSetupScriptRunnerLive.pipe( + Layer.provideMerge( + Layer.succeed(OrchestrationEngineService, { + getReadModel: () => Effect.succeed(emptySnapshot([])), + readEvents: () => Stream.empty, + dispatch: () => Effect.die(new Error("unused")), + streamDomainEvents: Stream.empty, + }), + ), + Layer.provideMerge( + Layer.succeed(TerminalManager, { + open, + write, + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.die(new Error("unused")), + close: () => Effect.void, + subscribe: () => Effect.succeed(() => undefined), + }), + ), + ), + ), + ), + ); + + const result = await Effect.runPromise( + runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }), + ); + + expect(result).toEqual({ status: "no-script" }); + expect(open).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it("opens the deterministic setup terminal with worktree env and writes the command", async () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + const runner = await Effect.runPromise( + Effect.service(ProjectSetupScriptRunner).pipe( + Effect.provide( + ProjectSetupScriptRunnerLive.pipe( + Layer.provideMerge( + Layer.succeed(OrchestrationEngineService, { + getReadModel: () => + Effect.succeed( + emptySnapshot([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]), + ), + readEvents: () => Stream.empty, + dispatch: () => Effect.die(new Error("unused")), + streamDomainEvents: Stream.empty, + }), + ), + Layer.provideMerge( + Layer.succeed(TerminalManager, { + open, + write, + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.die(new Error("unused")), + close: () => Effect.void, + subscribe: () => Effect.succeed(() => undefined), + }), + ), + ), + ), + ), + ); + + const result = await Effect.runPromise( + runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }), + ); + + expect(result).toEqual({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + }); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + }); + expect(write).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + data: "bun install\r", + }); + }); +}); diff --git a/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.ts new file mode 100644 index 0000000000..c3ac77fe52 --- /dev/null +++ b/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.ts @@ -0,0 +1,74 @@ +import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { Effect, Layer } from "effect"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { + type ProjectSetupScriptRunnerShape, + ProjectSetupScriptRunner, +} from "../Services/ProjectSetupScriptRunner.ts"; + +const makeProjectSetupScriptRunner = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const terminalManager = yield* TerminalManager; + + const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => + Effect.gen(function* () { + const readModel = yield* orchestrationEngine.getReadModel(); + const project = + (input.projectId + ? readModel.projects.find((entry) => entry.id === input.projectId) + : null) ?? + (input.projectCwd + ? readModel.projects.find((entry) => entry.workspaceRoot === input.projectCwd) + : null) ?? + null; + + if (!project) { + return yield* Effect.fail(new Error("Project was not found for setup script execution.")); + } + + const script = setupProjectScript(project.scripts); + if (!script) { + return { + status: "no-script", + } as const; + } + + const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; + const cwd = input.worktreePath; + const env = projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: input.worktreePath, + }); + + yield* terminalManager.open({ + threadId: input.threadId, + terminalId, + cwd, + env, + }); + yield* terminalManager.write({ + threadId: input.threadId, + terminalId, + data: `${script.command}\r`, + }); + + return { + status: "started", + scriptId: script.id, + scriptName: script.name, + terminalId, + cwd, + } as const; + }); + + return { + runForThread, + } satisfies ProjectSetupScriptRunnerShape; +}); + +export const ProjectSetupScriptRunnerLive = Layer.effect( + ProjectSetupScriptRunner, + makeProjectSetupScriptRunner, +); diff --git a/apps/server/src/projectScripts/Services/ProjectSetupScriptRunner.ts b/apps/server/src/projectScripts/Services/ProjectSetupScriptRunner.ts new file mode 100644 index 0000000000..efee54ae56 --- /dev/null +++ b/apps/server/src/projectScripts/Services/ProjectSetupScriptRunner.ts @@ -0,0 +1,37 @@ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ProjectSetupScriptRunnerResultNoScript { + readonly status: "no-script"; +} + +export interface ProjectSetupScriptRunnerResultStarted { + readonly status: "started"; + readonly scriptId: string; + readonly scriptName: string; + readonly terminalId: string; + readonly cwd: string; +} + +export type ProjectSetupScriptRunnerResult = + | ProjectSetupScriptRunnerResultNoScript + | ProjectSetupScriptRunnerResultStarted; + +export interface ProjectSetupScriptRunnerInput { + readonly threadId: string; + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; + readonly preferredTerminalId?: string; +} + +export interface ProjectSetupScriptRunnerShape { + readonly runForThread: ( + input: ProjectSetupScriptRunnerInput, + ) => Effect.Effect; +} + +export class ProjectSetupScriptRunner extends ServiceMap.Service< + ProjectSetupScriptRunner, + ProjectSetupScriptRunnerShape +>()("t3/projectScripts/ProjectSetupScriptRunner") {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index a8c1a13f7f..27773ad9d8 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -32,6 +32,7 @@ import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; +import { ProjectSetupScriptRunnerLive } from "./projectScripts/Layers/ProjectSetupScriptRunner"; import { PtyAdapter } from "./terminal/Services/PTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; @@ -129,17 +130,23 @@ export function makeServerRuntimeServicesLayer() { ); const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); + const projectSetupScriptRunnerLayer = ProjectSetupScriptRunnerLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(terminalLayer), + ); const gitManagerLayer = GitManagerLive.pipe( Layer.provideMerge(GitCoreLive), Layer.provideMerge(GitHubCliLive), Layer.provideMerge(textGenerationLayer), + Layer.provideMerge(projectSetupScriptRunnerLayer), ); return Layer.mergeAll( orchestrationReactorLayer, GitCoreLive, gitManagerLayer, + projectSetupScriptRunnerLayer, terminalLayer, KeybindingsLive, ).pipe(Layer.provideMerge(NodeServices.layer)); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0e06650fe6..8fc6035f6c 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -56,6 +56,10 @@ import { GitCommandError, GitManagerError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { ServerSettingsService } from "./serverSettings.ts"; +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerShape, +} from "./projectScripts/Services/ProjectSetupScriptRunner.ts"; const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); @@ -501,7 +505,8 @@ describe("WebSocket Server", () => { providerRegistry?: ProviderRegistryShape; open?: OpenShape; gitManager?: GitManagerShape; - gitCore?: Pick; + gitCore?: Partial; + projectSetupScriptRunner?: ProjectSetupScriptRunnerShape; terminalManager?: TerminalManagerShape; serverSettings?: Partial; } = {}, @@ -541,6 +546,9 @@ describe("WebSocket Server", () => { options.gitCore ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) : Layer.empty, + options.projectSetupScriptRunner + ? Layer.succeed(ProjectSetupScriptRunner, options.projectSetupScriptRunner) + : Layer.empty, options.terminalManager ? Layer.succeed(TerminalManager, options.terminalManager) : Layer.empty, @@ -1382,6 +1390,159 @@ describe("WebSocket Server", () => { expect(domainEvent.payload.text).toBe("hello from runtime"); }); + it("bootstraps first-send worktree turns on the server before dispatching turn start", async () => { + const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + const providerService: ProviderServiceShape = { + startSession: (threadId) => + Effect.succeed({ + provider: "codex", + status: "ready", + runtimeMode: "full-access", + threadId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + sendTurn: ({ threadId }) => + Effect.succeed({ + threadId, + turnId: asTurnId("provider-turn-bootstrap"), + }), + interruptTurn: () => unsupported(), + respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), + stopSession: () => unsupported(), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + rollbackConversation: () => unsupported(), + streamEvents: Stream.empty, + }; + const createWorktree = vi.fn(() => + Effect.succeed({ + worktree: { + branch: "t3code/bootstrap-branch", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + const runForThread = vi.fn(() => + Effect.succeed({ + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/tmp/bootstrap-worktree", + }), + ); + + server = await createTestServer({ + cwd: "/test", + providerLayer: Layer.succeed(ProviderService, providerService), + gitCore: { createWorktree }, + projectSetupScriptRunner: { runForThread }, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const workspaceRoot = makeTempDir("t3code-ws-bootstrap-project-"); + const createdAt = new Date().toISOString(); + const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "project.create", + commandId: "cmd-bootstrap-project-create", + projectId: "project-bootstrap", + title: "Bootstrap Project", + workspaceRoot, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }); + expect(createProjectResponse.error).toBeUndefined(); + + const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.turn.start", + commandId: "cmd-bootstrap-turn-start", + threadId: "thread-bootstrap", + message: { + messageId: "msg-bootstrap", + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: "project-bootstrap", + title: "Bootstrap Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: workspaceRoot, + baseBranch: "main", + branch: "t3code/bootstrap-branch", + }, + runSetupScript: true, + }, + createdAt, + }); + expect(startTurnResponse.error).toBeUndefined(); + expect(createWorktree).toHaveBeenCalledWith({ + cwd: workspaceRoot, + branch: "main", + newBranch: "t3code/bootstrap-branch", + path: null, + }); + expect(runForThread).toHaveBeenCalledWith({ + threadId: "thread-bootstrap", + projectId: "project-bootstrap", + projectCwd: workspaceRoot, + worktreePath: "/tmp/bootstrap-worktree", + }); + + const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); + expect(snapshotResponse.error).toBeUndefined(); + const snapshot = snapshotResponse.result as { threads: unknown[] }; + expect(snapshot.threads).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thread-bootstrap", + branch: "t3code/bootstrap-branch", + worktreePath: "/tmp/bootstrap-worktree", + messages: expect.arrayContaining([ + expect.objectContaining({ + id: "msg-bootstrap", + text: "hello", + }), + ]), + activities: expect.arrayContaining([ + expect.objectContaining({ + kind: "setup-script.requested", + }), + expect.objectContaining({ + kind: "setup-script.started", + }), + ]), + }), + ]), + ); + }); + it("routes terminal RPC methods and broadcasts terminal events", async () => { const cwd = makeTempDir("t3code-ws-terminal-cwd-"); const terminalManager = new MockTerminalManager(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 25f8158926..964bf72903 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -13,6 +13,7 @@ import Mime from "@effect/platform-node/Mime"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, type ClientOrchestrationCommand, type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, @@ -76,6 +77,7 @@ import { import { parseBase64DataUrl } from "./imageMime.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { expandHomePath } from "./os-jank.ts"; +import { ProjectSetupScriptRunner } from "./projectScripts/Services/ProjectSetupScriptRunner.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; @@ -216,6 +218,7 @@ export type ServerRuntimeServices = | GitManager | GitCore | TerminalManager + | ProjectSetupScriptRunner | Keybindings | ServerSettingsService | Open @@ -259,6 +262,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const keybindingsManager = yield* Keybindings; const serverSettingsManager = yield* ServerSettingsService; const providerRegistry = yield* ProviderRegistry; @@ -616,6 +620,176 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; const { openInEditor } = yield* Open; + const serverCommandId = (tag: string) => + CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); + + const appendSetupScriptActivity = (input: { + readonly threadId: ThreadId; + readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; + readonly summary: string; + readonly createdAt: string; + readonly payload: Record; + readonly tone: "info" | "error"; + }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("setup-script-activity"), + threadId: input.threadId, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + + const toBootstrapRouteRequestError = (error: unknown) => + Schema.is(RouteRequestError)(error) + ? error + : new RouteRequestError({ + message: + error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", + }); + + const dispatchBootstrapTurnStart = Effect.fnUntraced(function* ( + command: Extract, + ) { + const bootstrap = command.bootstrap; + if (!bootstrap) { + return yield* orchestrationEngine.dispatch(command); + } + + const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; + let createdThread = false; + let targetProjectId = bootstrap.createThread?.projectId; + let targetProjectCwd = bootstrap.prepareWorktree?.projectCwd; + let targetBranch = bootstrap.createThread?.branch ?? null; + let targetWorktreePath = bootstrap.createThread?.worktreePath ?? null; + + const cleanupCreatedThread = () => + createdThread + ? orchestrationEngine + .dispatch({ + type: "thread.delete", + commandId: serverCommandId("bootstrap-thread-delete"), + threadId: command.threadId, + }) + .pipe(Effect.ignoreCause({ log: true })) + : Effect.void; + + const runSetupProgram = () => + bootstrap.runSetupScript && targetWorktreePath + ? (() => { + const requestedAt = new Date().toISOString(); + return projectSetupScriptRunner + .runForThread({ + threadId: command.threadId, + ...(targetProjectId ? { projectId: targetProjectId } : {}), + ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), + worktreePath: targetWorktreePath, + }) + .pipe( + Effect.tap((setupResult) => { + if (setupResult.status !== "started") { + return Effect.void; + } + const payload = { + scriptId: setupResult.scriptId, + scriptName: setupResult.scriptName, + terminalId: setupResult.terminalId, + worktreePath: targetWorktreePath, + }; + return Effect.all([ + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: requestedAt, + payload, + tone: "info", + }), + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.started", + summary: "Setup script started", + createdAt: new Date().toISOString(), + payload, + tone: "info", + }), + ]).pipe(Effect.asVoid); + }), + Effect.catch((error) => + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.failed", + summary: "Setup script failed to start", + createdAt: requestedAt, + payload: { + detail: error instanceof Error ? error.message : "Unknown setup failure.", + worktreePath: targetWorktreePath, + }, + tone: "error", + }).pipe( + Effect.ignoreCause({ log: false }), + Effect.flatMap(() => Effect.fail(toBootstrapRouteRequestError(error))), + ), + ), + ); + })() + : Effect.void; + + const bootstrapProgram = Effect.gen(function* () { + if (bootstrap.createThread) { + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch: bootstrap.createThread.branch, + worktreePath: bootstrap.createThread.worktreePath, + createdAt: bootstrap.createThread.createdAt, + }); + createdThread = true; + } + + if (bootstrap.prepareWorktree) { + const worktree = yield* git.createWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + branch: bootstrap.prepareWorktree.baseBranch, + newBranch: bootstrap.prepareWorktree.branch, + path: null, + }); + targetProjectCwd = bootstrap.prepareWorktree.projectCwd; + targetBranch = worktree.worktree.branch; + targetWorktreePath = worktree.worktree.path; + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("bootstrap-thread-meta-update"), + threadId: command.threadId, + branch: targetBranch, + worktreePath: targetWorktreePath, + }); + } + + yield* runSetupProgram(); + + return yield* orchestrationEngine.dispatch(finalTurnStartCommand); + }).pipe(Effect.mapError(toBootstrapRouteRequestError)); + + return yield* bootstrapProgram.pipe( + Effect.catch((error) => + cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(error))), + ), + ); + }); const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -741,6 +915,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case ORCHESTRATION_WS_METHODS.dispatchCommand: { const { command } = request.body; const normalizedCommand = yield* normalizeDispatchCommand({ command }); + if (normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap) { + return yield* dispatchBootstrapTurnStart(normalizedCommand); + } return yield* orchestrationEngine.dispatch(normalizedCommand); } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0d612534e5..b55d1109b2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -30,6 +30,7 @@ import { import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; +import { useTerminalStateStore } from "../terminalStateStore"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; @@ -1382,7 +1383,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("runs setup scripts after preparing a pull request worktree thread", async () => { + it("lets the server own setup after preparing a pull request worktree thread", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { [THREAD_ID]: { @@ -1487,44 +1488,119 @@ describe("ChatView timeline estimator parity (full app)", () => { cwd: "/repo/project", reference: "1359", mode: "worktree", + threadId: THREAD_ID, }); }, { timeout: 8_000, interval: 16 }, ); + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", + ), + ).toBe(false); + } finally { + await mounted.cleanup(); + } + }); + + it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + }); + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + await vi.waitFor( () => { - const openRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalOpen && request.cwd === "/repo/worktrees/pr-1359", - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - threadId: expect.any(String), - cwd: "/repo/worktrees/pr-1359", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - T3CODE_WORKTREE_PATH: "/repo/worktrees/pr-1359", + const dispatchRequest = wsRequests.find( + (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, + ) as + | { + _tag: string; + command?: { + type?: string; + bootstrap?: { + createThread?: { projectId?: string }; + prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string }; + runSetupScript?: boolean; + }; + }; + } + | undefined; + expect(dispatchRequest?.command).toMatchObject({ + type: "thread.turn.start", + bootstrap: { + createThread: { + projectId: PROJECT_ID, + }, + prepareWorktree: { + projectCwd: "/repo/project", + baseBranch: "main", + branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), + }, + runSetupScript: true, }, }); }, { timeout: 8_000, interval: 16 }, ); - await vi.waitFor( - () => { - const writeRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", - ); - expect(writeRequest).toMatchObject({ - _tag: WS_METHODS.terminalWrite, - threadId: expect.any(String), - data: "bun install\r", - }); - }, - { timeout: 8_000, interval: 16 }, + expect(wsRequests.some((request) => request._tag === WS_METHODS.gitCreateWorktree)).toBe( + false, ); + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.terminalWrite && + request.threadId === THREAD_ID && + request.data === "bun install\r", + ), + ).toBe(false); } finally { await mounted.cleanup(); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..6dc1b50e31 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -24,10 +24,10 @@ import { import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { truncate } from "@t3tools/shared/String"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; @@ -110,7 +110,6 @@ import { projectScriptCwd, projectScriptRuntimeEnv, projectScriptIdFromCommand, - setupProjectScript, } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; @@ -239,10 +238,10 @@ interface ChatViewProps { threadId: ThreadId; } -interface PendingPullRequestSetupRequest { +interface TerminalLaunchContext { threadId: ThreadId; - worktreePath: string; - scriptId: string; + cwd: string; + worktreePath: string | null; } export default function ChatView({ threadId }: ChatViewProps) { @@ -251,7 +250,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const markThreadVisited = useStore((store) => store.markThreadVisited); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); - const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, @@ -264,7 +262,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); - const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; @@ -356,8 +353,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [pendingPullRequestSetupRequest, setPendingPullRequestSetupRequest] = - useState(null); + const [terminalLaunchContext, setTerminalLaunchContext] = useState( + null, + ); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); @@ -412,6 +410,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); + const storeServerTerminalLaunchContext = useTerminalStateStore( + (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, + ); + const storeClearTerminalLaunchContext = useTerminalStateStore( + (s) => s.clearTerminalLaunchContext, + ); const setPrompt = useCallback( (nextPrompt: string) => { @@ -574,24 +578,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const handlePreparedPullRequestThread = useCallback( async (input: { branch: string; worktreePath: string | null }) => { - const targetThreadId = await openOrReuseProjectDraftThread({ + await openOrReuseProjectDraftThread({ branch: input.branch, worktreePath: input.worktreePath, envMode: input.worktreePath ? "worktree" : "local", }); - const setupScript = - input.worktreePath && activeProject ? setupProjectScript(activeProject.scripts) : null; - if (targetThreadId && input.worktreePath && setupScript) { - setPendingPullRequestSetupRequest({ - threadId: targetThreadId, - worktreePath: input.worktreePath, - scriptId: setupScript.id, - }); - } else { - setPendingPullRequestSetupRequest(null); - } }, - [activeProject, openOrReuseProjectDraftThread], + [openOrReuseProjectDraftThread], ); useEffect(() => { @@ -1151,15 +1144,30 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; + const activeTerminalLaunchContext = + terminalLaunchContext?.threadId === activeThreadId + ? terminalLaunchContext + : (storeServerTerminalLaunchContext ?? null); + const setupTerminalWorktreeFallback = + activeTerminalLaunchContext && + activeTerminalLaunchContext.worktreePath === null && + terminalState.activeTerminalId.startsWith("setup-") + ? activeTerminalLaunchContext.cwd + : null; + const terminalDrawerWorktreePath = + activeTerminalLaunchContext?.worktreePath ?? + setupTerminalWorktreeFallback ?? + activeThreadWorktreePath; + const terminalDrawerCwd = activeTerminalLaunchContext?.cwd ?? gitCwd ?? activeProjectCwd; const threadTerminalRuntimeEnv = useMemo(() => { if (!activeProjectCwd) return {}; return projectScriptRuntimeEnv({ project: { cwd: activeProjectCwd, }, - worktreePath: activeThreadWorktreePath, + worktreePath: terminalDrawerWorktreePath, }); - }, [activeProjectCwd, activeThreadWorktreePath]); + }, [activeProjectCwd, terminalDrawerWorktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( @@ -1395,7 +1403,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; + const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; + setTerminalLaunchContext({ + threadId: activeThreadId, + cwd: targetCwd, + worktreePath: targetWorktreePath, + }); setTerminalOpen(true); if (shouldCreateNewTerminal) { storeNewTerminal(activeThreadId, targetTerminalId); @@ -1408,7 +1422,7 @@ export default function ChatView({ threadId }: ChatViewProps) { project: { cwd: activeProject.cwd, }, - worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, + worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); const openTerminalInput: Parameters[0] = shouldCreateNewTerminal @@ -1457,44 +1471,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ], ); - useEffect(() => { - if (!pendingPullRequestSetupRequest || !activeProject || !activeThreadId || !activeThread) { - return; - } - if (pendingPullRequestSetupRequest.threadId !== activeThreadId) { - return; - } - if (activeThread.worktreePath !== pendingPullRequestSetupRequest.worktreePath) { - return; - } - - const setupScript = - activeProject.scripts.find( - (script) => script.id === pendingPullRequestSetupRequest.scriptId, - ) ?? null; - setPendingPullRequestSetupRequest(null); - if (!setupScript) { - return; - } - - void runProjectScript(setupScript, { - cwd: pendingPullRequestSetupRequest.worktreePath, - worktreePath: pendingPullRequestSetupRequest.worktreePath, - rememberAsLastInvoked: false, - }).catch((error) => { - toastManager.add({ - type: "error", - title: "Failed to run setup script.", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }); - }, [ - activeProject, - activeThread, - activeThreadId, - pendingPullRequestSetupRequest, - runProjectScript, - ]); const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -2151,6 +2127,74 @@ export default function ChatView({ threadId }: ChatViewProps) { ? (draftThread?.envMode ?? "local") : "local"; + useEffect(() => { + if (!activeThreadId) { + setTerminalLaunchContext(null); + storeClearTerminalLaunchContext(threadId); + return; + } + setTerminalLaunchContext((current) => { + if (!current) return current; + if (current.threadId === activeThreadId) return current; + return null; + }); + }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); + + useEffect(() => { + if (!activeThreadId || !activeProjectCwd) { + return; + } + setTerminalLaunchContext((current) => { + if (!current || current.threadId !== activeThreadId) { + return current; + } + const settledCwd = projectScriptCwd({ + project: { cwd: activeProjectCwd }, + worktreePath: activeThreadWorktreePath, + }); + if ( + settledCwd === current.cwd && + (activeThreadWorktreePath ?? null) === current.worktreePath + ) { + storeClearTerminalLaunchContext(activeThreadId); + return null; + } + return current; + }); + }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]); + + useEffect(() => { + if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { + return; + } + const settledCwd = projectScriptCwd({ + project: { cwd: activeProjectCwd }, + worktreePath: activeThreadWorktreePath, + }); + if ( + settledCwd === storeServerTerminalLaunchContext.cwd && + (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath + ) { + storeClearTerminalLaunchContext(activeThreadId); + } + }, [ + activeProjectCwd, + activeThreadId, + activeThreadWorktreePath, + storeClearTerminalLaunchContext, + storeServerTerminalLaunchContext, + ]); + + useEffect(() => { + if (terminalState.terminalOpen) { + return; + } + if (activeThreadId) { + storeClearTerminalLaunchContext(activeThreadId); + } + setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); + }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]); + useEffect(() => { if (phase !== "running") return; const timer = window.setInterval(() => { @@ -2596,36 +2640,8 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(0); setComposerTrigger(null); - let createdServerThreadForLocalDraft = false; let turnStartSucceeded = false; - let nextThreadBranch = activeThread.branch; - let nextThreadWorktreePath = activeThread.worktreePath; await (async () => { - // On first message: lock in branch + create worktree if needed. - if (baseBranchForWorktree) { - beginSendPhase("preparing-worktree"); - const newBranch = buildTemporaryWorktreeBranchName(); - const result = await createWorktreeMutation.mutateAsync({ - cwd: activeProject.cwd, - branch: baseBranchForWorktree, - newBranch, - }); - nextThreadBranch = result.worktree.branch; - nextThreadWorktreePath = result.worktree.path; - if (isServerThread) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - branch: result.worktree.branch, - worktreePath: result.worktree.path, - }); - // Keep local thread state in sync immediately so terminal drawer opens - // with the worktree cwd/env instead of briefly using the project root. - setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); - } - } - let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { const firstComposerImage = composerImagesSnapshot[0]; @@ -2653,48 +2669,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), }; - if (isLocalDraftThread) { - await api.orchestration.dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId: threadIdForSend, - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, - branch: nextThreadBranch, - worktreePath: nextThreadWorktreePath, - createdAt: activeThread.createdAt, - }); - createdServerThreadForLocalDraft = true; - } - - let setupScript: ProjectScript | null = null; - if (baseBranchForWorktree) { - setupScript = setupProjectScript(activeProject.scripts); - } - if (setupScript) { - let shouldRunSetupScript = false; - if (isServerThread) { - shouldRunSetupScript = true; - } else { - if (createdServerThreadForLocalDraft) { - shouldRunSetupScript = true; - } - } - if (shouldRunSetupScript) { - const setupScriptOptions: Parameters[1] = { - worktreePath: nextThreadWorktreePath, - rememberAsLastInvoked: false, - }; - if (nextThreadWorktreePath) { - setupScriptOptions.cwd = nextThreadWorktreePath; - } - await runProjectScript(setupScript, setupScriptOptions); - } - } - // Auto-title from first message if (isFirstMessage && isServerThread) { await api.orchestration.dispatchCommand({ @@ -2717,6 +2691,35 @@ export default function ChatView({ threadId }: ChatViewProps) { beginSendPhase("sending-turn"); const turnAttachments = await turnAttachmentsPromise; + const bootstrap = + isLocalDraftThread || baseBranchForWorktree + ? { + ...(isLocalDraftThread + ? { + createThread: { + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + createdAt: activeThread.createdAt, + }, + } + : {}), + ...(baseBranchForWorktree + ? { + prepareWorktree: { + projectCwd: activeProject.cwd, + baseBranch: baseBranchForWorktree, + branch: buildTemporaryWorktreeBranchName(), + }, + runSetupScript: true, + } + : {}), + } + : undefined; await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), @@ -2731,19 +2734,11 @@ export default function ChatView({ threadId }: ChatViewProps) { titleSeed: title, runtimeMode, interactionMode, + ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }); turnStartSucceeded = true; })().catch(async (err: unknown) => { - if (createdServerThreadForLocalDraft && !turnStartSucceeded) { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadIdForSend, - }) - .catch(() => undefined); - } if ( !turnStartSucceeded && promptRef.current.length === 0 && @@ -4189,6 +4184,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { @@ -4231,7 +4227,7 @@ export default function ChatView({ threadId }: ChatViewProps) { void; @@ -32,6 +33,7 @@ interface PullRequestThreadDialogProps { export function PullRequestThreadDialog({ open, + threadId, cwd, initialReference, onOpenChange, @@ -130,6 +132,7 @@ export function PullRequestThreadDialog({ const result = await preparePullRequestThreadMutation.mutateAsync({ reference: parsedReference, mode, + ...(mode === "worktree" ? { threadId } : {}), }); await onPrepared({ branch: result.branch, @@ -147,6 +150,7 @@ export function PullRequestThreadDialog({ parsedReference, preparePullRequestThreadMutation, resolvedPullRequest, + threadId, ], ); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index cfa2c72f74..b7633c4e27 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,4 @@ -import { type GitStackedAction } from "@t3tools/contracts"; +import { type GitStackedAction, type ThreadId } from "@t3tools/contracts"; import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; @@ -202,13 +202,22 @@ export function gitPreparePullRequestThreadMutationOptions(input: { queryClient: QueryClient; }) { return mutationOptions({ - mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => { + mutationFn: async ({ + reference, + mode, + threadId, + }: { + reference: string; + mode: "local" | "worktree"; + threadId?: ThreadId; + }) => { const api = ensureNativeApi(); if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); return api.git.preparePullRequestThread({ cwd: input.cwd, reference, mode, + ...(threadId ? { threadId } : {}), }); }, mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index c11c3923bc..a1826971b0 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -4,6 +4,11 @@ import { type KeybindingCommand, type ProjectScript, } from "@t3tools/contracts"; +import { + projectScriptCwd as sharedProjectScriptCwd, + projectScriptRuntimeEnv as sharedProjectScriptRuntimeEnv, + setupProjectScript as sharedSetupProjectScript, +} from "@t3tools/shared/projectScripts"; import { Schema } from "effect"; function normalizeScriptId(value: string): string { @@ -69,22 +74,13 @@ export function projectScriptCwd(input: { }; worktreePath?: string | null; }): string { - return input.worktreePath ?? input.project.cwd; + return sharedProjectScriptCwd(input); } export function projectScriptRuntimeEnv( input: ProjectScriptRuntimeEnvInput, ): Record { - const env: Record = { - T3CODE_PROJECT_ROOT: input.project.cwd, - }; - if (input.worktreePath) { - env.T3CODE_WORKTREE_PATH = input.worktreePath; - } - if (input.extraEnv) { - return { ...env, ...input.extraEnv }; - } - return env; + return sharedProjectScriptRuntimeEnv(input); } export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { @@ -93,5 +89,5 @@ export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | } export function setupProjectScript(scripts: ProjectScript[]): ProjectScript | null { - return scripts.find((script) => script.runOnWorktreeCreate) ?? null; + return sharedSetupProjectScript(scripts); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e99ec50226..59308fad2b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -140,6 +140,8 @@ function EventRouter() { const removeOrphanedTerminalStates = useTerminalStateStore( (store) => store.removeOrphanedTerminalStates, ); + const ensureTerminal = useTerminalStateStore((store) => store.ensureTerminal); + const setTerminalLaunchContext = useTerminalStateStore((store) => store.setTerminalLaunchContext); const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useRouterState({ select: (state) => state.location.pathname }); @@ -221,6 +223,14 @@ function EventRouter() { domainEventFlushThrottler.maybeExecute(); }); const unsubTerminalEvent = api.terminal.onEvent((event) => { + if (event.type === "started" || event.type === "restarted") { + const threadId = ThreadId.makeUnsafe(event.threadId); + ensureTerminal(threadId, event.terminalId, { open: true, active: true }); + setTerminalLaunchContext(threadId, { + cwd: event.snapshot.cwd, + worktreePath: event.terminalId.startsWith("setup-") ? event.snapshot.cwd : null, + }); + } const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); if (hasRunningSubprocess === null) { return; @@ -327,10 +337,12 @@ function EventRouter() { unsubProvidersUpdated(); }; }, [ + ensureTerminal, navigate, queryClient, removeOrphanedTerminalStates, setProjectExpanded, + setTerminalLaunchContext, syncServerReadModel, ]); diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index d618275682..4e8b84963b 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -8,7 +8,10 @@ const THREAD_ID = ThreadId.makeUnsafe("thread-1"); describe("terminalStateStore actions", () => { beforeEach(() => { useTerminalStateStore.persist.clearStorage(); - useTerminalStateStore.setState({ terminalStateByThreadId: {} }); + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + terminalLaunchContextByThreadId: {}, + }); }); it("returns a closed default terminal state for unknown threads", () => { @@ -82,6 +85,23 @@ describe("terminalStateStore actions", () => { ]); }); + it("ensures unknown server terminals are registered, opened, and activated", () => { + const store = useTerminalStateStore.getState(); + store.ensureTerminal(THREAD_ID, "setup-setup", { open: true, active: true }); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + expect(terminalState.terminalOpen).toBe(true); + expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]); + expect(terminalState.activeTerminalId).toBe("setup-setup"); + expect(terminalState.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default"] }, + { id: "group-setup-setup", terminalIds: ["setup-setup"] }, + ]); + }); + it("allows unlimited groups while keeping each group capped at four terminals", () => { const store = useTerminalStateStore.getState(); store.splitTerminal(THREAD_ID, "terminal-2"); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 4f51e2ed8d..369c18631f 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -26,6 +26,11 @@ interface ThreadTerminalState { activeTerminalGroupId: string; } +export interface ThreadTerminalLaunchContext { + cwd: string; + worktreePath: string | null; +} + const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; function createTerminalStateStorage() { @@ -473,12 +478,20 @@ function updateTerminalStateByThreadId( interface TerminalStateStoreState { terminalStateByThreadId: Record; + terminalLaunchContextByThreadId: Record; setTerminalOpen: (threadId: ThreadId, open: boolean) => void; setTerminalHeight: (threadId: ThreadId, height: number) => void; splitTerminal: (threadId: ThreadId, terminalId: string) => void; newTerminal: (threadId: ThreadId, terminalId: string) => void; + ensureTerminal: ( + threadId: ThreadId, + terminalId: string, + options?: { open?: boolean; active?: boolean }, + ) => void; setActiveTerminal: (threadId: ThreadId, terminalId: string) => void; closeTerminal: (threadId: ThreadId, terminalId: string) => void; + setTerminalLaunchContext: (threadId: ThreadId, context: ThreadTerminalLaunchContext) => void; + clearTerminalLaunchContext: (threadId: ThreadId) => void; setTerminalActivity: ( threadId: ThreadId, terminalId: string, @@ -512,6 +525,7 @@ export const useTerminalStateStore = create()( return { terminalStateByThreadId: {}, + terminalLaunchContextByThreadId: {}, setTerminalOpen: (threadId, open) => updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), setTerminalHeight: (threadId, height) => @@ -520,27 +534,92 @@ export const useTerminalStateStore = create()( updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)), newTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)), + ensureTerminal: (threadId, terminalId, options) => + updateTerminal(threadId, (state) => { + let nextState = state; + if (!state.terminalIds.includes(terminalId)) { + nextState = newThreadTerminal(nextState, terminalId); + } + if (options?.active === false) { + nextState = { + ...nextState, + activeTerminalId: state.activeTerminalId, + activeTerminalGroupId: state.activeTerminalGroupId, + }; + } + if (options?.active ?? true) { + nextState = setThreadActiveTerminal(nextState, terminalId); + } + if (options?.open) { + nextState = setThreadTerminalOpen(nextState, true); + } + return normalizeThreadTerminalState(nextState); + }), setActiveTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)), closeTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)), + setTerminalLaunchContext: (threadId, context) => + set((state) => ({ + terminalLaunchContextByThreadId: { + ...state.terminalLaunchContextByThreadId, + [threadId]: context, + }, + })), + clearTerminalLaunchContext: (threadId) => + set((state) => { + if (!state.terminalLaunchContextByThreadId[threadId]) { + return state; + } + const { [threadId]: _removed, ...rest } = state.terminalLaunchContextByThreadId; + return { terminalLaunchContextByThreadId: rest }; + }), setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => updateTerminal(threadId, (state) => setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), ), clearTerminalState: (threadId) => - updateTerminal(threadId, () => createDefaultThreadTerminalState()), + set((state) => { + const nextTerminalStateByThreadId = updateTerminalStateByThreadId( + state.terminalStateByThreadId, + threadId, + () => createDefaultThreadTerminalState(), + ); + const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; + const { [threadId]: _removed, ...remainingLaunchContexts } = + state.terminalLaunchContextByThreadId; + if ( + nextTerminalStateByThreadId === state.terminalStateByThreadId && + !hadLaunchContext + ) { + return state; + } + return { + terminalStateByThreadId: nextTerminalStateByThreadId, + terminalLaunchContextByThreadId: remainingLaunchContexts, + }; + }), removeOrphanedTerminalStates: (activeThreadIds) => set((state) => { const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( (id) => !activeThreadIds.has(id as ThreadId), ); - if (orphanedIds.length === 0) return state; + const orphanedLaunchContextIds = Object.keys( + state.terminalLaunchContextByThreadId, + ).filter((id) => !activeThreadIds.has(id as ThreadId)); + if (orphanedIds.length === 0 && orphanedLaunchContextIds.length === 0) return state; const next = { ...state.terminalStateByThreadId }; for (const id of orphanedIds) { delete next[id as ThreadId]; } - return { terminalStateByThreadId: next }; + const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; + for (const id of orphanedLaunchContextIds) { + delete nextLaunchContexts[id as ThreadId]; + } + return { + terminalStateByThreadId: next, + terminalLaunchContextByThreadId: nextLaunchContexts, + }; }), }; }, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index f8b65abf2c..04144c7890 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -105,6 +105,7 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, reference: GitPullRequestReference, mode: GitPreparePullRequestThreadMode, + threadId: Schema.optional(ThreadId), }); export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 06bb35038d..53e84f1b98 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -195,6 +195,47 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( }), ); +it.effect("accepts bootstrap metadata in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-bootstrap", + threadId: "thread-1", + message: { + messageId: "msg-bootstrap", + role: "user", + text: "hello", + attachments: [], + }, + bootstrap: { + createThread: { + projectId: "project-1", + title: "Bootstrap thread", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }, + prepareWorktree: { + projectCwd: "/tmp/workspace", + baseBranch: "main", + branch: "t3code/example", + }, + runSetupScript: true, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); + assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); + assert.strictEqual(parsed.bootstrap?.runSetupScript, true); + }), +); + it.effect("decodes thread.created runtime mode for historical events", () => Effect.gen(function* () { const parsed = yield* decodeThreadCreatedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a780a55c78..45ade09c47 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -388,6 +388,31 @@ const ThreadInteractionModeSetCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + createdAt: IsoDateTime, +}); + +const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ + projectCwd: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, + branch: Schema.optional(TrimmedNonEmptyString), +}); + +const ThreadTurnStartBootstrap = Schema.Struct({ + createThread: Schema.optional(ThreadTurnStartBootstrapCreateThread), + prepareWorktree: Schema.optional(ThreadTurnStartBootstrapPrepareWorktree), + runSetupScript: Schema.optional(Schema.Boolean), +}); + +export type ThreadTurnStartBootstrap = typeof ThreadTurnStartBootstrap.Type; + export const ThreadTurnStartCommand = Schema.Struct({ type: Schema.Literal("thread.turn.start"), commandId: CommandId, @@ -404,6 +429,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), ), + bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), createdAt: IsoDateTime, }); @@ -422,6 +448,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 0d8d4dbec2..69b9202d0b 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -67,9 +67,13 @@ it.effect("accepts git.preparePullRequestThread requests", () => cwd: "/repo", reference: "#42", mode: "worktree", + threadId: "thread-1", }, }); assert.strictEqual(parsed.body._tag, WS_METHODS.gitPreparePullRequestThread); + if (parsed.body._tag === WS_METHODS.gitPreparePullRequestThread) { + assert.strictEqual(parsed.body.threadId, "thread-1"); + } }), ); diff --git a/packages/shared/package.json b/packages/shared/package.json index b35d23ef15..ca2b41eff4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -43,6 +43,10 @@ "./String": { "types": "./src/String.ts", "import": "./src/String.ts" + }, + "./projectScripts": { + "types": "./src/projectScripts.ts", + "import": "./src/projectScripts.ts" } }, "scripts": { diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts new file mode 100644 index 0000000000..199a55bf3c --- /dev/null +++ b/packages/shared/src/projectScripts.ts @@ -0,0 +1,37 @@ +import type { ProjectScript } from "@t3tools/contracts"; + +interface ProjectScriptRuntimeEnvInput { + project: { + cwd: string; + }; + worktreePath?: string | null; + extraEnv?: Record; +} + +export function projectScriptCwd(input: { + project: { + cwd: string; + }; + worktreePath?: string | null; +}): string { + return input.worktreePath ?? input.project.cwd; +} + +export function projectScriptRuntimeEnv( + input: ProjectScriptRuntimeEnvInput, +): Record { + const env: Record = { + T3CODE_PROJECT_ROOT: input.project.cwd, + }; + if (input.worktreePath) { + env.T3CODE_WORKTREE_PATH = input.worktreePath; + } + if (input.extraEnv) { + return { ...env, ...input.extraEnv }; + } + return env; +} + +export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null { + return scripts.find((script) => script.runOnWorktreeCreate) ?? null; +}