refactor(doctor): split doctor.go into modular package files (#653)

* refactor(doctor): split doctor.go into modular package files

Split the 3,171-line doctor.go into logical sub-files within the
cmd/bd/doctor/ package, reducing the main file to 834 lines (74% reduction).

New package structure:
- types.go: DoctorCheck struct and status constants
- installation.go: CheckInstallation, CheckMultipleDatabases, CheckPermissions
- git.go: CheckGitHooks, CheckMergeDriver, CheckSyncBranch* checks
- database.go: CheckDatabaseVersion, CheckSchemaCompatibility, CheckDatabaseJSONLSync
- version.go: CheckCLIVersion, CheckMetadataVersionTracking, CompareVersions
- integrity.go: CheckIDFormat, CheckDependencyCycles, CheckTombstones
- daemon.go: CheckDaemonStatus, CheckVersionMismatch
- quick.go: Quick checks for sync-branch and hooks

Updated tests to use exported doctor.CheckXxx() functions and
doctor.StatusXxx constants.

* fix(doctor): suppress gosec G204 false positives

Add #nosec G204 comments to exec.Command calls in CheckSyncBranchHealth
where variables come from trusted sources (config files or hardcoded
values like "main"/"master"/"origin"), not untrusted user input.
This commit is contained in:
Ryan
2025-12-19 17:29:36 -08:00
committed by GitHub
parent c84a2404b7
commit e9be35e374
11 changed files with 2834 additions and 2292 deletions

View File

@@ -2,21 +2,16 @@ package doctor
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// DoctorCheck represents a single diagnostic check result
type DoctorCheck struct {
Name string `json:"name"`
Status string `json:"status"` // "ok", "warning", or "error"
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
Fix string `json:"fix,omitempty"`
}
// CheckClaude returns Claude integration verification as a DoctorCheck
func CheckClaude() DoctorCheck {
// Check what's installed
@@ -350,3 +345,153 @@ func CheckDocumentationBdPrimeReference(repoPath string) DoctorCheck {
Detail: "Files: " + strings.Join(filesWithBdPrime, ", "),
}
}
// CheckClaudePlugin checks if the beads Claude Code plugin is installed and up to date.
func CheckClaudePlugin() DoctorCheck {
// Check if running in Claude Code
if os.Getenv("CLAUDECODE") != "1" {
return DoctorCheck{
Name: "Claude Plugin",
Status: StatusOK,
Message: "N/A (not running in Claude Code)",
}
}
// Get plugin version from installed_plugins.json
pluginVersion, pluginInstalled, err := GetClaudePluginVersion()
if err != nil {
return DoctorCheck{
Name: "Claude Plugin",
Status: StatusWarning,
Message: "Unable to check plugin version",
Detail: err.Error(),
}
}
if !pluginInstalled {
return DoctorCheck{
Name: "Claude Plugin",
Status: StatusWarning,
Message: "beads plugin not installed",
Fix: "Install plugin: /plugin install beads@beads-marketplace",
}
}
// Query PyPI for latest MCP version
latestMCPVersion, err := fetchLatestPyPIVersion("beads-mcp")
if err != nil {
// Network error - don't fail
return DoctorCheck{
Name: "Claude Plugin",
Status: StatusOK,
Message: fmt.Sprintf("version %s (unable to check for updates)", pluginVersion),
}
}
// Compare versions
if latestMCPVersion == "" || pluginVersion == latestMCPVersion {
return DoctorCheck{
Name: "Claude Plugin",
Status: StatusOK,
Message: fmt.Sprintf("version %s (latest)", pluginVersion),
}
}
if CompareVersions(latestMCPVersion, pluginVersion) > 0 {
return DoctorCheck{
Name: "Claude Plugin",
Status: StatusWarning,
Message: fmt.Sprintf("version %s (latest: %s)", pluginVersion, latestMCPVersion),
Fix: "Update plugin: /plugin update beads@beads-marketplace\nRestart Claude Code after update",
}
}
return DoctorCheck{
Name: "Claude Plugin",
Status: StatusOK,
Message: fmt.Sprintf("version %s", pluginVersion),
}
}
// GetClaudePluginVersion returns the installed beads Claude plugin version.
func GetClaudePluginVersion() (version string, installed bool, err error) {
// Get user home directory (cross-platform)
homeDir, err := os.UserHomeDir()
if err != nil {
return "", false, fmt.Errorf("unable to determine home directory: %w", err)
}
// Path to installed_plugins.json
pluginPath := filepath.Join(homeDir, ".claude", "plugins", "installed_plugins.json")
// Read plugin file
data, err := os.ReadFile(pluginPath) // #nosec G304 - path is controlled
if err != nil {
if os.IsNotExist(err) {
return "", false, nil
}
return "", false, fmt.Errorf("unable to read plugin file: %w", err)
}
// Parse JSON - handle nested structure
var pluginData struct {
Version int `json:"version"`
Plugins map[string]struct {
Version string `json:"version"`
} `json:"plugins"`
}
if err := json.Unmarshal(data, &pluginData); err != nil {
return "", false, fmt.Errorf("unable to parse plugin file: %w", err)
}
// Look for beads plugin
if plugin, ok := pluginData.Plugins["beads@beads-marketplace"]; ok {
return plugin.Version, true, nil
}
return "", false, nil
}
func fetchLatestPyPIVersion(packageName string) (string, error) {
url := fmt.Sprintf("https://pypi.org/pypi/%s/json", packageName)
client := &http.Client{
Timeout: 5 * time.Second,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
// Set User-Agent
req.Header.Set("User-Agent", "beads-cli-doctor")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("pypi api returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var data struct {
Info struct {
Version string `json:"version"`
} `json:"info"`
}
if err := json.Unmarshal(body, &data); err != nil {
return "", err
}
return data.Info.Version, nil
}