diff --git a/CLAUDE.md b/CLAUDE.md index 6c75437d..7d9202b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ Environment variables: - Reuse `FormatEventLine(event Event)` for all line-oriented rendering so plain and TUI output stay consistent. - Select output mode at the command boundary in `cmd/`: interactive TTY runs Bubble Tea, non-interactive mode uses `output.NewPlainSink(...)`. - Keep non-TTY mode non-interactive (no stdin prompts or input waits). -- Domain packages must not import Bubble Tea or UI packages. +- Domain packages (`internal/` minus `internal/ui/`) must not import Bubble Tea or UI packages. A useful test: domain code should work unchanged if `internal/ui/` were swapped for a different frontend. - Any feature/workflow package that produces user-visible progress should accept an `output.Sink` dependency and emit events through `internal/output`. - Do not pass UI callbacks like `onProgress func(...)` through domain layers; prefer typed output events. - Event payloads should be domain facts (phase/status/progress), not pre-rendered UI strings. @@ -118,9 +118,11 @@ Domain code must never read from stdin or wait for user input directly. Instead: - `SelectedKey`: which option was selected - `Cancelled`: true if user cancelled (e.g., Ctrl+C) -3. The TUI (`internal/ui/app.go`) handles these events by showing the prompt and sending the response when the user interacts. +3. The TUI (`internal/ui/app.go`) handles these events by showing the prompt and sending the response when the user interacts. `internal/ui/` is responsible only for the interaction itself — it does not contain the logic that acts on the response. -4. In non-interactive mode, commands requiring user input should fail early with a helpful error (e.g., "set LOCALSTACK_AUTH_TOKEN or run in interactive mode"). +4. The logic executed in response to the user's choice (e.g., writing config, starting a container) belongs in a domain package alongside the rest of the feature, not in `internal/ui/`. + +5. In non-interactive mode, commands requiring user input should fail early with a helpful error (e.g., "set LOCALSTACK_AUTH_TOKEN or run in interactive mode"). Example flow in auth login: ```go diff --git a/cmd/aws.go b/cmd/aws.go index 878f9419..e48ded6c 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -33,7 +33,7 @@ Examples: lstk aws sqs list-queues lstk aws s3 mb s3://my-bucket`, DisableFlagParsing: true, - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { diff --git a/cmd/config.go b/cmd/config.go index d1ed7d16..4d1a102c 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -23,7 +23,7 @@ func newConfigProfileCmd(cfg *env.Env) *cobra.Command { return &cobra.Command{ Use: "profile", Short: "Deprecated: use 'lstk setup aws' instead", - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { appConfig, err := config.Get() if err != nil { diff --git a/cmd/login.go b/cmd/login.go index 582cc404..60833c82 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -18,7 +18,7 @@ func newLoginCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. Use: "login", Short: "Manage login", Long: "Manage login and store credentials in system keyring", - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { if !isInteractiveMode(cfg) { return fmt.Errorf("login requires an interactive terminal") diff --git a/cmd/logout.go b/cmd/logout.go index 245f14f9..220a90ca 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -21,7 +21,7 @@ func newLogoutCmd(cfg *env.Env, logger log.Logger) *cobra.Command { return &cobra.Command{ Use: "logout", Short: "Remove stored authentication credentials", - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { platformClient := api.NewPlatformClient(cfg.APIEndpoint, logger) appConfig, err := config.Get() diff --git a/cmd/logs.go b/cmd/logs.go index 8febd699..3f43a70b 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -18,7 +18,7 @@ func newLogsCmd(cfg *env.Env) *cobra.Command { Use: "logs", Short: "Show emulator logs", Long: "Show logs from the emulator. Use --follow to stream in real-time.", - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { follow, err := cmd.Flags().GetBool("follow") if err != nil { diff --git a/cmd/restart.go b/cmd/restart.go index c31b697e..d543c876 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -20,7 +20,7 @@ func newRestartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobr Use: "restart", Short: "Restart emulator", Long: "Stop and restart emulator and services.", - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { diff --git a/cmd/root.go b/cmd/root.go index c2babb05..873728db 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -30,11 +30,12 @@ import ( ) func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command { + var firstRun bool root := &cobra.Command{ Use: "lstk", Short: "LocalStack CLI", Long: "lstk is the command-line interface for LocalStack.", - PreRunE: initConfig, + PreRunE: initConfig(&firstRun), RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { @@ -44,7 +45,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C if err != nil { return err } - return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist) + return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun) }, } @@ -152,8 +153,7 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger } } -func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool) error { - +func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool) error { appConfig, err := config.Get() if err != nil { return fmt.Errorf("failed to get config: %w", err) @@ -174,27 +174,25 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t } if isInteractiveMode(cfg) { - labelCh := make(chan string, 1) - go func() { - label, ok := container.ResolveEmulatorLabel(ctx, opts.PlatformClient, appConfig.Containers, cfg.AuthToken, logger) - if ok { - config.CachePlanLabel(label) - } - labelCh <- label - }() - return ui.Run(ctx, ui.RunOptions{ - Runtime: rt, - Version: version.Version(), - StartOptions: opts, - NotifyOptions: notifyOpts, - ConfigPath: configPath, - EmulatorLabel: config.CachedPlanLabel(), - LabelCh: labelCh, + Runtime: rt, + Version: version.Version(), + StartOptions: opts, + NotifyOptions: notifyOpts, + ConfigPath: configPath, + EmulatorLabel: config.CachedPlanLabel(), + NeedsEmulatorSelection: firstRun, }) } sink := output.NewPlainSink(os.Stdout) + if firstRun && len(appConfig.Containers) > 0 { + emName := appConfig.Containers[0].Type.ShortName() + sink.Emit(output.MessageEvent{ + Severity: output.SeverityNote, + Text: fmt.Sprintf("Configured with default emulator %s.", emName), + }) + } update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken}) return container.Start(ctx, rt, sink, opts, false) } @@ -288,13 +286,19 @@ func newLogger() (log.Logger, func(), error) { return log.New(f), func() { _ = f.Close() }, nil } -func initConfig(cmd *cobra.Command, _ []string) error { - path, err := cmd.Flags().GetString("config") - if err != nil { +func initConfig(firstRun *bool) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, _ []string) error { + path, err := cmd.Flags().GetString("config") + if err != nil { + return err + } + if path != "" { + return config.InitFromPath(path) + } + isFirstRun, err := config.Init() + if firstRun != nil { + *firstRun = isFirstRun + } return err } - if path != "" { - return config.InitFromPath(path) - } - return config.Init() } diff --git a/cmd/setup.go b/cmd/setup.go index 660dd003..f0663f05 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -24,7 +24,7 @@ func newSetupAWSCmd(cfg *env.Env) *cobra.Command { Use: "aws", Short: "Set up the LocalStack AWS profile", Long: "Set up the LocalStack AWS profile in ~/.aws/config and ~/.aws/credentials for use with AWS CLI and SDKs.", - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { appConfig, err := config.Get() if err != nil { diff --git a/cmd/start.go b/cmd/start.go index 1e1a7a3c..3ece0767 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -9,21 +9,22 @@ import ( ) func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command { + var firstRun bool cmd := &cobra.Command{ Use: "start", Short: "Start emulator", Long: "Start emulator and services.", - PreRunE: initConfig, - RunE: func(cmd *cobra.Command, args []string) error { + PreRunE: initConfig(&firstRun), + RunE: func(c *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err } - persist, err := cmd.Flags().GetBool("persist") + persist, err := c.Flags().GetBool("persist") if err != nil { return err } - return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist) + return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun) }, } cmd.Flags().Bool("persist", false, "Enable local persistence (sets LOCALSTACK_PERSISTENCE=1)") diff --git a/cmd/status.go b/cmd/status.go index 75091b11..d7af77d3 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -21,7 +21,7 @@ func newStatusCmd(cfg *env.Env) *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, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { diff --git a/cmd/stop.go b/cmd/stop.go index 6af4d624..79f5ef59 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -19,7 +19,7 @@ func newStopCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { Use: "stop", Short: "Stop emulator", Long: "Stop emulator and services", - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { diff --git a/cmd/update.go b/cmd/update.go index 7cdabf25..3235469b 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -17,7 +17,7 @@ func newUpdateCmd(cfg *env.Env) *cobra.Command { Use: "update", Short: "Update lstk to the latest version", Long: "Check for and apply updates to the lstk CLI. Respects the original installation method (Homebrew, npm, or direct binary).", - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { if isInteractiveMode(cfg) { return ui.RunUpdate(cmd.Context(), checkOnly, cfg.GitHubToken) diff --git a/cmd/volume.go b/cmd/volume.go index 6814f957..802271e1 100644 --- a/cmd/volume.go +++ b/cmd/volume.go @@ -26,7 +26,7 @@ func newVolumePathCmd(cfg *env.Env) *cobra.Command { return &cobra.Command{ Use: "path", Short: "Print the volume directory path", - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { appConfig, err := config.Get() if err != nil { @@ -56,7 +56,7 @@ func newVolumeClearCmd(cfg *env.Env) *cobra.Command { Use: "clear", Short: "Clear emulator volume data", Long: "Remove all data from the emulator volume directory. This resets cached state such as certificates, downloaded tools, and persistence data.", - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { appConfig, err := config.Get() if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 4221095a..14fdbff3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,7 +52,9 @@ func InitFromPath(path string) error { return loadConfig(path) } -func Init() error { +// Init loads the config file, searching the standard paths. If no config file +// exists, it creates one from the default template and returns firstRun=true. +func Init() (firstRun bool, err error) { viper.Reset() setDefaults() viper.SetConfigName(configName) @@ -60,7 +62,7 @@ func Init() error { dirs, err := configSearchDirs() if err != nil { - return err + return false, err } for _, dir := range dirs { viper.AddConfigPath(dir) @@ -70,43 +72,43 @@ func Init() error { var notFoundErr viper.ConfigFileNotFoundError if !errors.As(err, ¬FoundErr) { if used := viper.ConfigFileUsed(); filepath.Ext(used) == ".yaml" || filepath.Ext(used) == ".yml" { - return fmt.Errorf("%s is from an old lstk version; lstk now uses TOML format — remove it or replace it with a config.toml file", used) + return false, fmt.Errorf("%s is from an old lstk version; lstk now uses TOML format — remove it or replace it with a config.toml file", used) } - return fmt.Errorf("failed to read config file: %w", err) + return false, fmt.Errorf("failed to read config file: %w", err) } // No config found anywhere, create one using creation policy. creationDir, err := configCreationDir() if err != nil { - return err + return false, err } if err := os.MkdirAll(creationDir, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) + return false, fmt.Errorf("failed to create config directory: %w", err) } configPath := filepath.Join(creationDir, configFileName) f, err := os.OpenFile(configPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) if err != nil { if errors.Is(err, os.ErrExist) { - return loadConfig(configPath) + return false, loadConfig(configPath) } - return fmt.Errorf("failed to create config file: %w", err) + return false, fmt.Errorf("failed to create config file: %w", err) } _, writeErr := f.WriteString(defaultConfigTemplate) closeErr := f.Close() if writeErr != nil { _ = os.Remove(configPath) - return fmt.Errorf("failed to write config file: %w", writeErr) + return false, fmt.Errorf("failed to write config file: %w", writeErr) } if closeErr != nil { _ = os.Remove(configPath) - return fmt.Errorf("failed to close config file: %w", closeErr) + return false, fmt.Errorf("failed to close config file: %w", closeErr) } - return loadConfig(configPath) + return true, loadConfig(configPath) } - return nil + return false, nil } func resolvedConfigPath() string { diff --git a/internal/config/containers.go b/internal/config/containers.go index 4413bcce..6d21cbb9 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -25,6 +25,24 @@ var emulatorDisplayNames = map[EmulatorType]string{ EmulatorAzure: "Azure", } +// SelectableEmulatorTypes lists the emulator types available for interactive selection, +// in the order they should be presented. The selection key for each type is its first character. +var SelectableEmulatorTypes = []EmulatorType{EmulatorAWS, EmulatorSnowflake} + +func (e EmulatorType) SelectionKey() string { + return string(e)[0:1] +} + +func (e EmulatorType) ShortName() string { + if name, ok := emulatorDisplayNames[e]; ok { + return name + } + return string(e) +} + +func (e EmulatorType) DisplayName() string { + return fmt.Sprintf("LocalStack %s Emulator", e.ShortName()) +} var emulatorHealthPaths = map[EmulatorType]string{ EmulatorAWS: "/_localstack/health", EmulatorSnowflake: "/_localstack/health", @@ -68,13 +86,6 @@ func KnownImageReposForType(t EmulatorType) []string { return repos } -func DisplayNameForType(t EmulatorType) string { - name, ok := emulatorDisplayNames[t] - if !ok { - return fmt.Sprintf("LocalStack %s Emulator", t) - } - return fmt.Sprintf("LocalStack %s Emulator", name) -} type ContainerConfig struct { Type EmulatorType `mapstructure:"type"` @@ -168,7 +179,7 @@ func (c *ContainerConfig) ContainerPort() (string, error) { } func (c *ContainerConfig) DisplayName() string { - return DisplayNameForType(c.Type) + return c.Type.DisplayName() } func (c *ContainerConfig) ProductName() (string, error) { diff --git a/internal/config/emulator_type.go b/internal/config/emulator_type.go new file mode 100644 index 00000000..8383aa68 --- /dev/null +++ b/internal/config/emulator_type.go @@ -0,0 +1,34 @@ +package config + +import ( + "fmt" + "os" + "regexp" +) + +var typeLineRe = regexp.MustCompile(`type\s*=\s*["'](\w+)["']`) + +// SetEmulatorType rewrites the emulator type in the config file and reloads. +// No-op if the requested type is already set. +func SetEmulatorType(to EmulatorType) error { + path := resolvedConfigPath() + if path == "" { + return fmt.Errorf("no config file loaded") + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + m := typeLineRe.FindStringSubmatch(string(data)) + if m == nil { + return fmt.Errorf("no emulator type field found in config") + } + if EmulatorType(m[1]) == to { + return nil + } + updated := typeLineRe.ReplaceAllString(string(data), `type = "`+string(to)+`"`) + if err := os.WriteFile(path, []byte(updated), 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return loadConfig(path) +} diff --git a/internal/config/emulator_type_test.go b/internal/config/emulator_type_test.go new file mode 100644 index 00000000..483d2033 --- /dev/null +++ b/internal/config/emulator_type_test.go @@ -0,0 +1,61 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetEmulatorType_WritesAndReloads(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + require.NoError(t, os.WriteFile(path, []byte("[[containers]]\ntype = \"aws\"\nport = \"4566\"\n"), 0644)) + require.NoError(t, loadConfig(path)) + t.Cleanup(func() { viper.Reset() }) + + require.NoError(t, SetEmulatorType(EmulatorSnowflake)) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(got), `type = "snowflake"`) + assert.NotContains(t, string(got), `type = "aws"`) + + cfg, err := Get() + require.NoError(t, err) + require.Len(t, cfg.Containers, 1) + assert.Equal(t, EmulatorSnowflake, cfg.Containers[0].Type) +} + +func TestSetEmulatorType_NoOpWhenSameEmulator(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + require.NoError(t, loadConfig(path)) + t.Cleanup(func() { viper.Reset() }) + + require.NoError(t, SetEmulatorType(EmulatorAWS)) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, content, string(got)) +} + +func TestSetEmulatorType_PreservesInlineComments(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := "[[containers]]\ntype = \"aws\" # Emulator type\ntag = \"latest\"\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + require.NoError(t, loadConfig(path)) + t.Cleanup(func() { viper.Reset() }) + + require.NoError(t, SetEmulatorType(EmulatorSnowflake)) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(got), `type = "snowflake" # Emulator type`) +} diff --git a/internal/container/label.go b/internal/container/label.go index 924551ff..8be1622b 100644 --- a/internal/container/label.go +++ b/internal/container/label.go @@ -11,6 +11,14 @@ import ( "github.com/localstack/lstk/internal/log" ) +func ResolveAndCacheLabel(ctx context.Context, opts StartOptions, labelCh chan<- string) { + label, ok := ResolveEmulatorLabel(ctx, opts.PlatformClient, opts.Containers, opts.AuthToken, opts.Logger) + if ok { + config.CachePlanLabel(label) + } + labelCh <- label +} + const NoLicenseLabel = "LocalStack (No license)" // ResolveEmulatorLabel tries to fetch the plan name from the license API diff --git a/internal/container/select.go b/internal/container/select.go new file mode 100644 index 00000000..822223db --- /dev/null +++ b/internal/container/select.go @@ -0,0 +1,62 @@ +package container + +import ( + "context" + "fmt" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" +) + +func SelectEmulator( + ctx context.Context, + sink output.Sink, + configPath string, +) ([]config.ContainerConfig, error) { + options := make([]output.InputOption, len(config.SelectableEmulatorTypes)) + for i, t := range config.SelectableEmulatorTypes { + options[i] = output.InputOption{Key: t.SelectionKey(), Label: t.ShortName()} + } + + responseCh := make(chan output.InputResponse, 1) + sink.Emit(output.UserInputRequestEvent{ + Prompt: "Which emulator would you like to use?", + Options: options, + ResponseCh: responseCh, + Vertical: true, + }) + + var resp output.InputResponse + select { + case resp = <-responseCh: + case <-ctx.Done(): + return nil, context.Canceled + } + + if resp.Cancelled { + return nil, context.Canceled + } + + selected := config.SelectableEmulatorTypes[0] + for _, t := range config.SelectableEmulatorTypes { + if t.SelectionKey() == resp.SelectedKey { + selected = t + break + } + } + + if err := config.SetEmulatorType(selected); err != nil { + return nil, fmt.Errorf("failed to set emulator type: %w", err) + } + newCfg, err := config.Get() + if err != nil { + return nil, err + } + + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: selected.ShortName() + " emulator selected."}) + if configPath != "" { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Change configuration in " + configPath + "."}) + } + + return newCfg.Containers, nil +} diff --git a/internal/container/start.go b/internal/container/start.go index 8ad0691d..a0dabd84 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -220,7 +220,7 @@ func runPostStartSetups(ctx context.Context, sink output.Sink, containers []conf } func emitAlreadyRunning(sink output.Sink, c runtime.ContainerConfig, localStackHost, webAppURL string) { - sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: fmt.Sprintf("%s is already running", config.DisplayNameForType(c.EmulatorType))}) + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: fmt.Sprintf("%s is already running", c.EmulatorType.DisplayName())}) resolvedHost, dnsOK := endpoint.ResolveHost(c.Port, localStackHost) if !dnsOK { sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: endpoint.DNSRebindNote}) @@ -415,8 +415,8 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu foundType := config.EmulatorTypeForImage(found.Image) if foundType != "" && foundType != c.EmulatorType { sink.Emit(output.ErrorEvent{ - Title: fmt.Sprintf("%s is running on port %s", config.DisplayNameForType(foundType), found.BoundPort), - Summary: fmt.Sprintf("Your config specifies the %s. Only one emulator can run on a port at a time.", config.DisplayNameForType(c.EmulatorType)), + Title: fmt.Sprintf("%s is running on port %s", foundType.DisplayName(), found.BoundPort), + Summary: fmt.Sprintf("Your config specifies the %s. Only one emulator can run on a port at a time.", c.EmulatorType.DisplayName()), Actions: []output.ErrorAction{ {Label: "Stop the running emulator:", Value: fmt.Sprintf("docker stop %s", found.Name)}, }, @@ -428,11 +428,11 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu ErrorCode: telemetry.ErrCodeEmulatorMismatch, ErrorMsg: fmt.Sprintf("running %s on port %s, configured %s", foundType, found.BoundPort, c.EmulatorType), }) - return nil, output.NewSilentError(fmt.Errorf("%s is already running on port %s", config.DisplayNameForType(foundType), found.BoundPort)) + return nil, output.NewSilentError(fmt.Errorf("%s is already running on port %s", foundType.DisplayName(), found.BoundPort)) } if found.BoundPort != c.Port { sink.Emit(output.ErrorEvent{ - Title: fmt.Sprintf("%s is already running on port %s", config.DisplayNameForType(c.EmulatorType), found.BoundPort), + Title: fmt.Sprintf("%s is already running on port %s", c.EmulatorType.DisplayName(), found.BoundPort), Summary: fmt.Sprintf("Config expects port %s. Only one instance can run at a time.", c.Port), Actions: []output.ErrorAction{ {Label: "Stop existing emulator:", Value: "lstk stop"}, diff --git a/internal/ui/run.go b/internal/ui/run.go index f176b7cb..9bcc1786 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -27,13 +27,13 @@ func (s programSender) Send(msg any) { // RunOptions groups the parameters for Run. Bundling them keeps the call // site readable as the UI entry point grows new concerns. type RunOptions struct { - Runtime runtime.Runtime - Version string - StartOptions container.StartOptions - NotifyOptions update.NotifyOptions - ConfigPath string - EmulatorLabel string - LabelCh <-chan string + Runtime runtime.Runtime + Version string + StartOptions container.StartOptions + NotifyOptions update.NotifyOptions + ConfigPath string + EmulatorLabel string + NeedsEmulatorSelection bool } func Run(parentCtx context.Context, runOpts RunOptions) error { @@ -50,26 +50,43 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { p := tea.NewProgram(app) runErrCh := make(chan error, 1) - if runOpts.LabelCh != nil { - go func() { - select { - case label, ok := <-runOpts.LabelCh: - if ok && label != "" { - p.Send(headerLabelMsg{label: label}) - } - case <-ctx.Done(): + labelCh := make(chan string, 1) + go func() { + select { + case label := <-labelCh: + if label != "" { + p.Send(headerLabelMsg{label: label}) } - }() - } + case <-ctx.Done(): + } + }() go func() { var err error defer func() { runErrCh <- err }() sink := output.NewTUISink(programSender{p: p}) + // Start label resolution immediately when no emulator selection is needed, so + // headerLabelMsg always arrives even if NotifyUpdate returns early (update case). + // When emulator selection is needed, resolution starts after the user picks. + if !runOpts.NeedsEmulatorSelection { + go container.ResolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) + } if update.NotifyUpdate(ctx, sink, runOpts.NotifyOptions) { p.Send(runDoneMsg{}) return } + if runOpts.NeedsEmulatorSelection { + newContainers, selErr := container.SelectEmulator(ctx, sink, runOpts.ConfigPath) + if selErr != nil { + if errors.Is(selErr, context.Canceled) { + return + } + p.Send(runErrMsg{err: selErr}) + return + } + runOpts.StartOptions.Containers = newContainers + go container.ResolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) + } err = container.Start(ctx, runOpts.Runtime, sink, runOpts.StartOptions, true) if err != nil { if errors.Is(err, context.Canceled) { diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go new file mode 100644 index 00000000..62c80931 --- /dev/null +++ b/test/integration/emulator_select_test.go @@ -0,0 +1,132 @@ +package integration_test + +import ( + "bytes" + "context" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/creack/pty" + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNoEmulatorSelectionWhenConfigExists(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + e := env.Environ(testEnvWithHome(tmpHome, tmpHome)). + With(env.DisableEvents, "1") + + // Pre-create the config so lstk does not treat this as a first run. + configPath, _, err := runLstk(t, testContext(t), "", e, "config", "path") + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + require.NoError(t, os.WriteFile(configPath, []byte("[[containers]]\ntype = \"aws\"\ntag = \"latest\"\nport = \"4566\"\n"), 0644)) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binaryPath(), "start") + cmd.Env = e + + ptmx, err := pty.Start(cmd) + require.NoError(t, err, "failed to start lstk in PTY") + defer func() { _ = ptmx.Close() }() + + out := &syncBuffer{} + outputCh := make(chan struct{}) + go func() { + _, _ = io.Copy(out, ptmx) + close(outputCh) + }() + + assert.Never(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Which emulator would you like to use?")) + }, 2*time.Second, 100*time.Millisecond, "emulator selection prompt should not appear when config already exists") + + cancel() + <-outputCh +} + +func TestFirstRunShowsEmulatorSelectionPrompt(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + e := env.Environ(testEnvWithHome(tmpHome, tmpHome)). + With(env.DisableEvents, "1") + + // Confirm no config exists at the path lstk would use — this is what triggers first-run. + configPath, _, err := runLstk(t, testContext(t), "", e, "config", "path") + require.NoError(t, err) + require.NoFileExists(t, configPath) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binaryPath(), "start") + cmd.Env = e + + ptmx, err := pty.Start(cmd) + require.NoError(t, err, "failed to start lstk in PTY") + defer func() { _ = ptmx.Close() }() + + out := &syncBuffer{} + outputCh := make(chan struct{}) + go func() { + _, _ = io.Copy(out, ptmx) + close(outputCh) + }() + + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Which emulator would you like to use?")) + }, 10*time.Second, 100*time.Millisecond, "emulator selection prompt should appear on first run") + + // Confirm the default-highlighted option (AWS) by pressing Enter. + _, err = ptmx.Write([]byte("\r")) + require.NoError(t, err) + + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("AWS emulator selected.")) + }, 10*time.Second, 100*time.Millisecond, "selection confirmation should appear after pressing Enter") + + // SetEmulatorType writes the config before emitting the confirmation message, + // so the file is guaranteed to exist and contain the selection by this point. + configData, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(configData), `type = "aws"`) + + cancel() + <-outputCh +} + +func TestFirstRunNonInteractiveEmitsDefaultEmulatorNote(t *testing.T) { + t.Parallel() + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + e := env.Environ(testEnvWithHome(tmpHome, tmpHome)).With(env.DisableEvents, "1") + + // Verify no config exists — this is what triggers first-run. + configPath, _, err := runLstk(t, testContext(t), "", e, "config", "path") + require.NoError(t, err) + require.NoFileExists(t, configPath) + + // Process fails at container.Start (no Docker), but the note is emitted before that. + stdout, _, runErr := runLstk(t, testContext(t), "", e.With(env.AuthToken, "test-token"), "--non-interactive") + assert.Error(t, runErr, "expected failure: no Docker available") + assert.Contains(t, stdout, "Configured with default emulator", "non-interactive first run should note the default emulator") +}