Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions cmd/entire/cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}

Expand Down
35 changes: 35 additions & 0 deletions cmd/entire/cli/logout.go
Original file line number Diff line number Diff line change
@@ -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
}
82 changes: 82 additions & 0 deletions cmd/entire/cli/logout_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions cmd/entire/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading