Files
Enterprise-Knowledge-Base/cli/cmd/auth/logout.go
T
nullkey 5adcedf170 refactor(cli): v0.3 cross-cutting cleanup
Cross-cutting findings surfaced by the branch-completion review.

Perf bug:
- Factory.Client closure was not memoized. Factory.ResolveKB internally
  calls f.Client() to resolve --kb name → id, then the command's RunE
  calls f.Client() again. Two SDK clients, two keyring reads, two
  AuthRetryTransport allocations per name-resolved invocation, with
  *independent* token state (a refresh in one was invisible to the
  other). Switched to sync.Once like Secrets already does.

Silent bug bait:
- cmdutil.NormalizeHost docstring claimed CodeInputMissingFlag for the
  empty case; code returned CodeInputInvalidArgument. Aligned doc to
  code (present-but-empty is a bad value, not a missing flag).

Agent contract gaps:
- Five user-facing subcommands lacked SetAgentHelp: auth login /
  logout / list / status and chat. Added concise strings with error-
  code call-outs so agents can branch without parsing human strings.

Helper extraction (≥3 callers):
- text.KnowledgeDisplayName(fileName, title, id) — byte-identical
  formatter that was in both cmd/doc/list.go and cmd/search/docs.go.
  Takes fields directly so internal/text stays SDK-free.
- cmdutil.WrapHTTP(cause, fmt, args...) *Error — replaces the
  `Wrapf(ClassifyHTTPError(err), err, ...)` pattern across 24 SDK
  call sites. Sed-driven migration; off-pattern shapes in chat.go
  (used streamErr) and cmdutil/kb.go (in-package) hand-edited.
  Contract test gains a comment update: post-migration the dominant
  pattern is WrapHTTP which the AST scanner skips entirely (only
  NewError/Wrapf selectors inspected); ClassifyHTTPErrorOutputs()
  bridge still covers the dynamic codes those paths can yield.

UX consistency:
- cmd/doc/list.go --page-size help now reads "Items per page
  (1..1000)" matching cmd/session/list.go. The bounds validation
  already enforced 1..1000; the help text was the last drift.

Comment-discipline sweep:
- Deleted the WHAT-only "*Options captures `weknora ...` flag state"
  docstring across 23 files (context, kb, auth, doc, session, search,
  chat, doctor, link). Where the line carried a real WHY clause
  (kb/delete, doc/delete, session/delete, kb/edit), kept the WHY and
  dropped only the leading WHAT phrase.
- Stripped third-party project-name attribution from inline comments
  and one user-visible flag-help string across ~40 files in cli/cmd
  and cli/internal (plus 4 test-file comments). Removed phrases like
  "Mirrors `gh X`", "borrowed from lark-cli", "kubectl-style",
  "gcloud `--project`", "Stripe pattern", and the embedded GitHub
  URLs pointing at those projects. Behavioral descriptions and the
  WHY behind each comment are preserved; only the upstream-name
  attribution is gone. Inspiration / north-star references belong in
  cli/AGENTS.md (the design doc) and commit messages, not scattered
  through every file.

  Triggered by an audit round that surfaced several false / fragile
  parity claims (e.g. "Mirrors `gh repo edit`" — gh repo edit has no
  --name flag; "matches gcloud `--project` id-or-name" — gcloud's
  --project accepts ID only). Rather than fix them one by one, the
  whole category of in-comment external-project references was
  stripped uniformly.
2026-05-14 10:57:17 +08:00

137 lines
4.6 KiB
Go

package auth
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
)
type LogoutOptions struct {
Name string // --name: target a specific context (default: current)
All bool // --all: clear every context
JSONOut bool
}
// logoutResult is the typed payload emitted under data.
type logoutResult struct {
Removed []string `json:"removed"`
}
// NewCmdLogout builds `weknora auth logout`. Clears stored credentials
// (keyring + file fallback) and removes the context entry from config.yaml.
// No server-side revocation — local-only credential clear.
func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
opts := &LogoutOptions{}
cmd := &cobra.Command{
Use: "logout",
Short: "Remove stored credentials for a context",
Long: `Clear keyring + file-fallback secrets for one context (or all of
them with --all) and drop the context entry from ~/.config/weknora/config.yaml.
Note: this does NOT revoke the credential server-side — for API keys, you
must rotate them in the server UI; for JWT, the token will continue to be
accepted until it expires.`,
Example: ` weknora auth logout # current context
weknora auth logout --name staging # specific context
weknora auth logout --all`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runLogout(opts, f)
},
}
cmd.Flags().StringVar(&opts.Name, "name", "", "Context to log out (defaults to the current context)")
cmd.Flags().BoolVar(&opts.All, "all", false, "Log out of every configured context")
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
cmd.MarkFlagsMutuallyExclusive("name", "all")
agent.SetAgentHelp(cmd, "Clears local credentials only; the server-side token / api-key continues to be valid until expired or rotated. Returns data.removed: [...names]. Errors: auth.unauthenticated when no contexts configured.")
return cmd
}
func runLogout(opts *LogoutOptions, f *cmdutil.Factory) error {
cfg, err := f.Config()
if err != nil {
return err
}
if len(cfg.Contexts) == 0 {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no contexts configured; nothing to log out")
}
targets, err := pickLogoutTargets(opts, cfg)
if err != nil {
return err
}
store, err := f.Secrets()
if err != nil {
return err
}
for _, name := range targets {
clearContextSecrets(store, cfg.Contexts[name], name)
delete(cfg.Contexts, name)
}
// If we removed the active context, pick a remaining one (deterministic by
// map order would be flaky — leave CurrentContext empty so the next
// invocation surfaces a clear "no current context" error rather than
// silently switching).
if _, stillExists := cfg.Contexts[cfg.CurrentContext]; !stillExists {
cfg.CurrentContext = ""
}
if err := config.Save(cfg); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
}
if opts.JSONOut {
return cmdutil.NewJSONExporter().Write(iostreams.IO.Out,
format.Success(logoutResult{Removed: targets}, nil))
}
fmt.Fprintf(iostreams.IO.Out, "✓ Logged out of %d context(s): %s\n", len(targets), strings.Join(targets, ", "))
return nil
}
// pickLogoutTargets resolves the set of contexts to clear from flags + config.
func pickLogoutTargets(opts *LogoutOptions, cfg *config.Config) ([]string, error) {
if opts.All {
names := make([]string, 0, len(cfg.Contexts))
for n := range cfg.Contexts {
names = append(names, n)
}
return names, nil
}
name := opts.Name
if name == "" {
name = cfg.CurrentContext
}
if name == "" {
return nil, cmdutil.NewError(cmdutil.CodeInputMissingFlag,
"no current context set; pass --name <ctx> or --all")
}
if _, ok := cfg.Contexts[name]; !ok {
return nil, cmdutil.NewError(cmdutil.CodeLocalContextNotFound,
fmt.Sprintf("context %q not found in config", name))
}
return []string{name}, nil
}
// clearContextSecrets best-effort deletes every secret slot the context
// references. Errors are swallowed because a missing secret is a no-op
// (tested in keyring_test.go) — we don't want a stale ref to block logout.
func clearContextSecrets(store secrets.Store, c config.Context, name string) {
if c.TokenRef != "" {
_ = store.Delete(name, "access")
}
if c.RefreshRef != "" {
_ = store.Delete(name, "refresh")
}
if c.APIKeyRef != "" {
_ = store.Delete(name, "api_key")
}
}