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 { getConfigCheckDefinition } from "./config"
|
||||||
import { getAuthCheckDefinitions } from "./auth"
|
import { getAuthCheckDefinitions } from "./auth"
|
||||||
import { getDependencyCheckDefinitions } from "./dependencies"
|
import { getDependencyCheckDefinitions } from "./dependencies"
|
||||||
|
import { getGhCliCheckDefinition } from "./gh"
|
||||||
import { getLspCheckDefinition } from "./lsp"
|
import { getLspCheckDefinition } from "./lsp"
|
||||||
import { getMcpCheckDefinitions } from "./mcp"
|
import { getMcpCheckDefinitions } from "./mcp"
|
||||||
import { getVersionCheckDefinition } from "./version"
|
import { getVersionCheckDefinition } from "./version"
|
||||||
@@ -13,6 +14,7 @@ export * from "./plugin"
|
|||||||
export * from "./config"
|
export * from "./config"
|
||||||
export * from "./auth"
|
export * from "./auth"
|
||||||
export * from "./dependencies"
|
export * from "./dependencies"
|
||||||
|
export * from "./gh"
|
||||||
export * from "./lsp"
|
export * from "./lsp"
|
||||||
export * from "./mcp"
|
export * from "./mcp"
|
||||||
export * from "./version"
|
export * from "./version"
|
||||||
@@ -24,6 +26,7 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
|
|||||||
getConfigCheckDefinition(),
|
getConfigCheckDefinition(),
|
||||||
...getAuthCheckDefinitions(),
|
...getAuthCheckDefinitions(),
|
||||||
...getDependencyCheckDefinitions(),
|
...getDependencyCheckDefinitions(),
|
||||||
|
getGhCliCheckDefinition(),
|
||||||
getLspCheckDefinition(),
|
getLspCheckDefinition(),
|
||||||
...getMcpCheckDefinitions(),
|
...getMcpCheckDefinitions(),
|
||||||
getVersionCheckDefinition(),
|
getVersionCheckDefinition(),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const CHECK_IDS = {
|
|||||||
DEP_AST_GREP_CLI: "dep-ast-grep-cli",
|
DEP_AST_GREP_CLI: "dep-ast-grep-cli",
|
||||||
DEP_AST_GREP_NAPI: "dep-ast-grep-napi",
|
DEP_AST_GREP_NAPI: "dep-ast-grep-napi",
|
||||||
DEP_COMMENT_CHECKER: "dep-comment-checker",
|
DEP_COMMENT_CHECKER: "dep-comment-checker",
|
||||||
|
GH_CLI: "gh-cli",
|
||||||
LSP_SERVERS: "lsp-servers",
|
LSP_SERVERS: "lsp-servers",
|
||||||
MCP_BUILTIN: "mcp-builtin",
|
MCP_BUILTIN: "mcp-builtin",
|
||||||
MCP_USER: "mcp-user",
|
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_CLI]: "AST-Grep CLI",
|
||||||
[CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI",
|
[CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI",
|
||||||
[CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker",
|
[CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker",
|
||||||
|
[CHECK_IDS.GH_CLI]: "GitHub CLI",
|
||||||
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
|
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
|
||||||
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
|
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
|
||||||
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
|
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
|
||||||
|
|||||||
Reference in New Issue
Block a user