Skip to content
Merged
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
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether bubbles is directly imported by project source.

echo "=== Non-test files importing bubbles ==="
rg -n --type=go '"github.com/charmbracelet/bubbles' -g '!**/*_test.go'

echo ""
echo "=== Test files importing bubbles ==="
rg -n --type=go '"github.com/charmbracelet/bubbles' -g '**/*_test.go'

Repository: localstack/lstk

Length of output: 367


🏁 Script executed:

#!/bin/bash
# Check the current go.mod structure for bubbles
echo "=== go.mod direct requires section ==="
head -40 go.mod | cat -n

echo ""
echo "=== Search for bubbles in entire go.mod ==="
rg -n 'charmbracelet/bubbles' go.mod

Repository: localstack/lstk

Length of output: 2097


Move bubbles to direct dependencies in the require block

github.com/charmbracelet/bubbles is directly imported in internal/ui/app.go:7 and internal/ui/components/spinner.go:4, so it should be listed as a direct dependency for clearer ownership and stable go mod tidy behavior. Move it from the indirect section to the main require block.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@go.mod` at line 29, Move the module github.com/charmbracelet/bubbles from the
indirect list into the main require block of go.mod because it is directly
imported (see imports of github.com/charmbracelet/bubbles in your code). Edit
the require block to include github.com/charmbracelet/bubbles with the current
version (v1.0.0) and remove its indirect entry, then re-run go mod tidy to
update the module graph so imports in internal/ui (where bubbles is used)
resolve as a direct dependency.

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
4 changes: 2 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) {
return "", fmt.Errorf("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode")
}

output.EmitLog(a.sink, "No existing credentials found. Please log in:")
output.EmitInfo(a.sink, "No existing credentials found. Please log in:")
token, err := a.login.Login(ctx)
if err != nil {
output.EmitWarning(a.sink, "Authentication failed.")
Expand All @@ -52,7 +52,7 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) {
output.EmitWarning(a.sink, fmt.Sprintf("could not store token in keyring: %v", err))
}

output.EmitLog(a.sink, "Login successful.")
output.EmitSuccess(a.sink, "Login successful.")
return token, nil
}

Expand Down
4 changes: 2 additions & 2 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) {
assert.Equal(t, "test-token", token)
assert.Condition(t, func() bool {
for _, event := range events {
warningEvent, ok := event.(output.WarningEvent)
if ok && strings.Contains(warningEvent.Message, "could not store token in keyring") {
msgEvent, ok := event.(output.MessageEvent)
if ok && msgEvent.Severity == output.SeverityWarning && strings.Contains(msgEvent.Text, "could not store token in keyring") {
return true
}
}
Expand Down
12 changes: 6 additions & 6 deletions internal/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) {
}

authURL := fmt.Sprintf("%s/auth/request/%s", getWebAppURL(), authReq.ID)
output.EmitLog(l.sink, fmt.Sprintf("Visit: %s", authURL))
output.EmitLog(l.sink, fmt.Sprintf("Verification code: %s", authReq.Code))
output.EmitInfo(l.sink, fmt.Sprintf("Visit: %s", authURL))
output.EmitInfo(l.sink, fmt.Sprintf("Verification code: %s", authReq.Code))

// Ask whether to open the browser; ENTER or Y accepts (default yes), N skips
browserCh := make(chan output.InputResponse, 1)
Expand All @@ -53,7 +53,7 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) {
}
if resp.SelectedKey != "n" {
if err := browser.OpenURL(authURL); err != nil {
output.EmitLog(l.sink, fmt.Sprintf("Warning: Failed to open browser: %v", err))
output.EmitWarning(l.sink, fmt.Sprintf("Failed to open browser: %v", err))
}
}
case <-ctx.Done():
Expand Down Expand Up @@ -84,22 +84,22 @@ func getWebAppURL() string {
}

func (l *loginProvider) completeAuth(ctx context.Context, authReq *api.AuthRequest) (string, error) {
output.EmitLog(l.sink, "Checking if auth request is confirmed...")
output.EmitInfo(l.sink, "Checking if auth request is confirmed...")
confirmed, err := l.platformClient.CheckAuthRequestConfirmed(ctx, authReq.ID, authReq.ExchangeToken)
if err != nil {
return "", fmt.Errorf("failed to check auth request: %w", err)
}
if !confirmed {
return "", fmt.Errorf("auth request not confirmed - please complete the authentication in your browser")
}
output.EmitLog(l.sink, "Auth request confirmed, exchanging for token...")
output.EmitInfo(l.sink, "Auth request confirmed, exchanging for token...")

bearerToken, err := l.platformClient.ExchangeAuthRequest(ctx, authReq.ID, authReq.ExchangeToken)
if err != nil {
return "", fmt.Errorf("failed to exchange auth request: %w", err)
}

output.EmitLog(l.sink, "Fetching license token...")
output.EmitInfo(l.sink, "Fetching license token...")
licenseToken, err := l.platformClient.GetLicenseToken(ctx, bearerToken)
if err != nil {
return "", fmt.Errorf("failed to get license token: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu
return nil, fmt.Errorf("failed to check container status: %w", err)
}
if running {
output.EmitLog(sink, fmt.Sprintf("%s is already running", c.Name))
output.EmitInfo(sink, fmt.Sprintf("%s is already running", c.Name))
continue
}
if err := ports.CheckAvailable(c.Port); err != nil {
Expand Down
82 changes: 70 additions & 12 deletions internal/output/events.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,53 @@
// Package output defines events for the event/sink system
//
// MessageEvent (use via EmitInfo, EmitSuccess, EmitNote, EmitWarning):
// - SeverityInfo: Transient status ("Connecting...", "Validating...")
// - SeveritySuccess: Positive outcome ("Login successful")
// - SeverityNote: Informational outcome ("Not currently logged in")
// - SeverityWarning: Cautionary message ("Token expires soon")
//
// SpinnerEvent (use via EmitSpinnerStart, EmitSpinnerStop):
// - Show loading indicator during async operations
// - Always pair Start with Stop
//
// ErrorEvent (use via EmitError):
// - Structured errors with title, summary, detail, and recovery actions
// - Use for errors that need more than a single line
package output

type MessageSeverity int

const (
SeverityInfo MessageSeverity = iota
SeveritySuccess
SeverityNote
SeverityWarning
)

type MessageEvent struct {
Severity MessageSeverity
Text string
}

type SpinnerEvent struct {
Active bool
Text string
}

type ErrorAction struct {
Label string
Value string
}

type ErrorEvent struct {
Title string
Summary string
Detail string
Actions []ErrorAction
}

type Event interface {
LogEvent | WarningEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent
MessageEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent
}

type Sink interface {
Expand All @@ -18,14 +64,6 @@ func (f SinkFunc) emit(event any) {
f(event)
}

type LogEvent struct {
Message string
}

type WarningEvent struct {
Message string
}

type ContainerStatusEvent struct {
Phase string // e.g., "pulling", "starting", "waiting", "ready"
Container string
Expand Down Expand Up @@ -68,12 +106,20 @@ func Emit[E Event](sink Sink, event E) {
sink.emit(event)
}

func EmitLog(sink Sink, message string) {
Emit(sink, LogEvent{Message: message})
func EmitInfo(sink Sink, text string) {
Emit(sink, MessageEvent{Severity: SeverityInfo, Text: text})
}

func EmitSuccess(sink Sink, text string) {
Emit(sink, MessageEvent{Severity: SeveritySuccess, Text: text})
}

func EmitNote(sink Sink, text string) {
Emit(sink, MessageEvent{Severity: SeverityNote, Text: text})
}

func EmitWarning(sink Sink, message string) {
Emit(sink, WarningEvent{Message: message})
Emit(sink, MessageEvent{Severity: SeverityWarning, Text: message})
}
Comment thread
silv-io marked this conversation as resolved.

func EmitStatus(sink Sink, phase, container, detail string) {
Expand All @@ -97,3 +143,15 @@ func EmitUserInputRequest(sink Sink, event UserInputRequestEvent) {
func EmitContainerLogLine(sink Sink, line string) {
Emit(sink, ContainerLogLineEvent{Line: line})
}

func EmitSpinnerStart(sink Sink, text string) {
Emit(sink, SpinnerEvent{Active: true, Text: text})
}

func EmitSpinnerStop(sink Sink) {
Emit(sink, SpinnerEvent{Active: false})
Comment thread
silv-io marked this conversation as resolved.
}

func EmitError(sink Sink, event ErrorEvent) {
Emit(sink, event)
}
78 changes: 0 additions & 78 deletions internal/output/format_test.go

This file was deleted.

47 changes: 43 additions & 4 deletions internal/output/format.go → internal/output/plain_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ import (
// FormatEventLine converts an output event into a single display line.
func FormatEventLine(event any) (string, bool) {
switch e := event.(type) {
case LogEvent:
return e.Message, true
case WarningEvent:
return fmt.Sprintf("Warning: %s", e.Message), true
case MessageEvent:
return formatMessageEvent(e), true
case SpinnerEvent:
if e.Active {
return e.Text + "...", true
}
return "", false
case ErrorEvent:
return formatErrorEvent(e), true
case ContainerStatusEvent:
return formatStatusLine(e), true
case ProgressEvent:
Expand Down Expand Up @@ -71,3 +76,37 @@ func formatUserInputRequest(e UserInputRequestEvent) string {
return fmt.Sprintf("%s [%s]", e.Prompt, strings.Join(labels, "/"))
}
}

func formatMessageEvent(e MessageEvent) string {
switch e.Severity {
case SeveritySuccess:
return "> Success: " + e.Text
case SeverityNote:
return "> Note: " + e.Text
case SeverityWarning:
return "> Warning: " + e.Text
default:
return e.Text
}
}

func formatErrorEvent(e ErrorEvent) string {
var sb strings.Builder
sb.WriteString("Error: ")
sb.WriteString(e.Title)
if e.Summary != "" {
sb.WriteString("\n ")
sb.WriteString(e.Summary)
}
if e.Detail != "" {
sb.WriteString("\n ")
sb.WriteString(e.Detail)
}
for _, action := range e.Actions {
sb.WriteString("\n → ")
sb.WriteString(action.Label)
sb.WriteString(" ")
sb.WriteString(action.Value)
}
return sb.String()
}
Comment thread
silv-io marked this conversation as resolved.
Loading