From ca90ce422fd406dadee68f98249f28aab64283c7 Mon Sep 17 00:00:00 2001 From: nullkey Date: Mon, 11 May 2026 02:45:07 +0800 Subject: [PATCH] feat(cli): add auth logout and auth list commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ] [--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`. --- cli/AGENTS.md | 6 ++ cli/cmd/auth/auth.go | 6 +- cli/cmd/auth/list.go | 104 +++++++++++++++++++++++++ cli/cmd/auth/list_test.go | 89 +++++++++++++++++++++ cli/cmd/auth/logout.go | 137 ++++++++++++++++++++++++++++++++ cli/cmd/auth/logout_test.go | 150 ++++++++++++++++++++++++++++++++++++ 6 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 cli/cmd/auth/list.go create mode 100644 cli/cmd/auth/list_test.go create mode 100644 cli/cmd/auth/logout.go create mode 100644 cli/cmd/auth/logout_test.go diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 78db2710a..4f7673858 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -102,6 +102,12 @@ The command tree follows ` ` (gh style). Verbs are: | `upload` | Bulk write content | `doc upload ` | | `use` | Switch active selection | `context use ` | +`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`. diff --git a/cli/cmd/auth/auth.go b/cli/cmd/auth/auth.go index 52d2e6bb7..82eb571c0 100644 --- a/cli/cmd/auth/auth.go +++ b/cli/cmd/auth/auth.go @@ -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 } diff --git a/cli/cmd/auth/list.go b/cli/cmd/auth/list.go new file mode 100644 index 000000000..e4d3ce4e4 --- /dev/null +++ b/cli/cmd/auth/list.go @@ -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 +} diff --git a/cli/cmd/auth/list_test.go b/cli/cmd/auth/list_test.go new file mode 100644 index 000000000..3caf671cc --- /dev/null +++ b/cli/cmd/auth/list_test.go @@ -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") +} diff --git a/cli/cmd/auth/logout.go b/cli/cmd/auth/logout.go new file mode 100644 index 000000000..3ac4534ad --- /dev/null +++ b/cli/cmd/auth/logout.go @@ -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 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") + } +} diff --git a/cli/cmd/auth/logout_test.go b/cli/cmd/auth/logout_test.go new file mode 100644 index 000000000..9e2a922f5 --- /dev/null +++ b/cli/cmd/auth/logout_test.go @@ -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()) +}