diff --git a/.changeset/load-interactive-shell-profiles.md b/.changeset/load-interactive-shell-profiles.md new file mode 100644 index 0000000..e4e2041 --- /dev/null +++ b/.changeset/load-interactive-shell-profiles.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Load login shell profiles in new and continued interactive terminals so PATH-managed commands are available. diff --git a/src/server/terminals/terminalService.test.ts b/src/server/terminals/terminalService.test.ts index 75f3957..12eb051 100644 --- a/src/server/terminals/terminalService.test.ts +++ b/src/server/terminals/terminalService.test.ts @@ -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): Promise { + 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 { const output: string[] = []; return new Promise((resolve, reject) => { diff --git a/src/server/terminals/terminalService.ts b/src/server/terminals/terminalService.ts index 7186893..446d374 100644 --- a/src/server/terminals/terminalService.ts +++ b/src/server/terminals/terminalService.ts @@ -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}`; }