From 396f2e080221c4829c0e6d81eb56dbebd0c364ba Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Tue, 21 Apr 2026 19:16:08 +0300 Subject: [PATCH 1/2] Unify Docker error handling across all commands --- cmd/logs.go | 3 ++ cmd/root.go | 27 +++++++++- cmd/start.go | 2 +- cmd/status.go | 4 +- cmd/stop.go | 3 ++ test/integration/docker_unhealthy_test.go | 64 +++++++++++++++++++++++ 6 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 test/integration/docker_unhealthy_test.go diff --git a/cmd/logs.go b/cmd/logs.go index cd39493d..41324ede 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -31,6 +31,9 @@ func newLogsCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { } rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { + return dockerNotAvailableError(err) + } + if err := checkRuntimeHealth(cmd.Context(), rt); err != nil { return err } appConfig, err := config.Get() diff --git a/cmd/root.go b/cmd/root.go index bf90c017..b8586834 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -32,7 +32,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { - return err + return dockerNotAvailableError(err) } return runStart(cmd.Context(), cmd.Flags(), rt, cfg, tel, logger) }, @@ -208,6 +208,31 @@ func isInteractiveMode(cfg *env.Env) bool { return !cfg.NonInteractive && ui.IsInteractive() } +// dockerNotAvailableError emits a styled Docker-not-available error and +// returns a SilentError to suppress duplicate error printing. +func dockerNotAvailableError(err error) error { + output.EmitError(output.NewColorSink(os.Stdout), output.ErrorEvent{ + Title: "Docker is not available", + Summary: err.Error(), + Actions: []output.ErrorAction{ + {Label: "See help:", Value: "lstk -h"}, + {Label: "Install Docker:", Value: "https://docs.docker.com/get-docker/"}, + }, + }) + return output.NewSilentError(err) +} + +// checkRuntimeHealth checks if the runtime is healthy and emits an error +// through the sink if not in interactive mode. Returns a SilentError to +// suppress duplicate error printing. +func checkRuntimeHealth(ctx context.Context, rt runtime.Runtime) error { + if err := rt.IsHealthy(ctx); err != nil { + rt.EmitUnhealthyError(output.NewColorSink(os.Stdout), err) + return output.NewSilentError(err) + } + return nil +} + const maxLogSize = 1 << 20 // 1 MB func newLogger() (log.Logger, func(), error) { diff --git a/cmd/start.go b/cmd/start.go index b82a1bb3..e0a0b32f 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -17,7 +17,7 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { - return err + return dockerNotAvailableError(err) } return runStart(cmd.Context(), cmd.Flags(), rt, cfg, tel, logger) }, diff --git a/cmd/status.go b/cmd/status.go index a89a5904..b4cbceab 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -25,9 +25,11 @@ func newStatusCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { RunE: commandWithTelemetry("status", tel, func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { + return dockerNotAvailableError(err) + } + if err := checkRuntimeHealth(cmd.Context(), rt); err != nil { return err } - appCfg, err := config.Get() if err != nil { return fmt.Errorf("failed to get config: %w", err) diff --git a/cmd/stop.go b/cmd/stop.go index 348a1e85..5a9cf477 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -26,6 +26,9 @@ func newStopCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { startTime := time.Now() rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { + return dockerNotAvailableError(err) + } + if err := checkRuntimeHealth(cmd.Context(), rt); err != nil { return err } appConfig, err := config.Get() diff --git a/test/integration/docker_unhealthy_test.go b/test/integration/docker_unhealthy_test.go new file mode 100644 index 00000000..0df3be6b --- /dev/null +++ b/test/integration/docker_unhealthy_test.go @@ -0,0 +1,64 @@ +package integration_test + +import ( + "runtime" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// unhealthyDockerEnv returns an environment with DOCKER_HOST pointing to a +// non-existent Unix socket so that the Docker health check fails. +func unhealthyDockerEnv() env.Environ { + return env.With(env.Key("DOCKER_HOST"), "unix:///var/run/docker-does-not-exist.sock") +} + +func TestStartShowsDockerErrorWhenUnhealthy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix socket test") + } + + stdout, _, err := runLstk(t, testContext(t), "", unhealthyDockerEnv(), "start") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "Docker is not available") + assert.Contains(t, stdout, "Install Docker:") +} + +func TestStopShowsDockerErrorWhenUnhealthy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix socket test") + } + + stdout, _, err := runLstk(t, testContext(t), "", unhealthyDockerEnv(), "stop") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "Docker is not available") + assert.Contains(t, stdout, "Install Docker:") +} + +func TestStatusShowsDockerErrorWhenUnhealthy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix socket test") + } + + stdout, _, err := runLstk(t, testContext(t), "", unhealthyDockerEnv(), "status") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "Docker is not available") + assert.Contains(t, stdout, "Install Docker:") +} + +func TestLogsShowsDockerErrorWhenUnhealthy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix socket test") + } + + stdout, _, err := runLstk(t, testContext(t), "", unhealthyDockerEnv(), "logs") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "Docker is not available") + assert.Contains(t, stdout, "Install Docker:") +} From 45f92f07758300a421fffe9c7652475bf4018d51 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Tue, 21 Apr 2026 19:16:14 +0300 Subject: [PATCH 2/2] Add color sink for TTY-aware error rendering --- internal/output/color_format.go | 51 +++++++++++++++++++++ internal/output/color_sink.go | 65 +++++++++++++++++++++++++++ internal/output/color_sink_test.go | 71 ++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 internal/output/color_format.go create mode 100644 internal/output/color_sink.go create mode 100644 internal/output/color_sink_test.go diff --git a/internal/output/color_format.go b/internal/output/color_format.go new file mode 100644 index 00000000..4b40869d --- /dev/null +++ b/internal/output/color_format.go @@ -0,0 +1,51 @@ +package output + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ( + colorErrorTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#C33820")) + colorErrorSecondary = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + colorErrorDetail = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + colorErrorAction = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) + colorErrorMuted = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) +) + +// FormatColorEventLine is like FormatEventLine but renders ErrorEvent with ANSI color. +// All other event types delegate to FormatEventLine. +func FormatColorEventLine(event any) (string, bool) { + if e, ok := event.(ErrorEvent); ok { + return formatColorErrorEvent(e), true + } + return FormatEventLine(event) +} + +func formatColorErrorEvent(e ErrorEvent) string { + var sb strings.Builder + sb.WriteString(colorErrorTitle.Render("✗ " + e.Title)) + if e.Summary != "" { + sb.WriteString("\n") + sb.WriteString(colorErrorSecondary.Render("> ")) + sb.WriteString(e.Summary) + } + if e.Detail != "" { + sb.WriteString("\n ") + sb.WriteString(colorErrorDetail.Render(e.Detail)) + } + if len(e.Actions) > 0 { + sb.WriteString("\n") + for i, action := range e.Actions { + sb.WriteString("\n") + if i > 0 { + sb.WriteString(colorErrorMuted.Render(ErrorActionPrefix + action.Label + " " + action.Value)) + } else { + sb.WriteString(colorErrorAction.Render(ErrorActionPrefix+action.Label+" ")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Render(action.Value)) + } + } + } + return sb.String() +} diff --git a/internal/output/color_sink.go b/internal/output/color_sink.go new file mode 100644 index 00000000..d174335d --- /dev/null +++ b/internal/output/color_sink.go @@ -0,0 +1,65 @@ +package output + +import ( + "fmt" + "io" + "os" + + "golang.org/x/term" +) + +// ColorSink emits events like PlainSink but renders ErrorEvent with ANSI color +// when the output is a TTY and NO_COLOR is not set. +type ColorSink struct { + out io.Writer + colored bool + err error +} + +// NewColorSink returns a ColorSink. Color is enabled only when out is a real +// terminal and the NO_COLOR environment variable is unset. +func NewColorSink(out io.Writer) *ColorSink { + if out == nil { + out = os.Stdout + } + return &ColorSink{out: out, colored: isColorTerminal(out)} +} + +func (s *ColorSink) Err() error { + return s.err +} + +func (s *ColorSink) setErr(err error) { + if s.err == nil && err != nil { + s.err = err + } +} + +func (s *ColorSink) emit(event any) { + var ( + line string + ok bool + ) + if s.colored { + line, ok = FormatColorEventLine(event) + } else { + line, ok = FormatEventLine(event) + } + if !ok { + return + } + _, err := fmt.Fprintln(s.out, line) + s.setErr(err) +} + +// isColorTerminal reports whether w is a TTY and NO_COLOR is unset. +func isColorTerminal(w io.Writer) bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + f, ok := w.(*os.File) + if !ok { + return false + } + return term.IsTerminal(int(f.Fd())) +} diff --git a/internal/output/color_sink_test.go b/internal/output/color_sink_test.go new file mode 100644 index 00000000..ae73b0ae --- /dev/null +++ b/internal/output/color_sink_test.go @@ -0,0 +1,71 @@ +package output + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestColorSink_NoColor_FallsBackToPlain(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + var out bytes.Buffer + sink := NewColorSink(&out) + assert.False(t, sink.colored) + + Emit(sink, ErrorEvent{ + Title: "Connection failed", + Summary: "Cannot connect to Docker", + 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" + assert.Equal(t, expected, out.String()) +} + +func TestColorSink_NonTTY_FallsBackToPlain(t *testing.T) { + // bytes.Buffer is not an *os.File, so isColorTerminal returns false. + var out bytes.Buffer + sink := NewColorSink(&out) + assert.False(t, sink.colored) + + Emit(sink, ErrorEvent{Title: "Something broke"}) + assert.Equal(t, "Error: Something broke\n", out.String()) +} + +func TestColorSink_ColoredMode_RendersErrorWithMarkers(t *testing.T) { + // Force colored mode directly to test rendering without a real TTY. + var out bytes.Buffer + sink := &ColorSink{out: &out, colored: true} + + Emit(sink, ErrorEvent{ + Title: "Docker is not available", + Summary: "cannot connect to daemon", + Actions: []ErrorAction{ + {Label: "Start Docker:", Value: "open -a Docker"}, + {Label: "Install Docker:", Value: "https://docs.docker.com/get-docker/"}, + }, + }) + + got := out.String() + assert.Contains(t, got, "Docker is not available") + assert.Contains(t, got, "cannot connect to daemon") + assert.Contains(t, got, "Start Docker:") + assert.Contains(t, got, "open -a Docker") + assert.Contains(t, got, "Install Docker:") +} + +func TestColorSink_NonErrorEvents_DelegateToPlain(t *testing.T) { + var out bytes.Buffer + sink := &ColorSink{out: &out, colored: true} + + EmitInfo(sink, "hello world") + assert.Equal(t, "hello world\n", out.String()) +} + +func TestIsColorTerminal_NoColorEnv(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var out bytes.Buffer + assert.False(t, isColorTerminal(&out)) +}