diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 29a15a6f0715b..f383436cdb439 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,7 +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 * as HostProcess from "@t3tools/shared/HostProcess"; +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"; @@ -36,6 +40,7 @@ const openSnapshot = (history: string): TerminalSessionSnapshot => ({ const makeHarness = Effect.fn(function* (options: { readonly history?: string; readonly opening?: Deferred.Deferred; + readonly shell?: string | null; }) { const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const writes = yield* Queue.unbounded(); @@ -46,6 +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: () => + options.shell === null + ? Effect.fail(new TerminalNotRunningError({ threadId, terminalId })) + : Effect.succeed(options.shell ?? "/bin/zsh"), subscribe: (listener) => Effect.sync(() => { listeners.add(listener); @@ -66,8 +75,6 @@ const makeHarness = Effect.fn(function* (options: { NodeCrypto.layer, ), ), - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }), ); const start = runner.runForThread({ threadId, @@ -92,7 +99,12 @@ const makeHarness = Effect.fn(function* (options: { return { start, listeners, writes, outputLines, emit }; }); -const setup = Effect.fn(function* (options: { readonly history?: string } = {}) { +const setup = Effect.fn(function* ( + options: { + readonly history?: string; + readonly shell?: string | null; + } = {}, +) { const harness = yield* makeHarness(options); const result = yield* harness.start; const completion = result.status === "started" ? result.completion : undefined; @@ -101,6 +113,15 @@ const setup = Effect.fn(function* (options: { readonly history?: string } = {}) }); describe("ProjectSetupScriptRunner", () => { + it.effect("writes immediately when the spawned fallback is PowerShell", () => + Effect.gen(function* () { + const { writes } = yield* setup({ + shell: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }); + expect(yield* Queue.take(writes)).toContain("$global:LASTEXITCODE"); + }), + ); + it.effect("shows a startup prompt and types only once the line editor starts", () => Effect.gen(function* () { const { result, writes, outputLines, emit } = yield* setup(); @@ -160,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 095f20938c8b0..ec11ce9840448 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,4 @@ import { linkOriginIsFenced, ProjectId, type ProjectScript, ThreadId } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { projectScriptRuntimeEnv, projectScriptTerminalInput, @@ -107,9 +106,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; @@ -159,13 +168,9 @@ function lineEditorReading(text: string, previous: boolean): boolean { 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"; @@ -196,10 +201,6 @@ export const make = Effect.gen(function* () { const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; const serviceScope = yield* Effect.scope; - const completionShell = resolveCompletionShell( - yield* HostProcess.Platform, - yield* HostProcess.Environment, - ); const observeTerminalCompletion = Effect.fn("ProjectSetupScriptRunner.observeTerminalCompletion")( function* (input: { @@ -444,15 +445,7 @@ 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; - + const echoedWrapperLines: string[] = []; // Subscribe before opening so neither startup activity nor the sentinel can race the listeners. const observed = observe && completionToken @@ -461,49 +454,76 @@ export const make = Effect.gen(function* () { terminalId, sentinel: completionSentinel(completionToken), sentinelPattern: completionSentinelPattern(completionToken), - echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0), + echoedWrapperLines, 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 - .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, - }), - ), - Effect.onError(() => - Effect.sync(() => { - observed?.unsubscribe(); - readiness?.unsubscribe(); - }), - ), - ); + const readiness = yield* watchShellReadiness({ + threadId: input.threadId, + terminalId, + onStartupPrompt: observed?.flushPartialLine ?? Effect.void, + }); + const { completionShell, commandLine } = yield* Effect.gen(function* () { + const shell = yield* terminalManager + .open({ + threadId: input.threadId, + terminalId, + cwd, + worktreePath: input.worktreePath, + env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, + }) + .pipe( + Effect.andThen(() => + terminalManager + .getShell({ threadId: input.threadId, terminalId }) + .pipe(Effect.catchTags({ TerminalNotRunningError: () => Effect.succeed(null) })), + ), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); + if (shell === null) { + yield* observed?.abandon ?? Effect.void; + return { completionShell: null, commandLine: null }; + } + const completionShell = resolveCompletionShell(shell); + let commandLine = script.command; + if (observe && completionToken) { + if (completionShell === null) { + yield* terminalManager.closeIdle({ threadId: input.threadId, terminalId }); + return yield* new ProjectSetupScriptUnsupportedShellError({ shell }); + } + commandLine = wrapCommandForCompletion( + script.command, + completionShell, + completionSentinel(completionToken), + ); + } + echoedWrapperLines.push(...commandLine.split("\r").filter((line) => line.length > 0)); + return { completionShell, commandLine }; + }).pipe( + 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( + // PowerShell and cmd have no line-editor signal. + yield* ( + completionShell === "posix" || completionShell === "fish" + ? readiness.wait + : Effect.succeed("ready" as const) + ).pipe( Effect.flatMap((shell) => - shell === "exited" + shell === "exited" || commandLine === null ? Effect.void : terminalManager.write({ threadId: input.threadId, @@ -518,7 +538,7 @@ export const make = Effect.gen(function* () { cause, }).pipe(Effect.andThen(observed?.abandon ?? Effect.void)), ), - Effect.ensuring(Effect.sync(() => readiness?.unsubscribe())), + Effect.ensuring(Effect.sync(() => readiness.unsubscribe())), Effect.forkIn(serviceScope), ); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index e30a4cd3d720d..ccaaa7d8881a2 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -7,6 +7,7 @@ import { type TerminalOpenInput, ProviderDriverKind, ProviderInstanceId, + ProjectId, TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -26,8 +27,11 @@ import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import { expect, vi } from "vite-plus/test"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.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"; @@ -1085,6 +1089,61 @@ it.layer( }), ); + 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( + Effect.provide(layerWithHostPlatform("linux")), + ); + ptyAdapter.spawnFailures.push(new Error("spawn fish ENOENT")); + const written = yield* Deferred.make(); + const terminals = TerminalManager.TerminalManager.of({ + ...manager, + write: (input) => + manager.write(input).pipe(Effect.tap(() => Deferred.succeed(written, undefined))), + }); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, terminals), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => Effect.succeed(null), + }), + ServerSettings.layerTest(), + Layer.succeed(HostProcess.Platform, "linux"), + Layer.succeed(HostProcess.Environment, env), + ), + ), + ); + yield* runner.runForThread({ + threadId: "thread-setup-fallback", + worktreePath: baseDir, + project: { + id: ProjectId.make("project-setup-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }, + observeCompletion: {}, + }); + const process = ptyAdapter.processes[0]!; + process.emitData("\x1b[?2004h"); + yield* Deferred.await(written); + const command = process.writes[0]!; + expect(command).toContain('"$?"'); + }), + ); + it.effect("preserves Windows Path casing when appending managed ACP binaries", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1178,6 +1237,62 @@ it.layer( }), ); + 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)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => Effect.succeed(null), + }), + 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(true); + }), + ); + it.effect("turns off fish terminal queries unless the user chose query-term", () => Effect.gen(function* () { for (const [platform, env, runtimeEnv, expected] of [ diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 4c5229a7bae4f..bf3ee464157d3 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -162,6 +162,8 @@ export class TerminalManager extends Context.Service< input: TerminalOpenInput, ) => Effect.Effect; + readonly getShell: (input: TerminalObserveInput) => Effect.Effect; + /** * Attached viewers share one PTY sized to the smallest of them, so a narrow client * still sees the whole TUI. Streams and resizes with the same `viewerId` (one client @@ -286,6 +288,7 @@ interface TerminalSessionState { cols: number; rows: number; process: PtyAdapter.PtyProcess | null; + shell: string | null; unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; @@ -2329,7 +2332,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) { @@ -2372,6 +2375,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (attempt._tag === "Success") { return { process: attempt.success, + shell: candidate.shell, shellLabel: formatShellCandidate(candidate), }; } @@ -2400,6 +2404,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; @@ -2474,6 +2479,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. @@ -2859,6 +2865,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols, rows, process: null, + shell: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, @@ -2956,6 +2963,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* (requestedInput: TerminalAttachInput) { const terminalId = requestedInput.terminalId; const input = withSharedGrid(toSessionKey(requestedInput.threadId, terminalId), requestedInput); @@ -3437,6 +3452,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, + getShell, attachStream, attachOutputStream, observeStream,