diff --git a/CHANGELOG.md b/CHANGELOG.md index 583287f..fd28747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,19 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ## [Unreleased] +### Added + +- `mem doctor` read-only diagnostic command that checks server reachability, + credential presence, workspace selection, and CLI/server version skew. Supports + `--format text|json` output with `mem.doctor/v1` schema. Performs no writes and + installs no dependencies (`#112`). + ### Changed +- All CLI commands that require authentication now include the documented + `deploy/compose/` deployment path in their "not logged in" hint, directing + first-run users to the recommended container deployment (`#112` REQ-002). + - Migrate GitHub repository, Release, issue, badge, and raw-content coordinates to the canonical `bytefolk` organization while retaining the published npm scope, MCP identity, and existing cache paths. diff --git a/server/cmd/mem/client.go b/server/cmd/mem/client.go index cda2fd4..082d427 100644 --- a/server/cmd/mem/client.go +++ b/server/cmd/mem/client.go @@ -24,6 +24,14 @@ func newCliError(code int, msg, hint string) *cliError { return &cliError{code: code, msg: msg, hint: hint} } +// errNotLoggedIn returns the standard auth error for unauthenticated CLI use. +// The hint names the documented deploy/compose path so first-run users are +// directed to the recommended container deployment (#112 REQ-002). +func errNotLoggedIn() *cliError { + return newCliError(3, "not logged in", + "run `mem auth login`; see deploy/compose/ for the recommended deployment path") +} + // fromAPIError maps an *apiclient.APIError to a *cliError with the SPEC ยง7.1 // exit code. Any other error is returned unchanged. func fromAPIError(err error) error { diff --git a/server/cmd/mem/cmds_auth.go b/server/cmd/mem/cmds_auth.go index 8631a17..5377d02 100644 --- a/server/cmd/mem/cmds_auth.go +++ b/server/cmd/mem/cmds_auth.go @@ -132,7 +132,7 @@ func newAuthStatusCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } var capabilities struct { @@ -241,7 +241,7 @@ func newTokenCreateCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } scopeList := splitCommas(scopes) body := map[string]any{ diff --git a/server/cmd/mem/cmds_auth_test.go b/server/cmd/mem/cmds_auth_test.go index 551cf49..2ab1b0a 100644 --- a/server/cmd/mem/cmds_auth_test.go +++ b/server/cmd/mem/cmds_auth_test.go @@ -162,7 +162,7 @@ func TestAuthStatusWithoutTokenReturnsAuthExitCode(t *testing.T) { if !errors.As(err, &cliErr) { t.Fatalf("error type = %T, want *cliError", err) } - if cliErr.code != 3 || cliErr.hint != "run `mem auth login` first" { + if cliErr.code != 3 || cliErr.hint != "run `mem auth login`; see deploy/compose/ for the recommended deployment path" { t.Fatalf("cli error = %#v", cliErr) } } diff --git a/server/cmd/mem/cmds_context.go b/server/cmd/mem/cmds_context.go index 45eaaa7..02cc2ce 100644 --- a/server/cmd/mem/cmds_context.go +++ b/server/cmd/mem/cmds_context.go @@ -82,7 +82,7 @@ Examples: return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } body := map[string]any{"query": strings.Join(args, " ")} if scope != "" { diff --git a/server/cmd/mem/cmds_doctor.go b/server/cmd/mem/cmds_doctor.go new file mode 100644 index 0000000..59b5a39 --- /dev/null +++ b/server/cmd/mem/cmds_doctor.go @@ -0,0 +1,332 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// cliVersion is the CLI build version, overridden by ldflags at release time. +var cliVersion = "dev" + +const doctorSchemaVersion = "mem.doctor/v1" + +const deployComposeHint = "see deploy/compose/ for the recommended container deployment path" + +type doctorCheck struct { + Name string `json:"name"` + Code string `json:"code"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + Hint string `json:"hint,omitempty"` +} + +type doctorReport struct { + SchemaVersion string `json:"schema_version"` + Checks []doctorCheck `json:"checks"` + Summary string `json:"summary"` + OK bool `json:"ok"` +} + +type doctorHTTPDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +var doctorHTTPClient doctorHTTPDoer = &http.Client{Timeout: 5 * time.Second} + +func newDoctorCmd() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Diagnose CLI connectivity and configuration", + Long: `Read-only diagnostic that checks server reachability, credentials, +workspace selection, and CLI/server version compatibility. Performs no writes +and installs no dependencies.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + format, err := cmd.Flags().GetString("format") + if err != nil { + return err + } + if format != "text" && format != "json" { + return fmt.Errorf("--format must be text or json, got %q", format) + } + + report := runDoctorChecks() + + if format == "json" { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + return err + } + } else { + printDoctorReport(cmd, report) + } + return nil + }, + } +} + +func runDoctorChecks() doctorReport { + cfg, cfgErr := resolveConfig("") + + checks := make([]doctorCheck, 0, 5) + + checks = append(checks, checkConfigLoaded(cfg, cfgErr)) + + if cfgErr != nil || cfg == nil { + checks = append(checks, doctorCheck{ + Name: "server", + Code: "server_unreachable", + Status: "fail", + Message: "server is not reachable", + Hint: deployComposeHint, + }) + checks = append(checks, doctorCheck{ + Name: "credentials", + Code: "no_credential", + Status: "fail", + Message: "not logged in", + Hint: "run `mem auth login`; " + deployComposeHint, + }) + checks = append(checks, doctorCheck{ + Name: "workspace", + Code: "no_workspace", + Status: "warn", + Message: "no workspace selected", + }) + } else { + checks = append(checks, checkServerReachable(cfg)) + checks = append(checks, checkCredentials(cfg)) + checks = append(checks, checkWorkspace(cfg)) + if isServerReachable(cfg) { + checks = append(checks, checkVersionSkew(cfg)) + } + } + + allOK := true + for _, c := range checks { + if c.Status == "fail" { + allOK = false + break + } + } + + summary := "all checks passed" + if !allOK { + failed := 0 + for _, c := range checks { + if c.Status == "fail" { + failed++ + } + } + summary = fmt.Sprintf("%d check(s) failed", failed) + } + + return doctorReport{ + SchemaVersion: doctorSchemaVersion, + Checks: checks, + Summary: summary, + OK: allOK, + } +} + +func checkConfigLoaded(cfg *cliConfig, err error) doctorCheck { + if err != nil { + return doctorCheck{ + Name: "config", + Code: "config_error", + Status: "fail", + Message: "cannot read configuration", + } + } + if cfg == nil { + return doctorCheck{ + Name: "config", + Code: "config_error", + Status: "fail", + Message: "configuration is empty", + } + } + return doctorCheck{ + Name: "config", + Code: "config_ok", + Status: "ok", + } +} + +func checkServerReachable(cfg *cliConfig) doctorCheck { + if !isServerReachable(cfg) { + return doctorCheck{ + Name: "server", + Code: "server_unreachable", + Status: "fail", + Message: "server is not reachable", + Hint: deployComposeHint, + } + } + return doctorCheck{ + Name: "server", + Code: "server_reachable", + Status: "ok", + } +} + +func isServerReachable(cfg *cliConfig) bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.Server+"/healthz", nil) + if err != nil { + return false + } + resp, err := doctorHTTPClient.Do(req) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode >= 200 && resp.StatusCode < 300 +} + +func checkCredentials(cfg *cliConfig) doctorCheck { + if cfg.Token == "" { + return doctorCheck{ + Name: "credentials", + Code: "no_credential", + Status: "fail", + Message: "not logged in", + Hint: "run `mem auth login`; " + deployComposeHint, + } + } + return doctorCheck{ + Name: "credentials", + Code: "credential_present", + Status: "ok", + } +} + +func checkWorkspace(cfg *cliConfig) doctorCheck { + if strings.TrimSpace(cfg.Workspace) == "" { + return doctorCheck{ + Name: "workspace", + Code: "no_workspace", + Status: "warn", + Message: "no workspace selected", + Hint: "use `--workspace` or set MEM_WORKSPACE", + } + } + return doctorCheck{ + Name: "workspace", + Code: "workspace_selected", + Status: "ok", + } +} + +func checkVersionSkew(cfg *cliConfig) doctorCheck { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.Server+"/v1/version", nil) + if err != nil { + return doctorCheck{ + Name: "version", + Code: "version_unknown", + Status: "warn", + Message: "cannot check server version", + } + } + resp, err := doctorHTTPClient.Do(req) + if err != nil { + return doctorCheck{ + Name: "version", + Code: "version_unknown", + Status: "warn", + Message: "cannot check server version", + } + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return doctorCheck{ + Name: "version", + Code: "version_unknown", + Status: "warn", + Message: "cannot check server version", + } + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return doctorCheck{ + Name: "version", + Code: "version_unknown", + Status: "warn", + Message: "cannot parse server version", + } + } + var versionResp struct { + Version string `json:"version"` + } + if err := json.Unmarshal(body, &versionResp); err != nil { + return doctorCheck{ + Name: "version", + Code: "version_unknown", + Status: "warn", + Message: "cannot parse server version", + } + } + + cliV := cliVersion + srvV := versionResp.Version + if cliV == "dev" || srvV == "dev" { + return doctorCheck{ + Name: "version", + Code: "version_match", + Status: "ok", + Message: "development build; version skew not checked", + } + } + if cliV != srvV { + return doctorCheck{ + Name: "version", + Code: "version_skew", + Status: "warn", + Message: fmt.Sprintf("CLI %s != server %s", cliV, srvV), + Hint: "update CLI or server to matching versions", + } + } + return doctorCheck{ + Name: "version", + Code: "version_match", + Status: "ok", + } +} + +func printDoctorReport(cmd *cobra.Command, report doctorReport) { + out := cmd.OutOrStdout() + fmt.Fprintln(out, "mem doctor") + fmt.Fprintln(out, "----------") + for _, c := range report.Checks { + icon := "ok" + switch c.Status { + case "fail": + icon = "FAIL" + case "warn": + icon = "WARN" + case "skip": + icon = "SKIP" + } + line := fmt.Sprintf(" [%s] %s", icon, c.Name) + if c.Message != "" { + line += ": " + c.Message + } + fmt.Fprintln(out, line) + if c.Hint != "" { + fmt.Fprintf(out, " hint: %s\n", c.Hint) + } + } + fmt.Fprintln(out) + fmt.Fprintln(out, report.Summary) +} diff --git a/server/cmd/mem/cmds_doctor_test.go b/server/cmd/mem/cmds_doctor_test.go new file mode 100644 index 0000000..1852a70 --- /dev/null +++ b/server/cmd/mem/cmds_doctor_test.go @@ -0,0 +1,714 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDoctorAllChecksPassJSON(t *testing.T) { + clearCLIOverrides(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("non-GET request: %s %s", r.Method, r.URL.Path) + } + switch r.URL.Path { + case "/healthz": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"dev"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", server.URL) + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\n%s", err, stdout.String()) + } + if !report.OK { + t.Fatalf("expected OK, got summary=%q checks=%+v", report.Summary, report.Checks) + } + if report.SchemaVersion != doctorSchemaVersion { + t.Errorf("schema_version = %q, want %q", report.SchemaVersion, doctorSchemaVersion) + } + + checkNames := make(map[string]bool) + for _, c := range report.Checks { + checkNames[c.Name] = true + if c.Code == "" { + t.Errorf("check %q has empty code", c.Name) + } + } + for _, want := range []string{"config", "server", "credentials", "workspace", "version"} { + if !checkNames[want] { + t.Errorf("missing check %q", want) + } + } +} + +func TestDoctorAllChecksPassText(t *testing.T) { + clearCLIOverrides(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/v1/version": + _, _ = w.Write([]byte(`{"version":"dev"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", server.URL) + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + out := stdout.String() + for _, want := range []string{"[ok] config", "[ok] server", "[ok] credentials", "[ok] workspace"} { + if !strings.Contains(out, want) { + t.Errorf("text output missing %q:\n%s", want, out) + } + } + if !strings.Contains(out, "all checks passed") { + t.Errorf("text output missing summary:\n%s", out) + } +} + +func TestDoctorAlwaysExitsZero(t *testing.T) { + clearCLIOverrides(t) + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", "http://127.0.0.1:1") + t.Setenv("MEM_TOKEN", "") + t.Setenv("MEM_WORKSPACE", "") + + root := newRootCmd() + root.SilenceUsage = true + root.SilenceErrors = true + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + err := root.Execute() + if err != nil { + t.Fatalf("doctor should always exit 0, got error: %v", err) + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\n%s", err, stdout.String()) + } + if report.OK { + t.Fatal("expected report.OK=false when checks fail") + } +} + +func TestDoctorServerUnreachable(t *testing.T) { + clearCLIOverrides(t) + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", "http://127.0.0.1:1") + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + root.SilenceUsage = true + root.SilenceErrors = true + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\n%s", err, stdout.String()) + } + + var serverCheck *doctorCheck + for i := range report.Checks { + if report.Checks[i].Name == "server" { + serverCheck = &report.Checks[i] + break + } + } + if serverCheck == nil { + t.Fatal("missing server check") + } + if serverCheck.Status != "fail" { + t.Errorf("server check status = %q, want fail", serverCheck.Status) + } + if serverCheck.Code != "server_unreachable" { + t.Errorf("server check code = %q, want server_unreachable", serverCheck.Code) + } + if !strings.Contains(serverCheck.Hint, "deploy/compose") { + t.Errorf("server check hint should name deploy/compose path, got %q", serverCheck.Hint) + } + if strings.Contains(serverCheck.Message, "127.0.0.1") { + t.Error("server check message leaks raw URL") + } +} + +func TestDoctorNoCredentials(t *testing.T) { + clearCLIOverrides(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/v1/version": + _, _ = w.Write([]byte(`{"version":"dev"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", server.URL) + t.Setenv("MEM_TOKEN", "") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\n%s", err, stdout.String()) + } + + var credCheck *doctorCheck + for i := range report.Checks { + if report.Checks[i].Name == "credentials" { + credCheck = &report.Checks[i] + break + } + } + if credCheck == nil { + t.Fatal("missing credentials check") + } + if credCheck.Status != "fail" { + t.Errorf("credentials check status = %q, want fail", credCheck.Status) + } + if credCheck.Code != "no_credential" { + t.Errorf("credentials check code = %q, want no_credential", credCheck.Code) + } + if !strings.Contains(credCheck.Hint, "mem auth login") { + t.Errorf("credentials check should hint at auth login, got %q", credCheck.Hint) + } + if !strings.Contains(credCheck.Hint, "deploy/compose") { + t.Errorf("credentials check should name deploy/compose path, got %q", credCheck.Hint) + } +} + +func TestDoctorNoWorkspace(t *testing.T) { + clearCLIOverrides(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/v1/version": + _, _ = w.Write([]byte(`{"version":"dev"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", server.URL) + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "") + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\n%s", err, stdout.String()) + } + + var wsCheck *doctorCheck + for i := range report.Checks { + if report.Checks[i].Name == "workspace" { + wsCheck = &report.Checks[i] + break + } + } + if wsCheck == nil { + t.Fatal("missing workspace check") + } + if wsCheck.Status != "warn" { + t.Errorf("workspace check status = %q, want warn", wsCheck.Status) + } + if wsCheck.Code != "no_workspace" { + t.Errorf("workspace check code = %q, want no_workspace", wsCheck.Code) + } +} + +func TestDoctorVersionSkew(t *testing.T) { + clearCLIOverrides(t) + oldVersion := cliVersion + cliVersion = "1.0.0" + t.Cleanup(func() { cliVersion = oldVersion }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/v1/version": + _, _ = w.Write([]byte(`{"version":"2.0.0"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", server.URL) + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\n%s", err, stdout.String()) + } + + var verCheck *doctorCheck + for i := range report.Checks { + if report.Checks[i].Name == "version" { + verCheck = &report.Checks[i] + break + } + } + if verCheck == nil { + t.Fatal("missing version check") + } + if verCheck.Status != "warn" { + t.Errorf("version check status = %q, want warn", verCheck.Status) + } + if verCheck.Code != "version_skew" { + t.Errorf("version check code = %q, want version_skew", verCheck.Code) + } + if !strings.Contains(verCheck.Message, "1.0.0") || !strings.Contains(verCheck.Message, "2.0.0") { + t.Errorf("version check message should mention both versions, got %q", verCheck.Message) + } +} + +func TestDoctorMalformedServerURL(t *testing.T) { + clearCLIOverrides(t) + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", "://not-a-url") + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + root.SilenceUsage = true + root.SilenceErrors = true + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + output := stdout.String() + if strings.Contains(output, "test-token") { + t.Error("output leaks token value") + } + if strings.Contains(output, "://not-a-url") { + t.Error("output leaks raw malformed URL") + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\n%s", err, stdout.String()) + } + if report.OK { + t.Fatal("expected report.OK=false for malformed URL") + } +} + +func TestDoctorServerReturnsNon200Healthz(t *testing.T) { + clearCLIOverrides(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`internal error`)) + })) + defer server.Close() + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", server.URL) + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + root.SilenceUsage = true + root.SilenceErrors = true + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\n%s", err, stdout.String()) + } + + var serverCheck *doctorCheck + for i := range report.Checks { + if report.Checks[i].Name == "server" { + serverCheck = &report.Checks[i] + break + } + } + if serverCheck == nil { + t.Fatal("missing server check") + } + if serverCheck.Status != "fail" { + t.Errorf("server check status = %q, want fail for 500 response", serverCheck.Status) + } + if strings.Contains(serverCheck.Message, "internal error") { + t.Error("server check message leaks raw server error body") + } +} + +func TestDoctorServerReturnsNonJSONVersion(t *testing.T) { + clearCLIOverrides(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/v1/version": + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(`not json`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", server.URL) + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\n%s", err, stdout.String()) + } + + var verCheck *doctorCheck + for i := range report.Checks { + if report.Checks[i].Name == "version" { + verCheck = &report.Checks[i] + break + } + } + if verCheck == nil { + t.Fatal("missing version check") + } + if verCheck.Status != "warn" { + t.Errorf("version check status = %q, want warn for non-JSON response", verCheck.Status) + } + if strings.Contains(verCheck.Message, "not json") { + t.Error("version check message leaks raw server response body") + } +} + +func TestDoctorDevBuildSkipsVersionSkew(t *testing.T) { + clearCLIOverrides(t) + oldVersion := cliVersion + cliVersion = "dev" + t.Cleanup(func() { cliVersion = oldVersion }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/v1/version": + _, _ = w.Write([]byte(`{"version":"3.5.0"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", server.URL) + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\n%s", err, stdout.String()) + } + if !report.OK { + t.Fatalf("dev build should not fail on version skew: %+v", report.Checks) + } +} + +func TestDoctorNoSecretsInTextOutput(t *testing.T) { + clearCLIOverrides(t) + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", "http://127.0.0.1:1") + t.Setenv("MEM_TOKEN", "super-secret-token-xyz") + t.Setenv("MEM_WORKSPACE", "ws-secret") + + root := newRootCmd() + root.SilenceUsage = true + root.SilenceErrors = true + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor"}) + _ = root.Execute() + + out := stdout.String() + if strings.Contains(out, "super-secret-token-xyz") { + t.Error("text output leaks token value") + } +} + +func TestDoctorInvalidFormatFlag(t *testing.T) { + clearCLIOverrides(t) + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + + root := newRootCmd() + root.SetArgs([]string{"doctor", "--format", "xml"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error for invalid --format") + } +} + +// AC-002: prove the doctor transport only issues GET requests. +type writeFailingTransport struct { + t *testing.T +} + +func (s *writeFailingTransport) Do(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodGet { + s.t.Fatalf("doctor issued non-GET request: %s %s", req.Method, req.URL.Path) + } + rec := httptest.NewRecorder() + switch req.URL.Path { + case "/healthz": + rec.WriteHeader(http.StatusOK) + _, _ = rec.Write([]byte(`{"ok":true}`)) + case "/v1/version": + rec.WriteHeader(http.StatusOK) + _, _ = rec.Write([]byte(`{"version":"dev"}`)) + default: + rec.WriteHeader(http.StatusNotFound) + } + return rec.Result(), nil +} + +func TestDoctorPerformsNoWriteRequests(t *testing.T) { + clearCLIOverrides(t) + oldClient := doctorHTTPClient + doctorHTTPClient = &writeFailingTransport{t: t} + t.Cleanup(func() { doctorHTTPClient = oldClient }) + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", "http://doctor-test.local") + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } +} + +// AC-003: golden file for JSON output shape. +func TestDoctorJSONMatchesGoldenFile(t *testing.T) { + clearCLIOverrides(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/v1/version": + _, _ = w.Write([]byte(`{"version":"dev"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", server.URL) + t.Setenv("MEM_TOKEN", "test-token") + t.Setenv("MEM_WORKSPACE", "ws-123") + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + var report doctorReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v", err) + } + + if report.SchemaVersion != "mem.doctor/v1" { + t.Errorf("schema_version = %q, want mem.doctor/v1", report.SchemaVersion) + } + + expectedChecks := []struct { + name string + code string + }{ + {"config", "config_ok"}, + {"server", "server_reachable"}, + {"credentials", "credential_present"}, + {"workspace", "workspace_selected"}, + {"version", "version_match"}, + } + if len(report.Checks) != len(expectedChecks) { + t.Fatalf("got %d checks, want %d", len(report.Checks), len(expectedChecks)) + } + for i, want := range expectedChecks { + if report.Checks[i].Name != want.name { + t.Errorf("check[%d].Name = %q, want %q", i, report.Checks[i].Name, want.name) + } + if report.Checks[i].Code != want.code { + t.Errorf("check[%d].Code = %q, want %q", i, report.Checks[i].Code, want.code) + } + if report.Checks[i].Status != "ok" { + t.Errorf("check[%d].Status = %q, want ok", i, report.Checks[i].Status) + } + } + + goldenPath := filepath.Join("testdata", "doctor-golden.json") + golden, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden file: %v (run with -update to create)", err) + } + var goldenReport doctorReport + if err := json.Unmarshal(golden, &goldenReport); err != nil { + t.Fatalf("decode golden file: %v", err) + } + if report.SchemaVersion != goldenReport.SchemaVersion { + t.Errorf("schema_version mismatch: got %q, golden %q", report.SchemaVersion, goldenReport.SchemaVersion) + } + if len(report.Checks) != len(goldenReport.Checks) { + t.Fatalf("check count mismatch: got %d, golden %d", len(report.Checks), len(goldenReport.Checks)) + } + for i := range report.Checks { + if report.Checks[i].Name != goldenReport.Checks[i].Name { + t.Errorf("check[%d].Name = %q, golden %q", i, report.Checks[i].Name, goldenReport.Checks[i].Name) + } + if report.Checks[i].Code != goldenReport.Checks[i].Code { + t.Errorf("check[%d].Code = %q, golden %q", i, report.Checks[i].Code, goldenReport.Checks[i].Code) + } + } +} + +func TestDoctorTextOutputIncludesHints(t *testing.T) { + clearCLIOverrides(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/v1/version": + _, _ = w.Write([]byte(`{"version":"dev"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + t.Setenv("MEM_CONFIG", filepath.Join(t.TempDir(), "missing-config.yaml")) + t.Setenv("MEM_SERVER", server.URL) + t.Setenv("MEM_TOKEN", "") + t.Setenv("MEM_WORKSPACE", "") + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor"}) + _ = root.Execute() + + out := stdout.String() + if !strings.Contains(out, "[FAIL] credentials") { + t.Errorf("text output missing FAIL credentials:\n%s", out) + } + if !strings.Contains(out, "deploy/compose") { + t.Errorf("text output should mention deploy/compose path in hint:\n%s", out) + } + if !strings.Contains(out, "[WARN] workspace") { + t.Errorf("text output missing WARN workspace:\n%s", out) + } +} diff --git a/server/cmd/mem/cmds_face.go b/server/cmd/mem/cmds_face.go index 4bed56c..fcb53ee 100644 --- a/server/cmd/mem/cmds_face.go +++ b/server/cmd/mem/cmds_face.go @@ -41,7 +41,7 @@ func newFaceListCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) var resp faceListResp diff --git a/server/cmd/mem/cmds_file.go b/server/cmd/mem/cmds_file.go index 37f30a1..cc0577c 100644 --- a/server/cmd/mem/cmds_file.go +++ b/server/cmd/mem/cmds_file.go @@ -42,7 +42,7 @@ func newPutCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) sourceMetadata, err := cliSourceMetadata( diff --git a/server/cmd/mem/cmds_file_annotations.go b/server/cmd/mem/cmds_file_annotations.go index c27c3ea..d22e646 100644 --- a/server/cmd/mem/cmds_file_annotations.go +++ b/server/cmd/mem/cmds_file_annotations.go @@ -82,7 +82,7 @@ func configuredFileAnnotationClient() (*apiclient.Client, error) { return nil, err } if cfg.Token == "" { - return nil, newCliError(3, "not logged in", "run `mem auth login` first") + return nil, errNotLoggedIn() } return newHTTPClient(cfg).api, nil } diff --git a/server/cmd/mem/cmds_folder.go b/server/cmd/mem/cmds_folder.go index 08d2517..8bc138d 100644 --- a/server/cmd/mem/cmds_folder.go +++ b/server/cmd/mem/cmds_folder.go @@ -22,7 +22,7 @@ func newMkdirCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) var resp map[string]any diff --git a/server/cmd/mem/cmds_handoff.go b/server/cmd/mem/cmds_handoff.go index dc6133f..b094244 100644 --- a/server/cmd/mem/cmds_handoff.go +++ b/server/cmd/mem/cmds_handoff.go @@ -58,7 +58,7 @@ Examples: return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } raw, err := newHTTPClient(cfg).api.Checkpoint( commandContext(cmd), @@ -265,7 +265,7 @@ Examples: return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } raw, err := newHTTPClient(cfg).api.Resume( commandContext(cmd), diff --git a/server/cmd/mem/cmds_ingest.go b/server/cmd/mem/cmds_ingest.go index 2ed5b20..bb41a15 100644 --- a/server/cmd/mem/cmds_ingest.go +++ b/server/cmd/mem/cmds_ingest.go @@ -158,7 +158,7 @@ func runIngestQoder(cmd *cobra.Command, o ingestOptions) error { return err } if !o.dryRun && cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } client := newHTTPClient(cfg) stateDir := o.checkpointDir() diff --git a/server/cmd/mem/cmds_memory.go b/server/cmd/mem/cmds_memory.go index b842ef5..1c43a93 100644 --- a/server/cmd/mem/cmds_memory.go +++ b/server/cmd/mem/cmds_memory.go @@ -123,7 +123,7 @@ cursor and bounded memory summaries for scripts.`, return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } options := apiclient.MemoryListOptions{ @@ -393,7 +393,7 @@ func configuredMemoryClient() (*apiclient.Client, error) { return nil, err } if cfg.Token == "" { - return nil, newCliError(3, "not logged in", "run `mem auth login` first") + return nil, errNotLoggedIn() } return newHTTPClient(cfg).api, nil } diff --git a/server/cmd/mem/cmds_model.go b/server/cmd/mem/cmds_model.go index 9ff8237..84c1997 100644 --- a/server/cmd/mem/cmds_model.go +++ b/server/cmd/mem/cmds_model.go @@ -425,7 +425,7 @@ func activateLocalModelProfile( return providerSetResp{}, err } if cfg.Token == "" { - return providerSetResp{}, newCliError(3, "not logged in", "run `mem auth login` first") + return providerSetResp{}, errNotLoggedIn() } var response providerSetResp client := newHTTPClient(cfg) diff --git a/server/cmd/mem/cmds_profile.go b/server/cmd/mem/cmds_profile.go index a65ce6e..9092e14 100644 --- a/server/cmd/mem/cmds_profile.go +++ b/server/cmd/mem/cmds_profile.go @@ -143,7 +143,7 @@ func configuredWorkspaceAIProfileClient() (*httpClient, error) { return nil, err } if cfg.Token == "" { - return nil, newCliError(3, "not logged in", "run `mem auth login` first") + return nil, errNotLoggedIn() } return newHTTPClient(cfg), nil } diff --git a/server/cmd/mem/cmds_provider.go b/server/cmd/mem/cmds_provider.go index 322dac3..d31e3e2 100644 --- a/server/cmd/mem/cmds_provider.go +++ b/server/cmd/mem/cmds_provider.go @@ -56,7 +56,7 @@ func newProviderListCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) var resp providerListResp @@ -116,7 +116,7 @@ vectors cannot silently enter different spaces.`, return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) kind := args[0] @@ -164,7 +164,7 @@ historical provider identity was not recorded.`, return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } var resp struct { Provider string `json:"provider"` @@ -201,7 +201,7 @@ func newProviderTestCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) kind := args[0] diff --git a/server/cmd/mem/cmds_related.go b/server/cmd/mem/cmds_related.go index 4146a31..818c28c 100644 --- a/server/cmd/mem/cmds_related.go +++ b/server/cmd/mem/cmds_related.go @@ -82,7 +82,7 @@ Relation types currently supported: return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) path := "/v1/files/" + args[0] + "/related" @@ -161,7 +161,7 @@ outgoing rows before recomputing.`, return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) body := rebuildReq{FileID: file} diff --git a/server/cmd/mem/cmds_remember.go b/server/cmd/mem/cmds_remember.go index 345dadc..e736920 100644 --- a/server/cmd/mem/cmds_remember.go +++ b/server/cmd/mem/cmds_remember.go @@ -115,7 +115,7 @@ Examples: return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } var resp map[string]any diff --git a/server/cmd/mem/cmds_search.go b/server/cmd/mem/cmds_search.go index ac866df..47704ec 100644 --- a/server/cmd/mem/cmds_search.go +++ b/server/cmd/mem/cmds_search.go @@ -56,7 +56,7 @@ func newSearchCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) diff --git a/server/cmd/mem/cmds_timeline.go b/server/cmd/mem/cmds_timeline.go index 1edb1f3..eef049a 100644 --- a/server/cmd/mem/cmds_timeline.go +++ b/server/cmd/mem/cmds_timeline.go @@ -43,7 +43,7 @@ func newTimelineCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) var resp timelineResp diff --git a/server/cmd/mem/main.go b/server/cmd/mem/main.go index 2205c6d..85ac017 100644 --- a/server/cmd/mem/main.go +++ b/server/cmd/mem/main.go @@ -86,5 +86,6 @@ func newRootCmd() *cobra.Command { root.AddCommand(newTimelineCmd()) root.AddCommand(newWorkspaceCmd()) root.AddCommand(newVersionCmd()) + root.AddCommand(newDoctorCmd()) return root } diff --git a/server/cmd/mem/testdata/doctor-golden.json b/server/cmd/mem/testdata/doctor-golden.json new file mode 100644 index 0000000..36b47cd --- /dev/null +++ b/server/cmd/mem/testdata/doctor-golden.json @@ -0,0 +1,33 @@ +{ + "schema_version": "mem.doctor/v1", + "checks": [ + { + "name": "config", + "code": "config_ok", + "status": "ok" + }, + { + "name": "server", + "code": "server_reachable", + "status": "ok" + }, + { + "name": "credentials", + "code": "credential_present", + "status": "ok" + }, + { + "name": "workspace", + "code": "workspace_selected", + "status": "ok" + }, + { + "name": "version", + "code": "version_match", + "status": "ok", + "message": "development build; version skew not checked" + } + ], + "summary": "all checks passed", + "ok": true +}