From 15b0ee80e15eb849d83dc845dae0b04ce57b016b Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 1 Jan 2026 20:17:22 +0900 Subject: [PATCH] feat(doctor): add GitHub CLI check (#384) Add doctor check for GitHub CLI (gh) that verifies: - Binary installation status - Authentication status with GitHub - Account details and token scopes when authenticated Closes #374 Co-authored-by: sisyphus-dev-ai --- src/cli/doctor/checks/gh.test.ts | 106 +++++++++++++++++++ src/cli/doctor/checks/gh.ts | 171 +++++++++++++++++++++++++++++++ src/cli/doctor/checks/index.ts | 3 + src/cli/doctor/constants.ts | 2 + 4 files changed, 282 insertions(+) create mode 100644 src/cli/doctor/checks/gh.test.ts create mode 100644 src/cli/doctor/checks/gh.ts diff --git a/src/cli/doctor/checks/gh.test.ts b/src/cli/doctor/checks/gh.test.ts new file mode 100644 index 0000000..95a9bf1 --- /dev/null +++ b/src/cli/doctor/checks/gh.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, spyOn, afterEach } from "bun:test" +import * as gh from "./gh" + +describe("gh cli check", () => { + describe("getGhCliInfo", () => { + it("returns gh cli info structure", async () => { + // #given + // #when checking gh cli info + const info = await gh.getGhCliInfo() + + // #then should return valid info structure + expect(typeof info.installed).toBe("boolean") + expect(info.authenticated === true || info.authenticated === false).toBe(true) + expect(Array.isArray(info.scopes)).toBe(true) + }) + }) + + describe("checkGhCli", () => { + let getInfoSpy: ReturnType + + afterEach(() => { + getInfoSpy?.mockRestore() + }) + + it("returns warn when gh is not installed", async () => { + // #given gh not installed + getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({ + installed: false, + version: null, + path: null, + authenticated: false, + username: null, + scopes: [], + error: null, + }) + + // #when checking + const result = await gh.checkGhCli() + + // #then should warn (optional) + expect(result.status).toBe("warn") + expect(result.message).toContain("Not installed") + expect(result.details).toContain("Install: https://cli.github.com/") + }) + + it("returns warn when gh is installed but not authenticated", async () => { + // #given gh installed but not authenticated + getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({ + installed: true, + version: "2.40.0", + path: "/usr/local/bin/gh", + authenticated: false, + username: null, + scopes: [], + error: "not logged in", + }) + + // #when checking + const result = await gh.checkGhCli() + + // #then should warn about auth + expect(result.status).toBe("warn") + expect(result.message).toContain("2.40.0") + expect(result.message).toContain("not authenticated") + expect(result.details).toContain("Authenticate: gh auth login") + }) + + it("returns pass when gh is installed and authenticated", async () => { + // #given gh installed and authenticated + getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({ + installed: true, + version: "2.40.0", + path: "/usr/local/bin/gh", + authenticated: true, + username: "octocat", + scopes: ["repo", "read:org"], + error: null, + }) + + // #when checking + const result = await gh.checkGhCli() + + // #then should pass + expect(result.status).toBe("pass") + expect(result.message).toContain("2.40.0") + expect(result.message).toContain("octocat") + expect(result.details).toContain("Account: octocat") + expect(result.details).toContain("Scopes: repo, read:org") + }) + }) + + describe("getGhCliCheckDefinition", () => { + it("returns correct check definition", () => { + // #given + // #when getting definition + const def = gh.getGhCliCheckDefinition() + + // #then should have correct properties + expect(def.id).toBe("gh-cli") + expect(def.name).toBe("GitHub CLI") + expect(def.category).toBe("tools") + expect(def.critical).toBe(false) + expect(typeof def.check).toBe("function") + }) + }) +}) diff --git a/src/cli/doctor/checks/gh.ts b/src/cli/doctor/checks/gh.ts new file mode 100644 index 0000000..06b2ca8 --- /dev/null +++ b/src/cli/doctor/checks/gh.ts @@ -0,0 +1,171 @@ +import type { CheckResult, CheckDefinition } from "../types" +import { CHECK_IDS, CHECK_NAMES } from "../constants" + +export interface GhCliInfo { + installed: boolean + version: string | null + path: string | null + authenticated: boolean + username: string | null + scopes: string[] + error: string | null +} + +async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> { + try { + const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" }) + const output = await new Response(proc.stdout).text() + await proc.exited + if (proc.exitCode === 0) { + return { exists: true, path: output.trim() } + } + } catch { + // intentionally empty - binary not found + } + return { exists: false, path: null } +} + +async function getGhVersion(): Promise { + try { + const proc = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" }) + const output = await new Response(proc.stdout).text() + await proc.exited + if (proc.exitCode === 0) { + const match = output.match(/gh version (\S+)/) + return match?.[1] ?? output.trim().split("\n")[0] + } + } catch { + // intentionally empty - version unavailable + } + return null +} + +async function getGhAuthStatus(): Promise<{ + authenticated: boolean + username: string | null + scopes: string[] + error: string | null +}> { + try { + const proc = Bun.spawn(["gh", "auth", "status"], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" }, + }) + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + await proc.exited + + const output = stderr || stdout + + if (proc.exitCode === 0) { + const usernameMatch = output.match(/Logged in to github\.com account (\S+)/) + const username = usernameMatch?.[1]?.replace(/[()]/g, "") ?? null + + const scopesMatch = output.match(/Token scopes?:\s*(.+)/i) + const scopes = scopesMatch?.[1] + ? scopesMatch[1] + .split(/,\s*/) + .map((s) => s.replace(/['"]/g, "").trim()) + .filter(Boolean) + : [] + + return { authenticated: true, username, scopes, error: null } + } + + const errorMatch = output.match(/error[:\s]+(.+)/i) + return { + authenticated: false, + username: null, + scopes: [], + error: errorMatch?.[1]?.trim() ?? "Not authenticated", + } + } catch (err) { + return { + authenticated: false, + username: null, + scopes: [], + error: err instanceof Error ? err.message : "Failed to check auth status", + } + } +} + +export async function getGhCliInfo(): Promise { + const binaryCheck = await checkBinaryExists("gh") + + if (!binaryCheck.exists) { + return { + installed: false, + version: null, + path: null, + authenticated: false, + username: null, + scopes: [], + error: null, + } + } + + const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()]) + + return { + installed: true, + version, + path: binaryCheck.path, + authenticated: authStatus.authenticated, + username: authStatus.username, + scopes: authStatus.scopes, + error: authStatus.error, + } +} + +export async function checkGhCli(): Promise { + const info = await getGhCliInfo() + const name = CHECK_NAMES[CHECK_IDS.GH_CLI] + + if (!info.installed) { + return { + name, + status: "warn", + message: "Not installed (optional)", + details: [ + "GitHub CLI is used by librarian agent and scripts", + "Install: https://cli.github.com/", + ], + } + } + + if (!info.authenticated) { + return { + name, + status: "warn", + message: `${info.version ?? "installed"} - not authenticated`, + details: [ + info.path ? `Path: ${info.path}` : null, + "Authenticate: gh auth login", + info.error ? `Error: ${info.error}` : null, + ].filter((d): d is string => d !== null), + } + } + + const details: string[] = [] + if (info.path) details.push(`Path: ${info.path}`) + if (info.username) details.push(`Account: ${info.username}`) + if (info.scopes.length > 0) details.push(`Scopes: ${info.scopes.join(", ")}`) + + return { + name, + status: "pass", + message: `${info.version ?? "installed"} - authenticated as ${info.username ?? "unknown"}`, + details: details.length > 0 ? details : undefined, + } +} + +export function getGhCliCheckDefinition(): CheckDefinition { + return { + id: CHECK_IDS.GH_CLI, + name: CHECK_NAMES[CHECK_IDS.GH_CLI], + category: "tools", + check: checkGhCli, + critical: false, + } +} diff --git a/src/cli/doctor/checks/index.ts b/src/cli/doctor/checks/index.ts index 09457f5..af82d3c 100644 --- a/src/cli/doctor/checks/index.ts +++ b/src/cli/doctor/checks/index.ts @@ -4,6 +4,7 @@ import { getPluginCheckDefinition } from "./plugin" import { getConfigCheckDefinition } from "./config" import { getAuthCheckDefinitions } from "./auth" import { getDependencyCheckDefinitions } from "./dependencies" +import { getGhCliCheckDefinition } from "./gh" import { getLspCheckDefinition } from "./lsp" import { getMcpCheckDefinitions } from "./mcp" import { getVersionCheckDefinition } from "./version" @@ -13,6 +14,7 @@ export * from "./plugin" export * from "./config" export * from "./auth" export * from "./dependencies" +export * from "./gh" export * from "./lsp" export * from "./mcp" export * from "./version" @@ -24,6 +26,7 @@ export function getAllCheckDefinitions(): CheckDefinition[] { getConfigCheckDefinition(), ...getAuthCheckDefinitions(), ...getDependencyCheckDefinitions(), + getGhCliCheckDefinition(), getLspCheckDefinition(), ...getMcpCheckDefinitions(), getVersionCheckDefinition(), diff --git a/src/cli/doctor/constants.ts b/src/cli/doctor/constants.ts index 7a3d7d6..3b9a285 100644 --- a/src/cli/doctor/constants.ts +++ b/src/cli/doctor/constants.ts @@ -27,6 +27,7 @@ export const CHECK_IDS = { DEP_AST_GREP_CLI: "dep-ast-grep-cli", DEP_AST_GREP_NAPI: "dep-ast-grep-napi", DEP_COMMENT_CHECKER: "dep-comment-checker", + GH_CLI: "gh-cli", LSP_SERVERS: "lsp-servers", MCP_BUILTIN: "mcp-builtin", MCP_USER: "mcp-user", @@ -43,6 +44,7 @@ export const CHECK_NAMES: Record = { [CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI", [CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI", [CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker", + [CHECK_IDS.GH_CLI]: "GitHub CLI", [CHECK_IDS.LSP_SERVERS]: "LSP Servers", [CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers", [CHECK_IDS.MCP_USER]: "User MCP Configuration",