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 { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { RealtimeEvent, TerminalInfo } from "../../shared/apiTypes.js";
|
import type { RealtimeEvent, TerminalInfo } from "../../shared/apiTypes.js";
|
||||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||||
import { SessionEventHub } from "../realtime/sessionEventHub.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
|
// TerminalService spawns a POSIX shell (/bin/bash with -lc and commands like
|
||||||
// printf/true/exit). The terminal feature is not supported on native Windows,
|
// 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", () => {
|
describe("PI_WEB_TERMINAL propagation", () => {
|
||||||
let originalPiWebTerminal: string | undefined;
|
let originalPiWebTerminal: string | undefined;
|
||||||
|
|
||||||
@@ -223,6 +282,36 @@ function requireTerminal(service: TerminalService, terminalId: string): Terminal
|
|||||||
return 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> {
|
function terminalExit(service: TerminalService, terminalId: string): Promise<string> {
|
||||||
const output: string[] = [];
|
const output: string[] = [];
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export class TerminalService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create(options: { cwd: string; name?: string; cols?: number; rows?: number }): TerminalInfo {
|
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 {
|
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun {
|
||||||
@@ -158,7 +159,7 @@ export class TerminalService {
|
|||||||
record.buffer = trimReplayBuffer(record.buffer + marker);
|
record.buffer = trimReplayBuffer(record.buffer + marker);
|
||||||
record.events.emit("output", marker);
|
record.events.emit("output", marker);
|
||||||
const shell = process.env["SHELL"] ?? "/bin/bash";
|
const shell = process.env["SHELL"] ?? "/bin/bash";
|
||||||
record.pty = pty.spawn(shell, [], {
|
record.pty = pty.spawn(shell, interactiveShellArgs(shell), {
|
||||||
name: "xterm-256color",
|
name: "xterm-256color",
|
||||||
cwd: record.cwd,
|
cwd: record.cwd,
|
||||||
cols: 100,
|
cols: 100,
|
||||||
@@ -275,6 +276,12 @@ function trimReplayBuffer(buffer: string): string {
|
|||||||
return buffer.slice(buffer.length - MAX_REPLAY_BUFFER);
|
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 {
|
function commandRunShellScript(command: string): string {
|
||||||
return `printf '%s\\n' ${shellQuote(`$ ${command}`)}\n${command}`;
|
return `printf '%s\\n' ${shellQuote(`$ ${command}`)}\n${command}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user