Archived
fix(terminals): load interactive login profiles
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Load login shell profiles in new and continued interactive terminals so PATH-managed commands are available.
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user