From 191ca3e5a3380528b7ce6441ece143678671aeab Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Wed, 11 Mar 2026 15:52:54 +0100 Subject: [PATCH 1/2] status command --- CLAUDE.md | 1 + cmd/root.go | 1 + cmd/status.go | 39 +++++++ internal/auth/mock_token_storage.go | 4 +- internal/container/status.go | 94 ++++++++++++++++ internal/container/status_test.go | 135 +++++++++++++++++++++++ internal/emulator/aws/aws.go | 21 ++++ internal/emulator/aws/client.go | 143 +++++++++++++++++++++++++ internal/emulator/aws/client_test.go | 124 +++++++++++++++++++++ internal/output/events.go | 26 ++++- internal/output/plain_format.go | 127 +++++++++++++++++++--- internal/output/plain_format_test.go | 105 +++++++++++++++++- internal/output/terminal.go | 31 ++++++ internal/runtime/docker.go | 13 +++ internal/runtime/mock_runtime.go | 16 +++ internal/runtime/runtime.go | 2 + internal/ui/app.go | 4 +- internal/ui/components/input_prompt.go | 16 ++- internal/ui/run_status.go | 48 +++++++++ test/integration/env/env.go | 1 + test/integration/status_test.go | 93 ++++++++++++++++ 21 files changed, 1026 insertions(+), 18 deletions(-) create mode 100644 cmd/status.go create mode 100644 internal/container/status.go create mode 100644 internal/container/status_test.go create mode 100644 internal/emulator/aws/aws.go create mode 100644 internal/emulator/aws/client.go create mode 100644 internal/emulator/aws/client_test.go create mode 100644 internal/output/terminal.go create mode 100644 internal/ui/run_status.go create mode 100644 test/integration/status_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 7edc260..927b407 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,7 @@ Environment variables: - Errors returned by functions should always be checked unless in test files. - Terminology: in user-facing CLI/help/docs, prefer `emulator` over `container`/`runtime`; use `container`/`runtime` only for internal implementation details. - Avoid package-level global variables. Use constructor functions that return fresh instances and inject dependencies explicitly. This keeps packages testable in isolation and prevents shared mutable state between tests. +- Do not call `config.Get()` from domain/business-logic packages. Instead, extract the values you need at the command boundary (`cmd/`) and pass them as explicit function arguments. This keeps domain functions testable without requiring Viper/config initialization. # Testing diff --git a/cmd/root.go b/cmd/root.go index 5a104f0..c879b00 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -48,6 +48,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { root.AddCommand( newStartCmd(cfg, tel), newStopCmd(cfg), + newStatusCmd(cfg), newLoginCmd(cfg), newLogoutCmd(cfg), newLogsCmd(), diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..ccc9069 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/ui" + "github.com/spf13/cobra" +) + +func newStatusCmd(cfg *env.Env) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show emulator status and deployed resources", + Long: "Show the status of a running emulator and its deployed resources", + PreRunE: initConfig, + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := runtime.NewDockerRuntime() + if err != nil { + return err + } + + appCfg, err := config.Get() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + if isInteractiveMode(cfg) { + return ui.RunStatus(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost) + } + return container.Status(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, output.NewPlainSink(os.Stdout)) + }, + } +} diff --git a/internal/auth/mock_token_storage.go b/internal/auth/mock_token_storage.go index 6653a5a..1f58e09 100644 --- a/internal/auth/mock_token_storage.go +++ b/internal/auth/mock_token_storage.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: internal/auth/token_storage.go +// Source: token_storage.go // // Generated by this command: // -// mockgen -source=internal/auth/token_storage.go -destination=internal/auth/mock_token_storage.go -package=auth +// mockgen -source=token_storage.go -destination=mock_token_storage.go -package=auth // // Package auth is a generated GoMock package. diff --git a/internal/container/status.go b/internal/container/status.go new file mode 100644 index 0000000..6482d46 --- /dev/null +++ b/internal/container/status.go @@ -0,0 +1,94 @@ +package container + +import ( + "context" + "fmt" + "time" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/emulator/aws" + "github.com/localstack/lstk/internal/endpoint" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" +) + +const statusTimeout = 10 * time.Second + +func Status(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, localStackHost string, sink output.Sink) error { + ctx, cancel := context.WithTimeout(ctx, statusTimeout) + defer cancel() + + for _, c := range containers { + name := c.Name() + running, err := rt.IsRunning(ctx, name) + if err != nil { + return fmt.Errorf("checking %s running: %w", name, err) + } + if !running { + output.EmitError(sink, output.ErrorEvent{ + Title: fmt.Sprintf("%s is not running", c.DisplayName()), + Actions: []output.ErrorAction{ + {Label: "Start LocalStack:", Value: "lstk"}, + {Label: "See help:", Value: "lstk -h"}, + }, + }) + return output.NewSilentError(fmt.Errorf("%s is not running", name)) + } + + host, _ := endpoint.ResolveHost(c.Port, localStackHost) + + var uptime time.Duration + if startedAt, err := rt.ContainerStartedAt(ctx, name); err == nil { + uptime = time.Since(startedAt) + } + + var version string + switch c.Type { + case config.EmulatorAWS: + emulatorClient := aws.NewClient(nil) + if v, err := emulatorClient.FetchVersion(ctx, host); err != nil { + output.EmitWarning(sink, fmt.Sprintf("Could not fetch version: %v", err)) + } else { + version = v + } + + output.Emit(sink, output.InstanceInfoEvent{ + EmulatorName: c.DisplayName(), + Version: version, + Host: host, + ContainerName: name, + Uptime: uptime, + }) + + rows, err := emulatorClient.FetchResources(ctx, host) + if err != nil { + return err + } + + if len(rows) == 0 { + output.EmitNote(sink, "No resources deployed") + continue + } + + services := map[string]struct{}{} + for _, r := range rows { + services[r.Service] = struct{}{} + } + output.Emit(sink, output.ResourceSummaryEvent{ + ResourceCount: len(rows), + ServiceCount: len(services), + }) + output.Emit(sink, output.ResourceTableEvent{Rows: rows}) + default: + output.Emit(sink, output.InstanceInfoEvent{ + EmulatorName: c.DisplayName(), + Version: version, + Host: host, + ContainerName: name, + Uptime: uptime, + }) + } + } + + return nil +} diff --git a/internal/container/status_test.go b/internal/container/status_test.go new file mode 100644 index 0000000..de84d70 --- /dev/null +++ b/internal/container/status_test.go @@ -0,0 +1,135 @@ +package container + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestStatus_NotRunning(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil) + + containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} + sink := output.NewPlainSink(io.Discard) + + err := Status(context.Background(), mockRT, containers, "", sink) + + require.Error(t, err) + assert.True(t, output.IsSilent(err)) + assert.Contains(t, err.Error(), "is not running") +} + +func TestStatus_IsRunningError(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, fmt.Errorf("docker unavailable")) + + containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} + sink := output.NewPlainSink(io.Discard) + + err := Status(context.Background(), mockRT, containers, "", sink) + + require.Error(t, err) + assert.Contains(t, err.Error(), "docker unavailable") +} + +func TestStatus_RunningWithResources(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_localstack/health": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"version": "4.14.1"}`) + case "/_localstack/resources": + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = fmt.Fprintln(w, `{"AWS::S3::Bucket": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-bucket"}]}`) + _, _ = fmt.Fprintln(w, `{"AWS::Lambda::Function": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-func"}]}`) + } + })) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "http://") + + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) + mockRT.EXPECT().ContainerStartedAt(gomock.Any(), "localstack-aws").Return(time.Now().Add(-5*time.Minute), nil) + + containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} + var buf strings.Builder + sink := output.NewPlainSink(&buf) + + err := Status(context.Background(), mockRT, containers, host, sink) + + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "is running") + assert.Contains(t, out, "4.14.1") + assert.Contains(t, out, "S3") + assert.Contains(t, out, "my-bucket") + assert.Contains(t, out, "Lambda") + assert.Contains(t, out, "my-func") + assert.Contains(t, out, "2 resources") + assert.Contains(t, out, "2 services") +} + +func TestStatus_RunningNoResources(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_localstack/health": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"version": "4.14.1"}`) + case "/_localstack/resources": + w.Header().Set("Content-Type", "application/x-ndjson") + } + })) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "http://") + + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) + mockRT.EXPECT().ContainerStartedAt(gomock.Any(), "localstack-aws").Return(time.Time{}, fmt.Errorf("not found")) + + containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} + var buf strings.Builder + sink := output.NewPlainSink(&buf) + + err := Status(context.Background(), mockRT, containers, host, sink) + + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "is running") + assert.Contains(t, out, "No resources deployed") +} + +func TestStatus_MultipleContainers_StopsAtFirstNotRunning(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil) + + containers := []config.ContainerConfig{ + {Type: config.EmulatorAWS}, + {Type: config.EmulatorSnowflake}, + } + sink := output.NewPlainSink(io.Discard) + + err := Status(context.Background(), mockRT, containers, "", sink) + + require.Error(t, err) + assert.True(t, output.IsSilent(err)) +} diff --git a/internal/emulator/aws/aws.go b/internal/emulator/aws/aws.go new file mode 100644 index 0000000..56e1e8d --- /dev/null +++ b/internal/emulator/aws/aws.go @@ -0,0 +1,21 @@ +package aws + +import ( + "context" + "net/http" + + "github.com/localstack/lstk/internal/output" +) + +// Client defines the interface for communicating with a running AWS emulator instance. +type Client interface { + FetchVersion(ctx context.Context, host string) (string, error) + FetchResources(ctx context.Context, host string) ([]output.ResourceRow, error) +} + +func NewClient(httpClient *http.Client) Client { + if httpClient == nil { + httpClient = &http.Client{} + } + return &client{http: httpClient} +} diff --git a/internal/emulator/aws/client.go b/internal/emulator/aws/client.go new file mode 100644 index 0000000..4e06d41 --- /dev/null +++ b/internal/emulator/aws/client.go @@ -0,0 +1,143 @@ +package aws + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + + "github.com/localstack/lstk/internal/output" +) + +// Ensure client implements Client at compile time. +var _ Client = (*client)(nil) + +type client struct { + http *http.Client +} + +type healthResponse struct { + Version string `json:"version"` +} + +type instanceResource struct { + RegionName string `json:"region_name"` + AccountID string `json:"account_id"` + ID string `json:"id"` +} + +func (c *client) FetchVersion(ctx context.Context, host string) (string, error) { + url := fmt.Sprintf("http://%s/_localstack/health", host) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create health request: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch health: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("health endpoint returned status %d", resp.StatusCode) + } + + var h healthResponse + if err := json.NewDecoder(resp.Body).Decode(&h); err != nil { + return "", fmt.Errorf("failed to decode health response: %w", err) + } + return h.Version, nil +} + +func (c *client) FetchResources(ctx context.Context, host string) ([]output.ResourceRow, error) { + url := fmt.Sprintf("http://%s/_localstack/resources", host) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create resources request: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch resources: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch resources: status %d", resp.StatusCode) + } + + // Each line of the NDJSON stream is a JSON object mapping an AWS resource type + // (e.g. "AWS::S3::Bucket") to a list of resource entries. + var rows []output.ResourceRow + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 1024*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var chunk map[string][]instanceResource + if err := json.Unmarshal([]byte(line), &chunk); err != nil { + return nil, fmt.Errorf("failed to parse resource line: %w", err) + } + + for resourceType, entries := range chunk { + parts := strings.SplitN(resourceType, "::", 3) + service := resourceType + if len(parts) == 3 { + service = parts[1] + } + + for _, e := range entries { + rows = append(rows, output.ResourceRow{ + Service: service, + Resource: extractResourceName(e.ID), + Region: e.RegionName, + Account: e.AccountID, + }) + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read resources stream: %w", err) + } + + sort.Slice(rows, func(i, j int) bool { + if rows[i].Service != rows[j].Service { + return rows[i].Service < rows[j].Service + } + return rows[i].Resource < rows[j].Resource + }) + + return rows, nil +} + +// extractResourceName extracts the name from a resource ID. +// For ARNs (arn:partition:service:region:account:resource), it returns the resource part. +// For plain names, it returns the ID as-is. +func extractResourceName(id string) string { + if strings.HasPrefix(id, "arn:") { + parts := strings.SplitN(id, ":", 6) + if len(parts) == 6 { + resource := parts[5] + // Handle resources like "role/my-role" + if idx := strings.LastIndex(resource, "/"); idx != -1 { + return resource[idx+1:] + } + // Handle resources like "function:my-func" + if idx := strings.LastIndex(resource, ":"); idx != -1 { + return resource[idx+1:] + } + return resource + } + } + return id +} diff --git a/internal/emulator/aws/client_test.go b/internal/emulator/aws/client_test.go new file mode 100644 index 0000000..cd5478e --- /dev/null +++ b/internal/emulator/aws/client_test.go @@ -0,0 +1,124 @@ +package aws + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFetchVersion(t *testing.T) { + t.Parallel() + + t.Run("returns version from health endpoint", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/_localstack/health", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"version": "4.14.1", "services": {}}`) + })) + defer server.Close() + + c := NewClient(nil) + version, err := c.FetchVersion(context.Background(), server.Listener.Addr().String()) + require.NoError(t, err) + assert.Equal(t, "4.14.1", version) + }) + + t.Run("returns error on non-200", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + c := NewClient(nil) + _, err := c.FetchVersion(context.Background(), server.Listener.Addr().String()) + require.Error(t, err) + }) +} + +func TestFetchResources(t *testing.T) { + t.Parallel() + + t.Run("returns flat rows sorted by service then resource", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = fmt.Fprintln(w, `{"AWS::S3::Bucket": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-bucket"}]}`) + _, _ = fmt.Fprintln(w, `{"AWS::Lambda::Function": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-function"}]}`) + })) + defer server.Close() + + c := NewClient(nil) + rows, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) + require.NoError(t, err) + require.Len(t, rows, 2) + assert.Equal(t, "Lambda", rows[0].Service) + assert.Equal(t, "my-function", rows[0].Resource) + assert.Equal(t, "us-east-1", rows[0].Region) + assert.Equal(t, "000000000000", rows[0].Account) + assert.Equal(t, "S3", rows[1].Service) + assert.Equal(t, "my-bucket", rows[1].Resource) + }) + + t.Run("extracts name from ARN", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = fmt.Fprintln(w, `{"AWS::SNS::Topic": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "arn:aws:sns:us-east-1:000000000000:my-topic"}]}`) + })) + defer server.Close() + + c := NewClient(nil) + rows, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "my-topic", rows[0].Resource) + }) + + t.Run("returns empty slice when no resources", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + })) + defer server.Close() + + c := NewClient(nil) + rows, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) + require.NoError(t, err) + assert.Empty(t, rows) + }) + + t.Run("returns error on non-200", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + c := NewClient(nil) + _, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) + require.Error(t, err) + }) +} + +func TestExtractResourceName(t *testing.T) { + t.Parallel() + tests := []struct { + input string + want string + }{ + {"my-bucket", "my-bucket"}, + {"arn:aws:sns:us-east-1:000000000000:my-topic", "my-topic"}, + {"arn:aws:iam::000000000000:role/my-role", "my-role"}, + {"arn:aws:lambda:us-east-1:000000000000:function:my-func", "my-func"}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, extractResourceName(tt.input), "extractResourceName(%q)", tt.input) + } +} diff --git a/internal/output/events.go b/internal/output/events.go index 11c58d8..20eeed6 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -55,8 +55,32 @@ type AuthEvent struct { URL string } +type InstanceInfoEvent struct { + EmulatorName string + Version string + Host string + ContainerName string + Uptime time.Duration +} + +type ResourceRow struct { + Service string + Resource string + Region string + Account string +} + +type ResourceTableEvent struct { + Rows []ResourceRow +} + +type ResourceSummaryEvent struct { + ResourceCount int + ServiceCount int +} + type Event interface { - MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent + MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent | InstanceInfoEvent | ResourceTableEvent | ResourceSummaryEvent } type Sink interface { diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 0647d4f..332bf30 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -3,6 +3,7 @@ package output import ( "fmt" "strings" + "time" ) // FormatEventLine converts an output event into a single display line. @@ -27,6 +28,12 @@ func FormatEventLine(event any) (string, bool) { return formatUserInputRequest(e), true case ContainerLogLineEvent: return e.Line, true + case InstanceInfoEvent: + return formatInstanceInfo(e), true + case ResourceSummaryEvent: + return formatResourceSummary(e), true + case ResourceTableEvent: + return formatResourceTable(e) default: return "", false } @@ -58,30 +65,30 @@ func formatUserInputRequest(e UserInputRequestEvent) string { return FormatPrompt(e.Prompt, e.Options) } -// FormatPrompt formats a prompt string with its options into a display line. -func FormatPrompt(prompt string, options []InputOption) string { - lines := strings.Split(prompt, "\n") - firstLine := lines[0] - rest := lines[1:] +// FormatPromptLabels returns the formatted label suffix for a prompt's options, +// e.g. " [Y/n]" for multiple options or " (Y)" for a single option. +func FormatPromptLabels(options []InputOption) string { labels := make([]string, 0, len(options)) for _, opt := range options { if opt.Label != "" { labels = append(labels, opt.Label) } } - switch len(labels) { case 0: - if len(rest) == 0 { - return firstLine - } - return strings.Join(append([]string{firstLine}, rest...), "\n") + return "" case 1: - firstLine = fmt.Sprintf("%s (%s)", firstLine, labels[0]) + return fmt.Sprintf(" (%s)", labels[0]) default: - firstLine = fmt.Sprintf("%s [%s]", firstLine, strings.Join(labels, "/")) + return fmt.Sprintf(" [%s]", strings.Join(labels, "/")) } +} +// FormatPrompt formats a prompt string with its options into a display line. +func FormatPrompt(prompt string, options []InputOption) string { + lines := strings.Split(prompt, "\n") + firstLine := lines[0] + FormatPromptLabels(options) + rest := lines[1:] if len(rest) == 0 { return firstLine } @@ -139,3 +146,99 @@ func formatErrorEvent(e ErrorEvent) string { } return sb.String() } + +func formatInstanceInfo(e InstanceInfoEvent) string { + var sb strings.Builder + sb.WriteString("✓ " + e.EmulatorName + " is running (" + e.Host + ")") + var meta []string + if e.Uptime > 0 { + meta = append(meta, "UPTIME: "+formatUptime(e.Uptime)) + } + if e.ContainerName != "" { + meta = append(meta, "CONTAINER: "+e.ContainerName) + } + if e.Version != "" { + meta = append(meta, "VERSION: "+e.Version) + } + if len(meta) > 0 { + sb.WriteString("\n " + strings.Join(meta, " · ")) + } + return sb.String() +} + +func formatUptime(d time.Duration) string { + d = d.Round(time.Second) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%dh %dm %ds", h, m, s) + } + if m > 0 { + return fmt.Sprintf("%dm %ds", m, s) + } + return fmt.Sprintf("%ds", s) +} + +func formatResourceSummary(e ResourceSummaryEvent) string { + return fmt.Sprintf("~ %d resources · %d services", e.ResourceCount, e.ServiceCount) +} + +func formatResourceTable(e ResourceTableEvent) (string, bool) { + if len(e.Rows) == 0 { + return "", false + } + return formatResourceTableWidth(e, terminalWidth()), true +} + +func formatResourceTableWidth(e ResourceTableEvent, totalWidth int) string { + headers := [4]string{"SERVICE", "RESOURCE", "REGION", "ACCOUNT"} + + // Compute natural (uncapped) column widths from data. + widths := [4]int{len(headers[0]), len(headers[1]), len(headers[2]), len(headers[3])} + for _, r := range e.Rows { + cols := [4]string{r.Service, r.Resource, r.Region, r.Account} + for i, c := range cols { + if len(c) > widths[i] { + widths[i] = len(c) + } + } + } + + // Fixed overhead: 2 (indent) + 3×2 (gaps between 4 columns) = 8. + const overhead = 8 + // Cap SERVICE, REGION, ACCOUNT to their natural widths (they're short). + // Give RESOURCE whatever space remains. + fixedWidth := widths[0] + widths[2] + widths[3] + overhead + maxResource := totalWidth - fixedWidth + if maxResource < 10 { + maxResource = 10 + } + if widths[1] > maxResource { + widths[1] = maxResource + } + + maxWidths := [4]int{widths[0], widths[1], widths[2], widths[3]} + + var sb strings.Builder + writeRow := func(cols [4]string) { + sb.WriteString(" ") + for i, c := range cols { + val := truncate(c, maxWidths[i]) + sb.WriteString(val) + if i < len(cols)-1 { + padding := widths[i] - displayWidth(val) + 2 + for range padding { + sb.WriteByte(' ') + } + } + } + } + writeRow(headers) + for _, r := range e.Rows { + sb.WriteString("\n") + writeRow([4]string{r.Service, r.Resource, r.Region, r.Account}) + } + return sb.String() +} + diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index bb8f1e5..f5cdabe 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -1,6 +1,10 @@ package output -import "testing" +import ( + "strings" + "testing" + "time" +) func TestFormatEventLine(t *testing.T) { t.Parallel() @@ -107,6 +111,53 @@ func TestFormatEventLine(t *testing.T) { want: "Error: Docker not running\n Cannot connect to Docker daemon\n → Start Docker: open -a Docker", wantOK: true, }, + { + name: "instance info full", + event: InstanceInfoEvent{ + EmulatorName: "LocalStack AWS Emulator", + Version: "4.14.1", + Host: "localhost.localstack.cloud:4566", + ContainerName: "localstack-aws", + Uptime: 4*time.Minute + 23*time.Second, + }, + want: "✓ LocalStack AWS Emulator is running (localhost.localstack.cloud:4566)\n UPTIME: 4m 23s · CONTAINER: localstack-aws · VERSION: 4.14.1", + wantOK: true, + }, + { + name: "instance info minimal", + event: InstanceInfoEvent{ + EmulatorName: "LocalStack AWS Emulator", + Host: "127.0.0.1:4566", + }, + want: "✓ LocalStack AWS Emulator is running (127.0.0.1:4566)", + wantOK: true, + }, + { + name: "resource summary", + event: ResourceSummaryEvent{ + ResourceCount: 23, + ServiceCount: 12, + }, + want: "~ 23 resources · 12 services", + wantOK: true, + }, + { + name: "resource table with entries", + event: ResourceTableEvent{ + Rows: []ResourceRow{ + {Service: "Lambda", Resource: "handler", Region: "us-east-1", Account: "000000000000"}, + {Service: "S3", Resource: "my-bucket", Region: "us-east-1", Account: "000000000000"}, + }, + }, + want: " SERVICE RESOURCE REGION ACCOUNT\n Lambda handler us-east-1 000000000000\n S3 my-bucket us-east-1 000000000000", + wantOK: true, + }, + { + name: "resource table empty", + event: ResourceTableEvent{Rows: []ResourceRow{}}, + want: "", + wantOK: false, + }, { name: "unsupported event", event: struct{}{}, @@ -130,3 +181,55 @@ func TestFormatEventLine(t *testing.T) { }) } } + +func TestFormatResourceTableWidth(t *testing.T) { + t.Parallel() + + e := ResourceTableEvent{ + Rows: []ResourceRow{ + {Service: "CloudFormation", Resource: "8245db0d-5c05-4209-90f0-51ec48446a58", Region: "us-east-1", Account: "000000000000"}, + {Service: "EC2", Resource: "subnet-816649cee2efc65ac", Region: "eu-central-1", Account: "000000000000"}, + {Service: "Lambda", Resource: "HelloWorldFunctionJavaScript", Region: "us-east-1", Account: "000000000000"}, + }, + } + + t.Run("truncates resource column to fit terminal width", func(t *testing.T) { + t.Parallel() + got := formatResourceTableWidth(e, 80) + for i, line := range strings.Split(got, "\n") { + w := displayWidth(line) + if w > 80 { + t.Errorf("line %d has display width %d (>80): %q", i, w, line) + } + } + if !strings.Contains(got, "8245db0d") { + t.Error("expected truncated UUID to still contain prefix") + } + if !strings.Contains(got, "…") { + t.Error("expected truncation marker") + } + }) + + t.Run("no truncation when terminal is wide enough", func(t *testing.T) { + t.Parallel() + got := formatResourceTableWidth(e, 200) + if strings.Contains(got, "…") { + t.Error("expected no truncation at width 200") + } + if !strings.Contains(got, "8245db0d-5c05-4209-90f0-51ec48446a58") { + t.Error("expected full UUID") + } + }) + + t.Run("narrow terminal still renders without panic", func(t *testing.T) { + t.Parallel() + got := formatResourceTableWidth(e, 40) + if got == "" { + t.Error("expected non-empty output") + } + if !strings.Contains(got, "…") { + t.Error("expected truncation at narrow width") + } + }) +} + diff --git a/internal/output/terminal.go b/internal/output/terminal.go new file mode 100644 index 0000000..4e2e672 --- /dev/null +++ b/internal/output/terminal.go @@ -0,0 +1,31 @@ +package output + +import ( + "os" + + "golang.org/x/term" +) + +func terminalWidth() int { + for _, fd := range []uintptr{os.Stdout.Fd(), os.Stderr.Fd()} { + if w, _, err := term.GetSize(int(fd)); err == nil && w > 0 { + return w + } + } + return 80 +} + +func truncate(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + if max <= 1 { + return "…" + } + return string(r[:max-1]) + "…" +} + +func displayWidth(s string) int { + return len([]rune(s)) +} diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index dddb9af..8b782a6 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -9,6 +9,7 @@ import ( stdruntime "runtime" "strconv" "strings" + "time" "github.com/containerd/errdefs" "github.com/docker/docker/api/types/container" @@ -156,6 +157,18 @@ func (d *DockerRuntime) IsRunning(ctx context.Context, containerID string) (bool return inspect.State.Running, nil } +func (d *DockerRuntime) ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error) { + inspect, err := d.client.ContainerInspect(ctx, containerName) + if err != nil { + return time.Time{}, fmt.Errorf("failed to inspect container: %w", err) + } + t, err := time.Parse(time.RFC3339Nano, inspect.State.StartedAt) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse container start time: %w", err) + } + return t, nil +} + func (d *DockerRuntime) Logs(ctx context.Context, containerID string, tail int) (string, error) { options := container.LogsOptions{ ShowStdout: true, diff --git a/internal/runtime/mock_runtime.go b/internal/runtime/mock_runtime.go index 9848ca0..e620a41 100644 --- a/internal/runtime/mock_runtime.go +++ b/internal/runtime/mock_runtime.go @@ -13,6 +13,7 @@ import ( context "context" io "io" reflect "reflect" + time "time" output "github.com/localstack/lstk/internal/output" gomock "go.uber.org/mock/gomock" @@ -42,6 +43,21 @@ func (m *MockRuntime) EXPECT() *MockRuntimeMockRecorder { return m.recorder } +// ContainerStartedAt mocks base method. +func (m *MockRuntime) ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ContainerStartedAt", ctx, containerName) + ret0, _ := ret[0].(time.Time) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ContainerStartedAt indicates an expected call of ContainerStartedAt. +func (mr *MockRuntimeMockRecorder) ContainerStartedAt(ctx, containerName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerStartedAt", reflect.TypeOf((*MockRuntime)(nil).ContainerStartedAt), ctx, containerName) +} + // EmitUnhealthyError mocks base method. func (m *MockRuntime) EmitUnhealthyError(sink output.Sink, err error) { m.ctrl.T.Helper() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 7a54b5d..b0ec640 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -3,6 +3,7 @@ package runtime import ( "context" "io" + "time" "github.com/localstack/lstk/internal/output" ) @@ -33,6 +34,7 @@ type Runtime interface { Stop(ctx context.Context, containerName string) error Remove(ctx context.Context, containerName string) error IsRunning(ctx context.Context, containerID string) (bool, error) + ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error) Logs(ctx context.Context, containerID string, tail int) (string, error) StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool) error GetImageVersion(ctx context.Context, imageName string) (string, error) diff --git a/internal/ui/app.go b/internal/ui/app.go index 5388d8f..071383c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -200,7 +200,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Quit default: if line, ok := output.FormatEventLine(msg); ok { - a.lines = appendLine(a.lines, styledLine{text: line}) + for _, part := range strings.Split(line, "\n") { + a.lines = appendLine(a.lines, styledLine{text: part}) + } } } diff --git a/internal/ui/components/input_prompt.go b/internal/ui/components/input_prompt.go index 5a8f44b..560c0fc 100644 --- a/internal/ui/components/input_prompt.go +++ b/internal/ui/components/input_prompt.go @@ -1,6 +1,8 @@ package components import ( + "strings" + "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/styles" ) @@ -36,5 +38,17 @@ func (p InputPrompt) View() string { return "" } - return styles.SecondaryMessage.Render(output.FormatPrompt(p.prompt, p.options)) + lines := strings.Split(p.prompt, "\n") + + var sb strings.Builder + sb.WriteString(styles.Secondary.Render("? ")) + sb.WriteString(styles.Message.Render(lines[0])) + sb.WriteString(styles.Secondary.Render(output.FormatPromptLabels(p.options))) + + if len(lines) > 1 { + sb.WriteString("\n") + sb.WriteString(styles.SecondaryMessage.Render(strings.Join(lines[1:], "\n"))) + } + + return sb.String() } diff --git a/internal/ui/run_status.go b/internal/ui/run_status.go new file mode 100644 index 0000000..4ab7a02 --- /dev/null +++ b/internal/ui/run_status.go @@ -0,0 +1,48 @@ +package ui + +import ( + "context" + "errors" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" +) + +func RunStatus(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, localStackHost string) error { + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + app := NewApp("", "", "", cancel, withoutHeader()) + p := tea.NewProgram(app, tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout)) + runErrCh := make(chan error, 1) + + go func() { + err := container.Status(ctx, rt, containers, localStackHost, output.NewTUISink(programSender{p: p})) + if err != nil && !errors.Is(err, context.Canceled) { + p.Send(runErrMsg{err: err}) + } else { + p.Send(runDoneMsg{}) + } + runErrCh <- err + }() + + model, err := p.Run() + if err != nil { + return err + } + + if app, ok := model.(App); ok && app.Err() != nil { + return output.NewSilentError(app.Err()) + } + + runErr := <-runErrCh + if runErr != nil && !errors.Is(runErr, context.Canceled) { + return runErr + } + + return nil +} diff --git a/test/integration/env/env.go b/test/integration/env/env.go index 5146fd7..2dbf67f 100644 --- a/test/integration/env/env.go +++ b/test/integration/env/env.go @@ -10,6 +10,7 @@ type Key string const ( AuthToken Key = "LOCALSTACK_AUTH_TOKEN" + LocalStackHost Key = "LOCALSTACK_HOST" APIEndpoint Key = "LSTK_API_ENDPOINT" Keyring Key = "LSTK_KEYRING" CI Key = "CI" diff --git a/test/integration/status_test.go b/test/integration/status_test.go new file mode 100644 index 0000000..42adee1 --- /dev/null +++ b/test/integration/status_test.go @@ -0,0 +1,93 @@ +package integration_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatusCommandFailsWhenNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + stdout, _, err := runLstk(t, testContext(t), "", nil, "status") + require.Error(t, err, "expected lstk status to fail when emulator not running") + assert.Contains(t, stdout, "is not running") + assert.Contains(t, stdout, "Start LocalStack:") + assert.Contains(t, stdout, "See help:") +} + +func TestStatusCommandShowsResourcesWhenRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + // Mock the LocalStack HTTP API so we can test resource display without a real LocalStack instance. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_localstack/health": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"version": "4.14.1", "services": {}}`) + case "/_localstack/resources": + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = fmt.Fprintln(w, `{"AWS::S3::Bucket": [{"region_name": "global", "account_id": "000000000000", "id": "my-bucket"}]}`) + _, _ = fmt.Fprintln(w, `{"AWS::Lambda::Function": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-function"}]}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "http://") + + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.LocalStackHost, host), "status") + require.NoError(t, err, "lstk status failed: %s", stderr) + assert.Contains(t, stdout, "running") + assert.Contains(t, stdout, "4.14.1") + assert.Contains(t, stdout, "2 resources") + assert.Contains(t, stdout, "2 services") + assert.Contains(t, stdout, "SERVICE") + assert.Contains(t, stdout, "RESOURCE") + assert.Contains(t, stdout, "S3") + assert.Contains(t, stdout, "my-bucket") + assert.Contains(t, stdout, "Lambda") + assert.Contains(t, stdout, "my-function") +} + +func TestStatusCommandShowsNoResourcesWhenEmpty(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_localstack/health": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"version": "4.14.1", "services": {}}`) + case "/_localstack/resources": + w.Header().Set("Content-Type", "application/x-ndjson") + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "http://") + + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.LocalStackHost, host), "status") + require.NoError(t, err, "lstk status failed: %s", stderr) + assert.Contains(t, stdout, "No resources deployed") +} From 40a4944aab29baf4a748a3f7e8be0e2e447c47bb Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Thu, 12 Mar 2026 16:05:04 +0100 Subject: [PATCH 2/2] make events more generic --- internal/container/status.go | 58 +++++++------ internal/container/status_test.go | 89 -------------------- internal/emulator/aws/aws.go | 21 ----- internal/emulator/aws/client.go | 56 +++++-------- internal/emulator/aws/client_test.go | 33 ++------ internal/emulator/aws/resource_name.go | 25 ++++++ internal/emulator/aws/resource_name_test.go | 23 +++++ internal/output/events.go | 19 +---- internal/output/plain_format.go | 93 +++++++++++---------- internal/output/plain_format_test.go | 45 +++++----- internal/ui/app.go | 36 +++++++- internal/ui/components/input_prompt.go | 12 ++- internal/ui/components/message.go | 2 + 13 files changed, 230 insertions(+), 282 deletions(-) delete mode 100644 internal/emulator/aws/aws.go create mode 100644 internal/emulator/aws/resource_name.go create mode 100644 internal/emulator/aws/resource_name_test.go diff --git a/internal/container/status.go b/internal/container/status.go index 6482d46..fee0e8d 100644 --- a/internal/container/status.go +++ b/internal/container/status.go @@ -3,6 +3,7 @@ package container import ( "context" "fmt" + "net/http" "time" "github.com/localstack/lstk/internal/config" @@ -18,13 +19,17 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain ctx, cancel := context.WithTimeout(ctx, statusTimeout) defer cancel() + output.EmitSpinnerStart(sink, "Fetching LocalStack status") + for _, c := range containers { name := c.Name() running, err := rt.IsRunning(ctx, name) if err != nil { + output.EmitSpinnerStop(sink) return fmt.Errorf("checking %s running: %w", name, err) } if !running { + output.EmitSpinnerStop(sink) output.EmitError(sink, output.ErrorEvent{ Title: fmt.Sprintf("%s is not running", c.DisplayName()), Actions: []output.ErrorAction{ @@ -43,49 +48,52 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain } var version string + var rows []aws.Resource switch c.Type { case config.EmulatorAWS: - emulatorClient := aws.NewClient(nil) + emulatorClient := aws.NewClient(&http.Client{}) if v, err := emulatorClient.FetchVersion(ctx, host); err != nil { + output.EmitSpinnerStop(sink) output.EmitWarning(sink, fmt.Sprintf("Could not fetch version: %v", err)) } else { version = v } - output.Emit(sink, output.InstanceInfoEvent{ - EmulatorName: c.DisplayName(), - Version: version, - Host: host, - ContainerName: name, - Uptime: uptime, - }) - - rows, err := emulatorClient.FetchResources(ctx, host) - if err != nil { - return err + var fetchErr error + rows, fetchErr = emulatorClient.FetchResources(ctx, host) + if fetchErr != nil { + output.EmitSpinnerStop(sink) + return fetchErr } + } + output.EmitSpinnerStop(sink) + + output.Emit(sink, output.InstanceInfoEvent{ + EmulatorName: c.DisplayName(), + Version: version, + Host: host, + ContainerName: name, + Uptime: uptime, + }) + + if c.Type == config.EmulatorAWS { if len(rows) == 0 { output.EmitNote(sink, "No resources deployed") continue } + tableRows := make([][]string, len(rows)) services := map[string]struct{}{} - for _, r := range rows { + for i, r := range rows { + tableRows[i] = []string{r.Service, r.Name, r.Region, r.Account} services[r.Service] = struct{}{} } - output.Emit(sink, output.ResourceSummaryEvent{ - ResourceCount: len(rows), - ServiceCount: len(services), - }) - output.Emit(sink, output.ResourceTableEvent{Rows: rows}) - default: - output.Emit(sink, output.InstanceInfoEvent{ - EmulatorName: c.DisplayName(), - Version: version, - Host: host, - ContainerName: name, - Uptime: uptime, + + output.EmitInfo(sink, fmt.Sprintf("~ %d resources · %d services", len(rows), len(services))) + output.Emit(sink, output.TableEvent{ + Headers: []string{"SERVICE", "RESOURCE", "REGION", "ACCOUNT"}, + Rows: tableRows, }) } } diff --git a/internal/container/status_test.go b/internal/container/status_test.go index de84d70..43abd93 100644 --- a/internal/container/status_test.go +++ b/internal/container/status_test.go @@ -4,11 +4,7 @@ import ( "context" "fmt" "io" - "net/http" - "net/http/httptest" - "strings" "testing" - "time" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/output" @@ -18,21 +14,6 @@ import ( "go.uber.org/mock/gomock" ) -func TestStatus_NotRunning(t *testing.T) { - ctrl := gomock.NewController(t) - mockRT := runtime.NewMockRuntime(ctrl) - mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil) - - containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} - sink := output.NewPlainSink(io.Discard) - - err := Status(context.Background(), mockRT, containers, "", sink) - - require.Error(t, err) - assert.True(t, output.IsSilent(err)) - assert.Contains(t, err.Error(), "is not running") -} - func TestStatus_IsRunningError(t *testing.T) { ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) @@ -47,76 +28,6 @@ func TestStatus_IsRunningError(t *testing.T) { assert.Contains(t, err.Error(), "docker unavailable") } -func TestStatus_RunningWithResources(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/_localstack/health": - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintln(w, `{"version": "4.14.1"}`) - case "/_localstack/resources": - w.Header().Set("Content-Type", "application/x-ndjson") - _, _ = fmt.Fprintln(w, `{"AWS::S3::Bucket": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-bucket"}]}`) - _, _ = fmt.Fprintln(w, `{"AWS::Lambda::Function": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-func"}]}`) - } - })) - defer server.Close() - - host := strings.TrimPrefix(server.URL, "http://") - - ctrl := gomock.NewController(t) - mockRT := runtime.NewMockRuntime(ctrl) - mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) - mockRT.EXPECT().ContainerStartedAt(gomock.Any(), "localstack-aws").Return(time.Now().Add(-5*time.Minute), nil) - - containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} - var buf strings.Builder - sink := output.NewPlainSink(&buf) - - err := Status(context.Background(), mockRT, containers, host, sink) - - require.NoError(t, err) - out := buf.String() - assert.Contains(t, out, "is running") - assert.Contains(t, out, "4.14.1") - assert.Contains(t, out, "S3") - assert.Contains(t, out, "my-bucket") - assert.Contains(t, out, "Lambda") - assert.Contains(t, out, "my-func") - assert.Contains(t, out, "2 resources") - assert.Contains(t, out, "2 services") -} - -func TestStatus_RunningNoResources(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/_localstack/health": - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintln(w, `{"version": "4.14.1"}`) - case "/_localstack/resources": - w.Header().Set("Content-Type", "application/x-ndjson") - } - })) - defer server.Close() - - host := strings.TrimPrefix(server.URL, "http://") - - ctrl := gomock.NewController(t) - mockRT := runtime.NewMockRuntime(ctrl) - mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) - mockRT.EXPECT().ContainerStartedAt(gomock.Any(), "localstack-aws").Return(time.Time{}, fmt.Errorf("not found")) - - containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} - var buf strings.Builder - sink := output.NewPlainSink(&buf) - - err := Status(context.Background(), mockRT, containers, host, sink) - - require.NoError(t, err) - out := buf.String() - assert.Contains(t, out, "is running") - assert.Contains(t, out, "No resources deployed") -} - func TestStatus_MultipleContainers_StopsAtFirstNotRunning(t *testing.T) { ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) diff --git a/internal/emulator/aws/aws.go b/internal/emulator/aws/aws.go deleted file mode 100644 index 56e1e8d..0000000 --- a/internal/emulator/aws/aws.go +++ /dev/null @@ -1,21 +0,0 @@ -package aws - -import ( - "context" - "net/http" - - "github.com/localstack/lstk/internal/output" -) - -// Client defines the interface for communicating with a running AWS emulator instance. -type Client interface { - FetchVersion(ctx context.Context, host string) (string, error) - FetchResources(ctx context.Context, host string) ([]output.ResourceRow, error) -} - -func NewClient(httpClient *http.Client) Client { - if httpClient == nil { - httpClient = &http.Client{} - } - return &client{http: httpClient} -} diff --git a/internal/emulator/aws/client.go b/internal/emulator/aws/client.go index 4e06d41..7a95732 100644 --- a/internal/emulator/aws/client.go +++ b/internal/emulator/aws/client.go @@ -8,17 +8,23 @@ import ( "net/http" "sort" "strings" - - "github.com/localstack/lstk/internal/output" ) -// Ensure client implements Client at compile time. -var _ Client = (*client)(nil) +type Resource struct { + Service string + Name string + Region string + Account string +} -type client struct { +type Client struct { http *http.Client } +func NewClient(httpClient *http.Client) *Client { + return &Client{http: httpClient} +} + type healthResponse struct { Version string `json:"version"` } @@ -29,7 +35,7 @@ type instanceResource struct { ID string `json:"id"` } -func (c *client) FetchVersion(ctx context.Context, host string) (string, error) { +func (c *Client) FetchVersion(ctx context.Context, host string) (string, error) { url := fmt.Sprintf("http://%s/_localstack/health", host) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -53,7 +59,7 @@ func (c *client) FetchVersion(ctx context.Context, host string) (string, error) return h.Version, nil } -func (c *client) FetchResources(ctx context.Context, host string) ([]output.ResourceRow, error) { +func (c *Client) FetchResources(ctx context.Context, host string) ([]Resource, error) { url := fmt.Sprintf("http://%s/_localstack/resources", host) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -72,7 +78,7 @@ func (c *client) FetchResources(ctx context.Context, host string) ([]output.Reso // Each line of the NDJSON stream is a JSON object mapping an AWS resource type // (e.g. "AWS::S3::Bucket") to a list of resource entries. - var rows []output.ResourceRow + var rows []Resource scanner := bufio.NewScanner(resp.Body) buf := make([]byte, 1024*1024) scanner.Buffer(buf, 1024*1024) @@ -96,11 +102,11 @@ func (c *client) FetchResources(ctx context.Context, host string) ([]output.Reso } for _, e := range entries { - rows = append(rows, output.ResourceRow{ - Service: service, - Resource: extractResourceName(e.ID), - Region: e.RegionName, - Account: e.AccountID, + rows = append(rows, Resource{ + Service: service, + Name: extractResourceName(e.ID), + Region: e.RegionName, + Account: e.AccountID, }) } } @@ -114,30 +120,8 @@ func (c *client) FetchResources(ctx context.Context, host string) ([]output.Reso if rows[i].Service != rows[j].Service { return rows[i].Service < rows[j].Service } - return rows[i].Resource < rows[j].Resource + return rows[i].Name < rows[j].Name }) return rows, nil } - -// extractResourceName extracts the name from a resource ID. -// For ARNs (arn:partition:service:region:account:resource), it returns the resource part. -// For plain names, it returns the ID as-is. -func extractResourceName(id string) string { - if strings.HasPrefix(id, "arn:") { - parts := strings.SplitN(id, ":", 6) - if len(parts) == 6 { - resource := parts[5] - // Handle resources like "role/my-role" - if idx := strings.LastIndex(resource, "/"); idx != -1 { - return resource[idx+1:] - } - // Handle resources like "function:my-func" - if idx := strings.LastIndex(resource, ":"); idx != -1 { - return resource[idx+1:] - } - return resource - } - } - return id -} diff --git a/internal/emulator/aws/client_test.go b/internal/emulator/aws/client_test.go index cd5478e..d9fc014 100644 --- a/internal/emulator/aws/client_test.go +++ b/internal/emulator/aws/client_test.go @@ -23,7 +23,7 @@ func TestFetchVersion(t *testing.T) { })) defer server.Close() - c := NewClient(nil) + c := NewClient(&http.Client{}) version, err := c.FetchVersion(context.Background(), server.Listener.Addr().String()) require.NoError(t, err) assert.Equal(t, "4.14.1", version) @@ -36,7 +36,7 @@ func TestFetchVersion(t *testing.T) { })) defer server.Close() - c := NewClient(nil) + c := NewClient(&http.Client{}) _, err := c.FetchVersion(context.Background(), server.Listener.Addr().String()) require.Error(t, err) }) @@ -54,16 +54,16 @@ func TestFetchResources(t *testing.T) { })) defer server.Close() - c := NewClient(nil) + c := NewClient(&http.Client{}) rows, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) require.NoError(t, err) require.Len(t, rows, 2) assert.Equal(t, "Lambda", rows[0].Service) - assert.Equal(t, "my-function", rows[0].Resource) + assert.Equal(t, "my-function", rows[0].Name) assert.Equal(t, "us-east-1", rows[0].Region) assert.Equal(t, "000000000000", rows[0].Account) assert.Equal(t, "S3", rows[1].Service) - assert.Equal(t, "my-bucket", rows[1].Resource) + assert.Equal(t, "my-bucket", rows[1].Name) }) t.Run("extracts name from ARN", func(t *testing.T) { @@ -74,11 +74,11 @@ func TestFetchResources(t *testing.T) { })) defer server.Close() - c := NewClient(nil) + c := NewClient(&http.Client{}) rows, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) require.NoError(t, err) require.Len(t, rows, 1) - assert.Equal(t, "my-topic", rows[0].Resource) + assert.Equal(t, "my-topic", rows[0].Name) }) t.Run("returns empty slice when no resources", func(t *testing.T) { @@ -88,7 +88,7 @@ func TestFetchResources(t *testing.T) { })) defer server.Close() - c := NewClient(nil) + c := NewClient(&http.Client{}) rows, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) require.NoError(t, err) assert.Empty(t, rows) @@ -101,24 +101,9 @@ func TestFetchResources(t *testing.T) { })) defer server.Close() - c := NewClient(nil) + c := NewClient(&http.Client{}) _, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) require.Error(t, err) }) } -func TestExtractResourceName(t *testing.T) { - t.Parallel() - tests := []struct { - input string - want string - }{ - {"my-bucket", "my-bucket"}, - {"arn:aws:sns:us-east-1:000000000000:my-topic", "my-topic"}, - {"arn:aws:iam::000000000000:role/my-role", "my-role"}, - {"arn:aws:lambda:us-east-1:000000000000:function:my-func", "my-func"}, - } - for _, tt := range tests { - assert.Equal(t, tt.want, extractResourceName(tt.input), "extractResourceName(%q)", tt.input) - } -} diff --git a/internal/emulator/aws/resource_name.go b/internal/emulator/aws/resource_name.go new file mode 100644 index 0000000..8b035cf --- /dev/null +++ b/internal/emulator/aws/resource_name.go @@ -0,0 +1,25 @@ +package aws + +import "strings" + +// extractResourceName extracts the name from a resource ID. +// For ARNs (arn:partition:service:region:account:resource), it returns the resource part. +// For plain names, it returns the ID as-is. +func extractResourceName(id string) string { + if strings.HasPrefix(id, "arn:") { + parts := strings.SplitN(id, ":", 6) + if len(parts) == 6 { + resource := parts[5] + // Handle resources like "role/my-role" + if idx := strings.LastIndex(resource, "/"); idx != -1 { + return resource[idx+1:] + } + // Handle resources like "function:my-func" + if idx := strings.LastIndex(resource, ":"); idx != -1 { + return resource[idx+1:] + } + return resource + } + } + return id +} diff --git a/internal/emulator/aws/resource_name_test.go b/internal/emulator/aws/resource_name_test.go new file mode 100644 index 0000000..95dcaea --- /dev/null +++ b/internal/emulator/aws/resource_name_test.go @@ -0,0 +1,23 @@ +package aws + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractResourceName(t *testing.T) { + t.Parallel() + tests := []struct { + input string + want string + }{ + {"my-bucket", "my-bucket"}, + {"arn:aws:sns:us-east-1:000000000000:my-topic", "my-topic"}, + {"arn:aws:iam::000000000000:role/my-role", "my-role"}, + {"arn:aws:lambda:us-east-1:000000000000:function:my-func", "my-func"}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, extractResourceName(tt.input), "extractResourceName(%q)", tt.input) + } +} diff --git a/internal/output/events.go b/internal/output/events.go index 209faf6..69a0919 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -65,24 +65,13 @@ type InstanceInfoEvent struct { Uptime time.Duration } -type ResourceRow struct { - Service string - Resource string - Region string - Account string -} - -type ResourceTableEvent struct { - Rows []ResourceRow -} - -type ResourceSummaryEvent struct { - ResourceCount int - ServiceCount int +type TableEvent struct { + Headers []string + Rows [][]string } type Event interface { - MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | LogLineEvent | InstanceInfoEvent | ResourceTableEvent | ResourceSummaryEvent + MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | LogLineEvent | InstanceInfoEvent | TableEvent } type Sink interface { diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index fd03de0..fb3c8fa 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -30,10 +30,8 @@ func FormatEventLine(event any) (string, bool) { return e.Line, true case InstanceInfoEvent: return formatInstanceInfo(e), true - case ResourceSummaryEvent: - return formatResourceSummary(e), true - case ResourceTableEvent: - return formatResourceTable(e) + case TableEvent: + return formatTable(e) default: return "", false } @@ -60,13 +58,12 @@ func formatStatusLine(e ContainerStatusEvent) (string, bool) { } } - func formatUserInputRequest(e UserInputRequestEvent) string { return FormatPrompt(e.Prompt, e.Options) } -// FormatPromptLabels returns the formatted label suffix for a prompt's options, -// e.g. " [Y/n]" for multiple options or " (Y)" for a single option. +// FormatPromptLabels formats option labels into a suffix string. +// Returns " (label)" for a single option, " [a/b]" for multiple, or "" for none. func FormatPromptLabels(options []InputOption) string { labels := make([]string, 0, len(options)) for _, opt := range options { @@ -181,53 +178,66 @@ func formatUptime(d time.Duration) string { return fmt.Sprintf("%ds", s) } -func formatResourceSummary(e ResourceSummaryEvent) string { - return fmt.Sprintf("~ %d resources · %d services", e.ResourceCount, e.ServiceCount) -} - -func formatResourceTable(e ResourceTableEvent) (string, bool) { +func formatTable(e TableEvent) (string, bool) { if len(e.Rows) == 0 { return "", false } - return formatResourceTableWidth(e, terminalWidth()), true + return formatTableWidth(e, terminalWidth()), true } -func formatResourceTableWidth(e ResourceTableEvent, totalWidth int) string { - headers := [4]string{"SERVICE", "RESOURCE", "REGION", "ACCOUNT"} +func formatTableWidth(e TableEvent, totalWidth int) string { + ncols := len(e.Headers) + if ncols == 0 { + return "" + } - // Compute natural (uncapped) column widths from data. - widths := [4]int{len(headers[0]), len(headers[1]), len(headers[2]), len(headers[3])} - for _, r := range e.Rows { - cols := [4]string{r.Service, r.Resource, r.Region, r.Account} - for i, c := range cols { - if len(c) > widths[i] { - widths[i] = len(c) + widths := make([]int, ncols) + for i, h := range e.Headers { + widths[i] = len(h) + } + for _, row := range e.Rows { + for i := range min(len(row), ncols) { + if len(row[i]) > widths[i] { + widths[i] = len(row[i]) } } } - // Fixed overhead: 2 (indent) + 3×2 (gaps between 4 columns) = 8. - const overhead = 8 - // Cap SERVICE, REGION, ACCOUNT to their natural widths (they're short). - // Give RESOURCE whatever space remains. - fixedWidth := widths[0] + widths[2] + widths[3] + overhead - maxResource := totalWidth - fixedWidth - if maxResource < 10 { - maxResource = 10 + // Fixed overhead: 2 (indent) + (ncols-1)*2 (gaps between columns). + overhead := 2 + (ncols-1)*2 + + // Find the widest column and let it absorb any overflow. + maxCol := 0 + for i := 1; i < ncols; i++ { + if widths[i] > widths[maxCol] { + maxCol = i + } + } + fixedWidth := overhead + for i, w := range widths { + if i != maxCol { + fixedWidth += w + } } - if widths[1] > maxResource { - widths[1] = maxResource + maxFlexible := totalWidth - fixedWidth + if maxFlexible < 10 { + maxFlexible = 10 + } + if widths[maxCol] > maxFlexible { + widths[maxCol] = maxFlexible } - - maxWidths := [4]int{widths[0], widths[1], widths[2], widths[3]} var sb strings.Builder - writeRow := func(cols [4]string) { + writeRow := func(cols []string) { sb.WriteString(" ") - for i, c := range cols { - val := truncate(c, maxWidths[i]) + for i := range ncols { + cell := "" + if i < len(cols) { + cell = cols[i] + } + val := truncate(cell, widths[i]) sb.WriteString(val) - if i < len(cols)-1 { + if i < ncols-1 { padding := widths[i] - displayWidth(val) + 2 for range padding { sb.WriteByte(' ') @@ -235,11 +245,10 @@ func formatResourceTableWidth(e ResourceTableEvent, totalWidth int) string { } } } - writeRow(headers) - for _, r := range e.Rows { + writeRow(e.Headers) + for _, row := range e.Rows { sb.WriteString("\n") - writeRow([4]string{r.Service, r.Resource, r.Region, r.Account}) + writeRow(row) } return sb.String() } - diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index ab4f445..b29ffbb 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -133,28 +133,20 @@ func TestFormatEventLine(t *testing.T) { wantOK: true, }, { - name: "resource summary", - event: ResourceSummaryEvent{ - ResourceCount: 23, - ServiceCount: 12, - }, - want: "~ 23 resources · 12 services", - wantOK: true, - }, - { - name: "resource table with entries", - event: ResourceTableEvent{ - Rows: []ResourceRow{ - {Service: "Lambda", Resource: "handler", Region: "us-east-1", Account: "000000000000"}, - {Service: "S3", Resource: "my-bucket", Region: "us-east-1", Account: "000000000000"}, + name: "table with entries", + event: TableEvent{ + Headers: []string{"SERVICE", "RESOURCE", "REGION", "ACCOUNT"}, + Rows: [][]string{ + {"Lambda", "handler", "us-east-1", "000000000000"}, + {"S3", "my-bucket", "us-east-1", "000000000000"}, }, }, want: " SERVICE RESOURCE REGION ACCOUNT\n Lambda handler us-east-1 000000000000\n S3 my-bucket us-east-1 000000000000", wantOK: true, }, { - name: "resource table empty", - event: ResourceTableEvent{Rows: []ResourceRow{}}, + name: "table empty", + event: TableEvent{Headers: []string{"A"}, Rows: [][]string{}}, want: "", wantOK: false, }, @@ -182,20 +174,21 @@ func TestFormatEventLine(t *testing.T) { } } -func TestFormatResourceTableWidth(t *testing.T) { +func TestFormatTableWidth(t *testing.T) { t.Parallel() - e := ResourceTableEvent{ - Rows: []ResourceRow{ - {Service: "CloudFormation", Resource: "8245db0d-5c05-4209-90f0-51ec48446a58", Region: "us-east-1", Account: "000000000000"}, - {Service: "EC2", Resource: "subnet-816649cee2efc65ac", Region: "eu-central-1", Account: "000000000000"}, - {Service: "Lambda", Resource: "HelloWorldFunctionJavaScript", Region: "us-east-1", Account: "000000000000"}, + e := TableEvent{ + Headers: []string{"SERVICE", "RESOURCE", "REGION", "ACCOUNT"}, + Rows: [][]string{ + {"CloudFormation", "8245db0d-5c05-4209-90f0-51ec48446a58", "us-east-1", "000000000000"}, + {"EC2", "subnet-816649cee2efc65ac", "eu-central-1", "000000000000"}, + {"Lambda", "HelloWorldFunctionJavaScript", "us-east-1", "000000000000"}, }, } - t.Run("truncates resource column to fit terminal width", func(t *testing.T) { + t.Run("truncates widest column to fit terminal width", func(t *testing.T) { t.Parallel() - got := formatResourceTableWidth(e, 80) + got := formatTableWidth(e, 80) for i, line := range strings.Split(got, "\n") { w := displayWidth(line) if w > 80 { @@ -212,7 +205,7 @@ func TestFormatResourceTableWidth(t *testing.T) { t.Run("no truncation when terminal is wide enough", func(t *testing.T) { t.Parallel() - got := formatResourceTableWidth(e, 200) + got := formatTableWidth(e, 200) if strings.Contains(got, "…") { t.Error("expected no truncation at width 200") } @@ -223,7 +216,7 @@ func TestFormatResourceTableWidth(t *testing.T) { t.Run("narrow terminal still renders without panic", func(t *testing.T) { t.Parallel() - got := formatResourceTableWidth(e, 40) + got := formatTableWidth(e, 40) if got == "" { t.Error("expected non-empty output") } diff --git a/internal/ui/app.go b/internal/ui/app.go index 1ad3117..180071b 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -142,7 +142,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case output.MessageEvent: line := styledLine{text: components.RenderMessage(msg)} - if a.spinner.PendingStop() { + if msg.Severity == output.SeverityInfo { + blank := styledLine{text: ""} + if a.spinner.PendingStop() { + a.bufferedLines = append(a.bufferedLines, blank, line, blank) + } else { + a.lines = appendLine(a.lines, blank) + a.lines = appendLine(a.lines, line) + a.lines = appendLine(a.lines, blank) + } + } else if a.spinner.PendingStop() { a.bufferedLines = append(a.bufferedLines, line) } else { a.lines = appendLine(a.lines, line) @@ -208,10 +217,33 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.errorDisplay = a.errorDisplay.Show(output.ErrorEvent{Title: msg.err.Error()}) } return a, tea.Quit + case output.TableEvent: + if line, ok := output.FormatEventLine(msg); ok { + parts := strings.Split(line, "\n") + var lines []styledLine + if len(parts) > 0 { + lines = append(lines, styledLine{text: parts[0], secondary: true}) + } + for _, part := range parts[1:] { + lines = append(lines, styledLine{text: part}) + } + if a.spinner.PendingStop() { + a.bufferedLines = append(a.bufferedLines, lines...) + } else { + for _, l := range lines { + a.lines = appendLine(a.lines, l) + } + } + } default: if line, ok := output.FormatEventLine(msg); ok { for _, part := range strings.Split(line, "\n") { - a.lines = appendLine(a.lines, styledLine{text: part}) + l := styledLine{text: part} + if a.spinner.PendingStop() { + a.bufferedLines = append(a.bufferedLines, l) + } else { + a.lines = appendLine(a.lines, l) + } } } } diff --git a/internal/ui/components/input_prompt.go b/internal/ui/components/input_prompt.go index 560c0fc..68d4bbb 100644 --- a/internal/ui/components/input_prompt.go +++ b/internal/ui/components/input_prompt.go @@ -40,10 +40,18 @@ func (p InputPrompt) View() string { lines := strings.Split(p.prompt, "\n") + firstLine := lines[0] + var sb strings.Builder + + // "?" prefix in secondary color sb.WriteString(styles.Secondary.Render("? ")) - sb.WriteString(styles.Message.Render(lines[0])) - sb.WriteString(styles.Secondary.Render(output.FormatPromptLabels(p.options))) + + sb.WriteString(styles.Message.Render(firstLine)) + + if suffix := output.FormatPromptLabels(p.options); suffix != "" { + sb.WriteString(styles.Secondary.Render(suffix)) + } if len(lines) > 1 { sb.WriteString("\n") diff --git a/internal/ui/components/message.go b/internal/ui/components/message.go index 9ba2da6..f1b82f3 100644 --- a/internal/ui/components/message.go +++ b/internal/ui/components/message.go @@ -14,6 +14,8 @@ func RenderMessage(e output.MessageEvent) string { return prefix + styles.Note.Render("Note:") + " " + styles.Message.Render(e.Text) case output.SeverityWarning: return prefix + styles.Warning.Render("Warning:") + " " + styles.Message.Render(e.Text) + case output.SeverityInfo: + return styles.Highlight.Render(e.Text) default: return styles.Message.Render(e.Text) }