Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ it.layer(NodeServices.layer)("server settings", (it) => {
},
},
);

assert.deepEqual(
decodePatch({
terminal: {
profile: {
shellPath: "/bin/zsh",
shellArgs: ["-f"],
},
},
}),
{
terminal: {
profile: {
shellPath: "/bin/zsh",
shellArgs: ["-f"],
},
},
},
);
}),
);

Expand Down Expand Up @@ -154,6 +173,47 @@ it.layer(NodeServices.layer)("server settings", (it) => {
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("writes terminal profile overrides without serializing default values", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsService;
const serverConfig = yield* ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;

const next = yield* serverSettings.updateSettings({
terminal: {
profile: {
shellPath: "/bin/zsh",
shellArgs: ["-f"],
env: {
ZDOTDIR: "/tmp/t3code-zsh",
},
},
},
});

assert.deepEqual(next.terminal.profile, {
shellPath: "/bin/zsh",
shellArgs: ["-f"],
env: {
ZDOTDIR: "/tmp/t3code-zsh",
},
});

const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
assert.deepEqual(JSON.parse(raw), {
terminal: {
profile: {
shellPath: "/bin/zsh",
shellArgs: ["-f"],
env: {
ZDOTDIR: "/tmp/t3code-zsh",
},
},
},
});
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("writes only non-default server settings to disk", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsService;
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/terminal/Layers/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type TerminalEvent,
type TerminalOpenInput,
type TerminalRestartInput,
type TerminalProfileSettings,
} from "@t3tools/contracts";
import { afterEach, describe, expect, it } from "vitest";

Expand Down Expand Up @@ -187,6 +188,7 @@ describe("TerminalManager", () => {
processKillGraceMs?: number;
maxRetainedInactiveSessions?: number;
ptyAdapter?: FakePtyAdapter;
terminalProfileResolver?: () => TerminalProfileSettings;
} = {},
) {
const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
Expand All @@ -205,6 +207,9 @@ describe("TerminalManager", () => {
...(options.maxRetainedInactiveSessions
? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }
: {}),
...(options.terminalProfileResolver
? { terminalProfileResolver: options.terminalProfileResolver }
: {}),
});
return { logsDir, ptyAdapter, manager };
}
Expand Down Expand Up @@ -313,6 +318,41 @@ describe("TerminalManager", () => {
manager.dispose();
});

it("spawns terminals with the configured shell profile", async () => {
const { manager, ptyAdapter } = makeManager(5, {
shellResolver: () => "/bin/bash",
terminalProfileResolver: () => ({
shellPath: "/bin/zsh",
shellArgs: ["-f"],
env: {
ZDOTDIR: "/tmp/t3code-zdotdir",
PATH: "/opt/t3/bin",
},
}),
});

await manager.open(
openInput({
env: {
PATH: "/workspace/bin",
CUSTOM_FLAG: "1",
},
}),
);

const spawned = ptyAdapter.spawnInputs[0];
expect(spawned).toBeDefined();
if (!spawned) return;

expect(spawned.shell).toBe("/bin/zsh");
expect(spawned.args).toEqual(["-f"]);
expect(spawned.env.ZDOTDIR).toBe("/tmp/t3code-zdotdir");
expect(spawned.env.PATH).toBe("/workspace/bin");
expect(spawned.env.CUSTOM_FLAG).toBe("1");

manager.dispose();
});

it("supports multiple terminals per thread with isolated sessions", async () => {
const { manager, ptyAdapter } = makeManager();
await manager.open(openInput({ terminalId: "default" }));
Expand Down
116 changes: 48 additions & 68 deletions apps/server/src/terminal/Layers/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
TerminalRestartInput,
TerminalWriteInput,
type TerminalEvent,
type TerminalProfileSettings,
type TerminalSessionSnapshot,
} from "@t3tools/contracts";
import { Effect, Encoding, Layer, Schema } from "effect";
Expand All @@ -19,6 +20,7 @@ import { createLogger } from "../../logger";
import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY";
import { runProcess } from "../../processRunner";
import { ServerConfig } from "../../config";
import { ServerSettingsService } from "../../serverSettings";
import {
ShellCandidate,
TerminalError,
Expand All @@ -27,6 +29,7 @@ import {
TerminalSessionState,
TerminalStartInput,
} from "../Services/Manager";
import { resolveCurrentShell, resolveTerminalShellSpawnConfig } from "../terminalProfile";

const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
Expand All @@ -47,77 +50,14 @@ const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput);
type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;

function defaultShellResolver(): string {
if (process.platform === "win32") {
return process.env.ComSpec ?? "cmd.exe";
}
return process.env.SHELL ?? "bash";
}

function normalizeShellCommand(value: string | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
if (trimmed.length === 0) return null;

if (process.platform === "win32") {
return trimmed;
}

const firstToken = trimmed.split(/\s+/g)[0]?.trim();
if (!firstToken) return null;
return firstToken.replace(/^['"]|['"]$/g, "");
}

function shellCandidateFromCommand(command: string | null): ShellCandidate | null {
if (!command || command.length === 0) return null;
const shellName = path.basename(command).toLowerCase();
if (process.platform !== "win32" && shellName === "zsh") {
return { shell: command, args: ["-o", "nopromptsp"] };
}
return { shell: command };
return resolveCurrentShell(process.platform, process.env);
}

function formatShellCandidate(candidate: ShellCandidate): string {
if (!candidate.args || candidate.args.length === 0) return candidate.shell;
return `${candidate.shell} ${candidate.args.join(" ")}`;
}

function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] {
const seen = new Set<string>();
const ordered: ShellCandidate[] = [];
for (const candidate of candidates) {
if (!candidate) continue;
const key = formatShellCandidate(candidate);
if (seen.has(key)) continue;
seen.add(key);
ordered.push(candidate);
}
return ordered;
}

function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] {
const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver()));

if (process.platform === "win32") {
return uniqueShellCandidates([
requested,
shellCandidateFromCommand(process.env.ComSpec ?? null),
shellCandidateFromCommand("powershell.exe"),
shellCandidateFromCommand("cmd.exe"),
]);
}

return uniqueShellCandidates([
requested,
shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)),
shellCandidateFromCommand("/bin/zsh"),
shellCandidateFromCommand("/bin/bash"),
shellCandidateFromCommand("/bin/sh"),
shellCandidateFromCommand("zsh"),
shellCandidateFromCommand("bash"),
shellCandidateFromCommand("sh"),
]);
}

