diff --git a/scripts/test-shell-completion.ts b/scripts/test-shell-completion.ts index 40e995d3c0d9..7b2b573c5362 100644 --- a/scripts/test-shell-completion.ts +++ b/scripts/test-shell-completion.ts @@ -25,10 +25,17 @@ import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { confirm, isCancel } from "@clack/prompts"; import { stylePromptMessage } from "../packages/terminal-core/src/prompt-style.js"; import { theme } from "../packages/terminal-core/src/theme.js"; -import { installCompletion } from "../src/cli/completion-cli.js"; +import { + COMPLETION_SHELLS, + installCompletion, + isCompletionShell, + resolveCompletionProfilePath, + type CompletionShell, +} from "../src/cli/completion-runtime.js"; import { checkShellCompletionStatus, ensureCompletionCacheExists, @@ -40,6 +47,7 @@ interface Options { checkOnly: boolean; force: boolean; help: boolean; + shell?: CompletionShell; } function parseArgs(args: string[]): Options { @@ -49,19 +57,38 @@ function parseArgs(args: string[]): Options { help: false, }; - for (const arg of args) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; if (arg === "--check-only") { options.checkOnly = true; } else if (arg === "--force") { options.force = true; } else if (arg === "--help" || arg === "-h") { options.help = true; + } else if (arg === "--shell") { + const value = args[index + 1]; + options.shell = parseShellOptionValue(value); + index += 1; + } else if (arg.startsWith("--shell=")) { + options.shell = parseShellOptionValue(arg.slice("--shell=".length)); + } else { + throw new Error(`Unknown argument: ${arg}`); } } return options; } +function parseShellOptionValue(value: string | undefined): CompletionShell { + if (!value || value.startsWith("-")) { + throw new Error("--shell requires a value"); + } + if (!isCompletionShell(value)) { + throw new Error(`--shell must be one of: ${COMPLETION_SHELLS.join(", ")}`); + } + return value; +} + function printHelp(): void { console.log(` ${theme.heading("Shell Completion Test Script")} @@ -75,6 +102,7 @@ ${theme.heading("Usage (run from repo root):")} bun scripts/test-shell-completion.ts [options] ${theme.heading("Options:")} + --shell Override shell detection (zsh, bash, fish, powershell) --check-only Only check status, don't prompt to install --force Skip the "already installed" check and prompt anyway --help, -h Show this help message @@ -87,37 +115,11 @@ ${theme.heading("Behavior:")} ${theme.heading("Examples:")} node --import tsx scripts/test-shell-completion.ts node --import tsx scripts/test-shell-completion.ts --check-only + node --import tsx scripts/test-shell-completion.ts --shell bash node --import tsx scripts/test-shell-completion.ts --force `); } -function getShellProfilePath(shell: string): string { - const home = process.env.HOME || os.homedir(); - - switch (shell) { - case "zsh": - return path.join(home, ".zshrc"); - case "bash": - return process.platform === "darwin" - ? path.join(home, ".bash_profile") - : path.join(home, ".bashrc"); - case "fish": - return path.join(home, ".config", "fish", "config.fish"); - case "powershell": - if (process.platform === "win32") { - return path.join( - process.env.USERPROFILE || home, - "Documents", - "PowerShell", - "Microsoft.PowerShell_profile.ps1", - ); - } - return path.join(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"); - default: - return path.join(home, ".zshrc"); - } -} - async function main() { const args = process.argv.slice(2); const options = parseArgs(args); @@ -131,11 +133,12 @@ async function main() { console.log(""); // Get completion status using the same function used by doctor/update/onboard - const status = await checkShellCompletionStatus(CLI_NAME); + const status = await checkShellCompletionStatus(CLI_NAME, { shell: options.shell }); + const shellSource = options.shell ? "(from --shell)" : "(detected from $SHELL)"; - console.log(` Shell: ${theme.accent(status.shell)} ${theme.muted("(detected from $SHELL)")}`); + console.log(` Shell: ${theme.accent(status.shell)} ${theme.muted(shellSource)}`); console.log(` Platform: ${theme.muted(process.platform)} ${theme.muted(`(${os.release()})`)}`); - console.log(` Profile: ${theme.muted(getShellProfilePath(status.shell))}`); + console.log(` Profile: ${theme.muted(resolveCompletionProfilePath(status.shell))}`); console.log(` Cache path: ${theme.muted(status.cachePath)}`); console.log(""); console.log( @@ -155,7 +158,7 @@ async function main() { // Profile uses slow dynamic pattern - upgrade to cached version if (status.usesSlowPattern) { console.log(theme.warn("Profile uses slow dynamic completion. Upgrading to cached version...")); - const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME); + const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell }); if (cacheGenerated) { await installCompletion(status.shell, false, CLI_NAME); console.log(theme.success("Upgraded to cached completion.")); @@ -168,7 +171,7 @@ async function main() { // Profile has completion but no cache - auto-fix if (status.profileInstalled && !status.cacheExists) { console.log(theme.warn("Profile has completion but cache is missing. Regenerating...")); - const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME); + const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell }); if (cacheGenerated) { console.log(theme.success("Cache regenerated successfully.")); } else { @@ -205,7 +208,7 @@ async function main() { // Generate cache first (required for fast shell startup) if (!status.cacheExists) { console.log(theme.muted("Generating completion cache...")); - const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME); + const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell }); if (!cacheGenerated) { console.log(theme.error("Failed to generate completion cache.")); return; @@ -217,7 +220,14 @@ async function main() { await installCompletion(status.shell, false, CLI_NAME); } -main().catch((err: unknown) => { - console.error(theme.error(`Error: ${String(err)}`)); - process.exit(1); -}); +export const testing = { + parseArgs, +}; + +if (import.meta.url === pathToFileURL(path.resolve(process.argv[1] ?? "")).href) { + main().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + console.error(theme.error(`Error: ${message}`)); + process.exit(1); + }); +} diff --git a/src/commands/doctor-completion.test.ts b/src/commands/doctor-completion.test.ts index 61af8caa0f24..62f9170938fd 100644 --- a/src/commands/doctor-completion.test.ts +++ b/src/commands/doctor-completion.test.ts @@ -1,11 +1,35 @@ // Doctor completion tests cover final doctor status summaries and completion messaging. -import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { + checkShellCompletionStatus, shellCompletionStatusToHealthFindings, shellCompletionStatusToRepairEffects, type ShellCompletionStatus, } from "./doctor-completion.js"; +const originalEnv = { + HOME: process.env.HOME, + OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, + SHELL: process.env.SHELL, +}; +const tempDirs: string[] = []; + +afterEach(async () => { + for (const [name, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + for (const dir of tempDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + function status(overrides: Partial = {}): ShellCompletionStatus { return { shell: "zsh", @@ -18,6 +42,22 @@ function status(overrides: Partial = {}): ShellCompletion } describe("shell completion health mapping", () => { + it("checks an explicit shell instead of the detected environment shell", async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-home-")); + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-state-")); + tempDirs.push(homeDir, stateDir); + process.env.HOME = homeDir; + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.SHELL = "/bin/zsh"; + + const current = await checkShellCompletionStatus("openclaw", { shell: "fish" }); + + expect(current.shell).toBe("fish"); + expect(current.cachePath).toBe(path.join(stateDir, "completions", "openclaw.fish")); + expect(current.profileInstalled).toBe(false); + expect(current.cacheExists).toBe(false); + }); + it("reports slow dynamic shell completion with dry-run effects", () => { const current = status({ usesSlowPattern: true, cacheExists: false }); diff --git a/src/commands/doctor-completion.ts b/src/commands/doctor-completion.ts index f08d8a7963ac..a3e95defb079 100644 --- a/src/commands/doctor-completion.ts +++ b/src/commands/doctor-completion.ts @@ -12,16 +12,19 @@ import { resolveCompletionProfilePath, resolveShellFromEnv, usesSlowDynamicCompletion, + type CompletionShell, } from "../cli/completion-runtime.js"; import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; import type { RuntimeEnv } from "../runtime.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; -type CompletionShell = "zsh" | "bash" | "fish" | "powershell"; - const COMPLETION_CACHE_WRITE_TIMEOUT_MS = 30_000; +export type ShellCompletionStatusOptions = { + shell?: CompletionShell; +}; + function resolveCompletionReloadPath(shell: CompletionShell): string { if (shell === "powershell") { return resolveCompletionProfilePath("powershell"); @@ -38,7 +41,9 @@ function formatCompletionReloadNote( } /** Generate the completion cache by spawning the CLI. */ -async function generateCompletionCache(): Promise { +async function generateCompletionCache( + options: ShellCompletionStatusOptions = {}, +): Promise { const root = await resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, argv1: process.argv[1], @@ -49,7 +54,11 @@ async function generateCompletionCache(): Promise { } const binPath = path.join(root, "openclaw.mjs"); - const result = spawnSync(process.execPath, [binPath, "completion", "--write-state"], { + const args = [binPath, "completion", "--write-state"]; + if (options.shell) { + args.push("--shell", options.shell); + } + const result = spawnSync(process.execPath, args, { cwd: root, env: process.env, encoding: "utf-8", @@ -71,8 +80,9 @@ export type ShellCompletionStatus = { /** Check the status of shell completion for the current shell. */ export async function checkShellCompletionStatus( binName = "openclaw", + options: ShellCompletionStatusOptions = {}, ): Promise { - const shell = resolveShellFromEnv() as CompletionShell; + const shell = options.shell ?? resolveShellFromEnv(); const profileInstalled = await isCompletionInstalled(shell, binName); const cacheExists = await completionCacheExists(shell, binName); const cachePath = resolveCompletionCachePath(shell, binName); @@ -234,13 +244,16 @@ export async function doctorShellCompletion( } /** Ensures the shell completion cache exists without prompting during setup/update flows. */ -export async function ensureCompletionCacheExists(binName = "openclaw"): Promise { - const shell = resolveShellFromEnv() as CompletionShell; +export async function ensureCompletionCacheExists( + binName = "openclaw", + options: ShellCompletionStatusOptions = {}, +): Promise { + const shell = options.shell ?? resolveShellFromEnv(); const cacheExists = await completionCacheExists(shell, binName); if (cacheExists) { return true; } - return generateCompletionCache(); + return generateCompletionCache(options); } diff --git a/test/scripts/test-shell-completion.test.ts b/test/scripts/test-shell-completion.test.ts new file mode 100644 index 000000000000..d441d24f454d --- /dev/null +++ b/test/scripts/test-shell-completion.test.ts @@ -0,0 +1,103 @@ +// Shell completion test script tests cover local diagnostic CLI argument safety. +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { testing as shellCompletionTesting } from "../../scripts/test-shell-completion.ts"; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await fs.rm(dir, { force: true, recursive: true }); + } +}); + +describe("test-shell-completion script", () => { + it("parses explicit shell overrides", () => { + expect(shellCompletionTesting.parseArgs(["--shell", "bash", "--check-only"])).toEqual({ + checkOnly: true, + force: false, + help: false, + shell: "bash", + }); + expect(shellCompletionTesting.parseArgs(["--shell=fish"])).toEqual({ + checkOnly: false, + force: false, + help: false, + shell: "fish", + }); + }); + + it("rejects unknown or malformed arguments before touching shell state", () => { + expect(() => shellCompletionTesting.parseArgs(["--wat"])).toThrow("Unknown argument: --wat"); + expect(() => shellCompletionTesting.parseArgs(["--shell"])).toThrow("--shell requires a value"); + expect(() => shellCompletionTesting.parseArgs(["--shell", "--check-only"])).toThrow( + "--shell requires a value", + ); + expect(() => shellCompletionTesting.parseArgs(["--shell", "tcsh"])).toThrow( + "--shell must be one of:", + ); + }); + + it("prints help without running completion status checks", () => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "scripts/test-shell-completion.ts", "--help"], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("--shell "); + expect(result.stdout).not.toContain("Cache path:"); + }); + + it("fails unknown arguments before prompting or checking shell state", () => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "scripts/test-shell-completion.ts", "--wat"], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Unknown argument: --wat"); + }); + + it("uses --shell for check-only status instead of the detected shell", async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-shell-home-")); + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-shell-state-")); + tempDirs.push(homeDir, stateDir); + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "scripts/test-shell-completion.ts", "--shell", "fish", "--check-only"], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + HOME: homeDir, + NO_COLOR: "1", + OPENCLAW_STATE_DIR: stateDir, + SHELL: "/bin/zsh", + }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Shell:"); + expect(result.stdout).toContain("fish"); + expect(result.stdout).toContain("(from --shell)"); + expect(result.stdout).not.toContain("(detected from $SHELL)"); + }); +});