diff --git a/cmd/logout.go b/cmd/logout.go index eec3751..7b573ba 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -1,12 +1,13 @@ package cmd import ( - "fmt" + "errors" "os" "github.com/localstack/lstk/internal/api" "github.com/localstack/lstk/internal/auth" "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/ui" "github.com/spf13/cobra" ) @@ -15,18 +16,26 @@ var logoutCmd = &cobra.Command{ Short: "Remove stored authentication token", PreRunE: initConfig, RunE: func(cmd *cobra.Command, args []string) error { - sink := output.NewPlainSink(os.Stdout) platformClient := api.NewPlatformClient() tokenStorage, err := auth.NewTokenStorage() if err != nil { - return fmt.Errorf("failed to initialize token storage: %w", err) + return err + } + + if ui.IsInteractive() { + a := auth.New(nil, platformClient, tokenStorage, false) + return ui.RunLogout(cmd.Context(), version, a) } + + // Non-interactive mode: auth emits events through the sink + sink := output.NewPlainSink(os.Stdout) a := auth.New(sink, platformClient, tokenStorage, false) - if err := a.Logout(); err != nil { - return fmt.Errorf("failed to logout: %w", err) + + err = a.Logout() + if errors.Is(err, auth.ErrNotLoggedIn) { + return nil } - fmt.Println("Logged out successfully.") - return nil + return err }, } diff --git a/go.mod b/go.mod index 7996f9a..7b9824b 100644 --- a/go.mod +++ b/go.mod @@ -26,10 +26,11 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.3.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.10.0 // indirect github.com/clipperhouse/uax29/v2 v2.6.0 // indirect diff --git a/go.sum b/go.sum index 655141a..6042a51 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= @@ -26,6 +28,8 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/teatest v0.0.0-20260216111343-536eb63c1f4c h1:/pbU92+xMwttewB4XK69/B9ISH0HMhOMrTIVhV4AS7M= github.com/charmbracelet/x/exp/teatest v0.0.0-20260216111343-536eb63c1f4c/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index fb41c46..3641b2e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -56,11 +56,23 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) { return token, nil } -// Logout removes the stored auth token from the keyring +var ErrNotLoggedIn = errors.New("not currently logged in") + +// Logout removes the stored auth token from the keyring. +// Returns ErrNotLoggedIn if no token was stored. +// Emits success/note events through the sink if one is configured. func (a *Auth) Logout() error { + output.EmitLog(a.sink, "Logging out...") + err := a.tokenStorage.DeleteAuthToken() if errors.Is(err, keyring.ErrKeyNotFound) { - return nil + output.EmitNote(a.sink, "Not currently logged in.") + return ErrNotLoggedIn } - return err + if err != nil { + return err + } + + output.EmitSuccess(a.sink, "Logged out successfully.") + return nil } diff --git a/internal/auth/token_storage.go b/internal/auth/token_storage.go index 4e42748..ea8320a 100644 --- a/internal/auth/token_storage.go +++ b/internal/auth/token_storage.go @@ -83,8 +83,8 @@ func (s *authTokenStorage) SetAuthToken(token string) error { func (s *authTokenStorage) DeleteAuthToken() error { err := s.ring.Remove(keyringAuthTokenKey) - if errors.Is(err, keyring.ErrKeyNotFound) || os.IsNotExist(err) { - return nil + if os.IsNotExist(err) { + return keyring.ErrKeyNotFound } return err } diff --git a/internal/output/events.go b/internal/output/events.go index 5440cf9..c62d54c 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -1,7 +1,7 @@ package output type Event interface { - LogEvent | WarningEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent + LogEvent | WarningEvent | SuccessEvent | NoteEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent } type Sink interface { @@ -26,6 +26,14 @@ type WarningEvent struct { Message string } +type SuccessEvent struct { + Message string +} + +type NoteEvent struct { + Message string +} + type ContainerStatusEvent struct { Phase string // e.g., "pulling", "starting", "waiting", "ready" Container string @@ -76,6 +84,14 @@ func EmitWarning(sink Sink, message string) { Emit(sink, WarningEvent{Message: message}) } +func EmitSuccess(sink Sink, message string) { + Emit(sink, SuccessEvent{Message: message}) +} + +func EmitNote(sink Sink, message string) { + Emit(sink, NoteEvent{Message: message}) +} + func EmitStatus(sink Sink, phase, container, detail string) { Emit(sink, ContainerStatusEvent{Phase: phase, Container: container, Detail: detail}) } diff --git a/internal/output/format.go b/internal/output/format.go index 69c6595..c4fcbff 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -12,6 +12,10 @@ func FormatEventLine(event any) (string, bool) { return e.Message, true case WarningEvent: return fmt.Sprintf("Warning: %s", e.Message), true + case SuccessEvent: + return fmt.Sprintf("Success: %s", e.Message), true + case NoteEvent: + return fmt.Sprintf("Note: %s", e.Message), true case ContainerStatusEvent: return formatStatusLine(e), true case ProgressEvent: diff --git a/internal/output/format_test.go b/internal/output/format_test.go index 3b0d24c..8090dc4 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -23,6 +23,18 @@ func TestFormatEventLine(t *testing.T) { want: "Warning: careful", wantOK: true, }, + { + name: "success event", + event: SuccessEvent{Message: "done"}, + want: "Success: done", + wantOK: true, + }, + { + name: "note event", + event: NoteEvent{Message: "info"}, + want: "Note: info", + wantOK: true, + }, { name: "status pulling", event: ContainerStatusEvent{Phase: "pulling", Container: "localstack/localstack:latest"}, diff --git a/internal/output/plain_sink.go b/internal/output/plain_sink.go index 5a5e2b4..daaaf19 100644 --- a/internal/output/plain_sink.go +++ b/internal/output/plain_sink.go @@ -34,6 +34,12 @@ func (s *PlainSink) emit(event any) { if !ok { return } + + switch event.(type) { + case SuccessEvent, NoteEvent, WarningEvent: + line = "> " + line + } + _, err := fmt.Fprintln(s.out, line) s.setErr(err) } diff --git a/internal/output/plain_sink_test.go b/internal/output/plain_sink_test.go index b195823..f5cf4f0 100644 --- a/internal/output/plain_sink_test.go +++ b/internal/output/plain_sink_test.go @@ -32,10 +32,28 @@ func TestPlainSink_EmitsWarningEvent(t *testing.T) { Emit(sink, WarningEvent{Message: "something went wrong"}) - assert.Equal(t, "Warning: something went wrong\n", out.String()) + assert.Contains(t, out.String(), "Warning: something went wrong") } -func TestPlainSink_EmitsStatusEvent(t *testing.T) { +func TestPlainSink_EmitsSuccessEvent(t *testing.T) { + var out bytes.Buffer + sink := NewPlainSink(&out) + + Emit(sink, SuccessEvent{Message: "done"}) + + assert.Contains(t, out.String(), "Success: done") +} + +func TestPlainSink_EmitsNoteEvent(t *testing.T) { + var out bytes.Buffer + sink := NewPlainSink(&out) + + Emit(sink, NoteEvent{Message: "info"}) + + assert.Contains(t, out.String(), "Note: info") +} + +func TestPlainSink_EmitsContainerStatusEvent(t *testing.T) { tests := []struct { name string event ContainerStatusEvent @@ -182,9 +200,11 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) { events := []any{ LogEvent{Message: "hello"}, - WarningEvent{Message: "careful"}, ContainerStatusEvent{Phase: "starting", Container: "localstack"}, ProgressEvent{LayerID: "abc", Status: "Downloading", Current: 1, Total: 2}, + SuccessEvent{Message: "done"}, + NoteEvent{Message: "info"}, + WarningEvent{Message: "something went wrong"}, } for _, event := range events { @@ -194,12 +214,16 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) { switch e := event.(type) { case LogEvent: Emit(sink, e) - case WarningEvent: - Emit(sink, e) case ContainerStatusEvent: Emit(sink, e) case ProgressEvent: Emit(sink, e) + case SuccessEvent: + Emit(sink, e) + case NoteEvent: + Emit(sink, e) + case WarningEvent: + Emit(sink, e) default: t.Fatalf("unsupported event type in test: %T", event) } @@ -208,8 +232,21 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) { if !ok { t.Fatalf("expected formatter output for %T", event) } - if got, want := out.String(), fmt.Sprintf("%s\n", line); got != want { - t.Fatalf("output mismatch for %T: got=%q want=%q", event, got, want) + + got := out.String() + if !assert.Contains(t, got, line) { + t.Fatalf("output for %T should contain formatted line: got=%q want to contain=%q", event, got, line) + } + + switch event.(type) { + case SuccessEvent, NoteEvent, WarningEvent: + if !assert.Contains(t, got, "> ") { + t.Fatalf("output for %T should contain prefix: got=%q", event, got) + } + default: + if got != fmt.Sprintf("%s\n", line) { + t.Fatalf("output mismatch for %T: got=%q want=%q", event, got, fmt.Sprintf("%s\n", line)) + } } } } diff --git a/internal/ui/app.go b/internal/ui/app.go index 0c6622e..d879e33 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -18,10 +18,24 @@ type runErrMsg struct { err error } +type lineStyle int + +const ( + lineStyleDefault lineStyle = iota + lineStyleSuccess + lineStyleNote + lineStyleWarning +) + +type styledLine struct { + text string + style lineStyle +} + type App struct { header components.Header inputPrompt components.InputPrompt - lines []string + lines []styledLine cancel func() pendingInput *output.UserInputRequestEvent err error @@ -31,7 +45,7 @@ func NewApp(version string, cancel func()) App { return App{ header: components.NewHeader(version), inputPrompt: components.NewInputPrompt(), - lines: make([]string, 0, maxLines), + lines: make([]styledLine, 0, maxLines), cancel: cancel, } } @@ -94,7 +108,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Quit default: if line, ok := output.FormatEventLine(msg); ok { - a.lines = appendLine(a.lines, line) + style := lineStyleDefault + switch msg.(type) { + case output.SuccessEvent: + style = lineStyleSuccess + case output.NoteEvent: + style = lineStyleNote + case output.WarningEvent: + style = lineStyleWarning + } + a.lines = appendLine(a.lines, styledLine{text: line, style: style}) } } @@ -114,7 +137,7 @@ func sendInputResponseCmd(responseCh chan<- output.InputResponse, response outpu } } -func appendLine(lines []string, line string) []string { +func appendLine(lines []styledLine, line styledLine) []styledLine { lines = append(lines, line) if len(lines) > maxLines { lines = lines[len(lines)-maxLines:] @@ -128,7 +151,7 @@ func (a App) View() string { sb.WriteString("\n") for _, line := range a.lines { sb.WriteString(" ") - sb.WriteString(styles.Message.Render(line)) + sb.WriteString(renderStyledLine(line)) sb.WriteString("\n") } if promptView := a.inputPrompt.View(); promptView != "" { @@ -139,6 +162,23 @@ func (a App) View() string { return sb.String() } +func renderStyledLine(line styledLine) string { + prefix := styles.Secondary.Render("> ") + switch line.style { + case lineStyleSuccess: + msg := strings.TrimPrefix(line.text, "Success: ") + return prefix + styles.Success.Render("Success:") + " " + styles.Message.Render(msg) + case lineStyleNote: + msg := strings.TrimPrefix(line.text, "Note: ") + return prefix + styles.Note.Render("Note:") + " " + styles.Message.Render(msg) + case lineStyleWarning: + msg := strings.TrimPrefix(line.text, "Warning: ") + return prefix + styles.Warning.Render("Warning:") + " " + styles.Message.Render(msg) + default: + return styles.Message.Render(line.text) + } +} + func (a App) Err() error { return a.err } diff --git a/internal/ui/components/spinner.go b/internal/ui/components/spinner.go new file mode 100644 index 0000000..ce10dca --- /dev/null +++ b/internal/ui/components/spinner.go @@ -0,0 +1,57 @@ +package components + +import ( + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/localstack/lstk/internal/ui/styles" +) + +type Spinner struct { + spinner spinner.Model + message string + visible bool +} + +func NewSpinner() Spinner { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = styles.Secondary + return Spinner{ + spinner: s, + } +} + +func (s Spinner) Show(message string) Spinner { + s.message = message + s.visible = true + return s +} + +func (s Spinner) Hide() Spinner { + s.visible = false + return s +} + +func (s Spinner) Visible() bool { + return s.visible +} + +func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) { + if !s.visible { + return s, nil + } + var cmd tea.Cmd + s.spinner, cmd = s.spinner.Update(msg) + return s, cmd +} + +func (s Spinner) Tick() tea.Cmd { + return s.spinner.Tick +} + +func (s Spinner) View() string { + if !s.visible { + return "" + } + return s.spinner.View() + " " + styles.Secondary.Render(s.message) +} diff --git a/internal/ui/run_logout.go b/internal/ui/run_logout.go new file mode 100644 index 0000000..b4ddcf0 --- /dev/null +++ b/internal/ui/run_logout.go @@ -0,0 +1,150 @@ +package ui + +import ( + "context" + "errors" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/localstack/lstk/internal/auth" + "github.com/localstack/lstk/internal/ui/components" + "github.com/localstack/lstk/internal/ui/styles" +) + +const minSpinnerDuration = 400 * time.Millisecond + +type logoutSuccessMsg struct{} + +type logoutNotLoggedInMsg struct{} + +type logoutErrMsg struct { + err error +} + +type logoutState int + +const ( + logoutStateLoading logoutState = iota + logoutStateSuccess + logoutStateNotLoggedIn +) + +type LogoutApp struct { + header components.Header + spinner components.Spinner + state logoutState + err error +} + +func NewLogoutApp(version string) LogoutApp { + return LogoutApp{ + header: components.NewHeader(version), + spinner: components.NewSpinner().Show("Logging out"), + state: logoutStateLoading, + } +} + +func (a LogoutApp) Init() tea.Cmd { + return a.spinner.Tick() +} + +func (a LogoutApp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" || msg.String() == "q" { + return a, tea.Quit + } + + case logoutSuccessMsg: + a.spinner = a.spinner.Hide() + a.state = logoutStateSuccess + return a, tea.Quit + + case logoutNotLoggedInMsg: + a.spinner = a.spinner.Hide() + a.state = logoutStateNotLoggedIn + return a, tea.Quit + + case logoutErrMsg: + a.spinner = a.spinner.Hide() + a.err = msg.err + return a, tea.Quit + + default: + var cmd tea.Cmd + a.spinner, cmd = a.spinner.Update(msg) + return a, cmd + } + + return a, nil +} + +func (a LogoutApp) View() string { + var s string + s += a.header.View() + s += "\n" + + if a.spinner.Visible() { + s += a.spinner.View() + "\n" + } + + switch a.state { + case logoutStateSuccess: + s += renderLogoutSuccess() + "\n" + case logoutStateNotLoggedIn: + s += renderLogoutNotLoggedIn() + "\n" + } + + return s +} + +func renderLogoutSuccess() string { + prefix := styles.Secondary.Render("> ") + return prefix + styles.Success.Render("Success:") + " " + styles.Message.Render("Logged out successfully.") +} + +func renderLogoutNotLoggedIn() string { + prefix := styles.Secondary.Render("> ") + return prefix + styles.Note.Render("Note:") + " " + styles.Message.Render("Not currently logged in.") +} + +func (a LogoutApp) Err() error { + return a.err +} + +func RunLogout(ctx context.Context, version string, a *auth.Auth) error { + app := NewLogoutApp(version) + p := tea.NewProgram(app) + + go func() { + start := time.Now() + err := a.Logout() + + // Ensure spinner is visible for minimum duration + elapsed := time.Since(start) + if elapsed < minSpinnerDuration { + time.Sleep(minSpinnerDuration - elapsed) + } + + if errors.Is(err, auth.ErrNotLoggedIn) { + p.Send(logoutNotLoggedInMsg{}) + return + } + if err != nil { + p.Send(logoutErrMsg{err: err}) + return + } + p.Send(logoutSuccessMsg{}) + }() + + model, err := p.Run() + if err != nil { + return err + } + + if logoutApp, ok := model.(LogoutApp); ok && logoutApp.Err() != nil { + return logoutApp.Err() + } + + return nil +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 6b7ebbb..0514a55 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -27,4 +27,16 @@ var ( Message = lipgloss.NewStyle(). Foreground(lipgloss.Color("245")) + + Secondary = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + Success = lipgloss.NewStyle(). + Foreground(lipgloss.Color("10")) + + Note = lipgloss.NewStyle(). + Foreground(lipgloss.Color("11")) + + Warning = lipgloss.NewStyle(). + Foreground(lipgloss.Color("11")) ) diff --git a/test/integration/logout_test.go b/test/integration/logout_test.go index 140c780..4b7518c 100644 --- a/test/integration/logout_test.go +++ b/test/integration/logout_test.go @@ -29,6 +29,7 @@ func TestLogoutCommandRemovesToken(t *testing.T) { output, err := cmd.CombinedOutput() require.NoError(t, err, "lstk logout failed: %s", output) + assert.Contains(t, string(output), "Logging out...") assert.Contains(t, string(output), "Logged out successfully") // Verify token was removed @@ -48,4 +49,6 @@ func TestLogoutCommandSucceedsWhenNoToken(t *testing.T) { // Should succeed even if no token require.NoError(t, err, "lstk logout should succeed even with no token: %s", output) + assert.Contains(t, string(output), "Logging out...") + assert.Contains(t, string(output), "Not currently logged in") }