Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Running `lstk` will automatically handle configuration setup and start LocalStac

## Features

- **Start / stop / status** — manage LocalStack emulators with a single command
- **Start / stop / status / doctor** — manage and diagnose LocalStack emulators with a single command set
- **Interactive TUI** — a Bubble Tea-powered terminal UI shown in an interactive terminal for commands like `start`, `login`, `status`, etc.
- **Plain output** for CI/CD and scripting (auto-detected in non-interactive environments or forced with `--non-interactive`)
- **Log streaming** — tail emulator logs in real-time with `--follow`; use `--verbose` to show all logs without filtering
Expand Down Expand Up @@ -163,6 +163,9 @@ lstk stop
# Show emulator status and deployed resources
lstk status

# Diagnose config, Docker, auth, and emulator connectivity
lstk doctor

# Stream emulator logs
lstk logs --follow

Expand Down
111 changes: 111 additions & 0 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package cmd

import (
"fmt"
"net/http"
"os"
"path/filepath"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/doctor"
"github.com/localstack/lstk/internal/emulator/aws"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/telemetry"
"github.com/localstack/lstk/internal/ui"
"github.com/spf13/cobra"
)

func newDoctorCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
return &cobra.Command{
Use: "doctor",
Short: "Diagnose your LocalStack environment",
Long: "Run read-only checks for configuration, Docker, authentication, and emulator connectivity.",
RunE: commandWithTelemetry("doctor", tel, func(cmd *cobra.Command, args []string) error {
configState, containers, err := loadDoctorConfig(cmd)
if err != nil {
return err
}

rt, rtErr := runtime.NewDockerRuntime(cfg.DockerHost)
opts := doctor.Options{
Config: configState,
Containers: containers,
LocalStackHost: cfg.LocalStackHost,
EnvAuthToken: cfg.AuthToken,
ForceFileKeyring: cfg.ForceFileKeyring,
Logger: logger,
RuntimeInitError: rtErr,
}

awsClient := aws.NewClient(&http.Client{})
if isInteractiveMode(cfg) {
return ui.RunDoctor(cmd.Context(), rt, awsClient, opts)
}
return doctor.Run(cmd.Context(), rt, awsClient, output.NewPlainSink(os.Stdout), opts)
}),
}
}

func loadDoctorConfig(cmd *cobra.Command) (doctor.ConfigState, []config.ContainerConfig, error) {
explicitPath, err := cmd.Flags().GetString("config")
if err != nil {
return doctor.ConfigState{}, nil, err
}

if explicitPath != "" {
return loadDoctorConfigPath(explicitPath)
}

existingPath, found, err := config.ExistingConfigFilePath()
if err != nil {
return doctor.ConfigState{}, nil, err
}
if found {
return loadDoctorConfigPath(existingPath)
}

resolvedPath, err := config.ConfigFilePath()
if err != nil {
return doctor.ConfigState{}, nil, err
}

return doctor.ConfigState{
Path: resolvedPath,
Exists: false,
}, nil, nil
}

func loadDoctorConfigPath(path string) (doctor.ConfigState, []config.ContainerConfig, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return doctor.ConfigState{}, nil, fmt.Errorf("failed to resolve config path: %w", err)
}

state := doctor.ConfigState{
Path: absPath,
Exists: true,
}

if _, err := os.Stat(absPath); err != nil {
state.Exists = false
state.LoadError = err
return state, nil, nil
}

if err := config.InitFromPath(absPath); err != nil {
state.LoadError = err
return state, nil, nil
}

appConfig, err := config.Get()
if err != nil {
state.LoadError = err
return state, nil, nil
}

state.Loaded = true
return state, appConfig.Containers, nil
}
9 changes: 9 additions & 0 deletions cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ func TestSubcommandHelpUsesSubcommandUsageLine(t *testing.T) {
assertNotContains(t, out, "LSTK - LocalStack command-line interface")
}

func TestRootHelpIncludesDoctorCommand(t *testing.T) {
out, err := executeWithArgs(t, "--help")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}

assertContains(t, out, "doctor")
}

func assertContains(t *testing.T, s, want string) {
t.Helper()
if !strings.Contains(s, want) {
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
root.AddCommand(
newStartCmd(cfg, tel, logger),
newStopCmd(cfg, tel),
newDoctorCmd(cfg, tel, logger),
newLoginCmd(cfg, tel, logger),
newLogoutCmd(cfg, tel, logger),
newStatusCmd(cfg, tel),
Expand Down
16 changes: 16 additions & 0 deletions internal/config/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ func ConfigFilePath() (string, error) {
return absCreationPath, nil
}

func ExistingConfigFilePath() (string, bool, error) {
existingPath, found, err := firstExistingConfigPath()
if err != nil {
return "", false, err
}
if !found {
return "", false, nil
}

absPath, err := filepath.Abs(existingPath)
if err != nil {
return "", false, fmt.Errorf("failed to resolve absolute config path: %w", err)
}
return absPath, true, nil
}

func ConfigDir() (string, error) {
configPath, err := ConfigFilePath()
if err != nil {
Expand Down
Loading