diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts new file mode 100644 index 0000000000000..c295a06ac39ac --- /dev/null +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -0,0 +1,168 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import { ProjectId, type TerminalEvent, type TerminalSessionSnapshot } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import { TestClock } from "effect/testing"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as ProjectService from "./ProjectService.ts"; +import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; + +const threadId = "thread-1"; +const terminalId = "setup-setup"; +const event = { threadId, terminalId, sequence: 1, createdAt: "2026-01-01T00:00:00.000Z" }; + +const openSnapshot = (history: string): TerminalSessionSnapshot => ({ + threadId, + terminalId, + cwd: "/repo/.t3/worktrees/feature", + worktreePath: null, + status: "running", + pid: 123, + history, + exitCode: null, + exitSignal: null, + label: "zsh", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +const makeHarness = Effect.fn(function* (options: { + readonly history?: string; + readonly opening?: Deferred.Deferred; +}) { + const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); + const writes = yield* Queue.unbounded(); + const outputLines = yield* Queue.unbounded(); + const terminals = Layer.mock(TerminalManager.TerminalManager)({ + open: () => + (options.opening + ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void + ).pipe(Effect.as(openSnapshot(options.history ?? ""))), + subscribe: (listener) => + Effect.sync(() => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + write: (input) => Queue.offer(writes, input.data).pipe(Effect.asVoid), + closeIdle: () => Effect.void, + }); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + terminals, + Layer.mock(ProjectService.ProjectService)({}), + ServerSettings.layerTest(), + NodeCrypto.layer, + ), + ), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }), + ); + const start = runner.runForThread({ + threadId, + worktreePath: "/repo/.t3/worktrees/feature", + observeCompletion: { onOutputLine: (line) => Queue.offer(outputLines, line) }, + project: { + id: ProjectId.make("project-1"), + workspaceRoot: "/repo", + scripts: [ + { + id: "setup", + name: "Setup", + command: "vp i", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }, + }); + const emit = (terminalEvent: TerminalEvent) => + Effect.forEach([...listeners], (listener) => listener(terminalEvent), { discard: true }); + return { start, listeners, writes, outputLines, emit }; +}); + +const setup = Effect.fn(function* (options: { readonly history?: string } = {}) { + const harness = yield* makeHarness(options); + const result = yield* harness.start; + const completion = result.status === "started" ? result.completion : undefined; + if (!completion) return yield* Effect.die("setup did not start an observed run"); + return { ...harness, result, completion }; +}); + +describe("ProjectSetupScriptRunner", () => { + it.effect("shows a startup prompt and types only once the line editor starts", () => + Effect.gen(function* () { + const { result, writes, outputLines, emit } = yield* setup(); + expect(result).toMatchObject({ status: "started", terminalId }); + + yield* emit({ ...event, type: "output", data: "Missing Node.\r\nInstall it? [y/N]: " }); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + expect(yield* Queue.takeAll(outputLines)).toEqual(["Missing Node.", "Install it? [y/N]:"]); + + yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" }); + yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" }); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + + it.effect("types into a quiet shell that has no line editor", () => + Effect.gen(function* () { + const { writes } = yield* setup(); + yield* TestClock.adjust("3 seconds"); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + + it.effect("ignores line editor state restored from an earlier shell", () => + Effect.gen(function* () { + const { writes, emit } = yield* setup({ history: "old run\r\n% \x1b[?2004h" }); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + }), + ); + + it.effect("forgets a busy startup child when the shell restarts", () => + Effect.gen(function* () { + const { writes, emit } = yield* setup(); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + yield* emit({ ...event, type: "restarted", snapshot: openSnapshot("") }); + // The restart lands inside a quiet window, so the next full window decides. + yield* TestClock.adjust("6 seconds"); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + + it.effect("drops its terminal listeners when cancelled while the terminal opens", () => + Effect.gen(function* () { + const opening = yield* Deferred.make(); + const { start, listeners } = yield* makeHarness({ opening }); + const fiber = yield* Effect.forkChild(start); + yield* Deferred.await(opening); + expect(listeners.size).toBe(2); + yield* Fiber.interrupt(fiber); + expect(listeners.size).toBe(0); + }), + ); + + it.effect("does not type into a shell that exits during startup", () => + Effect.gen(function* () { + const { completion, writes, emit } = yield* setup(); + yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); + expect(yield* completion).toMatchObject({ exitCode: null }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + }), + ); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index da8bb9a6c56a9..a2f8e0fc8e201 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -74,7 +74,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedError off; +} + type CompletionShell = "posix" | "fish" | "powershell"; function resolveCompletionShell( @@ -176,6 +188,7 @@ export const make = Effect.gen(function* () { const terminalManager = yield* TerminalManager.TerminalManager; const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; + const serviceScope = yield* Effect.scope; const completionShell = resolveCompletionShell( yield* HostProcess.Platform, yield* HostProcess.Environment, @@ -263,7 +276,67 @@ export const make = Effect.gen(function* () { Effect.tap(() => Deferred.await(promptReturned).pipe(Effect.timeoutOption("1 second"))), Effect.ensuring(Effect.sync(() => unsubscribe())), ); - return { completion, unsubscribe }; + // A startup prompt waits for input on a line that has no newline yet. + const flushPartialLine = Effect.suspend(() => { + const partial = lineBuffer; + lineBuffer = ""; + return handleLine(partial); + }); + const abandon = settle(null).pipe( + Effect.andThen(Deferred.succeed(promptReturned, undefined)), + ); + return { completion, unsubscribe, flushPartialLine, abandon }; + }, + ); + + // Input typed during shell startup can be read by an rc-file prompt (fnm, nvm) instead of the + // shell, which loses the command. Subscribe before the terminal opens so no activity is missed. + const watchShellReadiness = Effect.fn("ProjectSetupScriptRunner.watchShellReadiness")( + function* (input: { + readonly threadId: string; + readonly terminalId: string; + readonly onStartupPrompt: Effect.Effect; + }) { + const outcome = yield* Deferred.make<"ready" | "exited">(); + let reading = false; + let carry = ""; + let busy = false; + let activity = 0; + const unsubscribe = yield* terminalManager.subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + activity += 1; + // A new shell process starts idle; the manager resets its subprocess state without an event. + if (event.type === "started" || event.type === "restarted") { + busy = false; + reading = false; + carry = ""; + } + if (event.type === "activity") busy = event.hasRunningSubprocess; + if (event.type === "output") { + const text = carry + event.data; + carry = text.slice(-(BRACKETED_PASTE_ON.length - 1)); + reading = lineEditorReading(text, reading); + } + if (event.type === "exited" || event.type === "closed") { + return Deferred.succeed(outcome, "exited").pipe(Effect.asVoid); + } + return reading ? Deferred.succeed(outcome, "ready").pipe(Effect.asVoid) : Effect.void; + }); + // A startup prompt runs as a child process, so a busy shell keeps waiting for the user. + const quiet = Effect.gen(function* () { + while (true) { + const seen = activity; + yield* Effect.sleep(SHELL_QUIET_FALLBACK); + if (activity !== seen) continue; + if (!busy) return "ready" as const; + yield* input.onStartupPrompt; + } + }); + // Restored history can hold a previous shell's line editor state, so only live output counts. + const wait = Deferred.await(outcome).pipe(Effect.raceFirst(quiet)); + return { wait, unsubscribe }; }, ); @@ -361,25 +434,7 @@ export const make = Effect.gen(function* () { ) : script.command; - yield* terminalManager - .open({ - threadId: input.threadId, - terminalId, - cwd, - worktreePath: input.worktreePath, - env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "openTerminal", - cause, - }), - ), - ); - // Subscribe before writing so the sentinel cannot race past the listener. + // Subscribe before opening so neither startup activity nor the sentinel can race the listeners. const observed = observe && completionToken ? yield* observeTerminalCompletion({ @@ -391,25 +446,63 @@ export const make = Effect.gen(function* () { onOutputLine: observe.onOutputLine, }) : undefined; + // PowerShell has no line-editor signal; keep its immediate write. + const readiness = + completionShell === "powershell" + ? undefined + : yield* watchShellReadiness({ + threadId: input.threadId, + terminalId, + onStartupPrompt: observed?.flushPartialLine ?? Effect.void, + }); yield* terminalManager - .write({ + .open({ threadId: input.threadId, terminalId, - data: projectScriptTerminalInput(commandLine), + cwd, + worktreePath: input.worktreePath, + env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, }) .pipe( Effect.mapError( (cause) => new ProjectSetupScriptOperationError({ ...errorContext, - operation: "writeCommand", + operation: "openTerminal", cause, }), ), - Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), + Effect.onError(() => + Effect.sync(() => { + observed?.unsubscribe(); + readiness?.unsubscribe(); + }), + ), ); + // Return once the terminal exists so callers can show it while a startup prompt waits. + yield* (readiness?.wait ?? Effect.succeed("ready" as const)).pipe( + Effect.flatMap((shell) => + shell === "exited" + ? Effect.void + : terminalManager.write({ + threadId: input.threadId, + terminalId, + data: projectScriptTerminalInput(commandLine), + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("project script command was not written", { + ...errorContext, + terminalId, + cause, + }).pipe(Effect.andThen(observed?.abandon ?? Effect.void)), + ), + Effect.ensuring(Effect.sync(() => readiness?.unsubscribe())), + Effect.forkIn(serviceScope), + ); + const completion = observed?.completion.pipe( Effect.tap(({ exitCode }) => exitCode === 0