function isRetryableShellSpawnError(error: unknown): boolean {
const queue: unknown[] = [error];
const seen = new Set<unknown>();
Expand Down Expand Up @@ -457,6 +397,7 @@ function shouldExcludeTerminalEnvKey(key: string): boolean {

function createTerminalSpawnEnv(
baseEnv: NodeJS.ProcessEnv,
profileEnv?: Record<string, string> | null,
runtimeEnv?: Record<string, string> | null,
): NodeJS.ProcessEnv {
const spawnEnv: NodeJS.ProcessEnv = {};
Expand All @@ -465,6 +406,11 @@ function createTerminalSpawnEnv(
if (shouldExcludeTerminalEnvKey(key)) continue;
spawnEnv[key] = value;
}
if (profileEnv) {
for (const [key, value] of Object.entries(profileEnv)) {
spawnEnv[key] = value;
}
}
if (runtimeEnv) {
for (const [key, value] of Object.entries(runtimeEnv)) {
spawnEnv[key] = value;
Expand All @@ -491,6 +437,7 @@ interface TerminalManagerOptions {
historyLineLimit?: number;
ptyAdapter: PtyAdapterShape;
shellResolver?: () => string;
terminalProfileResolver?: () => TerminalProfileSettings | Promise<TerminalProfileSettings>;
subprocessChecker?: TerminalSubprocessChecker;
subprocessPollIntervalMs?: number;
processKillGraceMs?: number;
Expand All @@ -503,6 +450,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
private readonly historyLineLimit: number;
private readonly ptyAdapter: PtyAdapterShape;
private readonly shellResolver: () => string;
private readonly terminalProfileResolver: () =>
| TerminalProfileSettings
| Promise<TerminalProfileSettings>;
private readonly persistQueues = new Map<string, Promise<void>>();
private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly pendingPersistHistory = new Map<string, string>();
Expand All @@ -523,6 +473,13 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
this.ptyAdapter = options.ptyAdapter;
this.shellResolver = options.shellResolver ?? defaultShellResolver;
this.terminalProfileResolver =
options.terminalProfileResolver ??
(() => ({
shellPath: "",
shellArgs: [],
env: {},
}));
this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS;
this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;
this.subprocessPollIntervalMs =
Expand Down Expand Up @@ -771,8 +728,19 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
let ptyProcess: PtyProcess | null = null;
let startedShell: string | null = null;
try {
const shellCandidates = resolveShellCandidates(this.shellResolver);
const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv);
const terminalProfile = await this.terminalProfileResolver();
const resolvedSpawnConfig = resolveTerminalShellSpawnConfig({
platform: process.platform,
processEnv: process.env,
shellResolver: this.shellResolver,
profile: terminalProfile,
});
const shellCandidates = resolvedSpawnConfig.shellCandidates;
const terminalEnv = createTerminalSpawnEnv(
process.env,
resolvedSpawnConfig.profileEnv,
session.runtimeEnv,
);
let lastSpawnError: unknown = null;

const spawnWithCandidate = (candidate: ShellCandidate) =>
Expand Down Expand Up @@ -1362,10 +1330,22 @@ export const TerminalManagerLive = Layer.effect(
TerminalManager,
Effect.gen(function* () {
const { terminalLogsDir } = yield* ServerConfig;

const ptyAdapter = yield* PtyAdapter;
const serverSettings = yield* ServerSettingsService;
const runtime = yield* Effect.acquireRelease(
Effect.sync(() => new TerminalManagerRuntime({ logsDir: terminalLogsDir, ptyAdapter })),
Effect.sync(
() =>
new TerminalManagerRuntime({
logsDir: terminalLogsDir,
ptyAdapter,
terminalProfileResolver: () =>
Effect.runPromise(
serverSettings.getSettings.pipe(
Effect.map((settings) => settings.terminal.profile),
),
),
}),
),
(r) => Effect.sync(() => r.dispose()),
);

Expand Down
Loading
Loading