fix: recognize Bash completion installed in login profiles (#113790)

* fix(cli): recognize Bash completion in login profiles

* test: track Bash completion profile fixtures

---------

Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
This commit is contained in:
Peter Steinberger
2026-07-25 11:08:59 -07:00
committed by GitHub
parent fca18dddc7
commit a1cc41acbc
4 changed files with 159 additions and 9 deletions

View File

@@ -1,18 +1,107 @@
// Completion runtime tests cover shell completion generation and runtime file writes.
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
formatCompletionReloadCommand,
installCompletion,
isCompletionInstalled,
resolveCompletionCachePath,
resolveCompletionProfilePath,
resolveShellFromEnv,
usesSlowDynamicCompletion,
} from "./completion-runtime.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
async function withBashCompletionHome(
run: (paths: { homeDir: string; stateDir: string }) => Promise<void>,
): Promise<void> {
const homeDir = tempDirs.make("openclaw-bash-completion-home-");
const stateDir = tempDirs.make("openclaw-bash-completion-state-");
await withEnvAsync({ HOME: homeDir, OPENCLAW_STATE_DIR: stateDir }, async () => {
await run({ homeDir, stateDir });
});
}
describe("completion-runtime", () => {
it("resolves the documented Bash login profile when .bashrc is absent", async () => {
await withBashCompletionHome(async ({ homeDir }) => {
expect(resolveCompletionProfilePath("bash")).toBe(path.join(homeDir, ".bash_profile"));
});
});
it("recognizes cached Bash completion installed into the login profile", async () => {
await withBashCompletionHome(async ({ homeDir }) => {
const cachePath = resolveCompletionCachePath("bash", "openclaw");
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8");
await installCompletion("bash", true, "openclaw");
const profilePath = path.join(homeDir, ".bash_profile");
await expect(fs.readFile(profilePath, "utf-8")).resolves.toContain(cachePath);
await expect(isCompletionInstalled("bash", "openclaw")).resolves.toBe(true);
await expect(usesSlowDynamicCompletion("bash", "openclaw")).resolves.toBe(false);
const shell = spawnSync(
"bash",
[
"--noprofile",
"--norc",
"-c",
'source "$1"; complete -p openclaw',
"openclaw",
profilePath,
],
{ encoding: "utf8" },
);
expect(shell.stderr).toBe("");
expect(shell.status).toBe(0);
expect(shell.stdout).toContain("complete -W 'status' openclaw");
});
});
it("detects slow dynamic Bash completion in the login profile", async () => {
await withBashCompletionHome(async ({ homeDir }) => {
await fs.writeFile(
path.join(homeDir, ".bash_profile"),
"source <(openclaw completion --shell bash)\n",
"utf-8",
);
await expect(isCompletionInstalled("bash", "openclaw")).resolves.toBe(true);
await expect(usesSlowDynamicCompletion("bash", "openclaw")).resolves.toBe(true);
});
});
it("prefers an existing .bashrc over the Bash login profile", async () => {
await withBashCompletionHome(async ({ homeDir }) => {
const bashrc = path.join(homeDir, ".bashrc");
const bashProfile = path.join(homeDir, ".bash_profile");
const cachePath = resolveCompletionCachePath("bash", "openclaw");
await fs.writeFile(bashrc, "# existing interactive Bash profile\n", "utf-8");
await fs.writeFile(bashProfile, "# existing Bash login profile\n", "utf-8");
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8");
expect(resolveCompletionProfilePath("bash")).toBe(bashrc);
await installCompletion("bash", true, "openclaw");
await expect(fs.readFile(bashrc, "utf-8")).resolves.toContain(cachePath);
await expect(fs.readFile(bashProfile, "utf-8")).resolves.toBe(
"# existing Bash login profile\n",
);
await expect(isCompletionInstalled("bash", "openclaw")).resolves.toBe(true);
await expect(usesSlowDynamicCompletion("bash", "openclaw")).resolves.toBe(false);
});
});
it("formats PowerShell reload commands with single-quoted paths", () => {
expect(formatCompletionReloadCommand("powershell", "C:\\Users\\Ada\\profile.ps1")).toBe(
". 'C:\\Users\\Ada\\profile.ps1'",

View File

@@ -1,4 +1,5 @@
// Shell completion runtime: cache paths, profile installation, and shell detection.
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -170,7 +171,9 @@ export function resolveCompletionProfilePath(
return path.join(home, ".zshrc");
}
if (shell === "bash") {
return path.join(home, ".bashrc");
// Installation, status, and repairs must inspect the same real Bash profile.
const bashrc = path.join(home, ".bashrc");
return existsSync(bashrc) ? bashrc : path.join(home, ".bash_profile");
}
if (shell === "fish") {
return path.join(home, ".config", "fish", "config.fish");
@@ -257,12 +260,6 @@ export async function installCompletion(shell: string, yes: boolean, binName = "
break;
case "bash":
profilePath = resolveCompletionProfilePath("bash");
try {
await fs.access(profilePath);
} catch {
const home = process.env.HOME || os.homedir();
profilePath = path.join(home, ".bash_profile");
}
sourceLine = formatCompletionSourceLine("bash", cachePath);
break;
case "fish":

View File

@@ -40,6 +40,53 @@ function status(overrides: Partial<ShellCompletionStatus> = {}): ShellCompletion
}
describe("shell completion health mapping", () => {
it("recognizes cached Bash completion from the documented login profile", async () => {
const homeDir = tempDirs.make("openclaw-bash-profile-home-");
const stateDir = tempDirs.make("openclaw-bash-profile-state-");
setTestEnvValue("HOME", homeDir);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
setTestEnvValue("SHELL", "/bin/bash");
const cachePath = path.join(stateDir, "completions", "openclaw.bash");
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8");
await fs.writeFile(
path.join(homeDir, ".bash_profile"),
`# OpenClaw Completion\n[ -f "${cachePath}" ] && source "${cachePath}"\n`,
"utf-8",
);
await expect(checkShellCompletionStatus("openclaw", { shell: "bash" })).resolves.toEqual({
shell: "bash",
profileInstalled: true,
cacheExists: true,
cachePath,
usesSlowPattern: false,
});
});
it("reports slow dynamic Bash completion from the documented login profile", async () => {
const homeDir = tempDirs.make("openclaw-bash-slow-profile-home-");
const stateDir = tempDirs.make("openclaw-bash-slow-profile-state-");
setTestEnvValue("HOME", homeDir);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
setTestEnvValue("SHELL", "/bin/bash");
await fs.writeFile(
path.join(homeDir, ".bash_profile"),
"source <(openclaw completion --shell bash)\n",
"utf-8",
);
await expect(checkShellCompletionStatus("openclaw", { shell: "bash" })).resolves.toEqual({
shell: "bash",
profileInstalled: true,
cacheExists: false,
cachePath: path.join(stateDir, "completions", "openclaw.bash"),
usesSlowPattern: true,
});
});
it("checks an explicit shell instead of the detected environment shell", async () => {
const homeDir = tempDirs.make("openclaw-completion-home-");
const stateDir = tempDirs.make("openclaw-completion-state-");
@@ -174,6 +221,20 @@ describe("doctorShellCompletion", () => {
spawnSyncMock.mockClear();
});
it("shows the Bash login profile after installing completion without .bashrc", async () => {
await setupDoctorCompletionTest(false);
installCompletionMock.mockResolvedValue(undefined);
const noteSpy = vi.spyOn(noteModule, "note");
await doctorShellCompletion({} as never, mockPrompter());
expect(installCompletionMock).toHaveBeenCalledWith("bash", true, "openclaw");
expect(noteSpy).toHaveBeenCalledWith(
expect.stringContaining("source ~/.bash_profile"),
"Shell completion",
);
});
it.each([
{ generationMode: "core-only" as const, expectedSkipValue: "1" },
{ generationMode: "full" as const, expectedSkipValue: undefined },

View File

@@ -44,7 +44,10 @@ function resolveCompletionReloadPath(shell: CompletionShell): string {
if (shell === "powershell") {
return resolveCompletionProfilePath("powershell");
}
return `~/.${shell === "zsh" ? "zshrc" : shell === "bash" ? "bashrc" : "config/fish/config.fish"}`;
if (shell === "bash") {
return `~/${path.basename(resolveCompletionProfilePath("bash"))}`;
}
return `~/.${shell === "zsh" ? "zshrc" : "config/fish/config.fish"}`;
}
function formatCompletionReloadNote(