Skip to content

Commit 73aec8d

Browse files
inline sink emit calls
1 parent 3cc2388 commit 73aec8d

28 files changed

Lines changed: 187 additions & 277 deletions

.claude/skills/add-event/SKILL.md

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Add a new event type `$ARGUMENTS` to the output event system.
1313

1414
Read these files first — they are the source of truth:
1515

16-
- `internal/output/events.go` — all event types, the `Event` union constraint, and emit helpers
16+
- `internal/output/events.go` — all event types, the `Event` marker interface, and its `sealedEvent()` implementations
1717
- `internal/output/plain_format.go``FormatEventLine()` switch for plain text rendering
1818
- `internal/output/plain_format_test.go` — test cases for format parity
1919
- `internal/ui/app.go``Update()` method that handles events in the TUI
@@ -29,18 +29,14 @@ In `internal/output/events.go`:
2929
}
3030
```
3131

32-
2. Add the new type to the `Event` union constraint:
32+
2. Add the marker method so the type satisfies the `Event` interface and `Sink.Emit` accepts it:
3333
```go
34-
type Event interface {
35-
MessageEvent | AuthEvent | ... | <Name>Event
36-
}
34+
func (<Name>Event) sealedEvent() {}
3735
```
3836

39-
3. Add an emit helper function:
37+
Call sites emit directly on the sink — no helper needed:
4038
```go
41-
func Emit<Name>(sink Sink, ...) {
42-
Emit(sink, <Name>Event{...})
43-
}
39+
sink.Emit(output.<Name>Event{...})
4440
```
4541

4642
## Step 2: Add plain text formatting
@@ -80,4 +76,5 @@ If the event doesn't need special TUI handling, the `default` case in `Update()`
8076
- Do NOT put pre-rendered UI strings in event fields — use typed domain data
8177
- Do NOT add lipgloss/styling imports to `plain_format.go`
8278
- Do NOT skip the format test — every event type needs parity coverage
83-
- Do NOT forget to add the type to the `Event` union — it won't compile without it
79+
- Do NOT add a package-level emit helper — call sites use `sink.Emit(output.<Name>Event{...})` directly
80+
- Do NOT forget to add `func (<Name>Event) sealedEvent() {}` — without it `Sink.Emit` will reject the type at compile time

CLAUDE.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,25 +84,25 @@ Environment variables:
8484

8585
# Output Routing and Events
8686

87-
- Emit typed events through `internal/output` (`EmitInfo`, `EmitSuccess`, `EmitNote`, `EmitWarning`, `EmitStatus`, `EmitProgress`, etc.) instead of printing from domain/command handlers.
88-
- Keep `output.Sink` sealed (unexported `emit`); sink implementations belong in `internal/output`.
89-
- Reuse `FormatEventLine(event any)` for all line-oriented rendering so plain and TUI output stay consistent.
87+
- 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`).
88+
- Sink implementations belong in `internal/output`; do not implement `output.Sink` outside that package.
89+
- Reuse `FormatEventLine(event Event)` for all line-oriented rendering so plain and TUI output stay consistent.
9090
- Select output mode at the command boundary in `cmd/`: interactive TTY runs Bubble Tea, non-interactive mode uses `output.NewPlainSink(...)`.
9191
- Keep non-TTY mode non-interactive (no stdin prompts or input waits).
9292
- Domain packages must not import Bubble Tea or UI packages.
9393
- Any feature/workflow package that produces user-visible progress should accept an `output.Sink` dependency and emit events through `internal/output`.
9494
- Do not pass UI callbacks like `onProgress func(...)` through domain layers; prefer typed output events.
9595
- Event payloads should be domain facts (phase/status/progress), not pre-rendered UI strings.
9696
- When adding a new event type, update all of:
97-
- `internal/output/events.go` (event type + `Event` union constraint + emit helper)
97+
- `internal/output/events.go` (event struct definition)
9898
- `internal/output/plain_format.go` (line formatting fallback)
9999
- tests in `internal/output/*_test.go` for formatter/sink behavior parity
100100

101101
## User Input Handling
102102

