Fix worktree setup scripts losing PATH (#1908)
* fix(worktree): preserve PATH for lifecycle scripts Lifecycle command strings were running through login shells, which could rewrite PATH before setup commands saw the daemon environment. Use the shared stable script-shell helper without login startup files, and route related project-authored command strings through it. * fix(worktree): ignore Bash startup env hook Project command strings should not source shell startup hooks before they see Paseo's supplied environment. Strip BASH_ENV from lifecycle, loop verify, and ACP string-command shells and pin the behavior in setup tests. * fix(worktree): resolve Bash through PATH * fix(worktree): preserve Windows cmd command strings * test(acp): expect Windows cmd terminal strings
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { type ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn());
|
||||
const spawnProcessMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("child_process")>("child_process");
|
||||
@@ -13,11 +16,34 @@ vi.mock("child_process", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./spawn.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./spawn.js")>("./spawn.js");
|
||||
return {
|
||||
...actual,
|
||||
spawnProcess: spawnProcessMock,
|
||||
};
|
||||
});
|
||||
|
||||
function emitSuccessfulClose(child: ChildProcess): void {
|
||||
child.emit("close", 0);
|
||||
}
|
||||
|
||||
function createSpawnChildStub(): ChildProcess {
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
Object.assign(child, {
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
});
|
||||
queueMicrotask(() => emitSuccessfulClose(child));
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("worktree shell selection", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset();
|
||||
spawnProcessMock.mockReset();
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
@@ -29,6 +55,7 @@ describe("worktree shell selection", () => {
|
||||
return {};
|
||||
},
|
||||
);
|
||||
spawnProcessMock.mockImplementation(createSpawnChildStub);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -46,6 +73,8 @@ describe("worktree shell selection", () => {
|
||||
});
|
||||
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-"));
|
||||
const originalBashEnv = process.env.BASH_ENV;
|
||||
process.env.BASH_ENV = "should-not-leak";
|
||||
try {
|
||||
mkdirSync(join(worktreePath, ".git"), { recursive: true });
|
||||
writeFileSync(
|
||||
@@ -79,7 +108,74 @@ describe("worktree shell selection", () => {
|
||||
expect.objectContaining({ cwd: worktreePath }),
|
||||
expect.any(Function),
|
||||
);
|
||||
const execOptions = execFileMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv };
|
||||
expect(execOptions.env?.BASH_ENV).toBeUndefined();
|
||||
} finally {
|
||||
if (originalBashEnv === undefined) {
|
||||
delete process.env.BASH_ENV;
|
||||
} else {
|
||||
process.env.BASH_ENV = originalBashEnv;
|
||||
}
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("routes streamed setup command execution through powershell on win32", async () => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-"));
|
||||
const originalBashEnv = process.env.BASH_ENV;
|
||||
process.env.BASH_ENV = "should-not-leak";
|
||||
try {
|
||||
writeFileSync(
|
||||
join(worktreePath, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ["Write-Output 'setup'"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const { runWorktreeSetupCommands } = await import("./worktree.js");
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath,
|
||||
branchName: "main",
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv: {
|
||||
PASEO_SOURCE_CHECKOUT_PATH: worktreePath,
|
||||
PASEO_ROOT_PATH: worktreePath,
|
||||
PASEO_WORKTREE_PATH: worktreePath,
|
||||
PASEO_BRANCH_NAME: "main",
|
||||
PASEO_WORKTREE_PORT: "12345",
|
||||
},
|
||||
onEvent: () => {},
|
||||
});
|
||||
|
||||
expect(spawnProcessMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnProcessMock).toHaveBeenCalledWith(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"Write-Output 'setup'",
|
||||
],
|
||||
expect.objectContaining({ cwd: worktreePath, shell: false }),
|
||||
);
|
||||
const spawnOptions = spawnProcessMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv };
|
||||
expect(spawnOptions.env?.BASH_ENV).toBeUndefined();
|
||||
} finally {
|
||||
if (originalBashEnv === undefined) {
|
||||
delete process.env.BASH_ENV;
|
||||
} else {
|
||||
process.env.BASH_ENV = originalBashEnv;
|
||||
}
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user