diff --git a/cmd/entire/cli/login.go b/cmd/entire/cli/login.go index f2ceb6aed..2c7036c29 100644 --- a/cmd/entire/cli/login.go +++ b/cmd/entire/cli/login.go @@ -67,22 +67,22 @@ func runLogin(ctx context.Context, outW, errW io.Writer, client deviceAuthClient } fmt.Fprintf(outW, "Device code: %s\n", start.UserCode) + approvalURL := start.VerificationURI - if approvalURL == "" { - approvalURL = start.VerificationURIComplete - } if canPromptInteractively() { - fmt.Fprintf(outW, "Press Enter to open %s in your browser...", approvalURL) + fmt.Fprintf(outW, "Press Enter to open %s in your browser and enter the generated device code...", approvalURL) // Read from /dev/tty so we get a real keypress and don't consume piped stdin. - waitForEnter() + if err := waitForEnter(ctx); err != nil { + return fmt.Errorf("wait for input: %w", err) + } fmt.Fprintln(outW) if err := openURL(ctx, approvalURL); err != nil { fmt.Fprintf(errW, "Warning: failed to open browser: %v\n", err) - fmt.Fprintln(outW, "Open the approval URL in your browser to continue.") + fmt.Fprintf(outW, "Open the approval URL in your browser to continue and enter the generated device code: %s\n", approvalURL) } } else { fmt.Fprintf(outW, "Approval URL: %s\n", approvalURL) @@ -170,16 +170,28 @@ func waitForApproval(ctx context.Context, poller deviceAuthClient, deviceCode st // waitForEnter reads a line from /dev/tty, blocking until the user presses Enter. // If /dev/tty cannot be opened (e.g. on Windows), it returns immediately. -func waitForEnter() { +// Returns ctx.Err() if the context is cancelled before the user presses Enter. +func waitForEnter(ctx context.Context) error { tty, err := os.Open("/dev/tty") if err != nil { - return - } - defer tty.Close() - - reader := bufio.NewReader(tty) - if _, err = reader.ReadString('\n'); err != nil { - return + return nil //nolint:nilerr // tty unavailable (e.g. Windows) — skip prompt silently + } + + done := make(chan error, 1) + go func() { + reader := bufio.NewReader(tty) + _, err := reader.ReadString('\n') + done <- err + }() + + select { + case <-ctx.Done(): + // Close tty to unblock the reading goroutine. + _ = tty.Close() + return fmt.Errorf("interrupted: %w", ctx.Err()) + case <-done: + _ = tty.Close() + return nil } } diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go new file mode 100644 index 000000000..e425e78b9 --- /dev/null +++ b/cmd/entire/cli/logout.go @@ -0,0 +1,35 @@ +package cli + +import ( + "fmt" + "io" + + apiurl "github.com/entireio/cli/cmd/entire/cli/api" + "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/spf13/cobra" +) + +// tokenDeleter abstracts token removal so runLogout can be unit-tested +// without hitting the real OS keyring. +type tokenDeleter interface { + DeleteToken(baseURL string) error +} + +func newLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Log out of Entire", + RunE: func(cmd *cobra.Command, _ []string) error { + return runLogout(cmd.OutOrStdout(), auth.NewStore(), apiurl.BaseURL()) + }, + } +} + +func runLogout(outW io.Writer, store tokenDeleter, baseURL string) error { + if err := store.DeleteToken(baseURL); err != nil { + return fmt.Errorf("remove auth token: %w", err) + } + + fmt.Fprintln(outW, "Logged out.") + return nil +} diff --git a/cmd/entire/cli/logout_test.go b/cmd/entire/cli/logout_test.go new file mode 100644 index 000000000..01f96428f --- /dev/null +++ b/cmd/entire/cli/logout_test.go @@ -0,0 +1,82 @@ +package cli + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +type mockTokenDeleter struct { + deleted map[string]bool + failWith error +} + +func newMockTokenDeleter() *mockTokenDeleter { + return &mockTokenDeleter{deleted: make(map[string]bool)} +} + +func (m *mockTokenDeleter) DeleteToken(baseURL string) error { + if m.failWith != nil { + return m.failWith + } + m.deleted[baseURL] = true + return nil +} + +func TestRunLogout_DeletesTokenAndPrintsMessage(t *testing.T) { + t.Parallel() + + store := newMockTokenDeleter() + var out bytes.Buffer + + err := runLogout(&out, store, "https://entire.io") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !store.deleted["https://entire.io"] { + t.Fatal("expected token to be deleted for https://entire.io") + } + + if !strings.Contains(out.String(), "Logged out.") { + t.Fatalf("output = %q, want to contain %q", out.String(), "Logged out.") + } +} + +func TestRunLogout_ReturnsErrorOnDeleteFailure(t *testing.T) { + t.Parallel() + + store := newMockTokenDeleter() + store.failWith = errors.New("keyring locked") + var out bytes.Buffer + + err := runLogout(&out, store, "https://entire.io") + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "keyring locked") { + t.Fatalf("error = %q, want to contain %q", err.Error(), "keyring locked") + } + + if strings.Contains(out.String(), "Logged out.") { + t.Fatal("should not print success message on error") + } +} + +func TestLogoutCmd_IsRegistered(t *testing.T) { + t.Parallel() + + root := NewRootCmd() + found := false + for _, c := range root.Commands() { + if c.Use == "logout" { + found = true + break + } + } + if !found { + t.Fatal("logout command not registered on root") + } +} diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 445fbf820..36680b954 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -88,6 +88,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newDisableCmd()) cmd.AddCommand(newStatusCmd()) cmd.AddCommand(newLoginCmd()) + cmd.AddCommand(newLogoutCmd()) cmd.AddCommand(newHooksCmd()) cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newExplainCmd())