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..3daf498 100644 --- a/internal/cmd/auth/switch.go +++ b/internal/cmd/auth/switch.go @@ -2,78 +2,141 @@ 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)) + 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, hint) + } + 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 +161,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 +172,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..cd69a94 --- /dev/null +++ b/internal/cmd/auth/switch_test.go @@ -0,0 +1,300 @@ +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: "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}, + 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() +}