From a6e9faf7b944f9f04eab7df99981a9422da534f9 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira Date: Thu, 22 Oct 2026 12:40:22 +0100 Subject: [PATCH 1/5] fix(server): setup scripts follow terminal shell fallbacks --- .../src/project/ProjectSetupScriptRunner.ts | 55 +++++---- apps/server/src/terminal/Manager.test.ts | 110 ++++++++++++++++++ apps/server/src/terminal/Manager.ts | 19 ++- 3 files changed, 162 insertions(+), 22 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 6799be186d1a4..961350c72a949 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,4 @@ import { ProjectId, type ProjectScript } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { projectScriptRuntimeEnv, projectScriptTerminalInput, @@ -99,9 +98,19 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedError

()( + "ProjectSetupScriptUnsupportedShellError", + { shell: Schema.String }, +) { + override get message(): string { + return "Project script completion is not supported in cmd.exe. Install PowerShell and retry."; + } +} + export const ProjectSetupScriptRunnerError = Schema.Union([ ProjectSetupScriptOperationError, ProjectSetupScriptProjectNotFoundError, + ProjectSetupScriptUnsupportedShellError, ]); export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type; @@ -146,13 +155,9 @@ function stripTerminalControl(text: string): string { type CompletionShell = "posix" | "fish" | "powershell"; -function resolveCompletionShell( - platform: NodeJS.Platform, - env: NodeJS.ProcessEnv, -): CompletionShell { - if (platform === "win32") return "powershell"; - const shell = env.SHELL ?? ""; - const name = shell.split("/").at(-1) ?? shell; +function resolveCompletionShell(shell: string): CompletionShell | null { + const name = (shell.split(/[/\\]/).at(-1) ?? shell).toLowerCase().replace(/\.exe$/, ""); + if (name === "cmd") return null; if (name === "fish") return "fish"; if (name === "pwsh" || name === "powershell") return "powershell"; return "posix"; @@ -186,10 +191,6 @@ export const make = Effect.gen(function* () { const terminalManager = yield* TerminalManager.TerminalManager; const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; - const completionShell = resolveCompletionShell( - yield* HostProcess.Platform, - yield* HostProcess.Environment, - ); const observeTerminalCompletion = Effect.fn("ProjectSetupScriptRunner.observeTerminalCompletion")( function* (input: { @@ -368,15 +369,6 @@ export const make = Effect.gen(function* () { const completionToken = observe ? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)).replaceAll("-", "") : null; - const commandLine = - observe && completionToken - ? wrapCommandForCompletion( - script.command, - completionShell, - completionSentinel(completionToken), - ) - : script.command; - yield* terminalManager .open({ threadId: input.threadId, @@ -395,6 +387,27 @@ export const make = Effect.gen(function* () { }), ), ); + let commandLine = script.command; + if (observe && completionToken) { + const shell = yield* terminalManager.getShell({ threadId: input.threadId, terminalId }).pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); + const completionShell = resolveCompletionShell(shell); + if (completionShell === null) + return yield* new ProjectSetupScriptUnsupportedShellError({ shell }); + commandLine = wrapCommandForCompletion( + script.command, + completionShell, + completionSentinel(completionToken), + ); + } // Subscribe before writing so the sentinel cannot race past the listener. const observed = observe && completionToken diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 413436ea452c5..41698044b7bf0 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -8,6 +8,7 @@ import { type TerminalRestartInput, ProviderDriverKind, ProviderInstanceId, + ProjectId, TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -32,6 +33,8 @@ import * as ServerConfig from "../config.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as TerminalManager from "./Manager.ts"; import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; @@ -925,6 +928,64 @@ it.layer( }), ); + it.effect.each(["setup", "settle"] as const)( + "observes %s completion in the fallback shell instead of the configured fish shell", + (trigger) => + Effect.gen(function* () { + const env = { SHELL: "/missing/fish" }; + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { env }).pipe( + Effect.provide(layerWithHostPlatform("linux")), + ); + ptyAdapter.spawnFailures.push(new Error("spawn fish ENOENT")); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, manager), + Layer.mock(ProjectService.ProjectService)({}), + ServerSettings.layerTest(), + Layer.succeed(HostProcess.Platform, "linux"), + Layer.succeed(HostProcess.Environment, env), + ), + ), + ); + const result = yield* runner.runForThread({ + threadId: "thread-setup-fallback", + worktreePath: baseDir, + trigger, + project: { + id: ProjectId.make("project-setup-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + runOnSettle: true, + }, + ], + }, + observeCompletion: {}, + }); + expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ + "/missing/fish", + "/bin/zsh", + ]); + const process = ptyAdapter.processes[0]!; + const command = process.writes[0]!; + expect(command).toContain('"$?"'); + expect(command).not.toContain("$status"); + const sentinel = /__T3_SETUP_DONE__[a-zA-Z0-9_]+:/.exec(command)?.[0]; + assert.isDefined(sentinel); + assert.equal(result.status, "started"); + if (result.status !== "started") return; + assert.isDefined(result.completion); + process.emitData(`setup-ready\r\n${sentinel}0\r\nprompt> `); + expect((yield* result.completion!).exitCode).toBe(0); + }), + ); + it.effect("preserves Windows Path casing when appending managed ACP binaries", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1015,6 +1076,55 @@ it.layer( ]); expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); + expect(yield* manager.getShell(openInput())).toBe( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + }), + ); + + it.effect("rejects cmd fallback before writing an unsupported completion wrapper", () => + Effect.gen(function* () { + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { + env: { SystemRoot: "C:\\Windows" }, + }).pipe(Effect.provide(layerWithHostPlatform("win32"))); + ptyAdapter.spawnFailures.push( + new Error("spawn pwsh ENOENT"), + new Error("spawn WindowsPowerShell ENOENT"), + new Error("spawn powershell ENOENT"), + ); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, manager), + Layer.mock(ProjectService.ProjectService)({}), + ServerSettings.layerTest(), + ), + ), + ); + const error = yield* runner + .runForThread({ + threadId: "thread-cmd-fallback", + worktreePath: baseDir, + project: { + id: ProjectId.make("project-cmd-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }, + observeCompletion: {}, + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("ProjectSetupScriptUnsupportedShellError"); + expect(ptyAdapter.spawnInputs.at(-1)?.shell).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(ptyAdapter.processes[0]?.writes).toEqual([]); }), ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index a75f6efccf050..ea38b777c9dcf 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -165,6 +165,8 @@ export class TerminalManager extends Context.Service< input: TerminalOpenInput, ) => Effect.Effect; + readonly getShell: (input: TerminalObserveInput) => Effect.Effect; + readonly attachStream: ( input: TerminalAttachInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, @@ -278,6 +280,7 @@ interface TerminalSessionState { cols: number; rows: number; process: PtyAdapter.PtyProcess | null; + shell: string | null; unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; @@ -2225,7 +2228,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func index = 0, lastError: PtyAdapter.PtySpawnError | null = null, ): Effect.fn.Return< - { process: PtyAdapter.PtyProcess; shellLabel: string }, + { process: PtyAdapter.PtyProcess; shell: string; shellLabel: string }, PtyAdapter.PtySpawnError > { if (index >= shellCandidates.length) { @@ -2268,6 +2271,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (attempt._tag === "Success") { return { process: attempt.success, + shell: candidate.shell, shellLabel: formatShellCandidate(candidate), }; } @@ -2296,6 +2300,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const startingAt = yield* nowIso; yield* modifyManagerState((state) => { session.status = "starting"; + session.shell = null; session.cwd = input.cwd; session.worktreePath = input.worktreePath ?? null; session.cols = input.cols; @@ -2372,6 +2377,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }; yield* modifyManagerState((state) => { session.process = ptyProcess; + session.shell = spawnResult.shell; session.pid = processPid; session.status = "running"; // onExit may replay an exit immediately; accept it before subscribing. @@ -2756,6 +2762,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols, rows, process: null, + shell: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, @@ -2862,6 +2869,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); + const getShell = Effect.fn("terminal.getShell")(function* (input: TerminalObserveInput) { + const session = yield* requireSession(input.threadId, input.terminalId); + if (session.status !== "running" || session.shell === null) { + return yield* new TerminalNotRunningError(input); + } + return session.shell; + }); + const openOrAttachForStream = Effect.fnUntraced(function* (input: TerminalAttachInput) { const terminalId = input.terminalId; const existing = yield* getSession(input.threadId, terminalId); @@ -3241,6 +3256,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols, rows, process: null, + shell: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, @@ -3365,6 +3381,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, + getShell, attachStream, observeStream, resyncStream, From 91ea2980a7c199235fc9ee537e3f17ced90d2010 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira Date: Thu, 22 Oct 2026 12:49:41 +0100 Subject: [PATCH 2/5] fix(server): close idle unsupported setup terminals --- .../src/project/ProjectSetupScriptRunner.ts | 4 +- apps/server/src/terminal/Manager.test.ts | 95 ++++++++++--------- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 961350c72a949..b78a946360282 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -400,8 +400,10 @@ export const make = Effect.gen(function* () { ), ); const completionShell = resolveCompletionShell(shell); - if (completionShell === null) + if (completionShell === null) { + yield* terminalManager.closeIdle({ threadId: input.threadId, terminalId }); return yield* new ProjectSetupScriptUnsupportedShellError({ shell }); + } commandLine = wrapCommandForCompletion( script.command, completionShell, diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 41698044b7bf0..1b293cb087567 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1082,50 +1082,59 @@ it.layer( }), ); - it.effect("rejects cmd fallback before writing an unsupported completion wrapper", () => - Effect.gen(function* () { - const { manager, ptyAdapter, baseDir } = yield* createManager(5, { - env: { SystemRoot: "C:\\Windows" }, - }).pipe(Effect.provide(layerWithHostPlatform("win32"))); - ptyAdapter.spawnFailures.push( - new Error("spawn pwsh ENOENT"), - new Error("spawn WindowsPowerShell ENOENT"), - new Error("spawn powershell ENOENT"), - ); - const runner = yield* ProjectSetupScriptRunner.make.pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(TerminalManager.TerminalManager, manager), - Layer.mock(ProjectService.ProjectService)({}), - ServerSettings.layerTest(), + it.effect.each([false, true])( + "rejects cmd fallback and closes only an idle terminal (busy: %s)", + (hasRunningSubprocess) => + Effect.gen(function* () { + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { + env: { SystemRoot: "C:\\Windows" }, + subprocessInspector: () => + Effect.succeed({ + hasRunningSubprocess, + childCommand: hasRunningSubprocess ? "node" : null, + processIds: [], + }), + }).pipe(Effect.provide(layerWithHostPlatform("win32"))); + ptyAdapter.spawnFailures.push( + new Error("spawn pwsh ENOENT"), + new Error("spawn WindowsPowerShell ENOENT"), + new Error("spawn powershell ENOENT"), + ); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, manager), + Layer.mock(ProjectService.ProjectService)({}), + ServerSettings.layerTest(), + ), ), - ), - ); - const error = yield* runner - .runForThread({ - threadId: "thread-cmd-fallback", - worktreePath: baseDir, - project: { - id: ProjectId.make("project-cmd-fallback"), - workspaceRoot: baseDir, - scripts: [ - { - id: "setup", - name: "Setup", - command: "echo setup-ready", - icon: "play", - runOnWorktreeCreate: true, - }, - ], - }, - observeCompletion: {}, - }) - .pipe(Effect.flip); - - expect(error._tag).toBe("ProjectSetupScriptUnsupportedShellError"); - expect(ptyAdapter.spawnInputs.at(-1)?.shell).toBe("C:\\Windows\\System32\\cmd.exe"); - expect(ptyAdapter.processes[0]?.writes).toEqual([]); - }), + ); + const error = yield* runner + .runForThread({ + threadId: "thread-cmd-fallback", + worktreePath: baseDir, + project: { + id: ProjectId.make("project-cmd-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }, + observeCompletion: {}, + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("ProjectSetupScriptUnsupportedShellError"); + expect(ptyAdapter.spawnInputs.at(-1)?.shell).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(ptyAdapter.processes[0]?.writes).toEqual([]); + expect(ptyAdapter.processes[0]?.killed).toBe(!hasRunningSubprocess); + }), ); it.effect("turns off fish terminal queries unless the user chose query-term", () => From 415a18359aa66ae07401f05350a33edc0baa32fc Mon Sep 17 00:00:00 2001 From: Afonso Ferreira Date: Sun, 25 Oct 2026 13:13:14 +0000 Subject: [PATCH 3/5] test(server): trim redundant shell fallback coverage --- .../project/ProjectSetupScriptRunner.test.ts | 35 +---- apps/server/src/terminal/Manager.test.ts | 127 ++++++++---------- 2 files changed, 57 insertions(+), 105 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 1dbace55c20a2..ce49c7c3f628a 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -35,30 +35,16 @@ const makeHarness = Effect.fn(function* (options: { readonly history?: string; readonly opening?: Deferred.Deferred; readonly shell?: string; - readonly openOutput?: string; }) { const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const writes = yield* Queue.unbounded(); const outputLines = yield* Queue.unbounded(); - const emit = (terminalEvent: TerminalEvent) => - Effect.forEach([...listeners], (listener) => listener(terminalEvent), { discard: true }); const terminals = Layer.mock(TerminalManager.TerminalManager)({ open: () => (options.opening ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void - ).pipe( - Effect.andThen( - options.openOutput === undefined - ? Effect.void - : emit({ - ...event, - type: "output", - data: options.openOutput, - }), - ), - Effect.as(openSnapshot(options.history ?? "")), - ), + ).pipe(Effect.as(openSnapshot(options.history ?? ""))), getShell: () => Effect.succeed(options.shell ?? "/bin/zsh"), subscribe: (listener) => Effect.sync(() => { @@ -96,6 +82,8 @@ const makeHarness = Effect.fn(function* (options: { ], }, }); + const emit = (terminalEvent: TerminalEvent) => + Effect.forEach([...listeners], (listener) => listener(terminalEvent), { discard: true }); return { start, listeners, writes, outputLines, emit }; }); @@ -103,7 +91,6 @@ const setup = Effect.fn(function* ( options: { readonly history?: string; readonly shell?: string; - readonly openOutput?: string; } = {}, ) { const harness = yield* makeHarness(options); @@ -114,13 +101,6 @@ const setup = Effect.fn(function* ( }); describe("ProjectSetupScriptRunner", () => { - it.effect("keeps a line-editor signal emitted while the fallback shell opens", () => - Effect.gen(function* () { - const { writes } = yield* setup({ openOutput: "% \x1b[?2004h" }); - expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); - }), - ); - it.effect("writes immediately when the spawned fallback is PowerShell", () => Effect.gen(function* () { const { writes } = yield* setup({ @@ -130,15 +110,6 @@ describe("ProjectSetupScriptRunner", () => { }), ); - it.effect("drops startup listeners when the fallback shell is unsupported", () => - Effect.gen(function* () { - const { start, listeners } = yield* makeHarness({ shell: "cmd.exe" }); - const error = yield* start.pipe(Effect.flip); - expect(error._tag).toBe("ProjectSetupScriptUnsupportedShellError"); - expect(listeners.size).toBe(0); - }), - ); - it.effect("shows a startup prompt and types only once the line editor starts", () => Effect.gen(function* () { const { result, writes, outputLines, emit } = yield* setup(); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 47ca90ba4584b..0f56bf2a37fa3 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -984,9 +984,9 @@ it.layer( }), ); - it.effect.each(["setup", "settle"] as const)( - "observes %s completion in the fallback shell instead of the configured fish shell", - (trigger) => + it.effect( + "writes completion syntax for the fallback shell instead of the configured fish shell", + () => Effect.gen(function* () { const env = { SHELL: "/missing/fish" }; const { manager, ptyAdapter, baseDir } = yield* createManager(5, { env }).pipe( @@ -1010,10 +1010,9 @@ it.layer( ), ), ); - const result = yield* runner.runForThread({ + yield* runner.runForThread({ threadId: "thread-setup-fallback", worktreePath: baseDir, - trigger, project: { id: ProjectId.make("project-setup-fallback"), workspaceRoot: baseDir, @@ -1024,29 +1023,16 @@ it.layer( command: "echo setup-ready", icon: "play", runOnWorktreeCreate: true, - runOnSettle: true, }, ], }, observeCompletion: {}, }); - expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ - "/missing/fish", - "/bin/zsh", - ]); const process = ptyAdapter.processes[0]!; process.emitData("\x1b[?2004h"); yield* Deferred.await(written); const command = process.writes[0]!; expect(command).toContain('"$?"'); - expect(command).not.toContain("$status"); - const sentinel = /__T3_SETUP_DONE__[a-zA-Z0-9_]+:/.exec(command)?.[0]; - assert.isDefined(sentinel); - assert.equal(result.status, "started"); - if (result.status !== "started") return; - assert.isDefined(result.completion); - process.emitData(`setup-ready\r\n${sentinel}0\r\nprompt> `); - expect((yield* result.completion!).exitCode).toBe(0); }), ); @@ -1140,65 +1126,60 @@ it.layer( ]); expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); - expect(yield* manager.getShell(openInput())).toBe( - "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - ); }), ); - it.effect.each([false, true])( - "rejects cmd fallback and closes only an idle terminal (busy: %s)", - (hasRunningSubprocess) => - Effect.gen(function* () { - const { manager, ptyAdapter, baseDir } = yield* createManager(5, { - env: { SystemRoot: "C:\\Windows" }, - subprocessInspector: () => - Effect.succeed({ - hasRunningSubprocess, - childCommand: hasRunningSubprocess ? "node" : null, - processIds: [], - }), - }).pipe(Effect.provide(layerWithHostPlatform("win32"))); - ptyAdapter.spawnFailures.push( - new Error("spawn pwsh ENOENT"), - new Error("spawn WindowsPowerShell ENOENT"), - new Error("spawn powershell ENOENT"), - ); - const runner = yield* ProjectSetupScriptRunner.make.pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(TerminalManager.TerminalManager, manager), - Layer.mock(ProjectService.ProjectService)({}), - ServerSettings.layerTest(), - ), + it.effect("closes an idle cmd fallback before rejecting its completion wrapper", () => + Effect.gen(function* () { + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { + env: { SystemRoot: "C:\\Windows" }, + subprocessInspector: () => + Effect.succeed({ + hasRunningSubprocess: false, + childCommand: null, + processIds: [], + }), + }).pipe(Effect.provide(layerWithHostPlatform("win32"))); + ptyAdapter.spawnFailures.push( + new Error("spawn pwsh ENOENT"), + new Error("spawn WindowsPowerShell ENOENT"), + new Error("spawn powershell ENOENT"), + ); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, manager), + Layer.mock(ProjectService.ProjectService)({}), + ServerSettings.layerTest(), ), - ); - const error = yield* runner - .runForThread({ - threadId: "thread-cmd-fallback", - worktreePath: baseDir, - project: { - id: ProjectId.make("project-cmd-fallback"), - workspaceRoot: baseDir, - scripts: [ - { - id: "setup", - name: "Setup", - command: "echo setup-ready", - icon: "play", - runOnWorktreeCreate: true, - }, - ], - }, - observeCompletion: {}, - }) - .pipe(Effect.flip); - - expect(error._tag).toBe("ProjectSetupScriptUnsupportedShellError"); - expect(ptyAdapter.spawnInputs.at(-1)?.shell).toBe("C:\\Windows\\System32\\cmd.exe"); - expect(ptyAdapter.processes[0]?.writes).toEqual([]); - expect(ptyAdapter.processes[0]?.killed).toBe(!hasRunningSubprocess); - }), + ), + ); + const error = yield* runner + .runForThread({ + threadId: "thread-cmd-fallback", + worktreePath: baseDir, + project: { + id: ProjectId.make("project-cmd-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }, + observeCompletion: {}, + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("ProjectSetupScriptUnsupportedShellError"); + expect(ptyAdapter.spawnInputs.at(-1)?.shell).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(ptyAdapter.processes[0]?.writes).toEqual([]); + expect(ptyAdapter.processes[0]?.killed).toBe(true); + }), ); it.effect("turns off fish terminal queries unless the user chose query-term", () => From a3dea0163c3b8524cbe70320c10ffa087ffcd91b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 11:48:01 +0000 Subject: [PATCH 4/5] refactor(server): simplify setup shell selection --- .../src/project/ProjectSetupScriptRunner.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index cfe3b5391a80d..bb89d770e0398 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -440,9 +440,8 @@ export const make = Effect.gen(function* () { terminalId, onStartupPrompt: observed?.flushPartialLine ?? Effect.void, }); - let commandLine = script.command; - const completionShell = yield* Effect.gen(function* () { - yield* terminalManager + const { completionShell, commandLine } = yield* Effect.gen(function* () { + const shell = yield* terminalManager .open({ threadId: input.threadId, terminalId, @@ -451,6 +450,7 @@ export const make = Effect.gen(function* () { env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, }) .pipe( + Effect.andThen(() => terminalManager.getShell({ threadId: input.threadId, terminalId })), Effect.mapError( (cause) => new ProjectSetupScriptOperationError({ @@ -460,17 +460,8 @@ export const make = Effect.gen(function* () { }), ), ); - const shell = yield* terminalManager.getShell({ threadId: input.threadId, terminalId }).pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "openTerminal", - cause, - }), - ), - ); const completionShell = resolveCompletionShell(shell); + let commandLine = script.command; if (observe && completionToken) { if (completionShell === null) { yield* terminalManager.closeIdle({ threadId: input.threadId, terminalId }); @@ -483,7 +474,7 @@ export const make = Effect.gen(function* () { ); } echoedWrapperLines.push(...commandLine.split("\r").filter((line) => line.length > 0)); - return completionShell; + return { completionShell, commandLine }; }).pipe( Effect.onError(() => Effect.sync(() => { From 7411af7f00e9ac39c542d84a2719f5c7926c2979 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira Date: Wed, 28 Oct 2026 09:55:41 +0000 Subject: [PATCH 5/5] fix(server): preserve setup completion when the shell exits early --- .../project/ProjectSetupScriptRunner.test.ts | 39 +++++++++++++------ .../src/project/ProjectSetupScriptRunner.ts | 12 +++++- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 53d0f64721ed9..f383436cdb439 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,6 +1,11 @@ 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 { + ProjectId, + TerminalNotRunningError, + type TerminalEvent, + type TerminalSessionSnapshot, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -35,7 +40,7 @@ const openSnapshot = (history: string): TerminalSessionSnapshot => ({ const makeHarness = Effect.fn(function* (options: { readonly history?: string; readonly opening?: Deferred.Deferred; - readonly shell?: string; + readonly shell?: string | null; }) { const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const writes = yield* Queue.unbounded(); @@ -46,7 +51,10 @@ const makeHarness = Effect.fn(function* (options: { ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void ).pipe(Effect.as(openSnapshot(options.history ?? ""))), - getShell: () => Effect.succeed(options.shell ?? "/bin/zsh"), + getShell: () => + options.shell === null + ? Effect.fail(new TerminalNotRunningError({ threadId, terminalId })) + : Effect.succeed(options.shell ?? "/bin/zsh"), subscribe: (listener) => Effect.sync(() => { listeners.add(listener); @@ -94,7 +102,7 @@ const makeHarness = Effect.fn(function* (options: { const setup = Effect.fn(function* ( options: { readonly history?: string; - readonly shell?: string; + readonly shell?: string | null; } = {}, ) { const harness = yield* makeHarness(options); @@ -173,13 +181,20 @@ describe("ProjectSetupScriptRunner", () => { }), ); - 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); - }), + it.effect.each([false, true])( + "does not type into a shell that exits during startup (before shell lookup: %s)", + (beforeShellLookup) => + Effect.gen(function* () { + const { completion, writes, emit, listeners } = yield* setup( + beforeShellLookup ? { shell: null } : {}, + ); + if (!beforeShellLookup) { + 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); + expect(listeners.size).toBe(0); + }), ); }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index cd1c891e354be..ec11ce9840448 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -473,7 +473,11 @@ export const make = Effect.gen(function* () { env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, }) .pipe( - Effect.andThen(() => terminalManager.getShell({ threadId: input.threadId, terminalId })), + Effect.andThen(() => + terminalManager + .getShell({ threadId: input.threadId, terminalId }) + .pipe(Effect.catchTags({ TerminalNotRunningError: () => Effect.succeed(null) })), + ), Effect.mapError( (cause) => new ProjectSetupScriptOperationError({ @@ -483,6 +487,10 @@ export const make = Effect.gen(function* () { }), ), ); + if (shell === null) { + yield* observed?.abandon ?? Effect.void; + return { completionShell: null, commandLine: null }; + } const completionShell = resolveCompletionShell(shell); let commandLine = script.command; if (observe && completionToken) { @@ -515,7 +523,7 @@ export const make = Effect.gen(function* () { : Effect.succeed("ready" as const) ).pipe( Effect.flatMap((shell) => - shell === "exited" + shell === "exited" || commandLine === null ? Effect.void : terminalManager.write({ threadId: input.threadId,