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
+90 -1
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'",
+4 -7
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":