diff --git a/.claude/skills/add-event/SKILL.md b/.claude/skills/add-event/SKILL.md index c0bf6e45..d7bf6e2a 100644 --- a/.claude/skills/add-event/SKILL.md +++ b/.claude/skills/add-event/SKILL.md @@ -13,7 +13,7 @@ Add a new event type `$ARGUMENTS` to the output event system. Read these files first — they are the source of truth: -- `internal/output/events.go` — all event types, the `Event` union constraint, and emit helpers +- `internal/output/events.go` — all event types, the `Event` marker interface, and its `sealedEvent()` implementations - `internal/output/plain_format.go` — `FormatEventLine()` switch for plain text rendering - `internal/output/plain_format_test.go` — test cases for format parity - `internal/ui/app.go` — `Update()` method that handles events in the TUI @@ -29,18 +29,14 @@ In `internal/output/events.go`: } ``` -2. Add the new type to the `Event` union constraint: +2. Add the marker method so the type satisfies the `Event` interface and `Sink.Emit` accepts it: ```go - type Event interface { - MessageEvent | AuthEvent | ... | Event - } + func (Event) sealedEvent() {} ``` -3. Add an emit helper function: + Call sites emit directly on the sink — no helper needed: ```go - func Emit(sink Sink, ...) { - Emit(sink, Event{...}) - } + sink.Emit(output.Event{...}) ``` ## Step 2: Add plain text formatting @@ -80,4 +76,5 @@ If the event doesn't need special TUI handling, the `default` case in `Update()` - Do NOT put pre-rendered UI strings in event fields — use typed domain data - Do NOT add lipgloss/styling imports to `plain_format.go` - Do NOT skip the format test — every event type needs parity coverage -- Do NOT forget to add the type to the `Event` union — it won't compile without it +- Do NOT add a package-level emit helper — call sites use `sink.Emit(output.Event{...})` directly +- Do NOT forget to add `func (Event) sealedEvent() {}` — without it `Sink.Emit` will reject the type at compile time diff --git a/CLAUDE.md b/CLAUDE.md index 8bc9a636..7351fcce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,9 +84,9 @@ Environment variables: # Output Routing and Events -- Emit typed events through `internal/output` (`EmitInfo`, `EmitSuccess`, `EmitNote`, `EmitWarning`, `EmitStatus`, `EmitProgress`, etc.) instead of printing from domain/command handlers. -- Keep `output.Sink` sealed (unexported `emit`); sink implementations belong in `internal/output`. -- Reuse `FormatEventLine(event any)` for all line-oriented rendering so plain and TUI output stay consistent. +- Emit typed events via `sink.Emit(output.XxxEvent{...})` instead of printing from domain/command handlers. For simple messages use `output.MessageEvent{Severity: output.SeverityInfo, Text: "..."}` (severities: `SeverityInfo`, `SeveritySuccess`, `SeverityNote`, `SeverityWarning`, `SeveritySecondary`). +- Sink implementations belong in `internal/output`; do not implement `output.Sink` outside that package. +- Reuse `FormatEventLine(event Event)` for all line-oriented rendering so plain and TUI output stay consistent. - Select output mode at the command boundary in `cmd/`: interactive TTY runs Bubble Tea, non-interactive mode uses `output.NewPlainSink(...)`. - Keep non-TTY mode non-interactive (no stdin prompts or input waits). - Domain packages must not import Bubble Tea or UI packages. @@ -94,7 +94,7 @@ Environment variables: - Do not pass UI callbacks like `onProgress func(...)` through domain layers; prefer typed output events. - Event payloads should be domain facts (phase/status/progress), not pre-rendered UI strings. - When adding a new event type, update all of: - - `internal/output/events.go` (event type + `Event` union constraint + emit helper) + - `internal/output/events.go` (event struct definition) - `internal/output/plain_format.go` (line formatting fallback) - tests in `internal/output/*_test.go` for formatter/sink behavior parity @@ -102,7 +102,7 @@ Environment variables: Domain code must never read from stdin or wait for user input directly. Instead: -1. Emit a `UserInputRequestEvent` via `output.EmitUserInputRequest()` with: +1. Emit a `UserInputRequestEvent` via `sink.Emit(output.UserInputRequestEvent{...})` with: - `Prompt`: message to display - `Options`: available choices (e.g., `{Key: "enter", Label: "Press ENTER to continue"}`) - `ResponseCh`: channel to receive the user's response @@ -118,7 +118,7 @@ Domain code must never read from stdin or wait for user input directly. Instead: Example flow in auth login: ```go responseCh := make(chan output.InputResponse, 1) -output.EmitUserInputRequest(sink, output.UserInputRequestEvent{ +sink.Emit(output.UserInputRequestEvent{ Prompt: "Waiting for authentication...", Options: []output.InputOption{{Key: "enter", Label: "Press ENTER when complete"}}, ResponseCh: responseCh, diff --git a/cmd/aws.go b/cmd/aws.go index e9b12f6f..013a9444 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -66,7 +66,7 @@ Examples: return fmt.Errorf("checking emulator status: %w", err) } if !running { - output.EmitError(sink, output.ErrorEvent{ + sink.Emit(output.ErrorEvent{ Title: fmt.Sprintf("%s is not running", awsContainer.DisplayName()), Actions: []output.ErrorAction{ {Label: "Start LocalStack:", Value: "lstk"}, @@ -80,7 +80,7 @@ Examples: profileExists, _ := awsconfig.ProfileExists() if !profileExists { - output.EmitNote(sink, "No AWS profile found, run 'lstk setup aws'") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "No AWS profile found, run 'lstk setup aws'"}) } stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr) diff --git a/cmd/logout.go b/cmd/logout.go index dbe45265..f6651d88 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -54,7 +54,7 @@ func newLogoutCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra if rt != nil { if running, err := container.AnyRunning(cmd.Context(), rt, appConfig.Containers); err == nil && running { - output.EmitNote(sink, "LocalStack is still running in the background") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack is still running in the background"}) } } return nil diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8bde4765..0ce88d24 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -51,38 +51,38 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) { if errors.Is(err, context.Canceled) { return "", err } - output.EmitWarning(a.sink, "Authentication failed.") + a.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: "Authentication failed."}) return "", err } if err := a.tokenStorage.SetAuthToken(token); err != nil { - output.EmitWarning(a.sink, fmt.Sprintf("could not store token in keyring: %v", err)) + a.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not store token in keyring: %v", err)}) } - output.EmitSuccess(a.sink, "Login successful.") + a.sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Login successful."}) return token, nil } // Logout removes the stored auth token from the keyring func (a *Auth) Logout() error { - output.EmitSpinnerStart(a.sink, "Logging out...") + a.sink.Emit(output.SpinnerStart("Logging out...")) _, err := a.tokenStorage.GetAuthToken() if err != nil { - output.EmitSpinnerStop(a.sink) + a.sink.Emit(output.SpinnerStop()) if a.authToken != "" { - output.EmitNote(a.sink, "Authenticated via LOCALSTACK_AUTH_TOKEN environment variable; unset it to log out") + a.sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Authenticated via LOCALSTACK_AUTH_TOKEN environment variable; unset it to log out"}) return nil } if !errors.Is(err, ErrTokenNotFound) { return fmt.Errorf("failed to read auth token: %w", err) } - output.EmitNote(a.sink, "Not currently logged in") + a.sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Not currently logged in"}) return ErrNotLoggedIn } if err := a.tokenStorage.DeleteAuthToken(); err != nil { - output.EmitSpinnerStop(a.sink) + a.sink.Emit(output.SpinnerStop()) return fmt.Errorf("failed to delete auth token: %w", err) } @@ -90,7 +90,7 @@ func (a *Auth) Logout() error { _ = os.Remove(a.licenseFilePath) } - output.EmitSpinnerStop(a.sink) - output.EmitSuccess(a.sink, "Logged out successfully") + a.sink.Emit(output.SpinnerStop()) + a.sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Logged out successfully"}) return nil } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 689f0198..64d2b424 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -24,8 +24,8 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) { mockStorage := NewMockAuthTokenStorage(ctrl) mockLogin := NewMockLoginProvider(ctrl) - var events []any - sink := output.SinkFunc(func(event any) { + var events []output.Event + sink := output.SinkFunc(func(event output.Event) { events = append(events, event) }) diff --git a/internal/auth/login.go b/internal/auth/login.go index beace0a5..ecff3a6c 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -40,7 +40,7 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) { authURL := buildAuthURL(l.webAppURL, authReq.ID, authReq.Code) - output.EmitAuth(l.sink, output.AuthEvent{ + l.sink.Emit(output.AuthEvent{ Preamble: "Welcome to lstk, a command-line interface for LocalStack", Code: authReq.Code, URL: authURL, @@ -48,13 +48,13 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) { browser.Stdout = io.Discard browser.Stderr = io.Discard if err := browser.OpenURL(authURL); err != nil { - output.EmitWarning(l.sink, fmt.Sprintf("Failed to open browser automatically. Open this URL manually to continue: %s", authURL)) + l.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Failed to open browser automatically. Open this URL manually to continue: %s", authURL)}) } - output.EmitSpinnerStart(l.sink, "Waiting for authorization...") + l.sink.Emit(output.SpinnerStart("Waiting for authorization...")) responseCh := make(chan output.InputResponse, 1) - output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{ + l.sink.Emit(output.UserInputRequestEvent{ Prompt: "Waiting for authorization...", Options: []output.InputOption{{Key: "any", Label: "Press any key when complete"}}, ResponseCh: responseCh, @@ -62,13 +62,13 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) { select { case resp := <-responseCh: - output.EmitSpinnerStop(l.sink) + l.sink.Emit(output.SpinnerStop()) if resp.Cancelled { return "", context.Canceled } return l.completeAuth(ctx, authReq) case <-ctx.Done(): - output.EmitSpinnerStop(l.sink) + l.sink.Emit(output.SpinnerStop()) return "", ctx.Err() } } @@ -85,7 +85,7 @@ func buildAuthURL(webAppURL, authRequestID, code string) string { } func (l *loginProvider) completeAuth(ctx context.Context, authReq *api.AuthRequest) (string, error) { - output.EmitInfo(l.sink, "Checking if auth request is confirmed...") + l.sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "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) @@ -93,14 +93,14 @@ func (l *loginProvider) completeAuth(ctx context.Context, authReq *api.AuthReque if !confirmed { return "", fmt.Errorf("auth request not confirmed - please complete the authentication in your browser") } - output.EmitInfo(l.sink, "Auth request confirmed, exchanging for token...") + l.sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "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.EmitInfo(l.sink, "Fetching license token...") + l.sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "Fetching license token..."}) licenseToken, err := l.platformClient.GetLicenseToken(ctx, bearerToken) if err != nil { return "", fmt.Errorf("failed to get license token: %w", err) diff --git a/internal/awsconfig/awsconfig.go b/internal/awsconfig/awsconfig.go index 40f9d784..125822f9 100644 --- a/internal/awsconfig/awsconfig.go +++ b/internal/awsconfig/awsconfig.go @@ -189,7 +189,7 @@ func writeCredsProfile(credsPath string) error { } func emitMissingProfileNote(sink output.Sink) { - output.EmitNote(sink, "LocalStack AWS profile is incomplete. Run 'lstk setup aws'.") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack AWS profile is incomplete. Run 'lstk setup aws'."}) } // checkProfileSetup returns both the profile status (which files need writing) and presence (which files exist). @@ -223,7 +223,7 @@ func checkProfileSetup(resolvedHost string) (profileStatus, bool, bool, error) { func EnsureProfile(ctx context.Context, sink output.Sink, interactive bool, resolvedHost string) error { status, configOK, credsOK, err := checkProfileSetup(resolvedHost) if err != nil { - output.EmitWarning(sink, fmt.Sprintf("could not check AWS profile: %v", err)) + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not check AWS profile: %v", err)}) return nil } if !status.anyNeeded() { @@ -242,18 +242,18 @@ func EnsureProfile(ctx context.Context, sink output.Sink, interactive bool, reso // status is passed in from EnsureProfile to avoid re-checking the profile status. func Setup(ctx context.Context, sink output.Sink, resolvedHost string, status profileStatus) error { if !status.anyNeeded() { - output.EmitNote(sink, "LocalStack AWS profile is already configured.") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack AWS profile is already configured."}) return nil } configPath, credsPath, err := awsPaths() if err != nil { - output.EmitWarning(sink, fmt.Sprintf("could not determine AWS config paths: %v", err)) + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not determine AWS config paths: %v", err)}) return nil } responseCh := make(chan output.InputResponse, 1) - output.EmitUserInputRequest(sink, output.UserInputRequestEvent{ + sink.Emit(output.UserInputRequestEvent{ Prompt: "Set up a LocalStack profile for AWS CLI and SDKs in ~/.aws?", Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}}, ResponseCh: responseCh, @@ -265,27 +265,27 @@ func Setup(ctx context.Context, sink output.Sink, resolvedHost string, status pr return nil } if resp.SelectedKey == "n" { - output.EmitNote(sink, "Skipped adding LocalStack AWS profile.") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Skipped adding LocalStack AWS profile."}) return nil } if status.configNeeded { if err := writeConfigProfile(configPath, resolvedHost); err != nil { - output.EmitWarning(sink, fmt.Sprintf("could not update ~/.aws/config: %v", err)) + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not update ~/.aws/config: %v", err)}) return nil } } if status.credsNeeded { if err := writeCredsProfile(credsPath); err != nil { - output.EmitWarning(sink, fmt.Sprintf("could not update ~/.aws/credentials: %v", err)) + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not update ~/.aws/credentials: %v", err)}) return nil } } if status.configNeeded && status.credsNeeded { - output.EmitSuccess(sink, "Created LocalStack profile in ~/.aws") + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Created LocalStack profile in ~/.aws"}) } else if status.configNeeded { - output.EmitSuccess(sink, "Created LocalStack profile in ~/.aws/config") + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Created LocalStack profile in ~/.aws/config"}) } else { - output.EmitSuccess(sink, "Updated LocalStack credentials in ~/.aws/credentials") + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Updated LocalStack credentials in ~/.aws/credentials"}) } case <-ctx.Done(): return ctx.Err() diff --git a/internal/container/logs.go b/internal/container/logs.go index e552a86b..0885e5eb 100644 --- a/internal/container/logs.go +++ b/internal/container/logs.go @@ -39,7 +39,7 @@ func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers continue } level, _ := parseLogLine(line) - output.EmitLogLine(sink, output.LogSourceEmulator, line, level) + sink.Emit(output.LogLineEvent{Source: output.LogSourceEmulator, Line: line, Level: level}) } if err := scanner.Err(); err != nil && ctx.Err() == nil { return err diff --git a/internal/container/start.go b/internal/container/start.go index d650a30d..a3624004 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -92,7 +92,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } if hasDuplicateContainerTypes(opts.Containers) { - output.EmitWarning(sink, "Multiple emulators of the same type are defined in your config; this setup is not supported yet") + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: "Multiple emulators of the same type are defined in your config; this setup is not supported yet"}) } tel := opts.Telemetry @@ -230,7 +230,7 @@ func runPostStartSetups(ctx context.Context, sink output.Sink, containers []conf if setup, ok := setups[t]; ok { resolvedHost, dnsOK := endpoint.ResolveHost(firstByType[t].Port, localStackHost) if !dnsOK { - output.EmitNote(sink, endpoint.DNSRebindNote) + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: endpoint.DNSRebindNote}) } if err := setup(ctx, sink, interactive, resolvedHost); err != nil { return err @@ -242,15 +242,15 @@ func runPostStartSetups(ctx context.Context, sink output.Sink, containers []conf } func emitPostStartPointers(sink output.Sink, resolvedHost, webAppURL string) { - output.EmitSecondary(sink, fmt.Sprintf("• Endpoint: %s", resolvedHost)) + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Endpoint: %s", resolvedHost)}) if webAppURL != "" { - output.EmitSecondary(sink, fmt.Sprintf("• Web app: %s", strings.TrimRight(webAppURL, "/"))) + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Web app: %s", strings.TrimRight(webAppURL, "/"))}) } tips := []string{ "> Tip: View emulator logs: lstk logs --follow", "> Tip: View deployed resources: lstk status", } - output.EmitSecondary(sink, tips[rand.IntN(len(tips))]) + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: tips[rand.IntN(len(tips))]}) } func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig) (map[string]bool, error) { @@ -261,25 +261,25 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel * return nil, fmt.Errorf("failed to remove existing container %s: %w", c.Name, err) } - output.EmitSpinnerStart(sink, fmt.Sprintf("Pulling %s", c.Image)) - output.EmitStatus(sink, "pulling", c.Image, "") + sink.Emit(output.SpinnerStart(fmt.Sprintf("Pulling %s", c.Image))) + sink.Emit(output.ContainerStatusEvent{Phase: "pulling", Container: c.Image}) progress := make(chan runtime.PullProgress) go func() { for p := range progress { - output.EmitProgress(sink, c.Image, p.LayerID, p.Status, p.Current, p.Total) + sink.Emit(output.ProgressEvent{Container: c.Image, LayerID: p.LayerID, Status: p.Status, Current: p.Current, Total: p.Total}) } }() if err := rt.PullImage(ctx, c.Image, progress); err != nil { - output.EmitSpinnerStop(sink) - output.EmitError(sink, output.ErrorEvent{ + sink.Emit(output.SpinnerStop()) + sink.Emit(output.ErrorEvent{ Title: fmt.Sprintf("Failed to pull %s", c.Image), Summary: err.Error(), }) emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodeImagePullFailed, err.Error()) return nil, output.NewSilentError(fmt.Errorf("failed to pull image %s: %w", c.Image, err)) } - output.EmitSpinnerStop(sink) - output.EmitSuccess(sink, fmt.Sprintf("Pulled %s", c.Image)) + sink.Emit(output.SpinnerStop()) + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Pulled %s", c.Image)}) pulled[c.Name] = true } return pulled, nil @@ -334,21 +334,21 @@ func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink ou func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, pulled map[string]bool) error { for _, c := range containers { startTime := time.Now() - output.EmitStatus(sink, "starting", c.Name, "") + sink.Emit(output.ContainerStatusEvent{Phase: "starting", Container: c.Name}) containerID, err := rt.Start(ctx, c) if err != nil { emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodeStartFailed, err.Error()) return fmt.Errorf("failed to start LocalStack: %w", err) } - output.EmitStatus(sink, "waiting", c.Name, "") + sink.Emit(output.ContainerStatusEvent{Phase: "waiting", Container: c.Name}) healthURL := fmt.Sprintf("http://localhost:%s%s", c.Port, c.HealthPath) if err := awaitStartup(ctx, rt, sink, containerID, "LocalStack", healthURL); err != nil { emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodeStartFailed, err.Error()) return err } - output.EmitStatus(sink, "ready", c.Name, fmt.Sprintf("containerId: %s", containerID[:12])) + sink.Emit(output.ContainerStatusEvent{Phase: "ready", Container: c.Name, Detail: fmt.Sprintf("containerId: %s", containerID[:12])}) lsInfo, _ := fetchLocalStackInfo(ctx, c.Port) emitEmulatorStartSuccess(ctx, tel, c, containerID[:12], time.Since(startTime).Milliseconds(), pulled[c.Name], lsInfo) @@ -364,10 +364,10 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu return nil, fmt.Errorf("failed to check container status: %w", err) } if running { - output.EmitNote(sink, "LocalStack is already running") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack is already running"}) resolvedHost, dnsOK := endpoint.ResolveHost(c.Port, localStackHost) if !dnsOK { - output.EmitNote(sink, endpoint.DNSRebindNote) + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: endpoint.DNSRebindNote}) } emitPostStartPointers(sink, resolvedHost, webAppURL) continue @@ -390,7 +390,7 @@ func emitPortInUseError(sink output.Sink, port string) { if pathErr == nil { actions = append(actions, output.ErrorAction{Label: "Use another port in the configuration:", Value: configPath}) } - output.EmitError(sink, output.ErrorEvent{ + sink.Emit(output.ErrorEvent{ Title: fmt.Sprintf("Port %s already in use", port), Summary: "LocalStack may already be running.", Actions: actions, @@ -399,7 +399,7 @@ func emitPortInUseError(sink output.Sink, port string) { func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, tel *telemetry.Client, containerConfig runtime.ContainerConfig, token, licenseFilePath string) error { version := containerConfig.Tag - output.EmitStatus(sink, "validating license", containerConfig.Name, version) + sink.Emit(output.ContainerStatusEvent{Phase: "validating license", Container: containerConfig.Name, Detail: version}) hostname, _ := os.Hostname() licenseReq := &api.LicenseRequest{ @@ -462,13 +462,13 @@ func awaitStartup(ctx context.Context, rt runtime.Runtime, sink output.Sink, con resp, err := client.Get(healthURL) if err == nil && resp.StatusCode == http.StatusOK { if err := resp.Body.Close(); err != nil { - output.EmitWarning(sink, fmt.Sprintf("failed to close response body: %v", err)) + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("failed to close response body: %v", err)}) } return nil } if resp != nil { if err := resp.Body.Close(); err != nil { - output.EmitWarning(sink, fmt.Sprintf("failed to close response body: %v", err)) + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("failed to close response body: %v", err)}) } } diff --git a/internal/container/status.go b/internal/container/status.go index 052c1dce..eac52c82 100644 --- a/internal/container/status.go +++ b/internal/container/status.go @@ -30,7 +30,7 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain return fmt.Errorf("checking %s running: %w", name, err) } if !running { - output.EmitError(sink, output.ErrorEvent{ + sink.Emit(output.ErrorEvent{ Title: fmt.Sprintf("%s is not running", c.DisplayName()), Actions: []output.ErrorAction{ {Label: "Start LocalStack:", Value: "lstk"}, @@ -60,22 +60,22 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain var rows []emulator.Resource switch c.Type { case config.EmulatorAWS: - output.EmitSpinnerStart(sink, "Fetching LocalStack status") + sink.Emit(output.SpinnerStart("Fetching LocalStack status")) if v, err := emulatorClient.FetchVersion(ctx, host); err != nil { - output.EmitWarning(sink, fmt.Sprintf("Could not fetch version: %v", err)) + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Could not fetch version: %v", err)}) } else { version = v } var fetchErr error rows, fetchErr = emulatorClient.FetchResources(ctx, host) - output.EmitSpinnerStop(sink) + sink.Emit(output.SpinnerStop()) if fetchErr != nil { return fetchErr } } - output.EmitInstanceInfo(sink, output.InstanceInfoEvent{ + sink.Emit(output.InstanceInfoEvent{ EmulatorName: c.DisplayName(), Version: version, Host: host, @@ -85,7 +85,7 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain if c.Type == config.EmulatorAWS { if len(rows) == 0 { - output.EmitNote(sink, "No resources deployed") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "No resources deployed"}) continue } @@ -96,8 +96,8 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain services[r.Service] = struct{}{} } - output.EmitResourceSummary(sink, len(rows), len(services)) - output.EmitTable(sink, output.TableEvent{ + sink.Emit(output.ResourceSummaryEvent{Resources: len(rows), Services: len(services)}) + sink.Emit(output.TableEvent{ Headers: []string{"Service", "Resource", "Region", "Account"}, Rows: tableRows, }) diff --git a/internal/container/stop.go b/internal/container/stop.go index 3cad5a30..7a03516d 100644 --- a/internal/container/stop.go +++ b/internal/container/stop.go @@ -41,16 +41,16 @@ func Stop(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers stopStart := time.Now() - output.EmitSpinnerStart(sink, "Stopping LocalStack...") + sink.Emit(output.SpinnerStart("Stopping LocalStack...")) stopCtx, stopCancel := context.WithTimeout(ctx, stopTimeout) if err := rt.Stop(stopCtx, name); err != nil { stopCancel() - output.EmitSpinnerStop(sink) + sink.Emit(output.SpinnerStop()) return fmt.Errorf("failed to stop LocalStack: %w", err) } stopCancel() - output.EmitSpinnerStop(sink) - output.EmitSuccess(sink, "LocalStack stopped") + sink.Emit(output.SpinnerStop()) + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "LocalStack stopped"}) if opts.Telemetry != nil { opts.Telemetry.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ diff --git a/internal/output/events.go b/internal/output/events.go index ea8ddac9..c86ea3c6 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -1,16 +1,16 @@ // Package output defines events for the event/sink system // -// MessageEvent (use via EmitInfo, EmitSuccess, EmitNote, EmitWarning): +// MessageEvent (use via sink.Emit with a MessageEvent): // - 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): +// SpinnerEvent (use via output.SpinnerStart/SpinnerStop constructors): // - Show loading indicator during async operations // - Always pair Start with Stop // -// ErrorEvent (use via EmitError): +// ErrorEvent (use via sink.Emit with an ErrorEvent): // - Structured errors with title, summary, detail, and recovery actions // - Use for errors that need more than a single line package output @@ -76,18 +76,29 @@ type ResourceSummaryEvent struct { Services int } -type Event interface { - MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | LogLineEvent | InstanceInfoEvent | TableEvent | ResourceSummaryEvent -} +// Event is a sealed marker — only event types in this package implement it, +// so Sink.Emit rejects unknown types at compile time. +type Event interface{ sealedEvent() } + +func (MessageEvent) sealedEvent() {} +func (SpinnerEvent) sealedEvent() {} +func (ErrorEvent) sealedEvent() {} +func (AuthEvent) sealedEvent() {} +func (InstanceInfoEvent) sealedEvent() {} +func (TableEvent) sealedEvent() {} +func (ResourceSummaryEvent) sealedEvent() {} +func (ContainerStatusEvent) sealedEvent() {} +func (ProgressEvent) sealedEvent() {} +func (UserInputRequestEvent) sealedEvent() {} +func (LogLineEvent) sealedEvent() {} type Sink interface { - // using any as the type only here; at call sites we'll have type safety from the union interface - emit(event any) + Emit(event Event) } -type SinkFunc func(event any) +type SinkFunc func(event Event) -func (f SinkFunc) emit(event any) { +func (f SinkFunc) Emit(event Event) { if f == nil { return } @@ -147,88 +158,16 @@ type LogLineEvent struct { Level LogLevel } -// Emit sends an event to the sink with compile-time type safety via generics. -func Emit[E Event](sink Sink, event E) { - if sink == nil { - return - } - sink.emit(event) -} - -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, text string) { - Emit(sink, MessageEvent{Severity: SeverityWarning, Text: text}) -} - -func EmitSecondary(sink Sink, text string) { - Emit(sink, MessageEvent{Severity: SeveritySecondary, Text: text}) -} - -func EmitStatus(sink Sink, phase, container, detail string) { - Emit(sink, ContainerStatusEvent{Phase: phase, Container: container, Detail: detail}) -} - -func EmitProgress(sink Sink, container, layerID, status string, current, total int64) { - Emit(sink, ProgressEvent{ - Container: container, - LayerID: layerID, - Status: status, - Current: current, - Total: total, - }) -} - -func EmitUserInputRequest(sink Sink, event UserInputRequestEvent) { - Emit(sink, event) -} - -func EmitAuth(sink Sink, event AuthEvent) { - Emit(sink, event) -} - -func EmitLogLine(sink Sink, source, line string, level LogLevel) { - Emit(sink, LogLineEvent{Source: source, Line: line, Level: level}) -} - const DefaultSpinnerMinDuration = 400 * time.Millisecond -// EmitSpinnerStart starts spinner with default min duration (400ms) -func EmitSpinnerStart(sink Sink, text string) { - Emit(sink, SpinnerEvent{Active: true, Text: text, MinDuration: DefaultSpinnerMinDuration}) -} - -// EmitSpinnerStartWithDuration starts spinner with custom min duration (0 = no minimum) -func EmitSpinnerStartWithDuration(sink Sink, text string, minDuration time.Duration) { - Emit(sink, SpinnerEvent{Active: true, Text: text, MinDuration: minDuration}) -} - -func EmitSpinnerStop(sink Sink) { - Emit(sink, SpinnerEvent{Active: false}) -} - -func EmitError(sink Sink, event ErrorEvent) { - Emit(sink, event) -} - -func EmitInstanceInfo(sink Sink, event InstanceInfoEvent) { - Emit(sink, event) +func SpinnerStart(text string) SpinnerEvent { + return SpinnerEvent{Active: true, Text: text, MinDuration: DefaultSpinnerMinDuration} } -func EmitTable(sink Sink, event TableEvent) { - Emit(sink, event) +func SpinnerStartWithDuration(text string, minDuration time.Duration) SpinnerEvent { + return SpinnerEvent{Active: true, Text: text, MinDuration: minDuration} } -func EmitResourceSummary(sink Sink, resources, services int) { - Emit(sink, ResourceSummaryEvent{Resources: resources, Services: services}) +func SpinnerStop() SpinnerEvent { + return SpinnerEvent{Active: false} } diff --git a/internal/output/events_test.go b/internal/output/events_test.go index 54d641fb..cc06c89d 100644 --- a/internal/output/events_test.go +++ b/internal/output/events_test.go @@ -3,10 +3,10 @@ package output import "testing" type captureSink struct { - events []any + events []Event } -func (s *captureSink) emit(event any) { +func (s *captureSink) Emit(event Event) { s.events = append(s.events, event) } @@ -14,7 +14,7 @@ func TestEmitAuth(t *testing.T) { t.Parallel() sink := &captureSink{} - EmitAuth(sink, AuthEvent{ + sink.Emit(AuthEvent{ Preamble: "Welcome", URL: "https://example.com", }) @@ -47,7 +47,7 @@ func TestEmitAuthWithCode(t *testing.T) { t.Parallel() sink := &captureSink{} - EmitAuth(sink, AuthEvent{ + sink.Emit(AuthEvent{ Preamble: "Welcome", URL: "https://example.com", Code: "1234", diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 2c1c0a84..a8471817 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -7,7 +7,7 @@ import ( ) // FormatEventLine converts an output event into a single display line. -func FormatEventLine(event any) (string, bool) { +func FormatEventLine(event Event) (string, bool) { switch e := event.(type) { case MessageEvent: return formatMessageEvent(e), true diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index 84fcb8ba..d124a4ca 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -11,7 +11,7 @@ func TestFormatEventLine(t *testing.T) { tests := []struct { name string - event any + event Event want string wantOK bool }{ @@ -162,12 +162,6 @@ func TestFormatEventLine(t *testing.T) { want: "Docker not available", wantOK: true, }, - { - name: "unsupported event", - event: struct{}{}, - want: "", - wantOK: false, - }, } for _, tt := range tests { diff --git a/internal/output/plain_sink.go b/internal/output/plain_sink.go index 5a5e2b41..fa6fed52 100644 --- a/internal/output/plain_sink.go +++ b/internal/output/plain_sink.go @@ -29,7 +29,7 @@ func (s *PlainSink) setErr(err error) { } } -func (s *PlainSink) emit(event any) { +func (s *PlainSink) Emit(event Event) { line, ok := FormatEventLine(event) if !ok { return diff --git a/internal/output/plain_sink_test.go b/internal/output/plain_sink_test.go index f6908d94..f7abc94d 100644 --- a/internal/output/plain_sink_test.go +++ b/internal/output/plain_sink_test.go @@ -23,7 +23,7 @@ func TestPlainSink_EmitsMessageEventInfo(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, MessageEvent{Severity: SeverityInfo, Text: "hello"}) + sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "hello"}) assert.Equal(t, "hello\n", out.String()) } @@ -32,7 +32,7 @@ func TestPlainSink_EmitsMessageEventWarning(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, MessageEvent{Severity: SeverityWarning, Text: "something went wrong"}) + sink.Emit(MessageEvent{Severity: SeverityWarning, Text: "something went wrong"}) assert.Equal(t, "> Warning: something went wrong\n", out.String()) } @@ -85,7 +85,7 @@ func TestPlainSink_EmitsStatusEvent(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, tt.event) + sink.Emit(tt.event) assert.Equal(t, tt.expected, out.String()) }) @@ -96,7 +96,7 @@ func TestPlainSink_SuppressesProgressEvent(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, ProgressEvent{ + sink.Emit(ProgressEvent{ Container: "localstack", LayerID: "abc123", Status: "Downloading", @@ -111,7 +111,7 @@ func TestPlainSink_EmitsLogLineEvent(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, LogLineEvent{Source: "container", Line: "2024-01-01 hello from container"}) + sink.Emit(LogLineEvent{Source: "container", Line: "2024-01-01 hello from container"}) assert.Equal(t, "2024-01-01 hello from container\n", out.String()) } @@ -121,7 +121,7 @@ func TestPlainSink_EmitsSpinnerEvent(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, SpinnerEvent{Active: true, Text: "Loading"}) + sink.Emit(SpinnerEvent{Active: true, Text: "Loading"}) assert.Equal(t, "Loading...\n", out.String()) }) @@ -130,7 +130,7 @@ func TestPlainSink_EmitsSpinnerEvent(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, SpinnerEvent{Active: false}) + sink.Emit(SpinnerEvent{Active: false}) assert.Equal(t, "", out.String()) }) @@ -140,7 +140,7 @@ func TestPlainSink_EmitsErrorEvent(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, ErrorEvent{ + sink.Emit(ErrorEvent{ Title: "Connection failed", Summary: "Cannot connect to Docker", Actions: []ErrorAction{{Label: "Start Docker:", Value: "open -a Docker"}}, @@ -155,7 +155,7 @@ func TestPlainSink_EmitsInstanceInfoEvent(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, InstanceInfoEvent{ + sink.Emit(InstanceInfoEvent{ EmulatorName: "LocalStack AWS Emulator", Version: "4.14.1", Host: "localhost.localstack.cloud:4566", @@ -172,7 +172,7 @@ func TestPlainSink_EmitsInstanceInfoEvent(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, InstanceInfoEvent{ + sink.Emit(InstanceInfoEvent{ EmulatorName: "LocalStack AWS Emulator", Host: "127.0.0.1:4566", }) @@ -186,7 +186,7 @@ func TestPlainSink_EmitsInstanceInfoEvent(t *testing.T) { writeErr := errors.New("write failed") sink := NewPlainSink(&failingWriter{err: writeErr}) - Emit(sink, InstanceInfoEvent{ + sink.Emit(InstanceInfoEvent{ EmulatorName: "LocalStack AWS Emulator", Host: "127.0.0.1:4566", }) @@ -200,7 +200,7 @@ func TestPlainSink_EmitsTableEvent(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, TableEvent{ + sink.Emit(TableEvent{ Headers: []string{"Service", "Resource", "Region", "Account"}, Rows: [][]string{ {"Lambda", "handler", "us-east-1", "000000000000"}, @@ -221,7 +221,7 @@ func TestPlainSink_EmitsTableEvent(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, TableEvent{Headers: []string{"A"}, Rows: [][]string{}}) + sink.Emit(TableEvent{Headers: []string{"A"}, Rows: [][]string{}}) assert.Equal(t, "", out.String()) assert.NoError(t, sink.Err()) @@ -231,7 +231,7 @@ func TestPlainSink_EmitsTableEvent(t *testing.T) { writeErr := errors.New("write failed") sink := NewPlainSink(&failingWriter{err: writeErr}) - Emit(sink, TableEvent{ + sink.Emit(TableEvent{ Headers: []string{"A"}, Rows: [][]string{{"val"}}, }) @@ -257,7 +257,7 @@ func TestPlainSink_TableWidth(t *testing.T) { got := formatTableWidth(tableEvent, 80) var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, tableEvent) + sink.Emit(tableEvent) // The sink output should contain the same table content (sink delegates to FormatEventLine // which calls formatTable → formatTableWidth with the current terminal width). @@ -290,7 +290,7 @@ func TestPlainSink_ErrReturnsNilOnSuccess(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - Emit(sink, MessageEvent{Severity: SeverityInfo, Text: "hello"}) + sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "hello"}) assert.NoError(t, sink.Err()) } @@ -299,7 +299,7 @@ func TestPlainSink_ErrCapturesWriteError(t *testing.T) { writeErr := errors.New("write failed") sink := NewPlainSink(&failingWriter{err: writeErr}) - Emit(sink, MessageEvent{Severity: SeverityInfo, Text: "hello"}) + sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "hello"}) assert.Equal(t, writeErr, sink.Err()) } @@ -308,8 +308,8 @@ func TestPlainSink_ErrStoresOnlyFirstError(t *testing.T) { firstErr := errors.New("first error") sink := NewPlainSink(&failingWriter{err: firstErr}) - Emit(sink, MessageEvent{Severity: SeverityInfo, Text: "first"}) - Emit(sink, MessageEvent{Severity: SeverityInfo, Text: "second"}) + sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "first"}) + sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "second"}) assert.Equal(t, firstErr, sink.Err()) } @@ -317,7 +317,7 @@ func TestPlainSink_ErrStoresOnlyFirstError(t *testing.T) { func TestPlainSink_UsesFormatterParity(t *testing.T) { t.Parallel() - events := []any{ + events := []Event{ MessageEvent{Severity: SeverityInfo, Text: "hello"}, MessageEvent{Severity: SeverityWarning, Text: "careful"}, MessageEvent{Severity: SeveritySuccess, Text: "done"}, @@ -352,28 +352,7 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) - switch e := event.(type) { - case MessageEvent: - Emit(sink, e) - case AuthEvent: - Emit(sink, e) - case SpinnerEvent: - Emit(sink, e) - case ErrorEvent: - Emit(sink, e) - case ContainerStatusEvent: - Emit(sink, e) - case LogLineEvent: - Emit(sink, e) - case InstanceInfoEvent: - Emit(sink, e) - case ResourceSummaryEvent: - Emit(sink, e) - case TableEvent: - Emit(sink, e) - default: - t.Fatalf("unsupported event type in test: %T", event) - } + sink.Emit(event) line, ok := FormatEventLine(event) if !ok { diff --git a/internal/output/tui_sink.go b/internal/output/tui_sink.go index d373a057..adfd1701 100644 --- a/internal/output/tui_sink.go +++ b/internal/output/tui_sink.go @@ -13,7 +13,7 @@ func NewTUISink(sender Sender) *TUISink { return &TUISink{sender: sender} } -func (s *TUISink) emit(event any) { +func (s *TUISink) Emit(event Event) { if s == nil || s.sender == nil { return } diff --git a/internal/output/tui_sink_test.go b/internal/output/tui_sink_test.go index e828fcca..4c462bc9 100644 --- a/internal/output/tui_sink_test.go +++ b/internal/output/tui_sink_test.go @@ -19,10 +19,10 @@ func TestTUISinkForwardsEvents(t *testing.T) { sender := &testSender{} sink := NewTUISink(sender) - Emit(sink, MessageEvent{Severity: SeverityInfo, Text: "hello"}) - Emit(sink, MessageEvent{Severity: SeverityWarning, Text: "careful"}) - Emit(sink, ContainerStatusEvent{Phase: "starting", Container: "localstack"}) - Emit(sink, ProgressEvent{LayerID: "abc", Status: "Downloading", Current: 1, Total: 2}) + sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "hello"}) + sink.Emit(MessageEvent{Severity: SeverityWarning, Text: "careful"}) + sink.Emit(ContainerStatusEvent{Phase: "starting", Container: "localstack"}) + sink.Emit(ProgressEvent{LayerID: "abc", Status: "Downloading", Current: 1, Total: 2}) want := []any{ MessageEvent{Severity: SeverityInfo, Text: "hello"}, @@ -39,5 +39,5 @@ func TestTUISinkNilSenderNoPanic(t *testing.T) { t.Parallel() sink := NewTUISink(nil) - Emit(sink, MessageEvent{Severity: SeverityInfo, Text: "noop"}) + sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "noop"}) } diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index c530560f..66caf49c 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -142,7 +142,7 @@ func (d *DockerRuntime) EmitUnhealthyError(sink output.Sink, err error) { // Suppress the raw error: on Windows it's a named-pipe message that users can't act on. summary = "" } - output.EmitError(sink, output.ErrorEvent{ + sink.Emit(output.ErrorEvent{ Title: "Docker is not available", Summary: summary, Actions: actions, diff --git a/internal/ui/app.go b/internal/ui/app.go index 77be4dc8..12a65203 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -275,9 +275,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil default: - if line, ok := output.FormatEventLine(msg); ok { - for _, part := range strings.Split(line, "\n") { - a.addLine(styledLine{text: part}) + if e, ok := msg.(output.Event); ok { + if line, ok := output.FormatEventLine(e); ok { + for _, part := range strings.Split(line, "\n") { + a.addLine(styledLine{text: part}) + } } } } diff --git a/internal/ui/run_awsconfig.go b/internal/ui/run_awsconfig.go index 34113f66..95b6470e 100644 --- a/internal/ui/run_awsconfig.go +++ b/internal/ui/run_awsconfig.go @@ -28,7 +28,7 @@ func RunConfigProfile(parentCtx context.Context, containers []config.ContainerCo return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { if !dnsOK { - output.EmitNote(sink, endpoint.DNSRebindNote) + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: endpoint.DNSRebindNote}) } status, err := awsconfig.CheckProfileStatus(resolvedHost) if err != nil { @@ -37,4 +37,3 @@ func RunConfigProfile(parentCtx context.Context, containers []config.ContainerCo return awsconfig.Setup(ctx, sink, resolvedHost, status) }) } - diff --git a/internal/ui/run_logout.go b/internal/ui/run_logout.go index 160d69e3..e0f702ae 100644 --- a/internal/ui/run_logout.go +++ b/internal/ui/run_logout.go @@ -38,7 +38,7 @@ func RunLogout(parentCtx context.Context, rt runtime.Runtime, platformClient api err = a.Logout() if err == nil && rt != nil { if running, runningErr := container.AnyRunning(ctx, rt, containers); runningErr == nil && running { - output.EmitNote(sink, "LocalStack is still running in the background") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack is still running in the background"}) } } diff --git a/internal/update/notify.go b/internal/update/notify.go index 4ba55e27..8c8c8867 100644 --- a/internal/update/notify.go +++ b/internal/update/notify.go @@ -61,7 +61,7 @@ func notifyUpdateWithVersion(ctx context.Context, sink output.Sink, opts NotifyO } if !opts.UpdatePrompt { - output.EmitNote(sink, fmt.Sprintf("Update available: %s → %s (run lstk update)", current, latest)) + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: fmt.Sprintf("Update available: %s → %s (run lstk update)", current, latest)}) return false } @@ -71,11 +71,11 @@ func notifyUpdateWithVersion(ctx context.Context, sink output.Sink, opts NotifyO func promptAndUpdate(ctx context.Context, sink output.Sink, opts NotifyOptions, current, latest string) (exitAfter bool) { releaseNotesURL := fmt.Sprintf("https://github.com/%s/releases/latest", githubRepo) - output.EmitNote(sink, fmt.Sprintf("New lstk version available! %s → %s", current, latest)) - output.EmitSecondary(sink, fmt.Sprintf("> Release notes: %s", releaseNotesURL)) + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: fmt.Sprintf("New lstk version available! %s → %s", current, latest)}) + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("> Release notes: %s", releaseNotesURL)}) responseCh := make(chan output.InputResponse, 1) - output.EmitUserInputRequest(sink, output.UserInputRequestEvent{ + sink.Emit(output.UserInputRequestEvent{ Prompt: "Update lstk to latest version?", Options: []output.InputOption{{Key: "u", Label: "Update now [U]"}, {Key: "r", Label: "Remind me next time [R]"}, {Key: "s", Label: "Skip this version [S]"}}, ResponseCh: responseCh, @@ -96,20 +96,20 @@ func promptAndUpdate(ctx context.Context, sink output.Sink, opts NotifyOptions, switch resp.SelectedKey { case "u": if err := applyUpdate(ctx, sink, latest, opts.GitHubToken); err != nil { - output.EmitWarning(sink, fmt.Sprintf("Update failed: %v", err)) + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Update failed: %v", err)}) return false } - output.EmitSuccess(sink, fmt.Sprintf("Updated to %s — please re-run your command.", latest)) + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Updated to %s — please re-run your command.", latest)}) return true case "r": return false case "s": if opts.PersistSkipVersion != nil { if err := opts.PersistSkipVersion(latest); err != nil { - output.EmitWarning(sink, fmt.Sprintf("Failed to persist skipped version: %v", err)) + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Failed to persist skipped version: %v", err)}) } } - output.EmitNote(sink, "Skipping version "+latest) + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Skipping version " + latest}) return false } diff --git a/internal/update/notify_test.go b/internal/update/notify_test.go index a5c543ef..05f13321 100644 --- a/internal/update/notify_test.go +++ b/internal/update/notify_test.go @@ -84,8 +84,8 @@ func TestNotifyUpdateNoUpdateAvailable(t *testing.T) { server := newTestGitHubServer(t, "v1.0.0") defer server.Close() - var events []any - sink := output.SinkFunc(func(event any) { events = append(events, event) }) + var events []output.Event + sink := output.SinkFunc(func(event output.Event) { events = append(events, event) }) exit := notifyUpdateWithVersion(context.Background(), sink, NotifyOptions{UpdatePrompt: true}, "v1.0.0", testFetcher(server.URL)) assert.False(t, exit) @@ -96,8 +96,8 @@ func TestNotifyUpdatePromptDisabled(t *testing.T) { server := newTestGitHubServer(t, "v2.0.0") defer server.Close() - var events []any - sink := output.SinkFunc(func(event any) { events = append(events, event) }) + var events []output.Event + sink := output.SinkFunc(func(event output.Event) { events = append(events, event) }) exit := notifyUpdateWithVersion(context.Background(), sink, NotifyOptions{}, "1.0.0", testFetcher(server.URL)) assert.False(t, exit) @@ -113,8 +113,8 @@ func TestNotifyUpdatePromptSkip(t *testing.T) { defer server.Close() var skippedVersion string - var events []any - sink := output.SinkFunc(func(event any) { + var events []output.Event + sink := output.SinkFunc(func(event output.Event) { events = append(events, event) if req, ok := event.(output.UserInputRequestEvent); ok { req.ResponseCh <- output.InputResponse{SelectedKey: "s"} @@ -136,8 +136,8 @@ func TestNotifyUpdateSkippedVersionSuppressesPrompt(t *testing.T) { server := newTestGitHubServer(t, "v2.0.0") defer server.Close() - var events []any - sink := output.SinkFunc(func(event any) { events = append(events, event) }) + var events []output.Event + sink := output.SinkFunc(func(event output.Event) { events = append(events, event) }) exit := notifyUpdateWithVersion(context.Background(), sink, NotifyOptions{ UpdatePrompt: true, @@ -151,8 +151,8 @@ func TestNotifyUpdatePromptRemind(t *testing.T) { server := newTestGitHubServer(t, "v2.0.0") defer server.Close() - var events []any - sink := output.SinkFunc(func(event any) { + var events []output.Event + sink := output.SinkFunc(func(event output.Event) { events = append(events, event) if req, ok := event.(output.UserInputRequestEvent); ok { req.ResponseCh <- output.InputResponse{SelectedKey: "r"} @@ -167,8 +167,8 @@ func TestNotifyUpdatePromptCancelled(t *testing.T) { server := newTestGitHubServer(t, "v2.0.0") defer server.Close() - var events []any - sink := output.SinkFunc(func(event any) { + var events []output.Event + sink := output.SinkFunc(func(event output.Event) { events = append(events, event) if req, ok := event.(output.UserInputRequestEvent); ok { assert.Equal(t, "Update lstk to latest version?", req.Prompt) diff --git a/internal/update/update.go b/internal/update/update.go index f1b35fb9..c1a90080 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -16,23 +16,23 @@ import ( func Check(ctx context.Context, sink output.Sink, githubToken string) (string, bool, error) { current := version.Version() if current == "dev" { - output.EmitNote(sink, "Running a development build, skipping update check") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Running a development build, skipping update check"}) return "", false, nil } - output.EmitSpinnerStart(sink, "Checking for updates") + sink.Emit(output.SpinnerStart("Checking for updates")) latest, err := fetchLatestVersion(ctx, githubToken) - output.EmitSpinnerStop(sink) + sink.Emit(output.SpinnerStop()) if err != nil { return "", false, fmt.Errorf("failed to check for updates: %w", err) } if normalizeVersion(current) == normalizeVersion(latest) { - output.EmitNote(sink, fmt.Sprintf("Already up to date (%s)", current)) + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: fmt.Sprintf("Already up to date (%s)", current)}) return latest, false, nil } - output.EmitInfo(sink, fmt.Sprintf("Update available: %s → %s", current, latest)) + sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: fmt.Sprintf("Update available: %s → %s", current, latest)}) return latest, true, nil } @@ -50,7 +50,7 @@ func Update(ctx context.Context, sink output.Sink, checkOnly bool, githubToken s return err } - output.EmitSuccess(sink, fmt.Sprintf("Updated to %s", latest)) + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Updated to %s", latest)}) return nil } @@ -60,15 +60,15 @@ func applyUpdate(ctx context.Context, sink output.Sink, latest, githubToken stri var err error switch info.Method { case InstallHomebrew: - output.EmitNote(sink, "Installed through Homebrew, running brew upgrade") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Installed through Homebrew, running brew upgrade"}) err = updateHomebrew(ctx, sink) case InstallNPM: - output.EmitNote(sink, "Installed through npm, running npm install -g") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Installed through npm, running npm install -g"}) err = updateNPM(ctx, sink) default: - output.EmitSpinnerStart(sink, "Downloading update") + sink.Emit(output.SpinnerStart("Downloading update")) err = updateBinary(ctx, latest, githubToken) - output.EmitSpinnerStop(sink) + sink.Emit(output.SpinnerStop()) } if err != nil { return fmt.Errorf("update failed: %w", err) @@ -103,7 +103,7 @@ func (w *logLineWriter) Write(p []byte) (int, error) { line := string(w.buf[:i]) w.buf = w.buf[i+1:] if line != "" { - output.EmitLogLine(w.sink, w.source, line, output.LogLevelUnknown) + w.sink.Emit(output.LogLineEvent{Source: w.source, Line: line, Level: output.LogLevelUnknown}) } } return len(p), nil @@ -114,7 +114,7 @@ func (w *logLineWriter) Flush() { w.mu.Lock() defer w.mu.Unlock() if len(w.buf) > 0 { - output.EmitLogLine(w.sink, w.source, string(w.buf), output.LogLevelUnknown) + w.sink.Emit(output.LogLineEvent{Source: w.source, Line: string(w.buf), Level: output.LogLevelUnknown}) w.buf = nil } } diff --git a/internal/volume/clear.go b/internal/volume/clear.go index 82f9b154..626f251a 100644 --- a/internal/volume/clear.go +++ b/internal/volume/clear.go @@ -33,12 +33,12 @@ func Clear(ctx context.Context, sink output.Sink, containers []config.ContainerC } for _, t := range targets { - output.EmitInfo(sink, fmt.Sprintf("%s: %s (%s)", t.name, t.path, units.BytesSize(float64(t.size)))) + sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: fmt.Sprintf("%s: %s (%s)", t.name, t.path, units.BytesSize(float64(t.size)))}) } if !force { responseCh := make(chan output.InputResponse, 1) - output.EmitUserInputRequest(sink, output.UserInputRequestEvent{ + sink.Emit(output.UserInputRequestEvent{ Prompt: "Clear volume data? This cannot be undone", Options: []output.InputOption{ {Key: "y", Label: "Yes"}, @@ -50,7 +50,7 @@ func Clear(ctx context.Context, sink output.Sink, containers []config.ContainerC select { case resp := <-responseCh: if resp.Cancelled || resp.SelectedKey != "y" { - output.EmitNote(sink, "Cancelled") + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Cancelled"}) return nil } case <-ctx.Done(): @@ -64,7 +64,7 @@ func Clear(ctx context.Context, sink output.Sink, containers []config.ContainerC } } - output.EmitSuccess(sink, "Volume data cleared") + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Volume data cleared"}) return nil }