d21ed3f765
Doctor checks spawned subprocesses (gh, opencode, sg) without timeouts, causing the process to hang indefinitely if any binary was stuck. The OS would then SIGKILL the process (exit code 137). - Add spawnWithTimeout utility with 10s per-spawn timeout and proper cleanup (timer cleared on success, proc.exited awaited after kill) - Capture both stdout and stderr to preserve gh auth status behavior - Add 30s overall doctor command timeout with JSON-mode support - Distinguish timeout errors from other failures in runner - Update all doctor check subprocess calls to use timeouts
105 lines
2.8 KiB
TypeScript
105 lines
2.8 KiB
TypeScript
import { spawnWithTimeout } from "../spawn-with-timeout"
|
|
|
|
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 binaryPath = Bun.which(binary)
|
|
return { exists: Boolean(binaryPath), path: binaryPath ?? null }
|
|
} catch {
|
|
return { exists: false, path: null }
|
|
}
|
|
}
|
|
|
|
async function getGhVersion(): Promise<string | null> {
|
|
try {
|
|
const result = await spawnWithTimeout(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
|
if (result.timedOut || result.exitCode !== 0) return null
|
|
|
|
const matchedVersion = result.stdout.match(/gh version (\S+)/)
|
|
return matchedVersion?.[1] ?? result.stdout.trim().split("\n")[0] ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function getGhAuthStatus(): Promise<{
|
|
authenticated: boolean
|
|
username: string | null
|
|
scopes: string[]
|
|
error: string | null
|
|
}> {
|
|
try {
|
|
const result = await spawnWithTimeout(
|
|
["gh", "auth", "status"],
|
|
{ stdout: "pipe", stderr: "pipe", env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" } }
|
|
)
|
|
|
|
if (result.timedOut) {
|
|
return { authenticated: false, username: null, scopes: [], error: "gh auth status timed out" }
|
|
}
|
|
|
|
const output = result.stderr || result.stdout
|
|
if (result.exitCode === 0) {
|
|
const usernameMatch = output.match(/Logged in to github\.com account (\S+)/)
|
|
const scopesMatch = output.match(/Token scopes?:\s*(.+)/i)
|
|
|
|
return {
|
|
authenticated: true,
|
|
username: usernameMatch?.[1]?.replace(/[()]/g, "") ?? null,
|
|
scopes: scopesMatch?.[1]?.split(/,\s*/).map((scope) => scope.trim()).filter(Boolean) ?? [],
|
|
error: null,
|
|
}
|
|
}
|
|
|
|
const errorMatch = output.match(/error[:\s]+(.+)/i)
|
|
return {
|
|
authenticated: false,
|
|
username: null,
|
|
scopes: [],
|
|
error: errorMatch?.[1]?.trim() ?? "Not authenticated",
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
authenticated: false,
|
|
username: null,
|
|
scopes: [],
|
|
error: error instanceof Error ? error.message : "Failed to check auth status",
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getGhCliInfo(): Promise<GhCliInfo> {
|
|
const binaryStatus = await checkBinaryExists("gh")
|
|
if (!binaryStatus.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: binaryStatus.path,
|
|
authenticated: authStatus.authenticated,
|
|
username: authStatus.username,
|
|
scopes: authStatus.scopes,
|
|
error: authStatus.error,
|
|
}
|
|
}
|