feat(cli): add auth logout and auth list commands

gh / lark / gcloud / stripe all ship a logout command and a way to
enumerate stored credentials on day one. WeKnora's `auth` subtree had
only login + status, leaving no documented purge path for keyring
secrets — a real concern for `--with-token` (sk-…) and JWT flows that
write credentials to OS keychains.

auth logout [--name <ctx>] [--all] [--json]
  Clears keyring + file-fallback secrets (access / refresh / api_key
  slots) for the named context (default: current) or every context
  with --all. Removes the context entry from ~/.config/weknora/config.yaml
  and clears current_context if the removed entry was active.

  Mirrors `gh auth logout` and `lark auth logout`. As gh documents,
  this does NOT revoke server-side — for API keys users must rotate in
  the server UI, JWTs continue to be accepted until expiry.

auth list [--json]
  Renders a compact table (NAME / HOST / USER / MODE) with the active
  context marked `*`. Reads only config.yaml — no network, no keyring
  touch. Mode is inferred from which credential ref is set (api_key
  → "api-key", token → "password"; both → "password" wins).

  Mirrors gh's per-host enumeration (gh auth status iterates accounts)
  and lark `auth list`. For weknora the contexts file already had this
  data — the command is a thin renderer to match user muscle memory.

Deferred to a follow-up release:
  - auth refresh + transparent 401 retry in the SDK (we already persist
    refresh_token at login but never spend it; explicit gap)
  - login --web browser OAuth flow (requires a server-side endpoint)
  - auth token printer (cheap; defer with the rest)

