Skip to content
55 changes: 34 additions & 21 deletions apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { ProjectId, type ProjectScript } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import {
projectScriptRuntimeEnv,
projectScriptTerminalInput,
Expand Down Expand Up @@ -99,9 +98,19 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedError<P
}
}

export class ProjectSetupScriptUnsupportedShellError extends Schema.TaggedError<ProjectSetupScriptUnsupportedShellError>()(
"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;

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
Comment thread
Aforno marked this conversation as resolved.
Resolved
commandLine = wrapCommandForCompletion(
script.command,
completionShell,
completionSentinel(completionToken),
);
}
// Subscribe before writing so the sentinel cannot race past the listener.
const observed =
observe && completionToken
Expand Down
110 changes: 110 additions & 0 deletions apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type TerminalRestartInput,
ProviderDriverKind,
ProviderInstanceId,
ProjectId,
TerminalProviderInstanceNotFoundError,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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([]);
}),
);

Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/terminal/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export class TerminalManager extends Context.Service<
input: TerminalOpenInput,
) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;

readonly getShell: (input: TerminalObserveInput) => Effect.Effect<string, TerminalError>;

readonly attachStream: (
input: TerminalAttachInput,
listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
};
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2756,6 +2762,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
cols,
rows,
process: null,
shell: null,
unsubscribeData: null,
unsubscribeExit: null,
hasRunningSubprocess: false,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -3241,6 +3256,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
cols,
rows,
process: null,
shell: null,
unsubscribeData: null,
unsubscribeExit: null,
hasRunningSubprocess: false,
Expand Down Expand Up @@ -3365,6 +3381,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func

return TerminalManager.of({
open,
getShell,
attachStream,
observeStream,
resyncStream,
Expand Down
Loading