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
23 changes: 16 additions & 7 deletions cmd/logout.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand All @@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought: It looks a bit funny that we're passing a nil sink in interactive mode, and that we basically have to define the output in 2 different places:

  1. TUI mode (ui/run_logout.go): strings "Logging out", "Not currently logged in.", "Logged out successfully."
  2. Non-interactive mode (cmd/logout.go): the same strings in output.Emit*() calls

What do you think @silv-io ? Not sure how this could be improved...

Maybe in the future we can implement something like a FileSink to capture everything in a log file when running in interactive mode.

Copy link
Member Author

@gtsiolis gtsiolis Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, rebased to keep fresh, and waiting for @silv-io to take a second look.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks funny because it's using the sinks wrong :P

The model defines own messages for success and failure even though we already emit those in the internal logout package.

What we need to do here is use a TUISink and handle the actual Logout success messages in the model

}

// 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
},
}

Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
18 changes: 15 additions & 3 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Comment on lines +65 to +76
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: We should do these events in a way so that the TUI can handle them in it's update flow. The success looks fine here. Not sure about the other ones yet :D

return nil
}
4 changes: 2 additions & 2 deletions internal/auth/token_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
18 changes: 17 additions & 1 deletion internal/output/events.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package output

type Event interface {
LogEvent | WarningEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent
LogEvent | WarningEvent | SuccessEvent | NoteEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I think we'll need to document a bit which event should be used for which kind of message, and what the difference is in how they're going to be displayed.

}

type Sink interface {
Expand All @@ -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
Expand Down Expand Up @@ -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})
}
Expand Down
4 changes: 4 additions & 0 deletions internal/output/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions internal/output/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
6 changes: 6 additions & 0 deletions internal/output/plain_sink.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
51 changes: 44 additions & 7 deletions internal/output/plain_sink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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))
}
}
}
}
50 changes: 45 additions & 5 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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})
}
}

Expand All @@ -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:]
Expand All @@ -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 != "" {
Expand All @@ -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
}
Loading
Loading