* feat(cli): enrich ao doctor * fix(cli): address doctor review feedback
This commit is contained in:
parent
bab0d2d167
commit
210c9df758
|
|
@ -2,11 +2,17 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
|
|
@ -24,6 +30,7 @@ const (
|
|||
|
||||
type doctorCheck struct {
|
||||
Level doctorLevel `json:"level"`
|
||||
Section string `json:"section,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
|
@ -34,6 +41,27 @@ type doctorReport struct {
|
|||
Checks []doctorCheck `json:"checks"`
|
||||
}
|
||||
|
||||
const (
|
||||
doctorSectionCore = "Core"
|
||||
doctorSectionTools = "Tools"
|
||||
doctorSectionAgents = "Agent harnesses"
|
||||
doctorSectionGitHub = "GitHub"
|
||||
minGitVersion = "2.25.0"
|
||||
githubDoctorUserAgent = "ao-agent-orchestrator/doctor"
|
||||
defaultDoctorGitHubRESTBase = "https://api.github.com"
|
||||
)
|
||||
|
||||
type harnessProbe struct {
|
||||
Name string
|
||||
BinaryName string
|
||||
VersionArg string
|
||||
}
|
||||
|
||||
var doctorHarnesses = []harnessProbe{
|
||||
{Name: "claude-code", BinaryName: "claude", VersionArg: "--version"},
|
||||
{Name: "codex", BinaryName: "codex", VersionArg: "--version"},
|
||||
}
|
||||
|
||||
func newDoctorCommand(ctx *commandContext) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
|
|
@ -56,10 +84,8 @@ func newDoctorCommand(ctx *commandContext) *cobra.Command {
|
|||
return err
|
||||
}
|
||||
} else {
|
||||
for _, check := range checks {
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeDoctorText(cmd, checks); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,29 +99,53 @@ func newDoctorCommand(ctx *commandContext) *cobra.Command {
|
|||
return cmd
|
||||
}
|
||||
|
||||
func writeDoctorText(cmd *cobra.Command, checks []doctorCheck) error {
|
||||
var lastSection string
|
||||
for _, check := range checks {
|
||||
if check.Section != "" && check.Section != lastSection {
|
||||
if lastSection != "" {
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s:\n", check.Section); err != nil {
|
||||
return err
|
||||
}
|
||||
lastSection = check.Section
|
||||
}
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
|
||||
checks := []doctorCheck{}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return append(checks, doctorCheck{Level: doctorFail, Name: "config", Message: err.Error()})
|
||||
return append(checks, doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "config", Message: err.Error()})
|
||||
}
|
||||
checks = append(checks, doctorCheck{
|
||||
Level: doctorPass, Name: "config",
|
||||
Level: doctorPass, Section: doctorSectionCore, Name: "config",
|
||||
Message: fmt.Sprintf("runFile=%s dataDir=%s port=%d", cfg.RunFilePath, cfg.DataDir, cfg.Port),
|
||||
})
|
||||
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil {
|
||||
checks = append(checks, doctorCheck{Level: doctorFail, Name: "data-dir", Message: err.Error()})
|
||||
checks = append(checks, doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "data-dir", Message: err.Error()})
|
||||
} else {
|
||||
checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir})
|
||||
checks = append(checks,
|
||||
doctorCheck{Level: doctorPass, Section: doctorSectionCore, Name: "data-dir", Message: cfg.DataDir},
|
||||
checkDataDirWritable(cfg.DataDir),
|
||||
)
|
||||
}
|
||||
|
||||
checks = append(checks, checkStore(cfg.DataDir))
|
||||
|
||||
st, err := c.inspectDaemon(ctx)
|
||||
if err != nil {
|
||||
checks = append(checks, doctorCheck{Level: doctorFail, Name: "daemon", Message: err.Error()})
|
||||
checks = append(checks, doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "daemon", Message: err.Error()})
|
||||
} else {
|
||||
level := doctorPass
|
||||
switch st.State {
|
||||
|
|
@ -111,13 +161,17 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
|
|||
if st.Error != "" {
|
||||
msg += " (" + st.Error + ")"
|
||||
}
|
||||
checks = append(checks, doctorCheck{Level: level, Name: "daemon", Message: msg})
|
||||
checks = append(checks, doctorCheck{Level: level, Section: doctorSectionCore, Name: "daemon", Message: msg})
|
||||
}
|
||||
|
||||
checks = append(checks,
|
||||
c.checkTool("git", true),
|
||||
c.checkGit(ctx),
|
||||
c.checkZellij(ctx),
|
||||
)
|
||||
for _, harness := range doctorHarnesses {
|
||||
checks = append(checks, c.checkHarness(ctx, harness))
|
||||
}
|
||||
checks = append(checks, c.checkGitHubToken(ctx))
|
||||
return checks
|
||||
}
|
||||
|
||||
|
|
@ -133,44 +187,249 @@ func checkStore(dataDir string) doctorCheck {
|
|||
switch {
|
||||
case err == nil:
|
||||
return doctorCheck{
|
||||
Level: doctorPass, Name: "sqlite",
|
||||
Level: doctorPass, Section: doctorSectionCore, Name: "sqlite",
|
||||
Message: fmt.Sprintf("%s (%d bytes); migrations are applied by the daemon at startup", dbPath, info.Size()),
|
||||
}
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return doctorCheck{
|
||||
Level: doctorWarn, Name: "sqlite",
|
||||
Level: doctorWarn, Section: doctorSectionCore, Name: "sqlite",
|
||||
Message: "database not created yet; run `ao start` to initialize and migrate it",
|
||||
}
|
||||
default:
|
||||
return doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()}
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "sqlite", Message: err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *commandContext) checkZellij(ctx context.Context) doctorCheck {
|
||||
path, err := c.deps.LookPath("zellij")
|
||||
func checkDataDirWritable(dataDir string) doctorCheck {
|
||||
f, err := os.CreateTemp(dataDir, ".ao-doctor-write-*")
|
||||
if err != nil {
|
||||
return doctorCheck{Level: doctorWarn, Name: "zellij", Message: "not found in PATH"}
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "data-dir-write", Message: err.Error()}
|
||||
}
|
||||
name := f.Name()
|
||||
if _, err := f.WriteString("ok\n"); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(name)
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "data-dir-write", Message: err.Error()}
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(name)
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "data-dir-write", Message: err.Error()}
|
||||
}
|
||||
if err := os.Remove(name); err != nil {
|
||||
return doctorCheck{Level: doctorWarn, Section: doctorSectionCore, Name: "data-dir-write", Message: fmt.Sprintf("write probe succeeded but cleanup failed: %v", err)}
|
||||
}
|
||||
return doctorCheck{Level: doctorPass, Section: doctorSectionCore, Name: "data-dir-write", Message: "write probe succeeded"}
|
||||
}
|
||||
|
||||
func (c *commandContext) checkGit(ctx context.Context) doctorCheck {
|
||||
path, err := c.deps.LookPath("git")
|
||||
if err != nil || path == "" {
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "git", Message: "not found in PATH"}
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
out, err := c.deps.CommandOutput(reqCtx, path, "--version")
|
||||
if err != nil {
|
||||
return doctorCheck{Level: doctorFail, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)}
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s: %v", path, err)}
|
||||
}
|
||||
version, err := parseGitVersion(string(out))
|
||||
if err != nil {
|
||||
return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version unknown: %s)", path, firstOutputLine(out))}
|
||||
}
|
||||
cmp, err := compareDottedVersion(version, minGitVersion)
|
||||
if err != nil {
|
||||
return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version unknown: %s)", path, firstOutputLine(out))}
|
||||
}
|
||||
if cmp < 0 {
|
||||
return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; AO expects >= %s for worktrees)", path, version, minGitVersion)}
|
||||
}
|
||||
return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; supports worktrees)", path, version)}
|
||||
}
|
||||
|
||||
func (c *commandContext) checkZellij(ctx context.Context) doctorCheck {
|
||||
path, err := c.deps.LookPath("zellij")
|
||||
if err != nil || path == "" {
|
||||
return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"}
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
out, err := c.deps.CommandOutput(reqCtx, path, "--version")
|
||||
if err != nil {
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)}
|
||||
}
|
||||
version, err := zellij.CheckVersionOutput(string(out))
|
||||
if err != nil {
|
||||
return doctorCheck{Level: doctorFail, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)}
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)}
|
||||
}
|
||||
return doctorCheck{Level: doctorPass, Name: "zellij", Message: fmt.Sprintf("%s (%s)", path, version)}
|
||||
return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s (version %s; require >= %s)", path, version, zellij.RequiredVersion())}
|
||||
}
|
||||
|
||||
func (c *commandContext) checkTool(name string, required bool) doctorCheck {
|
||||
path, err := c.deps.LookPath(name)
|
||||
if err == nil {
|
||||
return doctorCheck{Level: doctorPass, Name: name, Message: path}
|
||||
func (c *commandContext) checkHarness(ctx context.Context, harness harnessProbe) doctorCheck {
|
||||
path, err := c.deps.LookPath(harness.BinaryName)
|
||||
if err != nil || path == "" {
|
||||
return doctorCheck{
|
||||
Level: doctorWarn, Section: doctorSectionAgents, Name: harness.Name,
|
||||
Message: fmt.Sprintf("%s not found in PATH", harness.BinaryName),
|
||||
}
|
||||
}
|
||||
if required {
|
||||
return doctorCheck{Level: doctorFail, Name: name, Message: "not found in PATH"}
|
||||
if harness.VersionArg == "" {
|
||||
return doctorCheck{Level: doctorPass, Section: doctorSectionAgents, Name: harness.Name, Message: fmt.Sprintf("%s resolves to %s", harness.BinaryName, path)}
|
||||
}
|
||||
return doctorCheck{Level: doctorWarn, Name: name, Message: "not found in PATH"}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
out, err := c.deps.CommandOutput(reqCtx, path, harness.VersionArg)
|
||||
if err != nil {
|
||||
return doctorCheck{
|
||||
Level: doctorWarn, Section: doctorSectionAgents, Name: harness.Name,
|
||||
Message: fmt.Sprintf("%s resolves to %s, but `%s %s` failed: %v", harness.BinaryName, path, harness.BinaryName, harness.VersionArg, err),
|
||||
}
|
||||
}
|
||||
version := firstOutputLine(out)
|
||||
if version == "" {
|
||||
version = "version output was empty"
|
||||
}
|
||||
return doctorCheck{Level: doctorPass, Section: doctorSectionAgents, Name: harness.Name, Message: fmt.Sprintf("%s resolves to %s (%s)", harness.BinaryName, path, version)}
|
||||
}
|
||||
|
||||
func (c *commandContext) checkGitHubToken(ctx context.Context) doctorCheck {
|
||||
token, source, err := c.githubToken(ctx)
|
||||
if err != nil {
|
||||
return doctorCheck{Level: doctorWarn, Section: doctorSectionGitHub, Name: "github-token", Message: err.Error()}
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, strings.TrimRight(c.deps.DoctorGitHubRESTBase, "/")+"/user", http.NoBody)
|
||||
if err != nil {
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionGitHub, Name: "github-token", Message: err.Error()}
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("User-Agent", githubDoctorUserAgent)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := c.deps.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionGitHub, Name: "github-token", Message: fmt.Sprintf("%s token validation failed: %v", source, err)}
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionGitHub, Name: "github-token", Message: fmt.Sprintf("%s token rejected by GitHub (HTTP %d)", source, resp.StatusCode)}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return doctorCheck{Level: doctorWarn, Section: doctorSectionGitHub, Name: "github-token", Message: fmt.Sprintf("%s token probe returned HTTP %d", source, resp.StatusCode)}
|
||||
}
|
||||
|
||||
var user struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return doctorCheck{Level: doctorFail, Section: doctorSectionGitHub, Name: "github-token", Message: fmt.Sprintf("%s token probe decode failed: %v", source, err)}
|
||||
}
|
||||
login := user.Login
|
||||
if login == "" {
|
||||
login = "unknown user"
|
||||
}
|
||||
scopes := strings.TrimSpace(resp.Header.Get("X-OAuth-Scopes"))
|
||||
scopeMsg := "scopes unavailable"
|
||||
if scopes != "" {
|
||||
scopeMsg = "scopes: " + scopes
|
||||
}
|
||||
return doctorCheck{Level: doctorPass, Section: doctorSectionGitHub, Name: "github-token", Message: fmt.Sprintf("%s token valid for %s (%s)", source, login, scopeMsg)}
|
||||
}
|
||||
|
||||
func (c *commandContext) githubToken(ctx context.Context) (token, source string, err error) {
|
||||
for _, name := range []string{"AO_GITHUB_TOKEN", "GITHUB_TOKEN"} {
|
||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||
return v, name, nil
|
||||
}
|
||||
}
|
||||
path, lookErr := c.deps.LookPath("gh")
|
||||
if lookErr != nil || path == "" {
|
||||
return "", "", errors.New("no GitHub token found (set AO_GITHUB_TOKEN/GITHUB_TOKEN or run `gh auth login`)")
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
out, cmdErr := c.deps.CommandOutput(reqCtx, path, "auth", "token")
|
||||
if cmdErr != nil {
|
||||
return "", "", fmt.Errorf("gh is installed but no token was available (`gh auth token` failed: %w)", cmdErr)
|
||||
}
|
||||
token = strings.TrimSpace(string(out))
|
||||
if token == "" {
|
||||
return "", "", errors.New("gh is installed but returned an empty auth token")
|
||||
}
|
||||
return token, "gh", nil
|
||||
}
|
||||
|
||||
var (
|
||||
ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
|
||||
gitVersionRE = regexp.MustCompile(`(?i)\bgit version\s+(\d+(?:\.\d+){1,3})`)
|
||||
)
|
||||
|
||||
func parseGitVersion(out string) (string, error) {
|
||||
clean := ansiRE.ReplaceAllString(out, "")
|
||||
m := gitVersionRE.FindStringSubmatch(clean)
|
||||
if len(m) < 2 {
|
||||
return "", fmt.Errorf("parse git version from %q", strings.TrimSpace(clean))
|
||||
}
|
||||
return m[1], nil
|
||||
}
|
||||
|
||||
func firstOutputLine(out []byte) string {
|
||||
clean := strings.TrimSpace(ansiRE.ReplaceAllString(string(out), ""))
|
||||
if clean == "" {
|
||||
return ""
|
||||
}
|
||||
line := strings.SplitN(clean, "\n", 2)[0]
|
||||
return strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
func compareDottedVersion(a, b string) (int, error) {
|
||||
ap, err := dottedVersionParts(a)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
bp, err := dottedVersionParts(b)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
maxLen := len(ap)
|
||||
if len(bp) > maxLen {
|
||||
maxLen = len(bp)
|
||||
}
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var av, bv int
|
||||
if i < len(ap) {
|
||||
av = ap[i]
|
||||
}
|
||||
if i < len(bp) {
|
||||
bv = bp[i]
|
||||
}
|
||||
switch {
|
||||
case av < bv:
|
||||
return -1, nil
|
||||
case av > bv:
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func dottedVersionParts(s string) ([]int, error) {
|
||||
raw := strings.Split(s, ".")
|
||||
parts := make([]int, 0, len(raw))
|
||||
for _, part := range raw {
|
||||
if part == "" {
|
||||
return nil, fmt.Errorf("empty version segment in %q", s)
|
||||
}
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse version segment %q in %q: %w", part, s, err)
|
||||
}
|
||||
parts = append(parts, n)
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,69 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDoctorChecksGitVersion(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
if name != "/bin/git" || len(args) != 1 || args[0] != "--version" {
|
||||
t.Fatalf("unexpected command: %s %v", name, args)
|
||||
}
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
})
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "git")
|
||||
if check.Level != doctorPass || !strings.Contains(check.Message, "2.43.0") || !strings.Contains(check.Message, "supports worktrees") {
|
||||
t.Fatalf("git check = %+v, want PASS with version", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorWarnsOnUnsupportedGitVersion(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) {
|
||||
return []byte("git version 2.24.9\n"), nil
|
||||
})
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "git")
|
||||
if check.Level != doctorWarn || !strings.Contains(check.Message, ">= 2.25.0") {
|
||||
t.Fatalf("git check = %+v, want WARN with minimum version", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorFailsWhenGitMissing(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
c := doctorContext(t, map[string]string{}, nil)
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "git")
|
||||
if check.Level != doctorFail {
|
||||
t.Fatalf("git check = %+v, want FAIL", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorChecksZellijVersion(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
cmdPath := map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}
|
||||
c := &commandContext{deps: Deps{
|
||||
LookPath: func(name string) (string, error) { return cmdPath[name], nil },
|
||||
CommandOutput: func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
if name != "/bin/zellij" || len(args) != 1 || args[0] != "--version" {
|
||||
t.Fatalf("unexpected command: %s %v", name, args)
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
switch name {
|
||||
case "/bin/git":
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
case "/bin/zellij":
|
||||
if len(args) != 1 || args[0] != "--version" {
|
||||
t.Fatalf("unexpected zellij command: %s %v", name, args)
|
||||
}
|
||||
return []byte("zellij 0.44.3\n"), nil
|
||||
},
|
||||
}.withDefaults()}
|
||||
default:
|
||||
t.Fatalf("unexpected command: %s %v", name, args)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij")
|
||||
if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") {
|
||||
|
|
@ -28,13 +74,12 @@ func TestDoctorChecksZellijVersion(t *testing.T) {
|
|||
|
||||
func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
cmdPath := map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}
|
||||
c := &commandContext{deps: Deps{
|
||||
LookPath: func(name string) (string, error) { return cmdPath[name], nil },
|
||||
CommandOutput: func(context.Context, string, ...string) ([]byte, error) {
|
||||
return []byte("zellij 0.44.2\n"), nil
|
||||
},
|
||||
}.withDefaults()}
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, _ ...string) ([]byte, error) {
|
||||
if name == "/bin/git" {
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
}
|
||||
return []byte("zellij 0.44.2\n"), nil
|
||||
})
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij")
|
||||
if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") {
|
||||
|
|
@ -44,19 +89,236 @@ func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) {
|
|||
|
||||
func TestDoctorWarnsWhenZellijMissing(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
c := &commandContext{deps: Deps{
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) {
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
})
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij")
|
||||
if check.Level != doctorWarn {
|
||||
t.Fatalf("zellij check = %+v, want WARN", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorChecksHarnessVersions(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
cmdPath := map[string]string{
|
||||
"git": "/bin/git",
|
||||
"claude": "/bin/claude",
|
||||
"codex": "/bin/codex",
|
||||
}
|
||||
c := doctorContext(t, cmdPath, func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
switch name {
|
||||
case "/bin/git":
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
case "/bin/claude", "/bin/codex":
|
||||
if len(args) != 1 || args[0] != "--version" {
|
||||
t.Fatalf("unexpected harness command: %s %v", name, args)
|
||||
}
|
||||
return []byte(strings.TrimPrefix(name, "/bin/") + " 1.2.3\n"), nil
|
||||
default:
|
||||
t.Fatalf("unexpected command: %s %v", name, args)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
checks := c.runDoctor(context.Background())
|
||||
for _, name := range []string{"claude-code", "codex"} {
|
||||
check := findDoctorCheck(t, checks, name)
|
||||
if check.Level != doctorPass || !strings.Contains(check.Message, "resolves to") {
|
||||
t.Fatalf("%s check = %+v, want PASS with path/version", name, check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorWarnsWhenHarnessMissing(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) {
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
})
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "codex")
|
||||
if check.Level != doctorWarn || !strings.Contains(check.Message, "not found in PATH") {
|
||||
t.Fatalf("codex check = %+v, want WARN missing binary", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorWarnsWhenHarnessVersionFails(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git", "codex": "/bin/codex"}, func(_ context.Context, name string, _ ...string) ([]byte, error) {
|
||||
if name == "/bin/git" {
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
}
|
||||
return nil, errors.New("boom")
|
||||
})
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "codex")
|
||||
if check.Level != doctorWarn || !strings.Contains(check.Message, "failed") {
|
||||
t.Fatalf("codex check = %+v, want WARN version failure", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorChecksGitHubTokenFromEnv(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
srv := githubDoctorServer(t, http.StatusOK, `{"login":"octocat"}`, "repo, read:org")
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) {
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
})
|
||||
t.Setenv("AO_GITHUB_TOKEN", "env-token")
|
||||
c.deps.HTTPClient = srv.Client()
|
||||
c.deps.DoctorGitHubRESTBase = srv.URL
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "github-token")
|
||||
if check.Level != doctorPass || !strings.Contains(check.Message, "AO_GITHUB_TOKEN") || !strings.Contains(check.Message, "repo, read:org") {
|
||||
t.Fatalf("github-token check = %+v, want PASS with source and scopes", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorChecksGitHubTokenFromGHCLI(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
srv := githubDoctorServer(t, http.StatusOK, `{"login":"octocat"}`, "")
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git", "gh": "/bin/gh"}, func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
if name == "/bin/gh" {
|
||||
if len(args) != 2 || args[0] != "auth" || args[1] != "token" {
|
||||
t.Fatalf("unexpected gh command: %s %v", name, args)
|
||||
}
|
||||
return []byte("gh-token\n"), nil
|
||||
}
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
})
|
||||
c.deps.HTTPClient = srv.Client()
|
||||
c.deps.DoctorGitHubRESTBase = srv.URL
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "github-token")
|
||||
if check.Level != doctorPass || !strings.Contains(check.Message, "gh token valid") {
|
||||
t.Fatalf("github-token check = %+v, want PASS from gh", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorWarnsWhenGitHubTokenMissing(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) {
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
})
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "github-token")
|
||||
if check.Level != doctorWarn || !strings.Contains(check.Message, "no GitHub token found") {
|
||||
t.Fatalf("github-token check = %+v, want WARN missing token", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorFailsExpiredGitHubToken(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
srv := githubDoctorServer(t, http.StatusUnauthorized, `{"message":"Bad credentials"}`, "")
|
||||
c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) {
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
})
|
||||
t.Setenv("GITHUB_TOKEN", "expired-token")
|
||||
c.deps.HTTPClient = srv.Client()
|
||||
c.deps.DoctorGitHubRESTBase = srv.URL
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "github-token")
|
||||
if check.Level != doctorFail || !strings.Contains(check.Message, "HTTP 401") {
|
||||
t.Fatalf("github-token check = %+v, want FAIL rejected token", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorJSONOutputIsDecodable(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
clearDoctorGitHubEnv(t)
|
||||
out, errOut, err := executeCLI(t, Deps{
|
||||
LookPath: func(name string) (string, error) {
|
||||
if name == "git" {
|
||||
return "/bin/git", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
}.withDefaults()}
|
||||
|
||||
check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij")
|
||||
if check.Level != doctorWarn {
|
||||
t.Fatalf("zellij check = %+v, want WARN", check)
|
||||
CommandOutput: func(context.Context, string, ...string) ([]byte, error) {
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
},
|
||||
ProcessAlive: func(int) bool { return false },
|
||||
}, "doctor", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("doctor --json failed: %v\nstderr=%s\nstdout=%s", err, errOut, out)
|
||||
}
|
||||
var got doctorReport
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("decode doctor json: %v\nout=%s", err, out)
|
||||
}
|
||||
if !got.OK || len(got.Checks) == 0 {
|
||||
t.Fatalf("doctor json = %#v, want ok with checks", got)
|
||||
}
|
||||
if findDoctorCheck(t, got.Checks, "git").Section != doctorSectionTools {
|
||||
t.Fatalf("git json check missing section: %#v", findDoctorCheck(t, got.Checks, "git"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorTextOutputIsGrouped(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
clearDoctorGitHubEnv(t)
|
||||
out, errOut, err := executeCLI(t, Deps{
|
||||
LookPath: func(name string) (string, error) {
|
||||
if name == "git" {
|
||||
return "/bin/git", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
CommandOutput: func(context.Context, string, ...string) ([]byte, error) {
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
},
|
||||
ProcessAlive: func(int) bool { return false },
|
||||
}, "doctor")
|
||||
if err != nil {
|
||||
t.Fatalf("doctor failed: %v\nstderr=%s\nstdout=%s", err, errOut, out)
|
||||
}
|
||||
for _, want := range []string{"Core:\nPASS config:", "Tools:\nPASS git:", "Agent harnesses:\nWARN claude-code:", "WARN codex:", "GitHub:\nWARN github-token:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("doctor output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearDoctorGitHubEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("AO_GITHUB_TOKEN", "")
|
||||
t.Setenv("GITHUB_TOKEN", "")
|
||||
t.Setenv("GH_TOKEN", "")
|
||||
}
|
||||
|
||||
func doctorContext(t *testing.T, paths map[string]string, commandOutput func(context.Context, string, ...string) ([]byte, error)) *commandContext {
|
||||
t.Helper()
|
||||
clearDoctorGitHubEnv(t)
|
||||
deps := Deps{
|
||||
LookPath: func(name string) (string, error) {
|
||||
path, ok := paths[name]
|
||||
if !ok || path == "" {
|
||||
return "", fmt.Errorf("%s missing", name)
|
||||
}
|
||||
return path, nil
|
||||
},
|
||||
ProcessAlive: func(int) bool { return false },
|
||||
}
|
||||
if commandOutput != nil {
|
||||
deps.CommandOutput = commandOutput
|
||||
}
|
||||
return &commandContext{deps: deps.withDefaults()}
|
||||
}
|
||||
|
||||
func githubDoctorServer(t *testing.T, status int, body, scopes string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/user" {
|
||||
t.Fatalf("unexpected github probe: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
|
||||
t.Fatalf("missing bearer auth header: %q", got)
|
||||
}
|
||||
if scopes != "" {
|
||||
w.Header().Set("X-OAuth-Scopes", scopes)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
}
|
||||
|
||||
func findDoctorCheck(t *testing.T, checks []doctorCheck, name string) doctorCheck {
|
||||
|
|
|
|||
|
|
@ -80,13 +80,16 @@ func (e env) environ(portOverride string) []string {
|
|||
if strings.HasPrefix(kv, "AO_") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(kv, "GITHUB_TOKEN=") || strings.HasPrefix(kv, "GH_TOKEN=") || strings.HasPrefix(kv, "GH_CONFIG_DIR=") {
|
||||
continue
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
port := fmt.Sprintf("%d", e.port)
|
||||
if portOverride != "" {
|
||||
port = portOverride
|
||||
}
|
||||
return append(out, "AO_RUN_FILE="+e.runFile, "AO_DATA_DIR="+e.dataDir, "AO_PORT="+port)
|
||||
return append(out, "AO_RUN_FILE="+e.runFile, "AO_DATA_DIR="+e.dataDir, "AO_PORT="+port, "GH_CONFIG_DIR="+filepath.Join(e.dataDir, "gh-config"))
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
|
|
|
|||
|
|
@ -56,24 +56,28 @@ type Deps struct {
|
|||
ProcessAlive func(pid int) bool
|
||||
LookPath func(file string) (string, error)
|
||||
CommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
Now func() time.Time
|
||||
Sleep func(time.Duration)
|
||||
// DoctorGitHubRESTBase lets tests point the doctor GitHub token probe at
|
||||
// httptest without mutating package-global state.
|
||||
DoctorGitHubRESTBase string
|
||||
Now func() time.Time
|
||||
Sleep func(time.Duration)
|
||||
}
|
||||
|
||||
// DefaultDeps returns production dependencies.
|
||||
func DefaultDeps() Deps {
|
||||
return Deps{
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
Err: os.Stderr,
|
||||
HTTPClient: &http.Client{Timeout: 2 * time.Second},
|
||||
Executable: os.Executable,
|
||||
StartProcess: startProcess,
|
||||
ProcessAlive: processalive.Alive,
|
||||
LookPath: exec.LookPath,
|
||||
CommandOutput: commandOutput,
|
||||
Now: time.Now,
|
||||
Sleep: time.Sleep,
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
Err: os.Stderr,
|
||||
HTTPClient: &http.Client{Timeout: 2 * time.Second},
|
||||
Executable: os.Executable,
|
||||
StartProcess: startProcess,
|
||||
ProcessAlive: processalive.Alive,
|
||||
LookPath: exec.LookPath,
|
||||
CommandOutput: commandOutput,
|
||||
DoctorGitHubRESTBase: defaultDoctorGitHubRESTBase,
|
||||
Now: time.Now,
|
||||
Sleep: time.Sleep,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +114,9 @@ func (d Deps) withDefaults() Deps {
|
|||
if d.CommandOutput == nil {
|
||||
d.CommandOutput = def.CommandOutput
|
||||
}
|
||||
if d.DoctorGitHubRESTBase == "" {
|
||||
d.DoctorGitHubRESTBase = def.DoctorGitHubRESTBase
|
||||
}
|
||||
if d.Now == nil {
|
||||
d.Now = def.Now
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue