Skip to content
54 changes: 41 additions & 13 deletions apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -36,6 +40,7 @@ const openSnapshot = (history: string): TerminalSessionSnapshot => ({
const makeHarness = Effect.fn(function* (options: {
readonly history?: string;
readonly opening?: Deferred.Deferred<void>;
readonly shell?: string | null;
}) {
const listeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>();
const writes = yield* Queue.unbounded<string>();
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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);
}),
);
});
138 changes: 79 additions & 59 deletions apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { linkOriginIsFenced, ProjectId, type ProjectScript, ThreadId } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import {
projectScriptRuntimeEnv,
projectScriptTerminalInput,
Expand Down Expand Up @@ -107,9 +106,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 @@ -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";
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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),
);

Expand Down
Loading
Loading