fix(terminals): load interactive login profiles

This commit is contained in:
Federico Jaramillo Martinez
2026-07-18 20:47:07 +02:00
parent d2e2d512cd
commit 2b1507ba35
3 changed files with 104 additions and 3 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Load login shell profiles in new and continued interactive terminals so PATH-managed commands are available.
+90 -1
View File
@@ -1,8 +1,26 @@
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { RealtimeEvent, TerminalInfo } from "../../shared/apiTypes.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { TerminalService } from "./terminalService";
import { interactiveShellArgs, TerminalService } from "./terminalService";
describe("interactive shell arguments", () => {
it.each([
{ shell: "bash", expected: ["-l"] },
{ shell: "/usr/local/bin/zsh", expected: ["-l"] },
{ shell: "/opt/homebrew/bin/fish", expected: ["-l"] },
{ shell: String.raw`C:\Program Files\Git\bin\bash.exe`, expected: ["-l"] },
{ shell: "/bin/dash", expected: [] },
{ shell: "pwsh", expected: [] },
{ shell: "powershell.exe", expected: [] },
{ shell: "cmd.exe", expected: [] },
])("uses login mode only for a supported shell: $shell", ({ shell, expected }) => {
expect(interactiveShellArgs(shell)).toEqual(expected);
});
});
// TerminalService spawns a POSIX shell (/bin/bash with -lc and commands like
// printf/true/exit). The terminal feature is not supported on native Windows,
@@ -22,6 +40,47 @@ describe.skipIf(process.platform === "win32")("TerminalService command runs", ()
}
});
it("loads login-profile PATH entries in new interactive terminals", async () => {
await withBashLoginProfile(async () => {
const service = new TerminalService();
try {
const terminal = service.create({ cwd: process.cwd() });
const exit = terminalExit(service, terminal.id);
service.write(terminal.id, `${LOGIN_PROFILE_COMMAND}\nexit\n`);
expect(await exit).toContain(LOGIN_PROFILE_OUTPUT);
} finally {
service.dispose();
}
});
});
it("loads login-profile PATH entries in continued interactive terminals", async () => {
await withBashLoginProfile(async () => {
const service = new TerminalService();
try {
const run = service.runCommand({
origin: "core",
projectId: "p1",
workspaceId: "w1",
cwd: process.cwd(),
title: "Done command",
command: "true",
});
await terminalExit(service, run.terminalId);
service.continue(run.terminalId);
const exit = terminalExit(service, run.terminalId);
service.write(run.terminalId, `${LOGIN_PROFILE_COMMAND}\nexit\n`);
expect(await exit).toContain(LOGIN_PROFILE_OUTPUT);
} finally {
service.dispose();
}
});
});
describe("PI_WEB_TERMINAL propagation", () => {
let originalPiWebTerminal: string | undefined;
@@ -223,6 +282,36 @@ function requireTerminal(service: TerminalService, terminalId: string): Terminal
return terminal;
}
const LOGIN_PROFILE_COMMAND = "pi-web-test-login-profile-command";
const LOGIN_PROFILE_OUTPUT = "__PI_WEB_LOGIN_PROFILE_PATH_COMMAND__";
async function withBashLoginProfile(run: () => Promise<void>): Promise<void> {
const home = await mkdtemp(join(tmpdir(), "pi-web-terminal-home-"));
const profileBin = join(home, "profile-bin");
await mkdir(profileBin);
const commandPath = join(profileBin, LOGIN_PROFILE_COMMAND);
await writeFile(commandPath, `#!/bin/sh\nprintf '%s\\n' '${LOGIN_PROFILE_OUTPUT}'\n`);
await chmod(commandPath, 0o755);
await writeFile(join(home, ".bash_profile"), `export PATH="$HOME/profile-bin:$PATH"\n`);
const originalHome = process.env["HOME"];
const originalShell = process.env["SHELL"];
process.env["HOME"] = home;
process.env["SHELL"] = "/bin/bash";
try {
await run();
} finally {
restoreEnv("HOME", originalHome);
restoreEnv("SHELL", originalShell);
await rm(home, { recursive: true, force: true });
}
}
function restoreEnv(key: "HOME" | "SHELL", value: string | undefined): void {
if (value === undefined) Reflect.deleteProperty(process.env, key);
else process.env[key] = value;
}
function terminalExit(service: TerminalService, terminalId: string): Promise<string> {
const output: string[] = [];
return new Promise((resolve, reject) => {
+9 -2
View File
@@ -54,7 +54,8 @@ export class TerminalService {
}
create(options: { cwd: string; name?: string; cols?: number; rows?: number }): TerminalInfo {
return this.createTerminal({ ...options, shellArgs: [] });
const shell = process.env["SHELL"] ?? "/bin/bash";
return this.createTerminal({ ...options, shellArgs: interactiveShellArgs(shell) });
}
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun {
@@ -158,7 +159,7 @@ export class TerminalService {
record.buffer = trimReplayBuffer(record.buffer + marker);
record.events.emit("output", marker);
const shell = process.env["SHELL"] ?? "/bin/bash";
record.pty = pty.spawn(shell, [], {
record.pty = pty.spawn(shell, interactiveShellArgs(shell), {
name: "xterm-256color",
cwd: record.cwd,
cols: 100,
@@ -275,6 +276,12 @@ function trimReplayBuffer(buffer: string): string {
return buffer.slice(buffer.length - MAX_REPLAY_BUFFER);
}
export function interactiveShellArgs(shell: string): string[] {
const executable = shell.split(/[\\/]/).at(-1)?.toLowerCase().replace(/^-/, "").replace(/\.exe$/, "");
// Preserve the existing invocation for arbitrary SHELL values rather than guessing at an unsupported login flag.
return executable === "bash" || executable === "zsh" || executable === "fish" ? ["-l"] : [];
}
function commandRunShellScript(command: string): string {
return `printf '%s\\n' ${shellQuote(`$ ${command}`)}\n${command}`;
}