Tests: 24 cli packages green. New: cmd/auth/logout_test.go (current
context, named, --all, no-contexts, unknown-name, no-current-no-flag,
mutex flags) + cmd/auth/list_test.go (human render, empty, JSON
envelope, inferMode edge cases). AGENTS.md command-surface note adds
the four-command auth subtree; screenshot section 4 adds `auth list`
alongside `auth status`.
This commit is contained in:
nullkey
2026-05-12 13:20:42 +08:00
committed by lyingbug
parent 8bcbf5a154
commit ca90ce422f
6 changed files with 490 additions and 2 deletions
+6
View File
@@ -102,6 +102,12 @@ The command tree follows `<noun> <verb>` (gh style). Verbs are:
| `upload` | Bulk write content | `doc upload <file>` |
| `use` | Switch active selection | `context use <name>` |
`auth` subtree: `login` / `logout` / `list` / `status`. Mirrors gh's
`auth login / logout / status / switch / list`-style surface; weknora uses
`context use` instead of `auth switch` because contexts carry host + tenant
on top of credentials. Token refresh is deferred to v0.3 (will land as
transparent 401 retry in the SDK plus an explicit `auth refresh` command).
Top-level RAG / connectivity verbs: `chat`, `search`, `api`, `link`,
`auth`, `doctor`, `version`.
+4 -2
View File
@@ -1,6 +1,6 @@
// Package auth holds the cobra commands for authentication
// (login / status / logout / refresh). v0.0 ships login + status; logout
// lands in v0.4 and refresh in v0.3.
// (login / logout / list / status). Token refresh is deferred to v0.3 once
// the SDK gains automatic 401 retry against the stored refresh_token.
package auth
import (
@@ -24,6 +24,8 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
Run: func(c *cobra.Command, _ []string) { _ = c.Help() },
}
cmd.AddCommand(NewCmdLogin(f, nil))
cmd.AddCommand(NewCmdLogout(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdStatus(f))
return cmd
}
+104
View File
@@ -0,0 +1,104 @@
package auth
import (
"fmt"
"sort"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// ListOptions captures `weknora auth list` flag state.
type ListOptions struct {
JSONOut bool
}
// listEntry is one row in the rendered list / one element of envelope.data.
type listEntry struct {
Name string `json:"name"`
Host string `json:"host"`
User string `json:"user,omitempty"`
Mode string `json:"mode"` // "api-key" / "password" / "unknown"
Current bool `json:"current"`
}
// NewCmdList builds `weknora auth list`. Mirrors gh's per-host enumeration
// and lark `auth list`: render one row per registered context, marking the
// active one. Reads only ~/.config/weknora/config.yaml — no network, no
// keyring touch.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
cmd := &cobra.Command{
Use: "list",
Short: "List configured authentication contexts",
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runList(opts, f)
},
}
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
return cmd
}
func runList(opts *ListOptions, f *cmdutil.Factory) error {
cfg, err := f.Config()
if err != nil {
return err
}
entries := make([]listEntry, 0, len(cfg.Contexts))
for name, c := range cfg.Contexts {
entries = append(entries, listEntry{
Name: name,
Host: c.Host,
User: c.User,
Mode: inferMode(c.APIKeyRef, c.TokenRef),
Current: name == cfg.CurrentContext,
})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
if opts.JSONOut {
return cmdutil.NewJSONExporter().Write(iostreams.IO.Out,
format.Success(entries, &format.Meta{Context: cfg.CurrentContext}))
}
if len(entries) == 0 {
fmt.Fprintln(iostreams.IO.Out, "No contexts configured. Run `weknora auth login` to create one.")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, " NAME\tHOST\tUSER\tMODE")
for _, e := range entries {
marker := " "
if e.Current {
marker = "* "
}
fmt.Fprintf(tw, "%s%s\t%s\t%s\t%s\n", marker, e.Name, e.Host, dashIfEmpty(e.User), e.Mode)
}
return tw.Flush()
}
// inferMode reports which credential shape the context was logged in with.
// A context with both refs set (which shouldn't happen with the current
// login flow but might appear in hand-edited configs) is treated as
// password — JWT wins because it's the more capable mode.
func inferMode(apiKeyRef, tokenRef string) string {
switch {
case tokenRef != "":
return "password"
case apiKeyRef != "":
return "api-key"
default:
return "unknown"
}
}
func dashIfEmpty(s string) string {
if s == "" {
return "-"
}
return s
}
+89
View File
@@ -0,0 +1,89 @@
package auth
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
func newListFactory(cfg *config.Config) *cmdutil.Factory {
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
}
}
func TestList_HumanRender(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
"prod": {Host: "https://prod", User: "alice@example.com", TokenRef: "keychain://prod/access"},
"staging": {Host: "https://staging", APIKeyRef: "keychain://staging/api_key"},
},
}
require.NoError(t, runList(&ListOptions{}, newListFactory(cfg)))
got := out.String()
// One row per context, current marked with `*`.
assert.Contains(t, got, "* prod")
assert.Contains(t, got, " staging")
// Mode column.
assert.Contains(t, got, "password")
assert.Contains(t, got, "api-key")
// Sorted alphabetically — prod after staging? No: "prod" < "staging".
assert.Less(t, strings.Index(got, "prod"), strings.Index(got, "staging"),
"contexts should render sorted by name")
}
func TestList_Empty(t *testing.T) {
out, _ := iostreams.SetForTest(t)
require.NoError(t, runList(&ListOptions{}, newListFactory(&config.Config{})))
assert.Contains(t, out.String(), "No contexts configured")
}
func TestList_JSONEnvelope(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
"prod": {Host: "https://prod", User: "alice", TokenRef: "tok"},
"staging": {Host: "https://staging", APIKeyRef: "key"},
},
}
require.NoError(t, runList(&ListOptions{JSONOut: true}, newListFactory(cfg)))
var env struct {
OK bool `json:"ok"`
Data []listEntry `json:"data"`
Meta struct {
Context string `json:"context"`
} `json:"_meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.True(t, env.OK)
assert.Equal(t, "prod", env.Meta.Context)
require.Len(t, env.Data, 2)
// Sorted: prod < staging.
assert.Equal(t, "prod", env.Data[0].Name)
assert.True(t, env.Data[0].Current)
assert.Equal(t, "password", env.Data[0].Mode)
assert.Equal(t, "staging", env.Data[1].Name)
assert.False(t, env.Data[1].Current)
assert.Equal(t, "api-key", env.Data[1].Mode)
}
func TestList_InferModeUnknown(t *testing.T) {
// Hand-edited config with neither ref set — surface "unknown" rather than
// pretending the context is a valid login.
assert.Equal(t, "unknown", inferMode("", ""))
assert.Equal(t, "password", inferMode("", "tok"))
assert.Equal(t, "api-key", inferMode("key", ""))
assert.Equal(t, "password", inferMode("key", "tok"), "JWT wins when both set")
}
+137
View File
@@ -0,0 +1,137 @@
package auth
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"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"
)
// LogoutOptions captures `weknora auth logout` flag state.
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`. Mirrors `gh auth logout` and
// `lark auth logout`: clears stored credentials (keyring + file fallback) and
// removes the context entry from config.yaml. No server-side revocation —
// matches gh's documented behavior ("does not revoke … users must do that
// manually").
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")
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")
}
}
+150
View File
@@ -0,0 +1,150 @@
package auth
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
)
// newLogoutFactory builds a Factory whose Config closure mutates the supplied
// cfg in place — runLogout writes back via config.Save which touches disk, so
// tests use t.Setenv("XDG_CONFIG_HOME", t.TempDir()) at the call site to
// isolate the on-disk file.
func newLogoutFactory(t *testing.T, cfg *config.Config, store secrets.Store) *cmdutil.Factory {
t.Helper()
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
Secrets: func() (secrets.Store, error) { return store, nil },
}
}
func isolateConfig(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
}
func TestLogout_CurrentContext(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "access", "jwt-prod"))
require.NoError(t, store.Set("prod", "refresh", "rfr-prod"))
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
"prod": {Host: "https://prod", TokenRef: store.Ref("prod", "access"), RefreshRef: store.Ref("prod", "refresh")},
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
},
}
require.NoError(t, runLogout(&LogoutOptions{}, newLogoutFactory(t, cfg, store)))
assert.Empty(t, cfg.CurrentContext, "current_context should clear when removed")
assert.NotContains(t, cfg.Contexts, "prod")
assert.Contains(t, cfg.Contexts, "staging", "non-target context untouched")
// Secrets gone for the removed context, kept for the survivor.
if _, err := store.Get("prod", "access"); err == nil {
t.Error("prod access secret should be deleted")
}
if v, _ := store.Get("staging", "api_key"); v != "sk-staging" {
t.Errorf("staging secret unexpectedly cleared: %q", v)
}
}
func TestLogout_NamedContext(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
"prod": {Host: "https://prod", TokenRef: "tok"},
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
},
}
require.NoError(t, runLogout(&LogoutOptions{Name: "staging"}, newLogoutFactory(t, cfg, store)))
assert.Equal(t, "prod", cfg.CurrentContext, "current_context untouched when removing other")
assert.NotContains(t, cfg.Contexts, "staging")
assert.Contains(t, cfg.Contexts, "prod")
}
func TestLogout_All(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
"prod": {Host: "https://prod"},
"staging": {Host: "https://staging"},
},
}
require.NoError(t, runLogout(&LogoutOptions{All: true}, newLogoutFactory(t, cfg, store)))
assert.Empty(t, cfg.Contexts)
assert.Empty(t, cfg.CurrentContext)
}
func TestLogout_NoContexts(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{}
err := runLogout(&LogoutOptions{}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
func TestLogout_UnknownName(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{"prod": {Host: "https://prod"}},
}
err := runLogout(&LogoutOptions{Name: "ghost"}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeLocalContextNotFound, typed.Code)
}
func TestLogout_NoCurrentNoFlag(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
Contexts: map[string]config.Context{"prod": {Host: "https://prod"}},
}
err := runLogout(&LogoutOptions{}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
}
// TestLogout_Cobra exercises the cobra layer for the mutually-exclusive
// --name + --all flag pair.
func TestLogout_Cobra_FlagsMutuallyExclusive(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{Contexts: map[string]config.Context{"a": {}}}
cmd := NewCmdLogout(newLogoutFactory(t, cfg, secrets.NewMemStore()))
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"--name", "a", "--all"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
require.Error(t, cmd.Execute())
}