WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+487
View File
@@ -0,0 +1,487 @@
// Package doctor implements `weknora doctor` - 4-item self-check.
//
// Status semantics (4-tier):
//
// ok - passed
// warn - soft problem; non-blocking (e.g. server minor older than CLI,
// keychain unavailable so falling back to file store)
// fail - failed; "hint" actionable
// skip - cascade-skipped (prereq failed) or --offline mode
//
// JSON output emits the Result object directly (bare data). Exit-code
// signal:
// - any check is fail → exit 1 (RunE returns SilentError so the data
// object is still emitted)
// - warn only / all ok → exit 0
//
// summary.all_passed gives the agent a one-line short-circuit; it is true
// ONLY when no warn / fail / skip checks are present. Agents SHOULD also
// inspect checks[].status to distinguish warn from ok.
package doctor
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/build"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/compat"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// doctorFields enumerates the fields surfaced for `--format json` discovery on
// `doctor`. Items here refer to data.checks[*] entries (Check struct).
var doctorFields = []string{"name", "status", "details", "hint"}
type Options struct {
NoCache bool
Offline bool
}
// Status is the per-check outcome on the wire (JSON Marshal still emits the
// underlying string). Typed so cascade comparisons can't typo against bare
// "ok"/"fail"/"skip" string literals.
type Status string
const (
StatusOK Status = "ok"
StatusWarn Status = "warn"
StatusFail Status = "fail"
StatusSkip Status = "skip"
)
// Check is one row in the report.
type Check struct {
Name string `json:"name"`
Status Status `json:"status"`
Details string `json:"details,omitempty"`
Hint string `json:"hint,omitempty"`
}
// Summary is the agent-friendly short-circuit payload.
//
// AllPassed is true only when there are zero warn/fail/skip rows; warn does
// not block exit-0 but it does flip AllPassed so agents reading just the
// boolean still notice the soft issue.
type Summary struct {
AllPassed bool `json:"all_passed"`
Passed int `json:"passed"`
Warned int `json:"warned,omitempty"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
}
// Result is the bare JSON payload.
type Result struct {
Summary Summary `json:"summary"`
Checks []Check `json:"checks"`
}
// Services groups the narrow interfaces doctor needs. Implemented by
// realServices (production) and fakeServices (tests).
type Services interface {
PingBaseURL(ctx context.Context) error
GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error)
GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error)
}
// NewCmd builds `weknora doctor`.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
opts := &Options{}
cmd := &cobra.Command{
Use: "doctor",
Short: "Run 4 self-checks: base URL, auth, server version, credential storage",
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
svc, err := buildServices(f)
if err != nil {
return err
}
cliVer, _, _ := build.Info()
r := runChecks(c.Context(), opts, svc, cliVer)
emit(fopts, r)
// Exit-code policy: fail → exit 1; warn / ok / skip → exit 0.
// SilentError suppresses both the human "error: ..." line and
// the stderr error formatter, so the JSON already written by
// emit() is the only stdout content.
if r.Summary.Failed > 0 {
return cmdutil.SilentError
}
return nil
},
}
cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, "Bypass server-info cache (located at $XDG_CACHE_HOME/weknora/server-info.yaml); force re-probe")
cmd.Flags().BoolVar(&opts.Offline, "offline", false, "Skip network checks; only verify local keyring/file storage (credential_storage check still runs)")
cmdutil.AddFormatFlag(cmd, doctorFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "run self-checks: base-url reachability, auth credential, server version, credential storage",
Examples: []string{
"weknora doctor",
"weknora doctor --offline",
"weknora doctor --no-cache --format json",
},
Output: "envelope.data is {summary:{all_passed,passed,warned,failed,skipped}, checks:[{name,status,details,hint?}]}",
})
return cmd
}
// cascade implements the two short-circuits every gated check shares:
// offline-mode skip and prereq-failed skip. Returns true when the check has
// been completed (Status set on c) and the caller should NOT run its body.
//
// A prereq is considered "passing" if its Status is OK or Warn - warn signals
// a soft problem that does not block downstream functionality. Only fail/skip
// cascades into a downstream skip.
func cascade(c *Check, offline bool, prereqs ...*Check) bool {
if offline {
c.Status, c.Details = StatusSkip, "offline mode"
return true
}
for _, p := range prereqs {
if p.Status != StatusOK && p.Status != StatusWarn {
c.Status, c.Details = StatusSkip, "prereq failed: "+p.Name
return true
}
}
return false
}
// runChecks executes the 4-item check matrix with cascade-skip semantics.
// Pure function over Services so tests can drive it directly.
func runChecks(ctx context.Context, opts *Options, svc Services, cliVer string) Result {
checks := []Check{
{Name: "base_url_reachable"},
{Name: "auth_credential"},
{Name: "server_version"},
{Name: "credential_storage"},
}
// 1. base_url_reachable - gated by offline only.
if !cascade(&checks[0], opts.Offline) {
t0 := time.Now()
if err := svc.PingBaseURL(ctx); err != nil {
checks[0].Status = StatusFail
checks[0].Hint = "verify the host configured for the active profile (run `weknora profile list` / `weknora profile add <n> --host=...`) and network reachability"
checks[0].Details = err.Error()
} else {
checks[0].Status = StatusOK
checks[0].Details = fmt.Sprintf("reachable in %s", time.Since(t0).Round(time.Millisecond))
}
}
// 2. auth_credential - needs base_url.
if !cascade(&checks[1], opts.Offline, &checks[0]) {
if _, err := svc.GetCurrentUser(ctx); err != nil {
checks[1].Status = StatusFail
checks[1].Hint = "run `weknora auth login`"
checks[1].Details = err.Error()
} else {
checks[1].Status = StatusOK
}
}
// 3. server_version - needs auth_credential.
if !cascade(&checks[2], opts.Offline, &checks[1]) {
info, fromCache, err := loadOrProbeServerInfo(ctx, opts, svc)
if err != nil {
checks[2].Status = StatusFail
checks[2].Details = err.Error()
} else {
fillVersionCheck(&checks[2], info, cliVer, fromCache)
}
}
// 4. credential_storage - independent of network; never gated by offline.
fillCredentialStorageCheck(&checks[3])
return Result{Summary: summarize(checks), Checks: checks}
}
// fillVersionCheck applies compat.Compat to (server, cli) version pair and
// sets Status/Details/Hint on c. fromCache toggles the "cached" suffix -
// the loader knows authoritatively which branch it took, time-based
// derivation from ProbedAt is unreliable since SaveCache uses time.Now().
//
// Mapping:
//
// compat.OK → StatusOK
// compat.SoftWarn → StatusWarn (server older but in-range; soft skew)
// compat.HardError → StatusFail (incompatible major; upgrade required)
func fillVersionCheck(c *Check, info *compat.Info, cliVer string, fromCache bool) {
level, hint := compat.Compat(info.ServerVersion, cliVer)
suffix := ""
if fromCache {
suffix = " (cached, pass --no-cache to refresh)"
}
switch level {
case compat.HardError:
c.Status = StatusFail
c.Hint = hint
c.Details = "server " + info.ServerVersion + suffix
case compat.SoftWarn:
c.Status = StatusWarn
// Lowercase, no trailing punctuation: matches existing details style
// ("reachable in N", "keyring or file storage available"). The hint
// from compat.Compat already says "server is older (server X, client Y)…"
c.Details = hint + suffix
c.Hint = "some new commands may degrade gracefully; upgrade server when convenient"
default:
c.Status = StatusOK
if hint != "" {
c.Details = hint + suffix
} else {
c.Details = fmt.Sprintf("server %s%s", info.ServerVersion, suffix)
}
}
}
// credStoreFactory is the seam tests use to inject a fake-store outcome -
// keyring success, file-store fallback, or hard failure - without touching
// the lazy-resolve buildServices contract (round-4 fix). Production stays
// at secrets.NewBestEffortStore.
var credStoreFactory = func() (secrets.Store, error) { return secrets.NewBestEffortStore() }
// SetCredStoreFactoryForTest overrides the credential-storage factory used by
// runChecks and returns a cleanup function that restores the previous value.
// Exported so out-of-package tests (notably cli/acceptance/contract) can
// pin the credential_storage outcome - otherwise the check probes the real
// OS keyring, which is present on macOS dev machines but not on Linux CI
// runners without libsecret, producing host-dependent test flakes.
//
// Usage:
//
// restore := doctor.SetCredStoreFactoryForTest(func() (secrets.Store, error) {
// return secrets.NewMemStore(), nil // not *FileStore → StatusOK
// })
// defer restore()
func SetCredStoreFactoryForTest(fn func() (secrets.Store, error)) (restore func()) {
saved := credStoreFactory
credStoreFactory = fn
return func() { credStoreFactory = saved }
}
// fillCredentialStorageCheck distinguishes the three terminal states the
// secrets layer can produce:
//
// keyring usable → StatusOK (preferred path)
// file fallback → StatusWarn (keyring unavailable, secrets still persist
// with 0600 file perms - agent containers / WSL hit this)
// construction fails → StatusFail
//
// Detection of "fallback to file store" relies on the type returned by
// secrets.NewBestEffortStore: a *FileStore concrete value means keyring was
// unavailable and the layer degraded. The Ref() URI scheme would also work
// but type-assertion is structurally more robust to scheme renames.
func fillCredentialStorageCheck(c *Check) {
store, err := credStoreFactory()
if err != nil {
c.Status = StatusFail
c.Details = err.Error()
c.Hint = "verify keyring access; falls back to file store"
return
}
if _, isFile := store.(*secrets.FileStore); isFile {
c.Status = StatusWarn
c.Details = "falling back to file store: keychain unavailable"
c.Hint = "secrets persist at 0600 under $XDG_CONFIG_HOME/weknora/secrets/; install / unlock keyring for OS-backed storage"
return
}
c.Status = StatusOK
c.Details = "keyring or file storage available"
}
// loadOrProbeServerInfo respects --no-cache: load fresh cache when allowed,
// else call compat.Probe (which wraps svc.GetSystemInfo) and persist. Cache
// write is best-effort. Returns fromCache so the caller can render the
// "cached" presentation hint without a brittle ProbedAt heuristic.
func loadOrProbeServerInfo(ctx context.Context, opts *Options, svc Services) (info *compat.Info, fromCache bool, err error) {
if !opts.NoCache {
if cached, fresh, _ := compat.LoadCache(); fresh && cached != nil {
return cached, true, nil
}
}
probed, err := compat.Probe(ctx, svc)
if err != nil {
return nil, false, err
}
_ = compat.SaveCache(probed)
return probed, false, nil
}
func summarize(cs []Check) Summary {
s := Summary{}
for _, c := range cs {
switch c.Status {
case StatusOK:
s.Passed++
case StatusWarn:
s.Warned++
case StatusFail:
s.Failed++
case StatusSkip:
s.Skipped++
}
}
s.AllPassed = s.Failed == 0 && s.Skipped == 0 && s.Warned == 0
return s
}
// emit renders the doctor result. The JSON path emits the Result directly;
// pass/fail signaling is conveyed by summary.failed (and the process exit
// code, set by the caller).
func emit(fopts *cmdutil.FormatOptions, r Result) {
if fopts.WantsJSON() {
_ = fopts.Emit(iostreams.IO.Out, r, nil)
return
}
for _, c := range r.Checks {
// %-2s for the glyph: most are 1 column, leaves room for one trailing
// space. Status word follows so screen-readers / non-glyph terminals
// still get the textual classification.
line := fmt.Sprintf("%-2s %-20s %s", marker(c.Status), c.Name, c.Status)
if c.Details != "" {
line += " (" + c.Details + ")"
}
fmt.Fprintln(iostreams.IO.Out, line)
if c.Hint != "" {
fmt.Fprintf(iostreams.IO.Out, " hint: %s\n", c.Hint)
}
}
fmt.Fprintf(iostreams.IO.Out, "\nsummary: %d passed, %d warned, %d failed, %d skipped\n",
r.Summary.Passed, r.Summary.Warned, r.Summary.Failed, r.Summary.Skipped)
}
// marker returns the text-mode prefix glyph for a check status.
//
// Agent / JSON consumers read the stable status string from
// data.checks[].status; the glyphs are presentation-only and never appear
// in --format json output.
func marker(s Status) string {
switch s {
case StatusFail:
return "✗"
case StatusWarn:
return "⚠"
case StatusSkip:
return "⊘"
default:
return "✓"
}
}
// buildServices wires the Factory closures into the doctor.Services interface.
// Reads the active profile's host so PingBaseURL targets the user's actual
// server, not localhost.
//
// Critically: this does NOT pre-resolve f.Client(). doctor's package promise
// (top comment) is that credential_storage runs even when no auth is set up -
// e.g. first-time `weknora doctor` to diagnose setup. Pre-resolving Client
// here would early-exit with auth.unauthenticated before any check runs,
// contradicting the docs. Instead, GetCurrentUser / GetSystemInfo lazily
// resolve and surface their own failure as a per-check StatusFail.
func buildServices(f *cmdutil.Factory) (Services, error) {
cfg, err := f.Config()
if err != nil {
return nil, err
}
return &realServices{f: f, host: resolveDoctorHost(cfg)}, nil
}
// resolveDoctorHost picks the host base_url_reachable probes. Tiers 2 and 3
// mirror the client builder (buildClientFromEnv) so doctor probes the host the
// real commands actually connect to; tier 1 is a doctor-local test/dev knob:
//
// 1. WEKNORA_BASE_URL — doctor-only probe override (used by tests); NOT read
// by the client builder, so setting it points doctor at a host no real
// command uses. Kept for test/dev harnesses; leave unset in normal use.
// 2. WEKNORA_HOST — when stateless env credentials (WEKNORA_TOKEN /
// WEKNORA_API_KEY) are in effect, i.e. the headless agent path. Without
// this, `WEKNORA_API_KEY=… WEKNORA_HOST=… weknora doctor` falsely reported
// "no host configured" and exited 1 while every other command worked.
// 3. active profile host — the configured default.
func resolveDoctorHost(cfg *config.Config) string {
host := ""
if ctx, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
host = ctx.Host
}
// Env credentials authenticate via WEKNORA_HOST, bypassing the profile.
// Honor it only when such creds are actually set, matching the client
// builder (a bare WEKNORA_HOST without creds is ignored there too).
if envActive, _ := cmdutil.EnvCredential(); envActive {
if v := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); v != "" {
host = v
}
}
if v := os.Getenv("WEKNORA_BASE_URL"); v != "" {
host = v
}
return host
}
type realServices struct {
f *cmdutil.Factory
host string
}
// pingTimeout caps the HEAD /health probe so a wedged TCP connection
// can't hang doctor indefinitely.
const pingTimeout = 5 * time.Second
func (s *realServices) PingBaseURL(ctx context.Context) error {
if s.host == "" {
return fmt.Errorf("no host configured for active profile")
}
url := s.host + "/health"
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("server returned %d", resp.StatusCode)
}
return nil
}
// GetCurrentUser lazily resolves the SDK client. When no profile is configured
// or credentials missing, f.Client() returns auth.unauthenticated; we surface
// that as the auth_credential check's failure rather than aborting doctor.
func (s *realServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {
cli, err := s.f.Client()
if err != nil {
return nil, err
}
return cli.GetCurrentUser(ctx)
}
// GetSystemInfo lazily resolves the SDK client (same rationale as GetCurrentUser).
// In the cascade ordering, auth_credential gates server_version, so this only
// runs when auth_credential succeeded - but the lazy resolution keeps doctor
// useful when only credential_storage is checkable (e.g., user not yet logged in).
func (s *realServices) GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error) {
cli, err := s.f.Client()
if err != nil {
return nil, err
}
return cli.GetSystemInfo(ctx)
}
+561
View File
@@ -0,0 +1,561 @@
package doctor
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/compat"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// withCredStoreFactory swaps the package-level credStoreFactory hook for the
// duration of t. Tests that assert the credential_storage outcome use this
// rather than touching the real OS keyring (which is environment-dependent
// and would make CI flake on macOS vs Linux vs WSL). Restored in t.Cleanup.
//
// The hook intentionally lives at package scope (rather than as a runChecks
// parameter) so the production call site stays a zero-arg function - keeping
// the lazy-resolve buildServices contract unchanged (round-4 fix).
func withCredStoreFactory(t *testing.T, fn func() (secrets.Store, error)) {
t.Helper()
saved := credStoreFactory
credStoreFactory = fn
t.Cleanup(func() { credStoreFactory = saved })
}
type fakeServices struct {
systemInfo *sdk.SystemInfo
systemErr error
userResp *sdk.CurrentUserResponse
userErr error
pingErr error
systemInfoHits atomic.Int32 // count GetSystemInfo invocations
}
func (f *fakeServices) GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error) {
f.systemInfoHits.Add(1)
return f.systemInfo, f.systemErr
}
func (f *fakeServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {
return f.userResp, f.userErr
}
func (f *fakeServices) PingBaseURL(ctx context.Context) error { return f.pingErr }
func goodUserResp() *sdk.CurrentUserResponse {
r := &sdk.CurrentUserResponse{}
r.Data.User = &sdk.AuthUser{ID: "u1"}
return r
}
func TestDoctor_AllOK(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
out, _ := iostreams.SetForTest(t)
// Pin credential_storage to ok so this test doesn't probe the host OS
// keyring - macOS dev machines have Keychain, Linux CI without libsecret
// would otherwise warn and break the AllPassed assertion.
withCredStoreFactory(t, func() (secrets.Store, error) {
return secrets.NewMemStore(), nil
})
svc := &fakeServices{
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
userResp: goodUserResp(),
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if !r.Summary.AllPassed {
t.Errorf("expected all_passed, got summary %+v", r.Summary)
}
if r.Summary.Passed != 4 {
t.Errorf("expected Passed=4 (one per check), got %+v", r.Summary)
}
if r.Summary.Failed != 0 || r.Summary.Skipped != 0 {
t.Errorf("expected 0 fail / 0 skip, got %+v", r.Summary)
}
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
if !strings.Contains(out.String(), `"all_passed":true`) {
t.Errorf("bare output should embed all_passed=true, got %q", out.String())
}
}
func TestDoctor_BaseURLFails_DownstreamSkip(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{
pingErr: errors.New("connect refused"),
userResp: goodUserResp(),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Summary.Skipped != 2 {
t.Errorf("expected 2 skipped (auth_credential + server_version), got %d", r.Summary.Skipped)
}
if r.Checks[0].Status != StatusFail {
t.Errorf("base_url_reachable status = %q, want fail", r.Checks[0].Status)
}
if !strings.Contains(r.Checks[0].Hint, "profile") {
t.Errorf("base_url fail hint should reference the active profile's host config, got %q", r.Checks[0].Hint)
}
if r.Checks[1].Status != StatusSkip {
t.Errorf("auth_credential status = %q, want skip", r.Checks[1].Status)
}
if r.Checks[2].Status != StatusSkip {
t.Errorf("server_version status = %q, want skip", r.Checks[2].Status)
}
// credential_storage is network-independent and runs regardless of base_url failures.
if r.Checks[3].Name != "credential_storage" {
t.Errorf("Checks[3] = %q, want credential_storage", r.Checks[3].Name)
}
}
func TestDoctor_Offline_OnlyKeyringChecked(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{}
r := runChecks(context.Background(), &Options{Offline: true}, svc, "1.0.0")
if r.Summary.Skipped < 3 {
t.Errorf("expected >=3 skip in offline mode, got %d", r.Summary.Skipped)
}
last := r.Checks[3]
if last.Name != "credential_storage" {
t.Errorf("last check = %q, want credential_storage", last.Name)
}
if last.Status == StatusSkip {
t.Error("credential_storage should NOT be skipped even in offline mode")
}
}
func TestDoctor_AuthFails_VersionSkipped(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{
userErr: errors.New("HTTP error 401: unauthenticated"),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Checks[0].Status != StatusOK {
t.Errorf("base_url should pass, got %q", r.Checks[0].Status)
}
if r.Checks[1].Status != StatusFail {
t.Errorf("auth_credential should fail, got %q", r.Checks[1].Status)
}
if r.Checks[2].Status != StatusSkip {
t.Errorf("server_version should skip due to auth fail, got %q", r.Checks[2].Status)
}
}
func TestDoctor_CacheHit_SkipsProbe(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Pre-populate fresh cache
if err := compat.SaveCache(&compat.Info{ServerVersion: "1.0.0", ProbedAt: time.Now()}); err != nil {
t.Fatalf("seed cache: %v", err)
}
svc := &fakeServices{userResp: goodUserResp()}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Checks[2].Status != StatusOK {
t.Errorf("server_version should be ok via cache, got %q (%s)", r.Checks[2].Status, r.Checks[2].Details)
}
if svc.systemInfoHits.Load() != 0 {
t.Errorf("expected 0 GetSystemInfo calls (cache hit), got %d", svc.systemInfoHits.Load())
}
if !strings.Contains(r.Checks[2].Details, "cached") {
t.Errorf("details should mention cache, got %q", r.Checks[2].Details)
}
}
// TestDoctor_NoConfig_StillRunsCredentialStorage guards the package-doc
// promise that credential_storage runs even with zero configuration. Round-4
// reviewer surfaced that buildServices used to abort on f.Client() failure,
// silently violating the doc for any first-time user running `weknora doctor`
// to diagnose setup. The lazy-resolve fix means missing auth surfaces as
// auth_credential=fail, not a top-level command exit.
func TestDoctor_NoConfig_StillRunsCredentialStorage(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Pin credential_storage to ok regardless of host keyring presence
// (Linux CI without libsecret would otherwise warn).
withCredStoreFactory(t, func() (secrets.Store, error) {
return secrets.NewMemStore(), nil
})
f := cmdutil.New()
svc, err := buildServices(f)
if err != nil {
t.Fatalf("buildServices must succeed even with no config; got: %v", err)
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
// All 4 checks must run (no early-exit). Last one is credential_storage and
// has no network/auth dependency, so it must report ok.
if got := len(r.Checks); got != 4 {
t.Fatalf("expected 4 checks executed, got %d", got)
}
if r.Checks[3].Name != "credential_storage" {
t.Errorf("Checks[3] = %q, want credential_storage", r.Checks[3].Name)
}
if r.Checks[3].Status != StatusOK {
t.Errorf("credential_storage must run / pass even without auth, got %q (%s)",
r.Checks[3].Status, r.Checks[3].Details)
}
// base_url_reachable fails (no host); cascade then skips auth_credential
// and server_version. Whether auth/version are skip vs fail is an internal
// cascade detail; the user-visible promise is that 4 checks executed and
// the independent credential_storage one passed.
if r.Checks[0].Status != StatusFail {
t.Errorf("base_url must fail without host, got %q", r.Checks[0].Status)
}
if r.Checks[1].Status == StatusOK {
t.Errorf("auth_credential must NOT report ok without config, got %q", r.Checks[1].Status)
}
}
func TestDoctor_NoCache_BypassesCache(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Pre-populate fresh cache; --no-cache should ignore it
if err := compat.SaveCache(&compat.Info{ServerVersion: "9.9.9", ProbedAt: time.Now()}); err != nil {
t.Fatalf("seed cache: %v", err)
}
svc := &fakeServices{
userResp: goodUserResp(),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{NoCache: true}, svc, "1.0.0")
if svc.systemInfoHits.Load() != 1 {
t.Errorf("expected 1 GetSystemInfo call (--no-cache), got %d", svc.systemInfoHits.Load())
}
if !strings.Contains(r.Checks[2].Details, "1.0.0") {
t.Errorf("details should reflect probed version 1.0.0 not cached 9.9.9, got %q", r.Checks[2].Details)
}
}
// TestDoctor_VersionSkewWarns covers the soft-skew path: server is older
// than CLI by ≥ 1 minor (same major, in compat range) → server_version=warn,
// summary.failed=0 (exit 0), summary.all_passed=false (so agents reading
// just the boolean still notice). The compat decision lives in
// cli/internal/compat; this test pins the doctor-side mapping
// (compat.SoftWarn → StatusWarn).
func TestDoctor_VersionSkewWarns(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Force keyring path to ok so credential_storage doesn't itself warn -
// we want this test to assert ONLY on server_version.
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewMemStore(), nil })
svc := &fakeServices{
systemInfo: &sdk.SystemInfo{Version: "1.0.0"}, // server 1.0
userResp: goodUserResp(),
}
r := runChecks(context.Background(), &Options{}, svc, "1.5.0") // CLI 1.5
v := r.Checks[2]
if v.Name != "server_version" {
t.Fatalf("Checks[2] = %q, want server_version", v.Name)
}
if v.Status != StatusWarn {
t.Errorf("server_version status = %q, want warn (server older than CLI)", v.Status)
}
// compat.Compat hint contains "server is older" - that's the load-bearing
// substring agents may pattern-match. Not asserting full message text
// since compat.Compat owns the wording.
if !strings.Contains(v.Details, "older") {
t.Errorf("server_version details should mention older server, got %q", v.Details)
}
// Summary: warned counted, failed=0, all_passed flipped.
if r.Summary.Warned != 1 {
t.Errorf("expected Warned=1, got summary %+v", r.Summary)
}
if r.Summary.Failed != 0 {
t.Errorf("expected Failed=0, got summary %+v", r.Summary)
}
if r.Summary.AllPassed {
t.Error("AllPassed must be false when any check is warn")
}
// Wire shape: warn-only run has envelope wrapper with ok:true.
out, _ := iostreams.SetForTest(t)
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
body := out.String()
if !strings.Contains(body, `"ok":true`) {
t.Errorf("doctor output must carry envelope ok:true, got %q", body)
}
if !strings.Contains(body, `"failed":0`) {
t.Errorf("bare output must carry failed:0 on warn-only run, got %q", body)
}
if !strings.Contains(body, `"status":"warn"`) {
t.Errorf("bare output must surface status=warn, got %q", body)
}
}
// TestDoctor_HardErrorStillFails guards that a different-major skew remains
// fail (not warn). Without this, a later refactor could silently downgrade
// HardError to warn and we'd lose exit-1 on incompatible servers.
func TestDoctor_HardErrorStillFails(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewMemStore(), nil })
svc := &fakeServices{
systemInfo: &sdk.SystemInfo{Version: "2.0.0"},
userResp: goodUserResp(),
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Checks[2].Status != StatusFail {
t.Errorf("server_version status = %q, want fail (different major)", r.Checks[2].Status)
}
if r.Summary.Failed != 1 {
t.Errorf("expected Failed=1, got %+v", r.Summary)
}
}
// TestDoctor_KeychainFallbackWarns covers credential_storage's third
// state: keyring unavailable, fell back to FileStore (agent containers,
// headless CI, WSL without DBus). The check should warn - secrets still
// persist (0600 file perms) but the OS-backed path was unreachable.
func TestDoctor_KeychainFallbackWarns(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
withCredStoreFactory(t, func() (secrets.Store, error) {
// FileStore concrete type → fillCredentialStorageCheck reads warn.
return secrets.NewFileStore()
})
svc := &fakeServices{
userResp: goodUserResp(),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
cs := r.Checks[3]
if cs.Name != "credential_storage" {
t.Fatalf("Checks[3] = %q, want credential_storage", cs.Name)
}
if cs.Status != StatusWarn {
t.Errorf("credential_storage status = %q, want warn (file fallback)", cs.Status)
}
if !strings.Contains(strings.ToLower(cs.Details), "file store") {
t.Errorf("details should mention file store, got %q", cs.Details)
}
if r.Summary.Warned != 1 {
t.Errorf("expected Warned=1, got %+v", r.Summary)
}
if r.Summary.Failed != 0 {
t.Errorf("expected Failed=0 (warn doesn't fail), got %+v", r.Summary)
}
}
// TestDoctor_CredStoreFactoryError surfaces the constructor failure path as
// fail (not warn) - distinguishes "cannot persist credentials at all" from
// "downgraded to file store".
func TestDoctor_CredStoreFactoryError(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
withCredStoreFactory(t, func() (secrets.Store, error) {
return nil, errors.New("disk full")
})
r := runChecks(context.Background(), &Options{Offline: true}, &fakeServices{}, "1.0.0")
if r.Checks[3].Status != StatusFail {
t.Errorf("credential_storage status = %q, want fail on constructor error", r.Checks[3].Status)
}
if !strings.Contains(r.Checks[3].Details, "disk full") {
t.Errorf("details should propagate underlying error, got %q", r.Checks[3].Details)
}
}
// TestDoctor_BareJSON_WarnDoesNotSignalFail pins the wire contract: warn
// keeps summary.failed=0 (so exit code stays 0). emit() is the seam between
// Result and what agents observe.
func TestDoctor_BareJSON_WarnDoesNotSignalFail(t *testing.T) {
out, _ := iostreams.SetForTest(t)
r := Result{
Summary: Summary{AllPassed: false, Passed: 3, Warned: 1},
Checks: []Check{
{Name: "base_url_reachable", Status: StatusOK},
{Name: "auth_credential", Status: StatusOK},
{Name: "server_version", Status: StatusWarn, Details: "server is older"},
{Name: "credential_storage", Status: StatusOK},
},
}
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
got := out.String()
if !strings.Contains(got, `"failed":0`) {
t.Errorf("warn-only result must have summary.failed=0 (exit-0 signal), got %q", got)
}
// v0.7 envelope: ok:true is expected
if !strings.Contains(got, `"ok":true`) {
t.Errorf("output must carry envelope ok:true, got %q", got)
}
}
// TestDoctor_BareJSON_FailRaisesSummary pins the dual: any fail surfaces in
// summary.failed (caller maps that to exit 1 via SilentError).
func TestDoctor_BareJSON_FailRaisesSummary(t *testing.T) {
out, _ := iostreams.SetForTest(t)
r := Result{
Summary: Summary{AllPassed: false, Passed: 2, Failed: 1, Skipped: 1},
Checks: []Check{
{Name: "base_url_reachable", Status: StatusFail, Details: "refused"},
{Name: "auth_credential", Status: StatusSkip},
{Name: "server_version", Status: StatusOK},
{Name: "credential_storage", Status: StatusOK},
},
}
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
got := out.String()
if !strings.Contains(got, `"failed":1`) {
t.Errorf("fail must surface in summary.failed, got %q", got)
}
}
// TestDoctor_TextMarker_Warn confirms the human-mode glyph appears for warn
// rows. Glyph choice is presentation-only; we pin via substring (no width
// alignment assertion since terminal-width handling is environmental).
func TestDoctor_TextMarker_Warn(t *testing.T) {
out, _ := iostreams.SetForTest(t)
r := Result{
Summary: Summary{Warned: 1},
Checks: []Check{
{Name: "server_version", Status: StatusWarn, Details: "older"},
},
}
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, r)
got := out.String()
if !strings.Contains(got, "⚠") {
t.Errorf("text output should contain ⚠ glyph for warn, got %q", got)
}
if !strings.Contains(got, "warn") {
t.Errorf("text output should still contain status word `warn`, got %q", got)
}
}
// TestDoctor_WarnedField_OmittedAtZero protects the JSON wire compactness:
// `warned` carries omitempty, so a clean run has no warned key. Existing
// agents inspecting older outputs shouldn't see a sudden new field unless
// it actually fired.
func TestDoctor_WarnedField_OmittedAtZero(t *testing.T) {
out, _ := iostreams.SetForTest(t)
r := Result{
Summary: Summary{AllPassed: true, Passed: 4},
Checks: []Check{
{Name: "base_url_reachable", Status: StatusOK},
{Name: "auth_credential", Status: StatusOK},
{Name: "server_version", Status: StatusOK},
{Name: "credential_storage", Status: StatusOK},
},
}
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
got := out.String()
if strings.Contains(got, `"warned"`) {
t.Errorf("output should omit `warned` field when zero, got %q", got)
}
}
// TestDoctor_RunE_FailReturnsSilentError is a behavior test on NewCmd:
// when any check is fail, RunE must return cmdutil.SilentError so the
// framework exit-1 path runs without writing a second error line on top
// of the data object emit() already wrote.
//
// SilenceErrors/SilenceUsage on the leaf cobra.Command suppress cobra's own
// "Error: ..." + usage dump that would otherwise leak to stderr when running
// the leaf in isolation (root.go sets these on the root, not on every leaf).
func TestDoctor_RunE_FailReturnsSilentError(t *testing.T) {
// Force base_url to fail by leaving host empty.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewMemStore(), nil })
f := cmdutil.New()
cmd := NewCmd(f)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
// --format is persistent at root in v0.7; register locally for the
// isolated leaf-execution test path.
cmd.PersistentFlags().String("format", "", "")
cmd.PersistentFlags().String("jq", "", "")
cmd.SetArgs([]string{"--format", "json"})
cmd.SetContext(context.Background())
err := cmd.Execute()
if !errors.Is(err, cmdutil.SilentError) {
t.Errorf("RunE on fail must return SilentError, got %v", err)
}
}
// TestDoctor_RunE_WarnReturnsNil - soft skew path stays exit-0. Pairs with
// TestDoctor_RunE_FailReturnsSilentError: warn does NOT trigger SilentError.
func TestDoctor_RunE_WarnReturnsNil(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewFileStore() })
r := runChecks(context.Background(), &Options{Offline: true}, &fakeServices{}, "1.0.0")
// Smoke-check: warn-only result, no fails.
if r.Summary.Failed != 0 {
t.Fatalf("setup error: expected Failed=0, got %+v", r.Summary)
}
if r.Summary.Warned == 0 {
t.Fatalf("setup error: expected Warned>=1, got %+v", r.Summary)
}
}
// TestResolveDoctorHost_EnvCredentials - doctor's base_url probe must honor
// WEKNORA_HOST when stateless env credentials are in effect (the headless
// agent path), mirroring buildClientFromEnv. Regression: doctor previously
// read only the active profile host, so `WEKNORA_API_KEY=... WEKNORA_HOST=...
// weknora doctor` reported "no host configured" and exited 1 while every
// other command worked.
func TestResolveDoctorHost_EnvCredentials(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://profile-host"}},
}
t.Run("env creds + WEKNORA_HOST wins over profile", func(t *testing.T) {
t.Setenv("WEKNORA_TOKEN", "")
t.Setenv("WEKNORA_API_KEY", "sk-test")
t.Setenv("WEKNORA_HOST", "https://env-host:8080")
t.Setenv("WEKNORA_BASE_URL", "")
if got := resolveDoctorHost(cfg); got != "https://env-host:8080" {
t.Errorf("want env host, got %q", got)
}
})
t.Run("env creds without WEKNORA_HOST falls back to profile", func(t *testing.T) {
t.Setenv("WEKNORA_API_KEY", "sk-test")
t.Setenv("WEKNORA_HOST", "")
t.Setenv("WEKNORA_BASE_URL", "")
if got := resolveDoctorHost(cfg); got != "https://profile-host" {
t.Errorf("want profile host, got %q", got)
}
})
t.Run("no env creds ignores WEKNORA_HOST (matches client builder)", func(t *testing.T) {
t.Setenv("WEKNORA_TOKEN", "")
t.Setenv("WEKNORA_API_KEY", "")
t.Setenv("WEKNORA_HOST", "https://should-be-ignored")
t.Setenv("WEKNORA_BASE_URL", "")
if got := resolveDoctorHost(cfg); got != "https://profile-host" {
t.Errorf("want profile host, got %q", got)
}
})
t.Run("WEKNORA_BASE_URL test override always wins", func(t *testing.T) {
t.Setenv("WEKNORA_API_KEY", "sk-test")
t.Setenv("WEKNORA_HOST", "https://env-host")
t.Setenv("WEKNORA_BASE_URL", "https://base-url-override")
if got := resolveDoctorHost(cfg); got != "https://base-url-override" {
t.Errorf("want base-url override, got %q", got)
}
})
}