import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import { getShellConfig, resolveShellFromPath } from "./shell-utils.js"; const isWin = process.platform === "win32"; function createTempCommandDir( tempDirs: string[], files: Array<{ name: string; executable?: boolean }>, ): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-")); tempDirs.push(dir); for (const file of files) { const filePath = path.join(dir, file.name); fs.writeFileSync(filePath, ""); fs.chmodSync(filePath, file.executable === false ? 0o644 : 0o755); } return dir; } describe("getShellConfig", () => { let envSnapshot: ReturnType; const tempDirs: string[] = []; beforeEach(() => { envSnapshot = captureEnv(["SHELL", "PATH"]); if (!isWin) { process.env.SHELL = "/usr/bin/fish"; } }); afterEach(() => { envSnapshot.restore(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); if (isWin) { it("uses PowerShell on Windows", () => { const { shell } = getShellConfig(); expect(shell.toLowerCase()).toContain("powershell"); }); return; } it("prefers bash when fish is default and bash is on PATH", () => { const binDir = createTempCommandDir(tempDirs, [{ name: "bash" }]); process.env.PATH = binDir; const { shell } = getShellConfig(); expect(shell).toBe(path.join(binDir, "bash")); }); it("falls back to sh when fish is default and bash is missing", () => { const binDir = createTempCommandDir(tempDirs, [{ name: "sh" }]); process.env.PATH = binDir; const { shell } = getShellConfig(); expect(shell).toBe(path.join(binDir, "sh")); }); it("falls back to env shell when fish is default and no sh is available", () => { process.env.PATH = ""; const { shell } = getShellConfig(); expect(shell).toBe("/usr/bin/fish"); }); it("uses sh when SHELL is unset", () => { delete process.env.SHELL; process.env.PATH = ""; const { shell } = getShellConfig(); expect(shell).toBe("sh"); }); }); describe("resolveShellFromPath", () => { let envSnapshot: ReturnType; const tempDirs: string[] = []; beforeEach(() => { envSnapshot = captureEnv(["PATH"]); }); afterEach(() => { envSnapshot.restore(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); it("returns undefined when PATH is empty", () => { process.env.PATH = ""; expect(resolveShellFromPath("bash")).toBeUndefined(); }); if (isWin) { return; } it("returns the first executable match from PATH", () => { const notExecutable = createTempCommandDir(tempDirs, [{ name: "bash", executable: false }]); const executable = createTempCommandDir(tempDirs, [{ name: "bash", executable: true }]); process.env.PATH = [notExecutable, executable].join(path.delimiter); expect(resolveShellFromPath("bash")).toBe(path.join(executable, "bash")); }); it("returns undefined when command does not exist", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-empty-")); tempDirs.push(dir); process.env.PATH = dir; expect(resolveShellFromPath("bash")).toBeUndefined(); }); });