diff --git a/internal/app/doctor.go b/internal/app/doctor.go index 26a2ec7..341e2eb 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -64,6 +64,7 @@ func runDoctor(opts runOptions) error { results = append(results, checkAgentsMDSize(repoRoot)) results = append(results, checkREADMESkillInventory(repoRoot)) results = append(results, checkMemsearchIndex(home)) + results = append(results, checkHerdrPluginHealth()) results = append(results, checkExternalPackageAge(repoRoot, cfg, opts.SkipPackageAge, timeNow())) results = append(results, checkExternalSkillSources(cfg, home)) results = append(results, checkMaterializedExternalSkills(repoRoot, cfg, home)) diff --git a/internal/app/doctor_herdr.go b/internal/app/doctor_herdr.go new file mode 100644 index 0000000..a7e0661 --- /dev/null +++ b/internal/app/doctor_herdr.go @@ -0,0 +1,186 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" +) + +type herdrPluginCommand struct { + Command []string `json:"command"` + Platforms []string `json:"platforms"` +} + +type herdrPlugin struct { + PluginID string `json:"plugin_id"` + PluginRoot string `json:"plugin_root"` + Actions []herdrPluginCommand `json:"actions"` + Events []herdrPluginCommand `json:"events"` + Panes []herdrPluginCommand `json:"panes"` +} + +type herdrPluginListResponse struct { + Result struct { + Plugins []herdrPlugin `json:"plugins"` + } `json:"result"` +} + +type herdrPluginLog struct { + PluginID string `json:"plugin_id"` + Command []string `json:"command"` + Status string `json:"status"` + Error string `json:"error"` + Stderr string `json:"stderr"` + LogID string `json:"log_id"` + StartedUnixM int64 `json:"started_unix_ms"` +} + +type herdrPluginLogResponse struct { + Result struct { + Logs []herdrPluginLog `json:"logs"` + } `json:"result"` +} + +type executableLookup func(string) (string, error) + +func checkHerdrPluginHealth() checkResult { + if os.Getenv("HERDR_ENV") != "1" { + return checkResult{"herdr plugins", checkStatusPass, "not running inside Herdr, skipped"} + } + herdr, err := exec.LookPath("herdr") + if err != nil { + return checkResult{"herdr plugins", checkStatusWarn, "Herdr session detected but herdr CLI is not on PATH"} + } + + pluginData, err := exec.Command(herdr, "plugin", "list", "--json").Output() + if err != nil { + return checkResult{"herdr plugins", checkStatusWarn, fmt.Sprintf("cannot inspect installed plugins: %v", err)} + } + logData, err := exec.Command(herdr, "plugin", "log", "list").Output() + if err != nil { + return checkResult{"herdr plugins", checkStatusWarn, fmt.Sprintf("cannot inspect plugin command logs: %v", err)} + } + + var plugins herdrPluginListResponse + if err := json.Unmarshal(pluginData, &plugins); err != nil { + return checkResult{"herdr plugins", checkStatusWarn, fmt.Sprintf("cannot parse plugin inventory: %v", err)} + } + var logs herdrPluginLogResponse + if err := json.Unmarshal(logData, &logs); err != nil { + return checkResult{"herdr plugins", checkStatusWarn, fmt.Sprintf("cannot parse plugin command logs: %v", err)} + } + return assessHerdrPluginHealth(plugins.Result.Plugins, logs.Result.Logs, exec.LookPath) +} + +func assessHerdrPluginHealth(plugins []herdrPlugin, logs []herdrPluginLog, lookup executableLookup) checkResult { + latest := make(map[string]herdrPluginLog) + for _, log := range logs { + key := herdrCommandKey(log.PluginID, log.Command) + if prior, ok := latest[key]; !ok || log.StartedUnixM > prior.StartedUnixM { + latest[key] = log + } + } + + var issues []string + for _, plugin := range plugins { + commands := append([]herdrPluginCommand{}, plugin.Actions...) + commands = append(commands, plugin.Events...) + commands = append(commands, plugin.Panes...) + for _, declared := range commands { + if len(declared.Command) == 0 || !herdrCommandApplies(declared.Platforms, runtime.GOOS) { + continue + } + missingPath := missingHerdrCommandPath(plugin.PluginRoot, declared.Command) + if missingPath != "" { + issues = append(issues, fmt.Sprintf("%s declares missing command file %s; reinstall or update the plugin", plugin.PluginID, missingPath)) + continue + } + executable := declared.Command[0] + if !strings.ContainsRune(executable, filepath.Separator) { + if _, err := lookup(executable); err != nil { + issues = append(issues, fmt.Sprintf("%s requires executable %q but doctor cannot resolve it; install it or use an absolute executable path in the plugin manifest", plugin.PluginID, executable)) + continue + } + } + + log, ok := latest[herdrCommandKey(plugin.PluginID, declared.Command)] + if !ok || log.Status != "failed" { + continue + } + if strings.Contains(strings.ToLower(log.Error), "no such file or directory") { + if !strings.ContainsRune(executable, filepath.Separator) { + if resolved, err := lookup(executable); err == nil { + issues = append(issues, fmt.Sprintf("%s cannot start %q from the Herdr server PATH although doctor resolves it at %s; use that absolute executable in the plugin manifest or restart Herdr with its directory on PATH", plugin.PluginID, executable, resolved)) + continue + } + } + issues = append(issues, fmt.Sprintf("%s cannot start %q; install it or use an absolute executable path in the plugin manifest", plugin.PluginID, executable)) + continue + } + detail := strings.TrimSpace(log.Error) + if detail == "" { + detail = strings.TrimSpace(log.Stderr) + } + if detail == "" { + detail = "command failed" + } + issues = append(issues, fmt.Sprintf("%s latest command log %s failed: %s", plugin.PluginID, log.LogID, detail)) + } + } + + if len(issues) > 0 { + sort.Strings(issues) + return checkResult{"herdr plugins", checkStatusWarn, strings.Join(issues, "; ")} + } + return checkResult{"herdr plugins", checkStatusPass, fmt.Sprintf("%d installed plugins have valid commands and no current command failures", len(plugins))} +} + +func herdrCommandApplies(platforms []string, goos string) bool { + if len(platforms) == 0 { + return true + } + if goos == "darwin" { + goos = "macos" + } + for _, platform := range platforms { + if platform == goos { + return true + } + } + return false +} + +func herdrCommandKey(pluginID string, command []string) string { + return pluginID + "\x00" + strings.Join(command, "\x00") +} + +func missingHerdrCommandPath(pluginRoot string, command []string) string { + for i, token := range command { + if i == 0 && !hasHerdrPathSeparator(token) { + continue + } + if strings.HasPrefix(token, "-") || strings.ContainsAny(token, " \t\n\r$\"'`") || (!strings.HasPrefix(token, ".") && !hasHerdrPathSeparator(token)) { + continue + } + candidate := token + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(pluginRoot, candidate) + } + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + } + return "" +} + +// hasHerdrPathSeparator accepts both slash forms. A Windows plugin manifest may +// still write "bin/hook.exe" even though filepath.Separator is a backslash +// there, and such a command must not be reported as healthy. +func hasHerdrPathSeparator(token string) bool { + return strings.ContainsAny(token, `/\`) +} diff --git a/internal/app/doctor_herdr_test.go b/internal/app/doctor_herdr_test.go new file mode 100644 index 0000000..8642066 --- /dev/null +++ b/internal/app/doctor_herdr_test.go @@ -0,0 +1,137 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAssessHerdrPluginHealthDiagnosesServerPATHMismatch(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "src", "hook.js"), []byte("// hook\n"), 0o644); err != nil { + t.Fatal(err) + } + command := []string{"node", "src/hook.js"} + plugins := []herdrPlugin{{ + PluginID: "heeler", + PluginRoot: root, + Events: []herdrPluginCommand{{Command: command}}, + }} + logs := []herdrPluginLog{{ + PluginID: "heeler", + Command: command, + Status: "failed", + Error: "No such file or directory (os error 2)", + StartedUnixM: 10, + }} + + result := assessHerdrPluginHealth(plugins, logs, func(name string) (string, error) { + if name == "node" { + return "/home/test/.local/bin/node", nil + } + return "", errors.New("not found") + }) + if result.status != checkStatusWarn { + t.Fatalf("status = %q, want warn (%s)", result.status, result.detail) + } + for _, want := range []string{"Herdr server PATH", "/home/test/.local/bin/node", "absolute executable"} { + if !strings.Contains(result.detail, want) { + t.Fatalf("detail %q does not contain %q", result.detail, want) + } + } +} + +func TestAssessHerdrPluginHealthIgnoresRecoveredFailure(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "src", "hook.js"), []byte("// hook\n"), 0o644); err != nil { + t.Fatal(err) + } + executable := filepath.Join(root, "node") + if err := os.WriteFile(executable, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + command := []string{executable, "src/hook.js"} + plugins := []herdrPlugin{{ + PluginID: "heeler", + PluginRoot: root, + Events: []herdrPluginCommand{{Command: command}}, + }} + logs := []herdrPluginLog{ + {PluginID: "heeler", Command: command, Status: "failed", Error: "old failure", StartedUnixM: 10}, + {PluginID: "heeler", Command: command, Status: "succeeded", StartedUnixM: 20}, + } + + result := assessHerdrPluginHealth(plugins, logs, func(string) (string, error) { return "", nil }) + if result.status != checkStatusPass { + t.Fatalf("status = %q, want pass (%s)", result.status, result.detail) + } +} + +func TestHerdrCommandAppliesUsesManifestPlatformNames(t *testing.T) { + if !herdrCommandApplies([]string{"macos"}, "darwin") { + t.Fatal("macos command should apply on darwin") + } + if herdrCommandApplies([]string{"windows"}, "darwin") { + t.Fatal("windows command should not apply on darwin") + } +} + +func TestAssessHerdrPluginHealthIgnoresInlineShellProgram(t *testing.T) { + root := t.TempDir() + command := []string{"bash", "-lc", `exec "$HERDR_PLUGIN_ROOT/bin/plugin"`} + plugins := []herdrPlugin{{ + PluginID: "shell-plugin", + PluginRoot: root, + Actions: []herdrPluginCommand{{Command: command}}, + }} + + result := assessHerdrPluginHealth(plugins, nil, func(string) (string, error) { + return "/bin/bash", nil + }) + if result.status != checkStatusPass { + t.Fatalf("status = %q, want pass (%s)", result.status, result.detail) + } +} + +func TestAssessHerdrPluginHealthReportsMissingHookFile(t *testing.T) { + root := t.TempDir() + command := []string{"node", "src/missing-hook.js"} + plugins := []herdrPlugin{{ + PluginID: "broken-plugin", + PluginRoot: root, + Events: []herdrPluginCommand{{Command: command}}, + }} + + result := assessHerdrPluginHealth(plugins, nil, func(string) (string, error) { + return "/usr/bin/node", nil + }) + if result.status != checkStatusWarn { + t.Fatalf("status = %q, want warn (%s)", result.status, result.detail) + } + for _, want := range []string{"missing command file", "reinstall or update"} { + if !strings.Contains(result.detail, want) { + t.Fatalf("detail %q does not contain %q", result.detail, want) + } + } +} + +func TestMissingHerdrCommandPathRecognizesWindowsStyleSeparators(t *testing.T) { + root := t.TempDir() + if got := missingHerdrCommandPath(root, []string{"node", "bin/missing-hook.js"}); got == "" { + t.Fatal("forward-slash relative path was not recognized as a path") + } + if got := missingHerdrCommandPath(root, []string{`C:/plugin/bin/missing-hook.exe`}); got == "" { + t.Fatal("windows-style absolute path was not recognized as a path") + } + if got := missingHerdrCommandPath(root, []string{"node", "-e", "console.log(1)"}); got != "" { + t.Fatalf("inline program reported a missing path: %q", got) + } +}