From e9cfad5469770b37a890f4928d5132a6e756abdc Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 13 Apr 2026 20:53:36 +0200 Subject: [PATCH] feat(cli): add reusable doctor checks --- README.md | 50 ++++++- cli/doctor.go | 336 +++++++++++++++++++++++++++++++++++++++++++++ cli/doctor_test.go | 173 +++++++++++++++++++++++ 3 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 cli/doctor.go create mode 100644 cli/doctor_test.go diff --git a/README.md b/README.md index adff521..e8bcc12 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ go get gitea.lclr.dev/AI/mcp-framework ## Packages -- `cli` : helpers pour résoudre un profil, valider une URL et demander des valeurs à l'utilisateur. +- `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`. - `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. - `manifest` : lecture de `mcp.toml` à la racine du projet et conversion vers `update.ReleaseSource`. - `secretstore` : lecture/écriture de secrets dans le wallet natif. @@ -34,6 +34,7 @@ Le flux typique côté application est : 3. Lire les secrets avec `secretstore`. 4. Charger `mcp.toml` avec `manifest`. 5. Exécuter l'auto-update avec `update` si nécessaire. +6. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. ## Manifeste `mcp.toml` @@ -235,6 +236,53 @@ if err != nil { } ``` +Le package fournit aussi un socle réutilisable pour une commande `doctor`. +L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), +mais peut réutiliser les checks communs et ajouter ses propres hooks : + +```go +report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ + ConfigCheck: cli.NewConfigCheck(store), + SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + ServiceName: "my-mcp", + }) + }), + RequiredSecrets: []cli.DoctorSecret{ + {Name: "api-token", Label: "API token"}, + }, + SecretStoreFactory: func() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + ServiceName: "my-mcp", + }) + }, + ManifestDir: ".", + ConnectivityCheck: func(context.Context) cli.DoctorResult { + if err := pingBackend(); err != nil { + return cli.DoctorResult{ + Name: "connectivity", + Status: cli.DoctorStatusFail, + Summary: "backend is unreachable", + Detail: err.Error(), + } + } + return cli.DoctorResult{ + Name: "connectivity", + Status: cli.DoctorStatusOK, + Summary: "backend is reachable", + } + }, +}) + +if err := cli.RenderDoctorReport(os.Stdout, report); err != nil { + return err +} + +if report.HasFailures() { + os.Exit(1) +} +``` + ## Auto-Update Le package `update` ne déduit pas la forge ni l'authentification. diff --git a/cli/doctor.go b/cli/doctor.go new file mode 100644 index 0000000..ae2cdd9 --- /dev/null +++ b/cli/doctor.go @@ -0,0 +1,336 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "gitea.lclr.dev/AI/mcp-framework/config" + "gitea.lclr.dev/AI/mcp-framework/manifest" + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type DoctorStatus string + +const ( + DoctorStatusOK DoctorStatus = "ok" + DoctorStatusWarn DoctorStatus = "warn" + DoctorStatusFail DoctorStatus = "fail" +) + +type DoctorResult struct { + Name string + Status DoctorStatus + Summary string + Detail string +} + +type DoctorCheck func(context.Context) DoctorResult + +type DoctorReport struct { + Results []DoctorResult +} + +type DoctorSummary struct { + OK int + Warn int + Fail int + Total int +} + +type DoctorSecret struct { + Name string + Label string +} + +type DoctorManifestValidator func(manifest.File, string) []string + +type DoctorOptions struct { + ConfigCheck DoctorCheck + SecretStoreCheck DoctorCheck + RequiredSecrets []DoctorSecret + SecretStoreFactory func() (secretstore.Store, error) + ManifestDir string + ManifestValidator DoctorManifestValidator + ConnectivityCheck DoctorCheck + ExtraChecks []DoctorCheck +} + +func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport { + checks := make([]DoctorCheck, 0, 5+len(options.ExtraChecks)) + + if options.ConfigCheck != nil { + checks = append(checks, options.ConfigCheck) + } + if options.SecretStoreCheck != nil { + checks = append(checks, options.SecretStoreCheck) + } + if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil { + checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets)) + } + checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) + if options.ConnectivityCheck != nil { + checks = append(checks, options.ConnectivityCheck) + } + checks = append(checks, options.ExtraChecks...) + + results := make([]DoctorResult, 0, len(checks)) + for _, check := range checks { + if check == nil { + continue + } + results = append(results, normalizeDoctorResult(check(ctx))) + } + + return DoctorReport{Results: results} +} + +func (r DoctorReport) Summary() DoctorSummary { + summary := DoctorSummary{Total: len(r.Results)} + for _, result := range r.Results { + switch result.Status { + case DoctorStatusOK: + summary.OK++ + case DoctorStatusWarn: + summary.Warn++ + case DoctorStatusFail: + summary.Fail++ + default: + summary.Fail++ + } + } + return summary +} + +func (r DoctorReport) HasFailures() bool { + return r.Summary().Fail > 0 +} + +func RenderDoctorReport(w io.Writer, report DoctorReport) error { + for _, result := range report.Results { + if _, err := fmt.Fprintf(w, "[%s] %s: %s\n", doctorLabel(result.Status), result.Name, result.Summary); err != nil { + return err + } + if detail := strings.TrimSpace(result.Detail); detail != "" { + if _, err := fmt.Fprintf(w, " %s\n", detail); err != nil { + return err + } + } + } + + summary := report.Summary() + _, err := fmt.Fprintf( + w, + "Summary: %d ok, %d warning(s), %d failure(s), %d total\n", + summary.OK, + summary.Warn, + summary.Fail, + summary.Total, + ) + return err +} + +func NewConfigCheck[T any](store config.Store[T]) DoctorCheck { + return func(context.Context) DoctorResult { + path, err := store.ConfigPath() + if err != nil { + return DoctorResult{ + Name: "config", + Status: DoctorStatusFail, + Summary: "cannot resolve config path", + Detail: err.Error(), + } + } + + info, err := os.Stat(path) + switch { + case err == nil && info.IsDir(): + return DoctorResult{ + Name: "config", + Status: DoctorStatusFail, + Summary: "config path is a directory", + Detail: path, + } + case err == nil: + if _, err := store.Load(path); err != nil { + return DoctorResult{ + Name: "config", + Status: DoctorStatusFail, + Summary: "config file is unreadable or invalid", + Detail: err.Error(), + } + } + return DoctorResult{ + Name: "config", + Status: DoctorStatusOK, + Summary: "config file is readable", + Detail: path, + } + case errors.Is(err, os.ErrNotExist): + return DoctorResult{ + Name: "config", + Status: DoctorStatusWarn, + Summary: "config file is missing", + Detail: path, + } + default: + return DoctorResult{ + Name: "config", + Status: DoctorStatusFail, + Summary: "cannot access config file", + Detail: err.Error(), + } + } + } +} + +func SecretStoreAvailabilityCheck(factory func() (secretstore.Store, error)) DoctorCheck { + return func(context.Context) DoctorResult { + if factory == nil { + return DoctorResult{ + Name: "secret-store", + Status: DoctorStatusWarn, + Summary: "secret store check is not configured", + } + } + + _, err := factory() + if err != nil { + return DoctorResult{ + Name: "secret-store", + Status: DoctorStatusFail, + Summary: "secret backend is unavailable", + Detail: err.Error(), + } + } + + return DoctorResult{ + Name: "secret-store", + Status: DoctorStatusOK, + Summary: "secret backend is available", + } + } +} + +func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck { + return func(context.Context) DoctorResult { + store, err := factory() + if err != nil { + return DoctorResult{ + Name: "required-secrets", + Status: DoctorStatusFail, + Summary: "cannot inspect required secrets", + Detail: err.Error(), + } + } + + var missing []string + for _, secret := range required { + name := strings.TrimSpace(secret.Name) + if name == "" { + continue + } + + _, err := store.GetSecret(name) + switch { + case err == nil: + case errors.Is(err, secretstore.ErrNotFound): + if label := strings.TrimSpace(secret.Label); label != "" { + missing = append(missing, fmt.Sprintf("%s (%s)", name, label)) + } else { + missing = append(missing, name) + } + default: + return DoctorResult{ + Name: "required-secrets", + Status: DoctorStatusFail, + Summary: fmt.Sprintf("cannot read secret %q", name), + Detail: err.Error(), + } + } + } + + if len(missing) > 0 { + return DoctorResult{ + Name: "required-secrets", + Status: DoctorStatusFail, + Summary: "required secrets are missing", + Detail: strings.Join(missing, ", "), + } + } + + return DoctorResult{ + Name: "required-secrets", + Status: DoctorStatusOK, + Summary: "all required secrets are present", + } + } +} + +func ManifestCheck(startDir string, validator DoctorManifestValidator) DoctorCheck { + return func(context.Context) DoctorResult { + file, path, err := manifest.LoadDefault(startDir) + if err != nil { + return DoctorResult{ + Name: "manifest", + Status: DoctorStatusFail, + Summary: "manifest is missing or invalid", + Detail: err.Error(), + } + } + + if validator != nil { + issues := validator(file, path) + if len(issues) > 0 { + return DoctorResult{ + Name: "manifest", + Status: DoctorStatusFail, + Summary: "manifest validation failed", + Detail: strings.Join(issues, "; "), + } + } + } + + return DoctorResult{ + Name: "manifest", + Status: DoctorStatusOK, + Summary: "manifest is valid", + Detail: path, + } + } +} + +func normalizeDoctorResult(result DoctorResult) DoctorResult { + result.Name = strings.TrimSpace(result.Name) + if result.Name == "" { + result.Name = "unnamed-check" + } + + result.Summary = strings.TrimSpace(result.Summary) + if result.Summary == "" { + result.Summary = "no details provided" + } + + result.Detail = strings.TrimSpace(result.Detail) + switch result.Status { + case DoctorStatusOK, DoctorStatusWarn, DoctorStatusFail: + default: + result.Status = DoctorStatusFail + } + + return result +} + +func doctorLabel(status DoctorStatus) string { + switch status { + case DoctorStatusOK: + return "OK" + case DoctorStatusWarn: + return "WARN" + default: + return "FAIL" + } +} diff --git a/cli/doctor_test.go b/cli/doctor_test.go new file mode 100644 index 0000000..61ac464 --- /dev/null +++ b/cli/doctor_test.go @@ -0,0 +1,173 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/config" + "gitea.lclr.dev/AI/mcp-framework/manifest" + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type doctorProfile struct { + BaseURL string `json:"base_url"` +} + +func TestRunDoctorAggregatesCommonChecksAndExtras(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempHome) + t.Setenv("HOME", tempHome) + + store := config.NewStore[doctorProfile]("doctor-test") + path, err := store.ConfigPath() + if err != nil { + t.Fatalf("ConfigPath returned error: %v", err) + } + + cfg := config.FileConfig[doctorProfile]{ + Version: config.CurrentVersion, + CurrentProfile: "default", + Profiles: map[string]doctorProfile{ + "default": {BaseURL: "https://api.example.com"}, + }, + } + if err := store.Save(path, cfg); err != nil { + t.Fatalf("Save returned error: %v", err) + } + + manifestDir := t.TempDir() + if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + secretFactory := func() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + BackendPolicy: secretstore.BackendEnvOnly, + LookupEnv: func(name string) (string, bool) { + if name == "api-token" { + return "secret", true + } + return "", false + }, + }) + } + + report := RunDoctor(context.Background(), DoctorOptions{ + ConfigCheck: NewConfigCheck(store), + SecretStoreCheck: SecretStoreAvailabilityCheck(secretFactory), + RequiredSecrets: []DoctorSecret{ + {Name: "api-token", Label: "API token"}, + }, + SecretStoreFactory: secretFactory, + ManifestDir: manifestDir, + ConnectivityCheck: func(context.Context) DoctorResult { + return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "service is reachable"} + }, + ExtraChecks: []DoctorCheck{ + func(context.Context) DoctorResult { + return DoctorResult{Name: "custom", Status: DoctorStatusWarn, Summary: "non blocking drift"} + }, + }, + }) + + summary := report.Summary() + if summary.OK != 5 || summary.Warn != 1 || summary.Fail != 0 || summary.Total != 6 { + t.Fatalf("summary = %#v", summary) + } +} + +func TestNewConfigCheckWarnsWhenConfigIsMissing(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempHome) + t.Setenv("HOME", tempHome) + + store := config.NewStore[doctorProfile]("doctor-test") + result := NewConfigCheck(store)(context.Background()) + + if result.Status != DoctorStatusWarn { + t.Fatalf("status = %q, want warn", result.Status) + } + if !strings.Contains(result.Detail, "config.json") { + t.Fatalf("detail = %q, want config path", result.Detail) + } +} + +func TestRequiredSecretsCheckFailsWhenSecretIsMissing(t *testing.T) { + check := RequiredSecretsCheck(func() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + BackendPolicy: secretstore.BackendEnvOnly, + LookupEnv: func(name string) (string, bool) { + return "", false + }, + }) + }, []DoctorSecret{{Name: "API_TOKEN", Label: "API token"}}) + + result := check(context.Background()) + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } + if !strings.Contains(result.Detail, "API_TOKEN") { + t.Fatalf("detail = %q, want secret name", result.Detail) + } +} + +func TestManifestCheckUsesValidator(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + result := ManifestCheck(dir, func(_ manifest.File, path string) []string { + _ = path + return []string{"missing business section"} + })(context.Background()) + + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } +} + +func TestRenderDoctorReportFormatsStatusesAndSummary(t *testing.T) { + var out bytes.Buffer + report := DoctorReport{ + Results: []DoctorResult{ + {Name: "config", Status: DoctorStatusOK, Summary: "config file is readable", Detail: "/tmp/config.json"}, + {Name: "custom", Status: DoctorStatusWarn, Summary: "optional check skipped"}, + {Name: "manifest", Status: DoctorStatusFail, Summary: "manifest is invalid", Detail: "parse error"}, + }, + } + + if err := RenderDoctorReport(&out, report); err != nil { + t.Fatalf("RenderDoctorReport returned error: %v", err) + } + + text := out.String() + for _, needle := range []string{ + "[OK] config: config file is readable", + "[WARN] custom: optional check skipped", + "[FAIL] manifest: manifest is invalid", + "Summary: 1 ok, 1 warning(s), 1 failure(s), 3 total", + } { + if !strings.Contains(text, needle) { + t.Fatalf("output = %q, want substring %q", text, needle) + } + } +} + +func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) { + result := SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { + return nil, errors.New("backend missing") + })(context.Background()) + + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } + if !strings.Contains(result.Detail, "backend missing") { + t.Fatalf("detail = %q", result.Detail) + } +}