From a1cc41acbcdd285a7cdb423bdfe45ef06b2f97cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 11:08:59 -0700 Subject: [PATCH] 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 --- src/cli/completion-runtime.test.ts | 91 +++++++++++++++++++++++++- src/cli/completion-runtime.ts | 11 ++-- src/commands/doctor-completion.test.ts | 61 +++++++++++++++++ src/commands/doctor-completion.ts | 5 +- 4 files changed, 159 insertions(+), 9 deletions(-) diff --git a/src/cli/completion-runtime.test.ts b/src/cli/completion-runtime.test.ts index ad76f71bbb9a..9b0eb62276b2 100644 --- a/src/cli/completion-runtime.test.ts +++ b/src/cli/completion-runtime.test.ts @@ -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, +): Promise { + 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'", diff --git a/src/cli/completion-runtime.ts b/src/cli/completion-runtime.ts index 65e2cb66520b..9b60561b0b52 100644 --- a/src/cli/completion-runtime.ts +++ b/src/cli/completion-runtime.ts @@ -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": diff --git a/src/commands/doctor-completion.test.ts b/src/commands/doctor-completion.test.ts index 69d1b290b932..48954cf42587 100644 --- a/src/commands/doctor-completion.test.ts +++ b/src/commands/doctor-completion.test.ts @@ -40,6 +40,53 @@ function status(overrides: Partial = {}): 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 }, diff --git a/src/commands/doctor-completion.ts b/src/commands/doctor-completion.ts index 2769d002667f..88ba477aa0b8 100644 --- a/src/commands/doctor-completion.ts +++ b/src/commands/doctor-completion.ts @@ -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(