diff --git a/internal/container/start.go b/internal/container/start.go index da0c5b3..65afa3e 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -211,17 +211,29 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu continue } if err := ports.CheckAvailable(c.Port); err != nil { - configPath, pathErr := config.ConfigFilePath() - if pathErr != nil { - return nil, err - } - return nil, fmt.Errorf("%w\nTo use a different port, edit %s", err, configPath) + emitPortInUseError(sink, c.Port) + return nil, output.NewSilentError(err) } filtered = append(filtered, c) } return filtered, nil } +func emitPortInUseError(sink output.Sink, port string) { + actions := []output.ErrorAction{ + {Label: "Stop existing emulator:", Value: "lstk stop"}, + } + configPath, pathErr := config.ConfigFilePath() + if pathErr == nil { + actions = append(actions, output.ErrorAction{Label: "Use another port in the configuration:", Value: configPath}) + } + output.EmitError(sink, output.ErrorEvent{ + Title: fmt.Sprintf("Port %s already in use", port), + Summary: "LocalStack may already be running.", + Actions: actions, + }) +} + func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig, token string) error { version := containerConfig.Tag if version == "" || version == "latest" { diff --git a/internal/output/events.go b/internal/output/events.go index 11c58d8..c9e0531 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -37,6 +37,8 @@ type SpinnerEvent struct { MinDuration time.Duration // Minimum time spinner should display (0 = use default) } +const ErrorActionPrefix = "==> " + type ErrorAction struct { Label string Value string diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 0647d4f..6276cdb 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -132,7 +132,7 @@ func formatErrorEvent(e ErrorEvent) string { sb.WriteString(e.Detail) } for _, action := range e.Actions { - sb.WriteString("\n → ") + sb.WriteString("\n " + ErrorActionPrefix) sb.WriteString(action.Label) sb.WriteString(" ") sb.WriteString(action.Value) diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index bb8f1e5..200d812 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -104,7 +104,7 @@ func TestFormatEventLine(t *testing.T) { {Label: "Start Docker:", Value: "open -a Docker"}, }, }, - want: "Error: Docker not running\n Cannot connect to Docker daemon\n → Start Docker: open -a Docker", + want: "Error: Docker not running\n Cannot connect to Docker daemon\n ==> Start Docker: open -a Docker", wantOK: true, }, { diff --git a/internal/output/plain_sink_test.go b/internal/output/plain_sink_test.go index 796c28d..2fadcad 100644 --- a/internal/output/plain_sink_test.go +++ b/internal/output/plain_sink_test.go @@ -144,7 +144,7 @@ func TestPlainSink_EmitsErrorEvent(t *testing.T) { Actions: []ErrorAction{{Label: "Start Docker:", Value: "open -a Docker"}}, }) - expected := "Error: Connection failed\n Cannot connect to Docker\n → Start Docker: open -a Docker\n" + expected := "Error: Connection failed\n Cannot connect to Docker\n ==> Start Docker: open -a Docker\n" assert.Equal(t, expected, out.String()) } diff --git a/internal/ui/components/error_display.go b/internal/ui/components/error_display.go index 390eed4..a1d6763 100644 --- a/internal/ui/components/error_display.go +++ b/internal/ui/components/error_display.go @@ -62,9 +62,9 @@ func (e ErrorDisplay) View(maxWidth int) string { sb.WriteString("\n") for i, action := range e.event.Actions { if i > 0 { - sb.WriteString(styles.SecondaryMessage.Render("⇒ " + action.Label + " " + action.Value)) + sb.WriteString(styles.SecondaryMessage.Render(output.ErrorActionPrefix + action.Label + " " + action.Value)) } else { - sb.WriteString(styles.ErrorAction.Render("⇒ " + action.Label + " ")) + sb.WriteString(styles.ErrorAction.Render(output.ErrorActionPrefix + action.Label + " ")) sb.WriteString(styles.Message.Bold(true).Render(action.Value)) } sb.WriteString("\n") diff --git a/internal/ui/components/error_display_test.go b/internal/ui/components/error_display_test.go index 733f780..7017e84 100644 --- a/internal/ui/components/error_display_test.go +++ b/internal/ui/components/error_display_test.go @@ -50,6 +50,40 @@ func TestErrorDisplay_ShowView(t *testing.T) { } } +func TestErrorDisplay_MultiActionRenders(t *testing.T) { + t.Parallel() + + e := NewErrorDisplay() + e = e.Show(output.ErrorEvent{ + Title: "Port 4566 already in use", + Summary: "LocalStack may already be running.", + Actions: []output.ErrorAction{ + {Label: "Stop existing emulator:", Value: "lstk stop"}, + {Label: "Use another port in the configuration:", Value: "/home/user/.config/lstk/config.toml"}, + }, + }) + + view := e.View(80) + if !strings.Contains(view, "Port 4566 already in use") { + t.Fatalf("expected view to contain title, got: %q", view) + } + if !strings.Contains(view, "LocalStack may already be running.") { + t.Fatalf("expected view to contain summary, got: %q", view) + } + if !strings.Contains(view, "Stop existing emulator:") { + t.Fatalf("expected view to contain first action label, got: %q", view) + } + if !strings.Contains(view, "lstk stop") { + t.Fatalf("expected view to contain first action value, got: %q", view) + } + if !strings.Contains(view, "Use another port in the configuration:") { + t.Fatalf("expected view to contain second action label, got: %q", view) + } + if !strings.Contains(view, "/home/user/.config/lstk/config.toml") { + t.Fatalf("expected view to contain second action value, got: %q", view) + } +} + func TestErrorDisplay_MinimalEvent(t *testing.T) { t.Parallel() diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 4ba675e..2bf88f6 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -88,9 +88,11 @@ func TestStartCommandFailsWhenPortInUse(t *testing.T) { require.NoError(t, err, "failed to bind port 4566 for test") defer func() { _ = ln.Close() }() - _, stderr, err := runLstk(t, testContext(t), "", env.With(env.AuthToken, "fake-token"), "start") + stdout, _, err := runLstk(t, testContext(t), "", env.With(env.AuthToken, "fake-token"), "start") require.Error(t, err, "expected lstk start to fail when port is in use") - assert.Contains(t, stderr, "port 4566 already in use") + assert.Contains(t, stdout, "Port 4566 already in use") + assert.Contains(t, stdout, "LocalStack may already be running.") + assert.Contains(t, stdout, "lstk stop") } func cleanup() {