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 <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
106
src/cli/doctor/checks/gh.test.ts
Normal file
106
src/cli/doctor/checks/gh.test.ts
Normal file
@@ -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<typeof spyOn>
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
171
src/cli/doctor/checks/gh.ts
Normal file
171
src/cli/doctor/checks/gh.ts
Normal file
@@ -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<string | null> {
|
||||
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<GhCliInfo> {
|
||||
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<CheckResult> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<string, string> = {
|
||||
[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",
|
||||
|
||||
Reference in New Issue
Block a user