From 9ed05dd4bfbc0f3c7505b764acff7d6354326d15 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 19 Feb 2026 20:36:34 +0200 Subject: [PATCH 1/4] Add TUI for logout command --- cmd/logout.go | 28 ++++-- go.mod | 3 +- go.sum | 4 + internal/auth/auth.go | 17 +++- internal/auth/token_storage.go | 4 +- internal/output/events.go | 18 +++- internal/output/format.go | 4 + internal/output/format_test.go | 12 +++ internal/output/plain_sink.go | 27 +++++- internal/output/plain_sink_test.go | 27 ++++-- internal/ui/app.go | 50 +++++++++-- internal/ui/components/spinner.go | 57 ++++++++++++ internal/ui/run_logout.go | 135 +++++++++++++++++++++++++++++ internal/ui/styles/styles.go | 12 +++ test/integration/logout_test.go | 3 + 15 files changed, 376 insertions(+), 25 deletions(-) create mode 100644 internal/ui/components/spinner.go create mode 100644 internal/ui/run_logout.go diff --git a/cmd/logout.go b/cmd/logout.go index eec3751..68d038f 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -1,12 +1,12 @@ package cmd import ( - "fmt" "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,17 +15,33 @@ 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 } + + 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) + + if ui.IsInteractive() { + return ui.RunLogout(cmd.Context(), version, a) } - fmt.Println("Logged out successfully.") + + // Non-interactive mode + output.EmitLog(sink, "Logging out...") + + result, err := a.Logout() + if err != nil { + return err + } + + if result.TokenDeleted { + output.EmitSuccess(sink, "Logged out successfully.") + } else { + output.EmitNote(sink, "Not currently logged in.") + } + return nil }, } 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..0729262 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -56,11 +56,20 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) { return token, nil } -// Logout removes the stored auth token from the keyring -func (a *Auth) Logout() error { +// LogoutResult represents the outcome of a logout operation +type LogoutResult struct { + TokenDeleted bool +} + +// Logout removes the stored auth token from the keyring. +// Returns LogoutResult indicating whether a token was actually deleted. +func (a *Auth) Logout() (LogoutResult, error) { err := a.tokenStorage.DeleteAuthToken() if errors.Is(err, keyring.ErrKeyNotFound) { - return nil + return LogoutResult{TokenDeleted: false}, nil + } + if err != nil { + return LogoutResult{}, err } - return err + return LogoutResult{TokenDeleted: true}, 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..eb19f11 100644 --- a/internal/output/plain_sink.go +++ b/internal/output/plain_sink.go @@ -4,6 +4,14 @@ import ( "fmt" "io" "os" + + "github.com/charmbracelet/lipgloss" +) + +var ( + secondaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + cautionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // used for Note and Warning ) type PlainSink struct { @@ -30,10 +38,27 @@ func (s *PlainSink) setErr(err error) { } func (s *PlainSink) emit(event any) { - line, ok := FormatEventLine(event) + var line string + var ok bool + + switch e := event.(type) { + case SuccessEvent: + line = secondaryStyle.Render("> ") + successStyle.Render("Success:") + " " + e.Message + ok = true + case NoteEvent: + line = secondaryStyle.Render("> ") + cautionStyle.Render("Note:") + " " + e.Message + ok = true + case WarningEvent: + line = secondaryStyle.Render("> ") + cautionStyle.Render("Warning:") + " " + e.Message + ok = true + default: + line, ok = FormatEventLine(event) + } + if !ok { return } + _, 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..183e8ca 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 @@ -180,9 +198,10 @@ func TestPlainSink_ErrStoresOnlyFirstError(t *testing.T) { func TestPlainSink_UsesFormatterParity(t *testing.T) { t.Parallel() + // Note: SuccessEvent, NoteEvent, and WarningEvent have custom styling + // in PlainSink (with "> " prefix), so they're not included in this parity test events := []any{ LogEvent{Message: "hello"}, - WarningEvent{Message: "careful"}, ContainerStatusEvent{Phase: "starting", Container: "localstack"}, ProgressEvent{LayerID: "abc", Status: "Downloading", Current: 1, Total: 2}, } @@ -194,8 +213,6 @@ 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: 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..2f48eb3 --- /dev/null +++ b/internal/ui/run_logout.go @@ -0,0 +1,135 @@ +package ui + +import ( + "context" + "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 logoutDoneMsg struct { + result auth.LogoutResult +} + +type logoutErrMsg struct { + err error +} + +type LogoutApp struct { + header components.Header + spinner components.Spinner + result *logoutResultDisplay + err error +} + +type logoutResultDisplay struct { + success bool + message string +} + +func NewLogoutApp(version string) LogoutApp { + return LogoutApp{ + header: components.NewHeader(version), + spinner: components.NewSpinner().Show("Logging out"), + } +} + +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 logoutDoneMsg: + a.spinner = a.spinner.Hide() + if msg.result.TokenDeleted { + a.result = &logoutResultDisplay{success: true, message: "Logged out successfully."} + } else { + a.result = &logoutResultDisplay{success: false, message: "Not currently logged in."} + } + 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" + } + + if a.result != nil { + s += renderLogoutResult(a.result) + "\n" + } + + return s +} + +func renderLogoutResult(r *logoutResultDisplay) string { + prefix := styles.Secondary.Render("> ") + if r.success { + return prefix + styles.Success.Render("Success:") + " " + styles.Message.Render(r.message) + } + return prefix + styles.Note.Render("Note:") + " " + styles.Message.Render(r.message) +} + +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() + result, err := a.Logout() + + // Ensure spinner is visible for minimum duration + elapsed := time.Since(start) + if elapsed < minSpinnerDuration { + time.Sleep(minSpinnerDuration - elapsed) + } + + if err != nil { + p.Send(logoutErrMsg{err: err}) + return + } + p.Send(logoutDoneMsg{result: result}) + }() + + 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") } From 0745165996e71a46ca02d891d925d5c0c261ce32 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 19 Feb 2026 21:12:29 +0200 Subject: [PATCH 2/4] Route events through FormatEventLine --- internal/output/plain_sink.go | 29 +++++++---------------------- internal/output/plain_sink_test.go | 28 ++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/internal/output/plain_sink.go b/internal/output/plain_sink.go index eb19f11..07c9f35 100644 --- a/internal/output/plain_sink.go +++ b/internal/output/plain_sink.go @@ -8,11 +8,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -var ( - secondaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) - cautionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // used for Note and Warning -) +var secondaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) type PlainSink struct { out io.Writer @@ -38,27 +34,16 @@ func (s *PlainSink) setErr(err error) { } func (s *PlainSink) emit(event any) { - var line string - var ok bool - - switch e := event.(type) { - case SuccessEvent: - line = secondaryStyle.Render("> ") + successStyle.Render("Success:") + " " + e.Message - ok = true - case NoteEvent: - line = secondaryStyle.Render("> ") + cautionStyle.Render("Note:") + " " + e.Message - ok = true - case WarningEvent: - line = secondaryStyle.Render("> ") + cautionStyle.Render("Warning:") + " " + e.Message - ok = true - default: - line, ok = FormatEventLine(event) - } - + line, ok := FormatEventLine(event) if !ok { return } + switch event.(type) { + case SuccessEvent, NoteEvent, WarningEvent: + line = secondaryStyle.Render("> ") + 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 183e8ca..f5cf4f0 100644 --- a/internal/output/plain_sink_test.go +++ b/internal/output/plain_sink_test.go @@ -198,12 +198,13 @@ func TestPlainSink_ErrStoresOnlyFirstError(t *testing.T) { func TestPlainSink_UsesFormatterParity(t *testing.T) { t.Parallel() - // Note: SuccessEvent, NoteEvent, and WarningEvent have custom styling - // in PlainSink (with "> " prefix), so they're not included in this parity test events := []any{ LogEvent{Message: "hello"}, 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 { @@ -217,6 +218,12 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) { 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) } @@ -225,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)) + } } } } From 1c4c2dc9f64414ac14d8172d69f25da2ba510ff3 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Fri, 20 Feb 2026 12:52:39 +0200 Subject: [PATCH 3/4] Remove lipgloss styling from plain sink --- internal/output/plain_sink.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/output/plain_sink.go b/internal/output/plain_sink.go index 07c9f35..daaaf19 100644 --- a/internal/output/plain_sink.go +++ b/internal/output/plain_sink.go @@ -4,12 +4,8 @@ import ( "fmt" "io" "os" - - "github.com/charmbracelet/lipgloss" ) -var secondaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - type PlainSink struct { out io.Writer err error @@ -41,7 +37,7 @@ func (s *PlainSink) emit(event any) { switch event.(type) { case SuccessEvent, NoteEvent, WarningEvent: - line = secondaryStyle.Render("> ") + line + line = "> " + line } _, err := fmt.Fprintln(s.out, line) From a38176192b58a5bbbadf9c5a5b8a32e4e4a7c6f2 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Fri, 20 Feb 2026 12:52:47 +0200 Subject: [PATCH 4/4] Refactor logout to use sentinel error and separate messages --- cmd/logout.go | 25 ++++++---------- internal/auth/auth.go | 21 +++++++------ internal/ui/run_logout.go | 63 ++++++++++++++++++++++++--------------- 3 files changed, 60 insertions(+), 49 deletions(-) diff --git a/cmd/logout.go b/cmd/logout.go index 68d038f..7b573ba 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "os" "github.com/localstack/lstk/internal/api" @@ -21,28 +22,20 @@ var logoutCmd = &cobra.Command{ return err } - sink := output.NewPlainSink(os.Stdout) - a := auth.New(sink, platformClient, tokenStorage, false) - if ui.IsInteractive() { + a := auth.New(nil, platformClient, tokenStorage, false) return ui.RunLogout(cmd.Context(), version, a) } - // Non-interactive mode - output.EmitLog(sink, "Logging out...") - - result, err := a.Logout() - if err != nil { - return err - } + // Non-interactive mode: auth emits events through the sink + sink := output.NewPlainSink(os.Stdout) + a := auth.New(sink, platformClient, tokenStorage, false) - if result.TokenDeleted { - output.EmitSuccess(sink, "Logged out successfully.") - } else { - output.EmitNote(sink, "Not currently logged in.") + err = a.Logout() + if errors.Is(err, auth.ErrNotLoggedIn) { + return nil } - - return nil + return err }, } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 0729262..3641b2e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -56,20 +56,23 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) { return token, nil } -// LogoutResult represents the outcome of a logout operation -type LogoutResult struct { - TokenDeleted bool -} +var ErrNotLoggedIn = errors.New("not currently logged in") // Logout removes the stored auth token from the keyring. -// Returns LogoutResult indicating whether a token was actually deleted. -func (a *Auth) Logout() (LogoutResult, error) { +// 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 LogoutResult{TokenDeleted: false}, nil + output.EmitNote(a.sink, "Not currently logged in.") + return ErrNotLoggedIn } if err != nil { - return LogoutResult{}, err + return err } - return LogoutResult{TokenDeleted: true}, nil + + output.EmitSuccess(a.sink, "Logged out successfully.") + return nil } diff --git a/internal/ui/run_logout.go b/internal/ui/run_logout.go index 2f48eb3..b4ddcf0 100644 --- a/internal/ui/run_logout.go +++ b/internal/ui/run_logout.go @@ -2,6 +2,7 @@ package ui import ( "context" + "errors" "time" tea "github.com/charmbracelet/bubbletea" @@ -12,30 +13,34 @@ import ( const minSpinnerDuration = 400 * time.Millisecond -type logoutDoneMsg struct { - result auth.LogoutResult -} +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 - result *logoutResultDisplay + state logoutState err error } -type logoutResultDisplay struct { - success bool - message string -} - func NewLogoutApp(version string) LogoutApp { return LogoutApp{ header: components.NewHeader(version), spinner: components.NewSpinner().Show("Logging out"), + state: logoutStateLoading, } } @@ -50,13 +55,14 @@ func (a LogoutApp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Quit } - case logoutDoneMsg: + case logoutSuccessMsg: a.spinner = a.spinner.Hide() - if msg.result.TokenDeleted { - a.result = &logoutResultDisplay{success: true, message: "Logged out successfully."} - } else { - a.result = &logoutResultDisplay{success: false, message: "Not currently logged in."} - } + a.state = logoutStateSuccess + return a, tea.Quit + + case logoutNotLoggedInMsg: + a.spinner = a.spinner.Hide() + a.state = logoutStateNotLoggedIn return a, tea.Quit case logoutErrMsg: @@ -82,19 +88,24 @@ func (a LogoutApp) View() string { s += a.spinner.View() + "\n" } - if a.result != nil { - s += renderLogoutResult(a.result) + "\n" + switch a.state { + case logoutStateSuccess: + s += renderLogoutSuccess() + "\n" + case logoutStateNotLoggedIn: + s += renderLogoutNotLoggedIn() + "\n" } return s } -func renderLogoutResult(r *logoutResultDisplay) string { +func renderLogoutSuccess() string { prefix := styles.Secondary.Render("> ") - if r.success { - return prefix + styles.Success.Render("Success:") + " " + styles.Message.Render(r.message) - } - return prefix + styles.Note.Render("Note:") + " " + styles.Message.Render(r.message) + 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 { @@ -107,7 +118,7 @@ func RunLogout(ctx context.Context, version string, a *auth.Auth) error { go func() { start := time.Now() - result, err := a.Logout() + err := a.Logout() // Ensure spinner is visible for minimum duration elapsed := time.Since(start) @@ -115,11 +126,15 @@ func RunLogout(ctx context.Context, version string, a *auth.Auth) error { 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(logoutDoneMsg{result: result}) + p.Send(logoutSuccessMsg{}) }() model, err := p.Run()