From b4fd23e0cdce40e2e605557254d243a122bcf7b4 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 25 Sep 2026 13:59:10 -0500 Subject: [PATCH 1/2] fix: Pick a session by endpoint in auth switch When one email is signed in on two endpoints, such as production and staging, 'datumctl auth switch ' always opened the picker. With no terminal it failed with a hint to name the email and endpoint, but nothing accepted an endpoint, and the full session name was rejected too. Scripts and CI had no way to switch between the two. 'auth switch' now accepts the full session name and an --endpoint flag that takes the endpoint 'auth list' prints, with or without a scheme or trailing slash. An endpoint with no session fails and lists the endpoints that exist. Without a terminal, an ambiguous switch prints the exact command for each matching session, and the no-argument case no longer says "for this email". The 'ctx use' hint for a context in another session includes --endpoint when the email needs it. Fixes #277 Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cmd/auth/auth.go | 2 +- internal/cmd/auth/switch.go | 179 +++++++++++---- internal/cmd/auth/switch_test.go | 294 +++++++++++++++++++++++++ internal/cmd/ctx/use.go | 2 +- internal/cmd/ctx/use_test.go | 71 ++++++ internal/datumconfig/config_v1beta1.go | 10 + internal/picker/picker.go | 12 +- 7 files changed, 518 insertions(+), 52 deletions(-) create mode 100644 internal/cmd/auth/switch_test.go create mode 100644 internal/cmd/ctx/use_test.go diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go index 5574c94..b8c9190 100644 --- a/internal/cmd/auth/auth.go +++ b/internal/cmd/auth/auth.go @@ -58,7 +58,7 @@ Advanced — kubectl integration: cmd.AddCommand( getTokenCmd, listCmd, - switchCmd, + switchCmd(), updateKubeconfigCmd(), ) diff --git a/internal/cmd/auth/switch.go b/internal/cmd/auth/switch.go index 735f1f1..b7f293f 100644 --- a/internal/cmd/auth/switch.go +++ b/internal/cmd/auth/switch.go @@ -2,78 +2,140 @@ package auth import ( "fmt" + "strings" "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" "go.datum.net/datumctl/internal/datumconfig" customerrors "go.datum.net/datumctl/internal/errors" "go.datum.net/datumctl/internal/picker" ) -var switchCmd = &cobra.Command{ - Use: "switch [email]", - Short: "Switch the active Datum Cloud user session", - Long: `Change which locally stored user account is treated as active. - -The active user's credentials are used for all datumctl commands that -require authentication. +func switchCmd() *cobra.Command { + var endpoint string + + cmd := &cobra.Command{ + Use: "switch [email | session]", + Short: "Switch the active Datum Cloud user session", + Long: templates.LongDesc(` + Change which locally stored user account is treated as active. + + The active user's credentials are used for all datumctl commands that + require authentication. Other signed-in accounts stay signed in. + + Pass the email address shown by 'datumctl auth list'. When the same + email is signed in on more than one endpoint, add --endpoint with the + endpoint shown by 'datumctl auth list' to pick one. You can also pass + the full session name (email@endpoint) instead. + + With no argument, or when several sessions match and no --endpoint is + given, an interactive picker is shown. The picker needs a terminal; + without one, datumctl prints the command to run for each match. + + Each session remembers the last context you used, so switching users + also restores the context. To add a new account, run 'datumctl login'.`), + Example: templates.Examples(` + # Interactive session picker + datumctl auth switch + + # Switch to a specific account + datumctl auth switch user@example.com + + # Pick the staging session for an email signed in on two endpoints + datumctl auth switch user@example.com --endpoint api.staging.env.datum.net + + # Switch by full session name + datumctl auth switch user@example.com@api.datum.net`), + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runSwitch(args, endpoint) + }, + } -If no email is provided, an interactive picker is shown. If the email -address matches sessions on multiple endpoints, you will be prompted to -choose. Each session remembers the last context you used, so switching -users also restores the context. + cmd.Flags().StringVar(&endpoint, "endpoint", "", + "API endpoint of the session to switch to, as shown by 'datumctl auth list' (for example api.datum.net)") -The email address must match an account shown by 'datumctl auth list'. -To add a new account, run 'datumctl login' first.`, - Example: ` # Interactive session picker - datumctl auth switch + return cmd +} - # Switch to a specific account - datumctl auth switch user@example.com`, - Args: cobra.MaximumNArgs(1), - RunE: runSwitch, +// normalizeEndpoint reduces an endpoint to the bare host form that +// 'datumctl auth list' prints, so a scheme, trailing slash, or letter case in +// the user's input does not prevent a match. +func normalizeEndpoint(s string) string { + return strings.ToLower(datumconfig.StripScheme(datumconfig.CleanBaseServer(strings.TrimSpace(s)))) } -func runSwitch(_ *cobra.Command, args []string) error { +func runSwitch(args []string, endpoint string) error { cfg, err := datumconfig.LoadAuto() if err != nil { return err } - var sessionName string + if len(cfg.Sessions) == 0 { + return customerrors.NewUserErrorWithHint( + "No authenticated sessions.", + "Run 'datumctl login' to authenticate.", + ) + } - if len(args) == 0 { - // Interactive picker of all sessions. - if len(cfg.Sessions) == 0 { - return customerrors.NewUserErrorWithHint( - "No authenticated sessions.", - "Run 'datumctl login' to authenticate.", - ) - } - allSessions := make([]*datumconfig.Session, len(cfg.Sessions)) + // Gather the candidate sessions named by the argument: an exact session + // name wins, then every session for the email, then all sessions. + var ( + candidates []*datumconfig.Session + subject string // who the candidates belong to, for messages + ) + switch { + case len(args) == 0: for i := range cfg.Sessions { - allSessions[i] = &cfg.Sessions[i] + candidates = append(candidates, &cfg.Sessions[i]) } - sessionName, err = picker.SelectSession(allSessions, cfg.ActiveSession) - if err != nil { - return err - } - } else { - email := args[0] - sessions := cfg.SessionByEmail(email) - if len(sessions) == 0 { + case cfg.SessionByName(args[0]) != nil: + candidates = []*datumconfig.Session{cfg.SessionByName(args[0])} + subject = args[0] + default: + subject = args[0] + candidates = cfg.SessionByEmail(subject) + if len(candidates) == 0 { return customerrors.NewUserErrorWithHint( - fmt.Sprintf("No sessions found for %s.", email), + fmt.Sprintf("No sessions found for %s.", subject), "Run 'datumctl auth list' to see authenticated users, or 'datumctl login' to add a new one.", ) } - if len(sessions) == 1 { - sessionName = sessions[0].Name - } else { - sessionName, err = picker.SelectSession(sessions, cfg.ActiveSession) - if err != nil { - return err + } + + if endpoint != "" { + want := normalizeEndpoint(endpoint) + var matched []*datumconfig.Session + var known []string + for _, s := range candidates { + host := normalizeEndpoint(s.Endpoint.Server) + if host == want { + matched = append(matched, s) } + known = appendUniqueString(known, datumconfig.StripScheme(s.Endpoint.Server)) + } + if len(matched) == 0 { + msg := fmt.Sprintf("No session on endpoint %s.", datumconfig.StripScheme(endpoint)) + if subject != "" { + msg = fmt.Sprintf("No session for %s on endpoint %s.", subject, datumconfig.StripScheme(endpoint)) + } + return customerrors.NewUserErrorWithHint(msg, + "Signed-in endpoints: "+strings.Join(known, ", ")) + } + candidates = matched + } + + var sessionName string + switch { + case len(candidates) == 1: + sessionName = candidates[0].Name + case !picker.IsTerminal(): + return ambiguousSessionError(cfg, candidates, subject) + default: + sessionName, err = picker.SelectSession(candidates, cfg.ActiveSession) + if err != nil { + return err } } @@ -98,7 +160,7 @@ func runSwitch(_ *cobra.Command, args []string) error { return fmt.Errorf("save config: %w", err) } - fmt.Printf("\n\u2713 Switched to %s (%s)\n", session.UserName, session.UserEmail) + fmt.Printf("\nāœ“ Switched to %s (%s)\n", session.UserName, session.UserEmail) if ctxEntry := cfg.CurrentContextEntry(); ctxEntry != nil { fmt.Printf(" Context: %s\n", datumconfig.FormatWithID(cfg.DisplayRef(ctxEntry), ctxEntry.Ref())) } @@ -109,3 +171,26 @@ func runSwitch(_ *cobra.Command, args []string) error { return nil } +// ambiguousSessionError explains that several sessions match and the picker +// cannot run, listing a copy-pasteable command for each match. +func ambiguousSessionError(cfg *datumconfig.ConfigV1Beta1, sessions []*datumconfig.Session, subject string) error { + msg := "Multiple sessions found. Choosing one interactively requires a terminal." + if subject != "" { + msg = fmt.Sprintf("%s is signed in on more than one endpoint. Choosing one interactively requires a terminal.", subject) + } + var b strings.Builder + b.WriteString("Run one of:") + for _, s := range sessions { + fmt.Fprintf(&b, "\n datumctl auth switch %s", cfg.SwitchArgs(s)) + } + return customerrors.NewUserErrorWithHint(msg, b.String()) +} + +func appendUniqueString(s []string, v string) []string { + for _, x := range s { + if x == v { + return s + } + } + return append(s, v) +} diff --git a/internal/cmd/auth/switch_test.go b/internal/cmd/auth/switch_test.go new file mode 100644 index 0000000..2e812082 --- /dev/null +++ b/internal/cmd/auth/switch_test.go @@ -0,0 +1,294 @@ +package auth + +import ( + "bytes" + "strings" + "testing" + + "go.datum.net/datumctl/internal/authutil" + "go.datum.net/datumctl/internal/datumconfig" + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/keyring" + "go.datum.net/datumctl/internal/picker" +) + +const ( + sharedEmail = "swells@datum.net" + prodHost = "api.datum.net" + stagingHost = "api.staging.env.datum.net" + otherEmail = "solo@example.com" +) + +var ( + prodSession = datumconfig.SessionName(sharedEmail, prodHost) + stagingSession = datumconfig.SessionName(sharedEmail, stagingHost) + soloSession = datumconfig.SessionName(otherEmail, prodHost) + prodCtx = datumconfig.QualifiedContextName(prodSession, "org-prod") + stagingCtx = datumconfig.QualifiedContextName(stagingSession, "org-staging") + soloCtx = datumconfig.QualifiedContextName(soloSession, "org-solo") +) + +// setupSwitchEnv points HOME at a temp dir, mocks the keyring, forces the +// no-terminal path unless tty is true, and writes a config with one email +// signed in on prod and staging plus a second, single-session email. +func setupSwitchEnv(t *testing.T, tty bool) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + keyring.MockInit() + + orig := picker.IsTerminal + picker.IsTerminal = func() bool { return tty } + t.Cleanup(func() { picker.IsTerminal = orig }) + + cfg := datumconfig.NewV1Beta1() + cfg.Sessions = []datumconfig.Session{ + {Name: prodSession, UserKey: "key-prod", UserEmail: sharedEmail, UserName: "Scot Prod", + Endpoint: datumconfig.Endpoint{Server: "https://" + prodHost}, LastContext: prodCtx}, + {Name: stagingSession, UserKey: "key-staging", UserEmail: sharedEmail, UserName: "Scot Staging", + Endpoint: datumconfig.Endpoint{Server: "https://" + stagingHost}, LastContext: stagingCtx}, + {Name: soloSession, UserKey: "key-solo", UserEmail: otherEmail, UserName: "Solo", + Endpoint: datumconfig.Endpoint{Server: "https://" + prodHost}, LastContext: soloCtx}, + } + cfg.Contexts = []datumconfig.DiscoveredContext{ + {Name: prodCtx, Session: prodSession, OrganizationID: "org-prod"}, + {Name: stagingCtx, Session: stagingSession, OrganizationID: "org-staging"}, + {Name: soloCtx, Session: soloSession, OrganizationID: "org-solo"}, + } + cfg.ActiveSession = soloSession + cfg.CurrentContext = soloCtx + if err := datumconfig.SaveV1Beta1(cfg); err != nil { + t.Fatalf("save config: %v", err) + } +} + +func runSwitchCmd(t *testing.T, args ...string) error { + t.Helper() + cmd := Command() + cmd.SetArgs(append([]string{"switch"}, args...)) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + return cmd.Execute() +} + +func loadCfg(t *testing.T) *datumconfig.ConfigV1Beta1 { + t.Helper() + cfg, err := datumconfig.LoadAuto() + if err != nil { + t.Fatalf("load config: %v", err) + } + return cfg +} + +func TestSwitch(t *testing.T) { + tests := []struct { + name string + args []string + wantSession string + wantContext string + // Error expectations; set when the switch must fail. + wantMsg []string + wantHint []string + wantLines int // number of "datumctl auth switch" commands in the hint + }{ + { + name: "single-session email switches directly", + args: []string{otherEmail}, + wantSession: soloSession, + wantContext: soloCtx, + }, + { + name: "shared email with endpoint picks staging", + args: []string{sharedEmail, "--endpoint", stagingHost}, + wantSession: stagingSession, + wantContext: stagingCtx, + }, + { + name: "shared email with endpoint picks prod", + args: []string{sharedEmail, "--endpoint", prodHost}, + wantSession: prodSession, + wantContext: prodCtx, + }, + { + name: "endpoint with scheme and trailing slash", + args: []string{sharedEmail, "--endpoint", "https://" + stagingHost + "/"}, + wantSession: stagingSession, + wantContext: stagingCtx, + }, + { + name: "exact session name", + args: []string{stagingSession}, + wantSession: stagingSession, + wantContext: stagingCtx, + }, + { + name: "unknown endpoint lists the endpoints for that email", + args: []string{sharedEmail, "--endpoint", "api.nowhere.example"}, + wantMsg: []string{"No session for " + sharedEmail + " on endpoint api.nowhere.example."}, + wantHint: []string{"Signed-in endpoints: " + prodHost + ", " + stagingHost}, + }, + { + name: "ambiguous email without terminal lists one command per session", + args: []string{sharedEmail}, + wantMsg: []string{sharedEmail + " is signed in on more than one endpoint", "requires a terminal"}, + wantHint: []string{ + "datumctl auth switch " + sharedEmail + " --endpoint " + prodHost, + "datumctl auth switch " + sharedEmail + " --endpoint " + stagingHost, + }, + wantLines: 2, + }, + { + name: "no argument without terminal lists every session", + args: nil, + wantMsg: []string{"Multiple sessions found. Choosing one interactively requires a terminal."}, + wantHint: []string{ + "datumctl auth switch " + sharedEmail + " --endpoint " + prodHost, + "datumctl auth switch " + sharedEmail + " --endpoint " + stagingHost, + "datumctl auth switch " + otherEmail + "\n", + }, + wantLines: 3, + }, + { + name: "unknown email", + args: []string{"nobody@example.com"}, + wantMsg: []string{"No sessions found for nobody@example.com."}, + wantHint: []string{"datumctl auth list"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setupSwitchEnv(t, false) + err := runSwitchCmd(t, tc.args...) + + if tc.wantMsg == nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + cfg := loadCfg(t) + if cfg.ActiveSession != tc.wantSession { + t.Errorf("ActiveSession = %q, want %q", cfg.ActiveSession, tc.wantSession) + } + if cfg.CurrentContext != tc.wantContext { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, tc.wantContext) + } + if got := cfg.ActiveSessionEntry(); got == nil || got.Name != tc.wantSession { + t.Errorf("ActiveSessionEntry = %v, want %q", got, tc.wantSession) + } + return + } + + if err == nil { + t.Fatal("expected error, got nil") + } + userErr, ok := customerrors.IsUserError(err) + if !ok { + t.Fatalf("expected *UserError, got %T: %v", err, err) + } + for _, want := range tc.wantMsg { + if !strings.Contains(userErr.Message, want) { + t.Errorf("Message = %q, want it to contain %q", userErr.Message, want) + } + } + // Trailing newline lets a case pin an exact line end. + hint := userErr.Hint + "\n" + for _, want := range tc.wantHint { + if !strings.Contains(hint, want) { + t.Errorf("Hint = %q, want it to contain %q", userErr.Hint, want) + } + } + if tc.wantLines > 0 { + if got := strings.Count(userErr.Hint, "datumctl auth switch "); got != tc.wantLines { + t.Errorf("Hint has %d commands, want %d:\n%s", got, tc.wantLines, userErr.Hint) + } + } + if strings.Contains(userErr.Message, "for this email") { + t.Errorf("Message still says %q: %q", "for this email", userErr.Message) + } + + // A failed switch must not change the active session. + cfg := loadCfg(t) + if cfg.ActiveSession != soloSession || cfg.CurrentContext != soloCtx { + t.Errorf("config changed on failure: session=%q context=%q", cfg.ActiveSession, cfg.CurrentContext) + } + }) + } +} + +// Switching back and forth must flip the active session and current context +// every time, not only on the first switch. +func TestSwitchBackAndForthByEndpoint(t *testing.T) { + setupSwitchEnv(t, false) + + steps := []struct { + endpoint string + wantSession string + wantContext string + }{ + {prodHost, prodSession, prodCtx}, + {stagingHost, stagingSession, stagingCtx}, + {prodHost, prodSession, prodCtx}, + } + for i, step := range steps { + if err := runSwitchCmd(t, sharedEmail, "--endpoint", step.endpoint); err != nil { + t.Fatalf("step %d: switch to %s: %v", i, step.endpoint, err) + } + cfg := loadCfg(t) + if cfg.ActiveSession != step.wantSession || cfg.CurrentContext != step.wantContext { + t.Errorf("step %d (%s): session=%q context=%q, want %q %q", + i, step.endpoint, cfg.ActiveSession, cfg.CurrentContext, step.wantSession, step.wantContext) + } + } +} + +// The endpoint 'datumctl auth list' prints must be accepted by --endpoint. +func TestSwitchAcceptsListedEndpoint(t *testing.T) { + setupSwitchEnv(t, false) + cfg := loadCfg(t) + for _, s := range cfg.SessionByEmail(sharedEmail) { + listed := datumconfig.StripScheme(s.Endpoint.Server) // what auth list prints + if err := runSwitchCmd(t, sharedEmail, "--endpoint", listed); err != nil { + t.Fatalf("--endpoint %q from auth list rejected: %v", listed, err) + } + if got := loadCfg(t).ActiveSession; got != s.Name { + t.Errorf("--endpoint %q: ActiveSession = %q, want %q", listed, got, s.Name) + } + } +} + +// Switching selects one session without signing out the others. +func TestSwitchLeavesOtherSessionsSignedIn(t *testing.T) { + setupSwitchEnv(t, false) + creds := map[string]string{ + "key-prod": `{"token":"prod"}`, + "key-staging": `{"token":"staging"}`, + "key-solo": `{"token":"solo"}`, + } + for k, v := range creds { + if err := keyring.Set(authutil.ServiceName, k, v); err != nil { + t.Fatalf("seed keyring: %v", err) + } + } + before := loadCfg(t) + + if err := runSwitchCmd(t, sharedEmail, "--endpoint", stagingHost); err != nil { + t.Fatalf("switch: %v", err) + } + + after := loadCfg(t) + if len(after.Sessions) != len(before.Sessions) { + t.Fatalf("sessions = %d, want %d", len(after.Sessions), len(before.Sessions)) + } + for i := range before.Sessions { + if after.Sessions[i] != before.Sessions[i] { + t.Errorf("session %q changed:\n got %+v\nwant %+v", before.Sessions[i].Name, after.Sessions[i], before.Sessions[i]) + } + } + for k, want := range creds { + got, err := keyring.Get(authutil.ServiceName, k) + if err != nil || got != want { + t.Errorf("keyring %q = %q, %v; want %q", k, got, err, want) + } + } +} diff --git a/internal/cmd/ctx/use.go b/internal/cmd/ctx/use.go index b70388d..3bd4238 100644 --- a/internal/cmd/ctx/use.go +++ b/internal/cmd/ctx/use.go @@ -52,7 +52,7 @@ func runUse(_ *cobra.Command, args []string) error { if owner := cfg.FindContextOwner(args[0], activeSession); owner != nil { return customerrors.NewUserErrorWithHint( fmt.Sprintf("Context %q belongs to the session for %s, which is not active.", args[0], owner.UserEmail), - fmt.Sprintf("Run 'datumctl auth switch %s' first, then 'datumctl ctx use %s'.", owner.UserEmail, args[0]), + fmt.Sprintf("Run 'datumctl auth switch %s' first, then 'datumctl ctx use %s'.", cfg.SwitchArgs(owner), args[0]), ) } return customerrors.NewUserErrorWithHint( diff --git a/internal/cmd/ctx/use_test.go b/internal/cmd/ctx/use_test.go new file mode 100644 index 0000000..90ea856 --- /dev/null +++ b/internal/cmd/ctx/use_test.go @@ -0,0 +1,71 @@ +package ctx + +import ( + "strings" + "testing" + + "go.datum.net/datumctl/internal/datumconfig" + customerrors "go.datum.net/datumctl/internal/errors" +) + +// A context owned by another session must point at a switch command that +// works, including --endpoint when that session's email spans endpoints. +func TestUseHintForContextInOtherSession(t *testing.T) { + const email = "swells@datum.net" + prod := datumconfig.SessionName(email, "api.datum.net") + staging := datumconfig.SessionName(email, "api.staging.env.datum.net") + solo := datumconfig.SessionName("solo@example.com", "api.datum.net") + + tests := []struct { + name string + active string + ref string + wantHint string + }{ + { + name: "shared email includes endpoint", + active: prod, + ref: "org-staging", + wantHint: "Run 'datumctl auth switch " + email + " --endpoint api.staging.env.datum.net' first, then 'datumctl ctx use org-staging'.", + }, + { + name: "unique email stays bare", + active: prod, + ref: "org-solo", + wantHint: "Run 'datumctl auth switch solo@example.com' first, then 'datumctl ctx use org-solo'.", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + cfg := datumconfig.NewV1Beta1() + cfg.Sessions = []datumconfig.Session{ + {Name: prod, UserEmail: email, Endpoint: datumconfig.Endpoint{Server: "https://api.datum.net"}}, + {Name: staging, UserEmail: email, Endpoint: datumconfig.Endpoint{Server: "https://api.staging.env.datum.net"}}, + {Name: solo, UserEmail: "solo@example.com", Endpoint: datumconfig.Endpoint{Server: "https://api.datum.net"}}, + } + cfg.Contexts = []datumconfig.DiscoveredContext{ + {Name: datumconfig.QualifiedContextName(prod, "org-prod"), Session: prod, OrganizationID: "org-prod"}, + {Name: datumconfig.QualifiedContextName(staging, "org-staging"), Session: staging, OrganizationID: "org-staging"}, + {Name: datumconfig.QualifiedContextName(solo, "org-solo"), Session: solo, OrganizationID: "org-solo"}, + } + cfg.ActiveSession = tc.active + if err := datumconfig.SaveV1Beta1(cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + err := runUse(nil, []string{tc.ref}) + userErr, ok := customerrors.IsUserError(err) + if !ok { + t.Fatalf("expected *UserError, got %T: %v", err, err) + } + if !strings.Contains(userErr.Hint, tc.wantHint) { + t.Errorf("Hint = %q, want %q", userErr.Hint, tc.wantHint) + } + }) + } +} diff --git a/internal/datumconfig/config_v1beta1.go b/internal/datumconfig/config_v1beta1.go index 4cc544c..e6fff28 100644 --- a/internal/datumconfig/config_v1beta1.go +++ b/internal/datumconfig/config_v1beta1.go @@ -227,6 +227,16 @@ func (c *ConfigV1Beta1) SessionByEmail(email string) []*Session { return sessions } +// SwitchArgs returns the arguments that select session s with +// "datumctl auth switch": the email alone when it is unique, or the email plus +// "--endpoint " when the same email is signed in on several endpoints. +func (c *ConfigV1Beta1) SwitchArgs(s *Session) string { + if len(c.SessionByEmail(s.UserEmail)) > 1 { + return fmt.Sprintf("%s --endpoint %s", s.UserEmail, StripScheme(s.Endpoint.Server)) + } + return s.UserEmail +} + // ContextByName returns the context with the given name, or nil. func (c *ConfigV1Beta1) ContextByName(name string) *DiscoveredContext { for i := range c.Contexts { diff --git a/internal/picker/picker.go b/internal/picker/picker.go index 66f9ad9..516bdf4 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -139,8 +139,8 @@ func SelectSession(sessions []*datumconfig.Session, activeSessionName string) (s if !isTerminal() { return "", customerrors.NewUserErrorWithHint( - "Multiple sessions found for this email. Interactive selection requires a terminal.", - "Run 'datumctl auth list' to see sessions and identify the email + endpoint to use.", + "Multiple sessions match. Interactive selection requires a terminal.", + "Run 'datumctl auth list' to see sessions, then 'datumctl auth switch --endpoint '.", ) } @@ -186,6 +186,12 @@ func SelectSession(sessions []*datumconfig.Session, activeSessionName string) (s return selected, nil } -func isTerminal() bool { +// IsTerminal reports whether stdin is an interactive terminal. It is a +// variable so callers' tests can simulate running with or without a terminal. +var IsTerminal = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) } + +func isTerminal() bool { + return IsTerminal() +} From 212e0e4fd7358daef4e5c81022d8d96dc5e7c35b Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 25 Sep 2026 14:04:29 -0500 Subject: [PATCH 2/2] fix: Clarify no-email unknown-endpoint hint The hint listing signed-in endpoints read as if it scoped to one email even when no email was given, since the wording did not change for the all-accounts case. State plainly that the list spans any account when no email narrows it, and cover the case with a test. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cmd/auth/switch.go | 5 +++-- internal/cmd/auth/switch_test.go | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/cmd/auth/switch.go b/internal/cmd/auth/switch.go index b7f293f..3daf498 100644 --- a/internal/cmd/auth/switch.go +++ b/internal/cmd/auth/switch.go @@ -117,11 +117,12 @@ func runSwitch(args []string, endpoint string) error { } if len(matched) == 0 { msg := fmt.Sprintf("No session on endpoint %s.", datumconfig.StripScheme(endpoint)) + hint := "Endpoints any account is signed in on: " + strings.Join(known, ", ") if subject != "" { msg = fmt.Sprintf("No session for %s on endpoint %s.", subject, datumconfig.StripScheme(endpoint)) + hint = "Signed-in endpoints: " + strings.Join(known, ", ") } - return customerrors.NewUserErrorWithHint(msg, - "Signed-in endpoints: "+strings.Join(known, ", ")) + return customerrors.NewUserErrorWithHint(msg, hint) } candidates = matched } diff --git a/internal/cmd/auth/switch_test.go b/internal/cmd/auth/switch_test.go index 2e812082..cd69a94 100644 --- a/internal/cmd/auth/switch_test.go +++ b/internal/cmd/auth/switch_test.go @@ -128,6 +128,12 @@ func TestSwitch(t *testing.T) { wantMsg: []string{"No session for " + sharedEmail + " on endpoint api.nowhere.example."}, wantHint: []string{"Signed-in endpoints: " + prodHost + ", " + stagingHost}, }, + { + name: "unknown endpoint without email lists endpoints across every account", + args: []string{"--endpoint", "api.nowhere.example"}, + wantMsg: []string{"No session on endpoint api.nowhere.example."}, + wantHint: []string{"Endpoints any account is signed in on: " + prodHost + ", " + stagingHost}, + }, { name: "ambiguous email without terminal lists one command per session", args: []string{sharedEmail},