103103
Domain code must never read from stdin or wait for user input directly. Instead:
104104

105-
1. Emit a `UserInputRequestEvent` via `output.EmitUserInputRequest()` with:
105+
1. Emit a `UserInputRequestEvent` via `sink.Emit(output.UserInputRequestEvent{...})` with:
106106
- `Prompt`: message to display
107107
- `Options`: available choices (e.g., `{Key: "enter", Label: "Press ENTER to continue"}`)
108108
- `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:
118118
Example flow in auth login:
119119
```go
120120
responseCh := make(chan output.InputResponse, 1)
121-
output.EmitUserInputRequest(sink, output.UserInputRequestEvent{
121+
sink.Emit(output.UserInputRequestEvent{
122122
Prompt: "Waiting for authentication...",
123123
Options: []output.InputOption{{Key: "enter", Label: "Press ENTER when complete"}},
124124
ResponseCh: responseCh,

cmd/aws.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Examples:
6666
return fmt.Errorf("checking emulator status: %w", err)
6767
}
6868
if !running {
69-
output.EmitError(sink, output.ErrorEvent{
69+
sink.Emit(output.ErrorEvent{
7070
Title: fmt.Sprintf("%s is not running", awsContainer.DisplayName()),
7171
Actions: []output.ErrorAction{
7272
{Label: "Start LocalStack:", Value: "lstk"},
@@ -80,7 +80,7 @@ Examples:
8080

8181
profileExists, _ := awsconfig.ProfileExists()
8282
if !profileExists {
83-
output.EmitNote(sink, "No AWS profile found, run 'lstk setup aws'")
83+
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "No AWS profile found, run 'lstk setup aws'"})
8484
}
8585

8686
stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr)

cmd/logout.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func newLogoutCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra
5454

5555
if rt != nil {
5656
if running, err := container.AnyRunning(cmd.Context(), rt, appConfig.Containers); err == nil && running {
57-
output.EmitNote(sink, "LocalStack is still running in the background")
57+
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack is still running in the background"})
5858
}
5959
}
6060
return nil

internal/auth/auth.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,46 +51,46 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) {
5151
if errors.Is(err, context.Canceled) {
5252
return "", err
5353
}
54-
output.EmitWarning(a.sink, "Authentication failed.")
54+
a.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: "Authentication failed."})
5555
return "", err
5656
}
5757

5858
if err := a.tokenStorage.SetAuthToken(token); err != nil {
59-
output.EmitWarning(a.sink, fmt.Sprintf("could not store token in keyring: %v", err))
59+
a.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not store token in keyring: %v", err)})
6060
}
6161

62-
output.EmitSuccess(a.sink, "Login successful.")
62+
a.sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Login successful."})
6363
return token, nil
6464
}
6565

6666
// Logout removes the stored auth token from the keyring
6767
func (a *Auth) Logout() error {
68-
output.EmitSpinnerStart(a.sink, "Logging out...")
68+
a.sink.Emit(output.SpinnerStart("Logging out..."))
6969

7070
_, err := a.tokenStorage.GetAuthToken()
7171
if err != nil {
72-
output.EmitSpinnerStop(a.sink)
72+
a.sink.Emit(output.SpinnerStop())
7373
if a.authToken != "" {
74-
output.EmitNote(a.sink, "Authenticated via LOCALSTACK_AUTH_TOKEN environment variable; unset it to log out")
74+
a.sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Authenticated via LOCALSTACK_AUTH_TOKEN environment variable; unset it to log out"})
7575
return nil
7676
}
7777
if !errors.Is(err, ErrTokenNotFound) {
7878
return fmt.Errorf("failed to read auth token: %w", err)
7979
}
80-
output.EmitNote(a.sink, "Not currently logged in")
80+
a.sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Not currently logged in"})
8181
return ErrNotLoggedIn
8282
}
8383

8484
if err := a.tokenStorage.DeleteAuthToken(); err != nil {
85-
output.EmitSpinnerStop(a.sink)
85+
a.sink.Emit(output.SpinnerStop())
8686
return fmt.Errorf("failed to delete auth token: %w", err)
8787
}
8888

8989
if a.licenseFilePath != "" {
9090
_ = os.Remove(a.licenseFilePath)
9191
}
9292

93-
output.EmitSpinnerStop(a.sink)
94-
output.EmitSuccess(a.sink, "Logged out successfully")
93+
a.sink.Emit(output.SpinnerStop())
94+
a.sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Logged out successfully"})
9595
return nil
9696
}

internal/auth/auth_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) {
2424
mockStorage := NewMockAuthTokenStorage(ctrl)
2525
mockLogin := NewMockLoginProvider(ctrl)
2626

27-
var events []any
28-
sink := output.SinkFunc(func(event any) {
27+
var events []output.Event
28+
sink := output.SinkFunc(func(event output.Event) {
2929
events = append(events, event)
3030
})
3131

internal/auth/login.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,35 +40,35 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) {
4040

4141
authURL := buildAuthURL(l.webAppURL, authReq.ID, authReq.Code)
4242

43-
output.EmitAuth(l.sink, output.AuthEvent{
43+
l.sink.Emit(output.AuthEvent{
4444
Preamble: "Welcome to lstk, a command-line interface for LocalStack",
4545
Code: authReq.Code,
4646
URL: authURL,
4747
})
4848
browser.Stdout = io.Discard
4949
browser.Stderr = io.Discard
5050
if err := browser.OpenURL(authURL); err != nil {
51-
output.EmitWarning(l.sink, fmt.Sprintf("Failed to open browser automatically. Open this URL manually to continue: %s", authURL))
51+
l.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Failed to open browser automatically. Open this URL manually to continue: %s", authURL)})
5252
}
5353

54-
output.EmitSpinnerStart(l.sink, "Waiting for authorization...")
54+
l.sink.Emit(output.SpinnerStart("Waiting for authorization..."))
5555

5656
responseCh := make(chan output.InputResponse, 1)
57-
output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{
57+
l.sink.Emit(output.UserInputRequestEvent{
5858
Prompt: "Waiting for authorization...",
5959
Options: []output.InputOption{{Key: "any", Label: "Press any key when complete"}},
6060
ResponseCh: responseCh,
6161
})
6262

6363
select {
6464
case resp := <-responseCh:
65-
output.EmitSpinnerStop(l.sink)
65+
l.sink.Emit(output.SpinnerStop())
6666
if resp.Cancelled {
6767
return "", context.Canceled
6868
}
6969
return l.completeAuth(ctx, authReq)
7070
case <-ctx.Done():
71-
output.EmitSpinnerStop(l.sink)
71+
l.sink.Emit(output.SpinnerStop())
7272
return "", ctx.Err()
7373
}
7474
}
@@ -85,22 +85,22 @@ func buildAuthURL(webAppURL, authRequestID, code string) string {
8585
}
8686

8787
func (l *loginProvider) completeAuth(ctx context.Context, authReq *api.AuthRequest) (string, error) {
88-
output.EmitInfo(l.sink, "Checking if auth request is confirmed...")
88+
l.sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "Checking if auth request is confirmed..."})
8989
confirmed, err := l.platformClient.CheckAuthRequestConfirmed(ctx, authReq.ID, authReq.ExchangeToken)
9090
if err != nil {
9191
return "", fmt.Errorf("failed to check auth request: %w", err)
9292
}
9393
if !confirmed {
9494
return "", fmt.Errorf("auth request not confirmed - please complete the authentication in your browser")
9595
}
96-
output.EmitInfo(l.sink, "Auth request confirmed, exchanging for token...")
96+
l.sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "Auth request confirmed, exchanging for token..."})
9797

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

103-
output.EmitInfo(l.sink, "Fetching license token...")
103+
l.sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "Fetching license token..."})
104104
licenseToken, err := l.platformClient.GetLicenseToken(ctx, bearerToken)
105105
if err != nil {
106106
return "", fmt.Errorf("failed to get license token: %w", err)

internal/awsconfig/awsconfig.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ func writeCredsProfile(credsPath string) error {
189189
}
190190

191191
func emitMissingProfileNote(sink output.Sink) {
192-
output.EmitNote(sink, "LocalStack AWS profile is incomplete. Run 'lstk setup aws'.")
192+
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack AWS profile is incomplete. Run 'lstk setup aws'."})
193193
}
194194

195195
// 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) {
223223
func EnsureProfile(ctx context.Context, sink output.Sink, interactive bool, resolvedHost string) error {
224224
status, configOK, credsOK, err := checkProfileSetup(resolvedHost)
225225
if err != nil {
226-
output.EmitWarning(sink, fmt.Sprintf("could not check AWS profile: %v", err))
226+
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not check AWS profile: %v", err)})
227227
return nil
228228
}
229229
if !status.anyNeeded() {
@@ -242,18 +242,18 @@ func EnsureProfile(ctx context.Context, sink output.Sink, interactive bool, reso
242242
// status is passed in from EnsureProfile to avoid re-checking the profile status.
243243
func Setup(ctx context.Context, sink output.Sink, resolvedHost string, status profileStatus) error {
244244
if !status.anyNeeded() {
245-
output.EmitNote(sink, "LocalStack AWS profile is already configured.")
245+
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack AWS profile is already configured."})
246246
return nil
247247
}
248248

249249
configPath, credsPath, err := awsPaths()
250250
if err != nil {
251-
output.EmitWarning(sink, fmt.Sprintf("could not determine AWS config paths: %v", err))
251+
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not determine AWS config paths: %v", err)})
252252
return nil
253253
}
254254

255255
responseCh := make(chan output.InputResponse, 1)
256-
output.EmitUserInputRequest(sink, output.UserInputRequestEvent{
256+
sink.Emit(output.UserInputRequestEvent{
257257
Prompt: "Set up a LocalStack profile for AWS CLI and SDKs in ~/.aws?",
258258
Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}},
259259
ResponseCh: responseCh,
@@ -265,27 +265,27 @@ func Setup(ctx context.Context, sink output.Sink, resolvedHost string, status pr
265265
return nil
266266
}
267267
if resp.SelectedKey == "n" {
268-
output.EmitNote(sink, "Skipped adding LocalStack AWS profile.")
268+
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Skipped adding LocalStack AWS profile."})
269269
return nil
270270
}
271271
if status.configNeeded {
272272
if err := writeConfigProfile(configPath, resolvedHost); err != nil {
273-
output.EmitWarning(sink, fmt.Sprintf("could not update ~/.aws/config: %v", err))
273+
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not update ~/.aws/config: %v", err)})
274274
return nil
275275
}
276276
}
277277
if status.credsNeeded {
278278
if err := writeCredsProfile(credsPath); err != nil {
279-
output.EmitWarning(sink, fmt.Sprintf("could not update ~/.aws/credentials: %v", err))
279+
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not update ~/.aws/credentials: %v", err)})
280280
return nil
281281
}
282282
}
283283
if status.configNeeded && status.credsNeeded {
284-
output.EmitSuccess(sink, "Created LocalStack profile in ~/.aws")
284+
sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Created LocalStack profile in ~/.aws"})
285285
} else if status.configNeeded {
286-
output.EmitSuccess(sink, "Created LocalStack profile in ~/.aws/config")
286+
sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Created LocalStack profile in ~/.aws/config"})
287287
} else {
288-
output.EmitSuccess(sink, "Updated LocalStack credentials in ~/.aws/credentials")
288+
sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Updated LocalStack credentials in ~/.aws/credentials"})
289289
}
290290
case <-ctx.Done():
291291
return ctx.Err()

internal/container/logs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers
3939
continue
4040
}
4141
level, _ := parseLogLine(line)
42-
output.EmitLogLine(sink, output.LogSourceEmulator, line, level)
42+
sink.Emit(output.LogLineEvent{Source: output.LogSourceEmulator, Line: line, Level: level})
4343
}
4444
if err := scanner.Err(); err != nil && ctx.Err() == nil {
4545
return err

0 commit comments

Comments
 (0)