From 0c17622d81b4c0f58ae7fd782932b211369ecb1a Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 30 Apr 2026 17:01:02 +0200 Subject: [PATCH 01/22] Select default emulator on first run --- cmd/root.go | 114 ++++++++++++--- cmd/start.go | 14 +- internal/config/config.go | 26 ++-- internal/config/containers.go | 6 + internal/config/switch.go | 175 +++++++++++++++++++++++ internal/config/switch_test.go | 162 +++++++++++++++++++++ internal/ui/run.go | 78 +++++++++- test/integration/emulator_select_test.go | 103 +++++++++++++ 8 files changed, 636 insertions(+), 42 deletions(-) create mode 100644 internal/config/switch.go create mode 100644 internal/config/switch_test.go create mode 100644 test/integration/emulator_select_test.go diff --git a/cmd/root.go b/cmd/root.go index c2babb05..fc222b42 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -30,12 +30,17 @@ 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: initConfigCapturingFirstRun(&firstRun), RunE: func(cmd *cobra.Command, args []string) error { + emulator, err := cmd.Flags().GetString("emulator") + if err != nil { + return err + } rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err @@ -44,7 +49,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, emulator) }, } @@ -55,6 +60,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C root.PersistentFlags().String("config", "", "Path to config file") root.PersistentFlags().BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive mode") root.Flags().Bool("persist", false, "Enable local persistence (sets LOCALSTACK_PERSISTENCE=1)") + root.Flags().String("emulator", "", "Emulator to use (aws|snowflake)") configureHelp(root) @@ -152,13 +158,28 @@ 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, requestedEmulator string) error { appConfig, err := config.Get() if err != nil { return fmt.Errorf("failed to get config: %w", err) } + if requestedEmulator != "" { + emType, err := parseEmulatorType(requestedEmulator) + if err != nil { + return err + } + if len(appConfig.Containers) == 0 || appConfig.Containers[0].Type != emType { + if err := config.SwitchEmulator(emType); err != nil { + return fmt.Errorf("failed to switch emulator: %w", err) + } + appConfig, err = config.Get() + if err != nil { + return fmt.Errorf("failed to reload config: %w", err) + } + } + } + opts := buildStartOptions(cfg, appConfig, logger, tel, persist) notifyOpts := update.NotifyOptions{ @@ -173,32 +194,72 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t logger.Info("could not resolve friendly config path: %v", err) } + needsEmulatorSelection := firstRun && requestedEmulator == "" && isInteractiveMode(cfg) + 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 - }() + if !needsEmulatorSelection { + 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(), + LabelCh: labelCh, + NeedsEmulatorSelection: needsEmulatorSelection, + OnEmulatorSelected: func(emType config.EmulatorType) ([]config.ContainerConfig, error) { + if err := config.SwitchEmulator(emType); err != nil { + return nil, fmt.Errorf("failed to switch emulator: %w", err) + } + newCfg, err := config.Get() + if err != nil { + return nil, err + } + go func() { + label, ok := container.ResolveEmulatorLabel(ctx, opts.PlatformClient, newCfg.Containers, cfg.AuthToken, logger) + if ok { + config.CachePlanLabel(label) + } + labelCh <- label + }() + return newCfg.Containers, nil + }, }) } sink := output.NewPlainSink(os.Stdout) + if firstRun && requestedEmulator == "" && len(appConfig.Containers) > 0 { + emName := appConfig.Containers[0].Type.DisplayName() + sink.Emit(output.MessageEvent{ + Severity: output.SeverityNote, + Text: fmt.Sprintf("No emulator configured; defaulting to %s. Use --emulator to change this.", emName), + }) + } update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken}) return container.Start(ctx, rt, sink, opts, false) } +func parseEmulatorType(s string) (config.EmulatorType, error) { + switch config.EmulatorType(strings.ToLower(s)) { + case config.EmulatorAWS: + return config.EmulatorAWS, nil + case config.EmulatorSnowflake: + return config.EmulatorSnowflake, nil + default: + return "", fmt.Errorf("unsupported emulator %q: must be 'aws' or 'snowflake'", s) + } +} + // instrumentCommands walks the Cobra command tree and wraps every RunE with telemetry emission. func instrumentCommands(cmd *cobra.Command, tel *telemetry.Client) { if cmd.RunE != nil { @@ -296,5 +357,20 @@ func initConfig(cmd *cobra.Command, _ []string) error { if path != "" { return config.InitFromPath(path) } - return config.Init() + _, err = config.Init() + return err +} + +func initConfigCapturingFirstRun(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) + } + *firstRun, err = config.Init() + return err + } } diff --git a/cmd/start.go b/cmd/start.go index 1e1a7a3c..d482e896 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -9,23 +9,29 @@ 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: initConfigCapturingFirstRun(&firstRun), + RunE: func(c *cobra.Command, args []string) error { + emulator, err := c.Flags().GetString("emulator") + if err != nil { + return err + } 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, emulator) }, } cmd.Flags().Bool("persist", false, "Enable local persistence (sets LOCALSTACK_PERSISTENCE=1)") + cmd.Flags().String("emulator", "", "Emulator to use (aws|snowflake)") return cmd } 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..01b7f4e2 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -25,6 +25,12 @@ var emulatorDisplayNames = map[EmulatorType]string{ EmulatorAzure: "Azure", } +func (e EmulatorType) DisplayName() string { + if name, ok := emulatorDisplayNames[e]; ok { + return name + } + return string(e) +} var emulatorHealthPaths = map[EmulatorType]string{ EmulatorAWS: "/_localstack/health", EmulatorSnowflake: "/_localstack/health", diff --git a/internal/config/switch.go b/internal/config/switch.go new file mode 100644 index 00000000..325f2689 --- /dev/null +++ b/internal/config/switch.go @@ -0,0 +1,175 @@ +package config + +import ( + "fmt" + "os" + "regexp" + "strings" +) + +const awsContainerBlock = `[[containers]] +type = "aws" +tag = "latest" +port = "4566" +# volume = "" # Host directory for persistent state (default: OS cache dir) +# env = [] # Named environment profiles to apply (see [env.*] sections below)` + +const snowflakeContainerBlock = `[[containers]] +type = "snowflake" +tag = "latest" +port = "4566" +# volume = "" # Host directory for persistent state (default: OS cache dir) +# env = [] # Named environment profiles to apply (see [env.*] sections below)` + +// SwitchEmulator updates the config file to activate the given emulator type. +// Active container blocks for other types are commented out. If a previously +// commented block for the target type exists it is restored; otherwise a fresh +// block is appended. No-op when the target is already the only active emulator. +func SwitchEmulator(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) + } + + updated, changed, err := switchEmulatorContent(string(data), to) + if err != nil { + return err + } + if !changed { + return nil + } + + if err := os.WriteFile(path, []byte(updated), 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return loadConfig(path) +} + +func switchEmulatorContent(content string, to EmulatorType) (updated string, changed bool, err error) { + lines := strings.Split(content, "\n") + blocks := parseContainerBlocks(lines) + + if isEmulatorAlreadyActive(blocks, to) { + return content, false, nil + } + + newLines := make([]string, len(lines)) + copy(newLines, lines) + + hasActiveTarget := false + restoredCommented := false + + for _, b := range blocks { + switch { + case !b.isCommented && b.emulType == to: + hasActiveTarget = true + case !b.isCommented && b.emulType != to: + for i := b.start; i < b.end; i++ { + if newLines[i] != "" { + newLines[i] = "# " + newLines[i] + } + } + case b.isCommented && b.emulType == to && !restoredCommented: + for i := b.start; i < b.end; i++ { + newLines[i] = strings.TrimPrefix(newLines[i], "# ") + } + restoredCommented = true + } + } + + result := strings.Join(newLines, "\n") + if !hasActiveTarget && !restoredCommented { + tmpl := containerBlockTemplate(to) + result = strings.TrimRight(result, "\n") + "\n\n" + tmpl + "\n" + } + + return result, true, nil +} + +func isEmulatorAlreadyActive(blocks []containerBlock, to EmulatorType) bool { + hasActiveTarget := false + for _, b := range blocks { + if b.isCommented { + continue + } + if b.emulType != to { + return false + } + hasActiveTarget = true + } + return hasActiveTarget +} + +type containerBlock struct { + start int + end int // exclusive + emulType EmulatorType + isCommented bool +} + +func parseContainerBlocks(lines []string) []containerBlock { + var blocks []containerBlock + n := len(lines) + + for i := 0; i < n; i++ { + trimmed := strings.TrimSpace(lines[i]) + isActive := trimmed == "[[containers]]" + isCommented := trimmed == "# [[containers]]" + if !isActive && !isCommented { + continue + } + + end := n + for j := i + 1; j < n; j++ { + t := strings.TrimSpace(lines[j]) + if t == "[[containers]]" || t == "# [[containers]]" { + end = j + break + } + if len(t) > 0 && t[0] == '[' { + end = j + break + } + } + + blocks = append(blocks, containerBlock{ + start: i, + end: end, + emulType: detectBlockType(lines[i:end], isCommented), + isCommented: isCommented, + }) + i = end - 1 + } + return blocks +} + +var typeLineRe = regexp.MustCompile(`type\s*=\s*"(\w+)"`) + +func detectBlockType(lines []string, isCommented bool) EmulatorType { + for _, line := range lines { + effective := strings.TrimSpace(line) + if isCommented { + effective = strings.TrimSpace(strings.TrimPrefix(effective, "#")) + } + if m := typeLineRe.FindStringSubmatch(effective); m != nil { + return EmulatorType(strings.ToLower(m[1])) + } + } + return "" +} + +func containerBlockTemplate(t EmulatorType) string { + switch t { + case EmulatorAWS: + return awsContainerBlock + case EmulatorSnowflake: + return snowflakeContainerBlock + default: + return "" + } +} diff --git a/internal/config/switch_test.go b/internal/config/switch_test.go new file mode 100644 index 00000000..c705ad4c --- /dev/null +++ b/internal/config/switch_test.go @@ -0,0 +1,162 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSwitchEmulatorContent_NoOp_AlreadyAWS(t *testing.T) { + content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n" + result, changed, err := switchEmulatorContent(content, EmulatorAWS) + require.NoError(t, err) + assert.False(t, changed) + assert.Equal(t, content, result) +} + +func TestSwitchEmulatorContent_NoOp_AlreadySnowflake(t *testing.T) { + content := "[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n" + result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) + require.NoError(t, err) + assert.False(t, changed) + assert.Equal(t, content, result) +} + +func TestSwitchEmulatorContent_CommentAWSAndAppendSnowflake(t *testing.T) { + content := `[[containers]] +type = "aws" +port = "4566" + +[cli] +update_skipped_version = "" +` + result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) + require.NoError(t, err) + assert.True(t, changed) + + assert.Contains(t, result, "# [[containers]]") + assert.Contains(t, result, `# type = "aws"`) + assert.Contains(t, result, `# port = "4566"`) + assert.Contains(t, result, `type = "snowflake"`) + assert.Contains(t, result, "[cli]") + // aws block should not appear as active + assert.NotContains(t, result, "\n[[containers]]\ntype = \"aws\"") +} + +func TestSwitchEmulatorContent_RestoresCommentedAWS(t *testing.T) { + content := "# [[containers]]\n# type = \"aws\"\n# port = \"4566\"\n\n[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n" + result, changed, err := switchEmulatorContent(content, EmulatorAWS) + require.NoError(t, err) + assert.True(t, changed) + + assert.Contains(t, result, "[[containers]]\ntype = \"aws\"") + assert.Contains(t, result, "# [[containers]]") + assert.Contains(t, result, `# type = "snowflake"`) + assert.NotContains(t, result, "\n[[containers]]\ntype = \"snowflake\"") +} + +func TestSwitchEmulatorContent_RestoresCommentedSnowflake(t *testing.T) { + content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n\n# [[containers]]\n# type = \"snowflake\"\n# port = \"4566\"\n" + result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) + require.NoError(t, err) + assert.True(t, changed) + + assert.Contains(t, result, "[[containers]]\ntype = \"snowflake\"") + assert.Contains(t, result, "# [[containers]]") + assert.Contains(t, result, `# type = "aws"`) +} + +func TestSwitchEmulatorContent_PreservesNonContainerContent(t *testing.T) { + content := `# lstk configuration file + +[[containers]] +type = "aws" +port = "4566" +# volume = "" # some comment + +# [env.debug] +# DEBUG = "1" + +[cli] +update_skipped_version = "v1.2.3" +` + result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) + require.NoError(t, err) + assert.True(t, changed) + + assert.Contains(t, result, "# lstk configuration file") + assert.Contains(t, result, `update_skipped_version = "v1.2.3"`) + assert.Contains(t, result, "# [env.debug]") + assert.Contains(t, result, `type = "snowflake"`) +} + +func TestSwitchEmulatorContent_PreservesInlineComments(t *testing.T) { + content := "[[containers]]\ntype = \"aws\" # Emulator type\ntag = \"latest\" # Docker image tag\nport = \"4566\" # Host port\n# volume = \"\" # persistent state\n" + result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) + require.NoError(t, err) + assert.True(t, changed) + + // Original inline comments should be preserved in the commented-out block + assert.Contains(t, result, "# type = \"aws\" # Emulator type") + assert.Contains(t, result, "# # volume = \"\" # persistent state") +} + +func TestSwitchEmulatorContent_RoundTrip(t *testing.T) { + original := `[[containers]] +type = "aws" +port = "4566" +` + // Switch to snowflake + afterSnowflake, changed, err := switchEmulatorContent(original, EmulatorSnowflake) + require.NoError(t, err) + assert.True(t, changed) + assert.Contains(t, afterSnowflake, `type = "snowflake"`) + + // Switch back to AWS — should restore the commented block + afterAWS, changed, err := switchEmulatorContent(afterSnowflake, EmulatorAWS) + require.NoError(t, err) + assert.True(t, changed) + assert.Contains(t, afterAWS, "[[containers]]\ntype = \"aws\"") + assert.NotContains(t, afterAWS, "\n[[containers]]\ntype = \"snowflake\"") +} + +func TestSwitchEmulator_WritesAndReloads(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, SwitchEmulator(EmulatorSnowflake)) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(got), `type = "snowflake"`) + assert.True(t, strings.Contains(string(got), "# [[containers]]")) + + cfg, err := Get() + require.NoError(t, err) + require.Len(t, cfg.Containers, 1) + assert.Equal(t, EmulatorSnowflake, cfg.Containers[0].Type) +} + +func TestSwitchEmulator_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, SwitchEmulator(EmulatorAWS)) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, content, string(got)) +} diff --git a/internal/ui/run.go b/internal/ui/run.go index f176b7cb..37bf9f01 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -3,9 +3,11 @@ package ui import ( "context" "errors" + "fmt" "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" @@ -27,13 +29,17 @@ 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 + LabelCh <-chan string + NeedsEmulatorSelection bool + // OnEmulatorSelected is called with the user's choice when NeedsEmulatorSelection is true. + // It should switch the config and return the updated container configs to use for this run. + OnEmulatorSelected func(config.EmulatorType) ([]config.ContainerConfig, error) } func Run(parentCtx context.Context, runOpts RunOptions) error { @@ -70,6 +76,17 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { p.Send(runDoneMsg{}) return } + if runOpts.NeedsEmulatorSelection { + newContainers, selErr := selectEmulatorInTUI(ctx, sink, runOpts.ConfigPath, runOpts.OnEmulatorSelected) + if selErr != nil { + if errors.Is(selErr, context.Canceled) { + return + } + p.Send(runErrMsg{err: selErr}) + return + } + runOpts.StartOptions.Containers = newContainers + } err = container.Start(ctx, runOpts.Runtime, sink, runOpts.StartOptions, true) if err != nil { if errors.Is(err, context.Canceled) { @@ -98,6 +115,53 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { return nil } +func selectEmulatorInTUI( + ctx context.Context, + sink output.Sink, + configPath string, + onSelected func(config.EmulatorType) ([]config.ContainerConfig, error), +) ([]config.ContainerConfig, error) { + responseCh := make(chan output.InputResponse, 1) + sink.Emit(output.UserInputRequestEvent{ + Prompt: "Which emulator would you like to use?", + Options: []output.InputOption{ + {Key: "a", Label: "AWS [A]"}, + {Key: "s", Label: "Snowflake [S]"}, + }, + 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.EmulatorAWS + if resp.SelectedKey == "s" { + selected = config.EmulatorSnowflake + } + + containers, err := onSelected(selected) + if err != nil { + return nil, err + } + + msg := selected.DisplayName() + " emulator selected." + if configPath != "" { + msg += fmt.Sprintf(" You can change this anytime in %s.", configPath) + } + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: msg}) + + return containers, nil +} + func IsInteractive() bool { return term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stdin.Fd())) } diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go new file mode 100644 index 00000000..4fedf3ba --- /dev/null +++ b/test/integration/emulator_select_test.go @@ -0,0 +1,103 @@ +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 TestEmulatorFlagSwitchesConfigToSnowflake(t *testing.T) { + t.Parallel() + // config.SwitchEmulator writes the file before container.Start is called, + // so we can verify the switch even when the process ultimately fails (no Docker). + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + e := env.Environ(testEnvWithHome(tmpHome, tmpHome)).With(env.DisableEvents, "1") + + configDir := filepath.Join(tmpHome, ".config", "lstk") + require.NoError(t, os.MkdirAll(configDir, 0755)) + configPath := filepath.Join(configDir, "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`[[containers]] +type = "aws" +tag = "latest" +port = "4566" +`), 0644)) + + ctx := testContext(t) + // The process will fail at container.Start (no Docker / no real auth), but the + // config switch happens earlier so the file should already be updated. + _, _, _ = runLstk(t, ctx, "", e.With(env.AuthToken, "test-token"), "--emulator", "snowflake", "--non-interactive") + + got, err := os.ReadFile(configPath) + require.NoError(t, err, "config file should still exist after the run") + assert.Contains(t, string(got), `type = "snowflake"`, "config should be switched to snowflake") + assert.NotContains(t, string(got), "\n[[containers]]\ntype = \"aws\"", "original aws block should be commented out") +} + +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") + + 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, _, _ := runLstk(t, testContext(t), "", e.With(env.AuthToken, "test-token"), "--non-interactive") + assert.Contains(t, stdout, "defaulting to AWS", "non-interactive first run should note the default emulator") +} From d7fcdfb51931f083e58479701d8fb22a23b2e514 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 4 May 2026 17:41:38 +0200 Subject: [PATCH 02/22] Parallelize new tests --- internal/config/switch_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/config/switch_test.go b/internal/config/switch_test.go index c705ad4c..ecf8e249 100644 --- a/internal/config/switch_test.go +++ b/internal/config/switch_test.go @@ -12,6 +12,7 @@ import ( ) func TestSwitchEmulatorContent_NoOp_AlreadyAWS(t *testing.T) { + t.Parallel() content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n" result, changed, err := switchEmulatorContent(content, EmulatorAWS) require.NoError(t, err) @@ -20,6 +21,7 @@ func TestSwitchEmulatorContent_NoOp_AlreadyAWS(t *testing.T) { } func TestSwitchEmulatorContent_NoOp_AlreadySnowflake(t *testing.T) { + t.Parallel() content := "[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n" result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) require.NoError(t, err) @@ -28,6 +30,7 @@ func TestSwitchEmulatorContent_NoOp_AlreadySnowflake(t *testing.T) { } func TestSwitchEmulatorContent_CommentAWSAndAppendSnowflake(t *testing.T) { + t.Parallel() content := `[[containers]] type = "aws" port = "4566" @@ -49,6 +52,7 @@ update_skipped_version = "" } func TestSwitchEmulatorContent_RestoresCommentedAWS(t *testing.T) { + t.Parallel() content := "# [[containers]]\n# type = \"aws\"\n# port = \"4566\"\n\n[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n" result, changed, err := switchEmulatorContent(content, EmulatorAWS) require.NoError(t, err) @@ -61,6 +65,7 @@ func TestSwitchEmulatorContent_RestoresCommentedAWS(t *testing.T) { } func TestSwitchEmulatorContent_RestoresCommentedSnowflake(t *testing.T) { + t.Parallel() content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n\n# [[containers]]\n# type = \"snowflake\"\n# port = \"4566\"\n" result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) require.NoError(t, err) @@ -72,6 +77,7 @@ func TestSwitchEmulatorContent_RestoresCommentedSnowflake(t *testing.T) { } func TestSwitchEmulatorContent_PreservesNonContainerContent(t *testing.T) { + t.Parallel() content := `# lstk configuration file [[containers]] @@ -96,6 +102,7 @@ update_skipped_version = "v1.2.3" } func TestSwitchEmulatorContent_PreservesInlineComments(t *testing.T) { + t.Parallel() content := "[[containers]]\ntype = \"aws\" # Emulator type\ntag = \"latest\" # Docker image tag\nport = \"4566\" # Host port\n# volume = \"\" # persistent state\n" result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) require.NoError(t, err) @@ -107,6 +114,7 @@ func TestSwitchEmulatorContent_PreservesInlineComments(t *testing.T) { } func TestSwitchEmulatorContent_RoundTrip(t *testing.T) { + t.Parallel() original := `[[containers]] type = "aws" port = "4566" From ea7166adf2c72798bbc8d4352a45e5f16f807ec4 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 12:37:27 +0200 Subject: [PATCH 03/22] Remove error & rename message for default emulator --- cmd/root.go | 2 +- internal/config/switch.go | 11 ++++------- internal/config/switch_test.go | 27 +++++++++------------------ 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index fc222b42..688ce802 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -242,7 +242,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t emName := appConfig.Containers[0].Type.DisplayName() sink.Emit(output.MessageEvent{ Severity: output.SeverityNote, - Text: fmt.Sprintf("No emulator configured; defaulting to %s. Use --emulator to change this.", emName), + Text: fmt.Sprintf("Configured with default emulator %s. Pass --emulator to change.", emName), }) } update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken}) diff --git a/internal/config/switch.go b/internal/config/switch.go index 325f2689..edc3d4e8 100644 --- a/internal/config/switch.go +++ b/internal/config/switch.go @@ -36,10 +36,7 @@ func SwitchEmulator(to EmulatorType) error { return fmt.Errorf("failed to read config file: %w", err) } - updated, changed, err := switchEmulatorContent(string(data), to) - if err != nil { - return err - } + updated, changed := switchEmulatorContent(string(data), to) if !changed { return nil } @@ -50,12 +47,12 @@ func SwitchEmulator(to EmulatorType) error { return loadConfig(path) } -func switchEmulatorContent(content string, to EmulatorType) (updated string, changed bool, err error) { +func switchEmulatorContent(content string, to EmulatorType) (updated string, changed bool) { lines := strings.Split(content, "\n") blocks := parseContainerBlocks(lines) if isEmulatorAlreadyActive(blocks, to) { - return content, false, nil + return content, false } newLines := make([]string, len(lines)) @@ -88,7 +85,7 @@ func switchEmulatorContent(content string, to EmulatorType) (updated string, cha result = strings.TrimRight(result, "\n") + "\n\n" + tmpl + "\n" } - return result, true, nil + return result, true } func isEmulatorAlreadyActive(blocks []containerBlock, to EmulatorType) bool { diff --git a/internal/config/switch_test.go b/internal/config/switch_test.go index ecf8e249..3dd11cf3 100644 --- a/internal/config/switch_test.go +++ b/internal/config/switch_test.go @@ -14,8 +14,7 @@ import ( func TestSwitchEmulatorContent_NoOp_AlreadyAWS(t *testing.T) { t.Parallel() content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n" - result, changed, err := switchEmulatorContent(content, EmulatorAWS) - require.NoError(t, err) + result, changed := switchEmulatorContent(content, EmulatorAWS) assert.False(t, changed) assert.Equal(t, content, result) } @@ -23,8 +22,7 @@ func TestSwitchEmulatorContent_NoOp_AlreadyAWS(t *testing.T) { func TestSwitchEmulatorContent_NoOp_AlreadySnowflake(t *testing.T) { t.Parallel() content := "[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n" - result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) - require.NoError(t, err) + result, changed := switchEmulatorContent(content, EmulatorSnowflake) assert.False(t, changed) assert.Equal(t, content, result) } @@ -38,8 +36,7 @@ port = "4566" [cli] update_skipped_version = "" ` - result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) - require.NoError(t, err) + result, changed := switchEmulatorContent(content, EmulatorSnowflake) assert.True(t, changed) assert.Contains(t, result, "# [[containers]]") @@ -54,8 +51,7 @@ update_skipped_version = "" func TestSwitchEmulatorContent_RestoresCommentedAWS(t *testing.T) { t.Parallel() content := "# [[containers]]\n# type = \"aws\"\n# port = \"4566\"\n\n[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n" - result, changed, err := switchEmulatorContent(content, EmulatorAWS) - require.NoError(t, err) + result, changed := switchEmulatorContent(content, EmulatorAWS) assert.True(t, changed) assert.Contains(t, result, "[[containers]]\ntype = \"aws\"") @@ -67,8 +63,7 @@ func TestSwitchEmulatorContent_RestoresCommentedAWS(t *testing.T) { func TestSwitchEmulatorContent_RestoresCommentedSnowflake(t *testing.T) { t.Parallel() content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n\n# [[containers]]\n# type = \"snowflake\"\n# port = \"4566\"\n" - result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) - require.NoError(t, err) + result, changed := switchEmulatorContent(content, EmulatorSnowflake) assert.True(t, changed) assert.Contains(t, result, "[[containers]]\ntype = \"snowflake\"") @@ -91,8 +86,7 @@ port = "4566" [cli] update_skipped_version = "v1.2.3" ` - result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) - require.NoError(t, err) + result, changed := switchEmulatorContent(content, EmulatorSnowflake) assert.True(t, changed) assert.Contains(t, result, "# lstk configuration file") @@ -104,8 +98,7 @@ update_skipped_version = "v1.2.3" func TestSwitchEmulatorContent_PreservesInlineComments(t *testing.T) { t.Parallel() content := "[[containers]]\ntype = \"aws\" # Emulator type\ntag = \"latest\" # Docker image tag\nport = \"4566\" # Host port\n# volume = \"\" # persistent state\n" - result, changed, err := switchEmulatorContent(content, EmulatorSnowflake) - require.NoError(t, err) + result, changed := switchEmulatorContent(content, EmulatorSnowflake) assert.True(t, changed) // Original inline comments should be preserved in the commented-out block @@ -120,14 +113,12 @@ type = "aws" port = "4566" ` // Switch to snowflake - afterSnowflake, changed, err := switchEmulatorContent(original, EmulatorSnowflake) - require.NoError(t, err) + afterSnowflake, changed := switchEmulatorContent(original, EmulatorSnowflake) assert.True(t, changed) assert.Contains(t, afterSnowflake, `type = "snowflake"`) // Switch back to AWS — should restore the commented block - afterAWS, changed, err := switchEmulatorContent(afterSnowflake, EmulatorAWS) - require.NoError(t, err) + afterAWS, changed := switchEmulatorContent(afterSnowflake, EmulatorAWS) assert.True(t, changed) assert.Contains(t, afterAWS, "[[containers]]\ntype = \"aws\"") assert.NotContains(t, afterAWS, "\n[[containers]]\ntype = \"snowflake\"") From d6713e3e24f977af28451cabdb6efd7c6f3c3fc7 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 12:47:43 +0200 Subject: [PATCH 04/22] Match single quotes in config & handle commented section in detectBlockType --- internal/config/switch.go | 4 +++- internal/config/switch_test.go | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/config/switch.go b/internal/config/switch.go index edc3d4e8..a1f7b73e 100644 --- a/internal/config/switch.go +++ b/internal/config/switch.go @@ -145,13 +145,15 @@ func parseContainerBlocks(lines []string) []containerBlock { return blocks } -var typeLineRe = regexp.MustCompile(`type\s*=\s*"(\w+)"`) +var typeLineRe = regexp.MustCompile(`type\s*=\s*["'](\w+)["']`) func detectBlockType(lines []string, isCommented bool) EmulatorType { for _, line := range lines { effective := strings.TrimSpace(line) if isCommented { effective = strings.TrimSpace(strings.TrimPrefix(effective, "#")) + } else if strings.HasPrefix(effective, "#") { + continue } if m := typeLineRe.FindStringSubmatch(effective); m != nil { return EmulatorType(strings.ToLower(m[1])) diff --git a/internal/config/switch_test.go b/internal/config/switch_test.go index 3dd11cf3..34b4d2bd 100644 --- a/internal/config/switch_test.go +++ b/internal/config/switch_test.go @@ -106,6 +106,25 @@ func TestSwitchEmulatorContent_PreservesInlineComments(t *testing.T) { assert.Contains(t, result, "# # volume = \"\" # persistent state") } +func TestSwitchEmulatorContent_SingleQuotedType(t *testing.T) { + t.Parallel() + content := "[[containers]]\ntype = 'aws'\nport = \"4566\"\n" + result, changed := switchEmulatorContent(content, EmulatorSnowflake) + assert.True(t, changed) + assert.Contains(t, result, `type = "snowflake"`) +} + +func TestSwitchEmulatorContent_IgnoresCommentedTypeLine(t *testing.T) { + t.Parallel() + // A block with a commented-out type line followed by the real type line. + // detectBlockType must not match the commented line. + content := "[[containers]]\n# type = \"snowflake\"\ntype = \"aws\"\nport = \"4566\"\n" + result, changed := switchEmulatorContent(content, EmulatorSnowflake) + assert.True(t, changed) + assert.Contains(t, result, `type = "snowflake"`) + assert.NotContains(t, result, "\n[[containers]]\ntype = \"aws\"") +} + func TestSwitchEmulatorContent_RoundTrip(t *testing.T) { t.Parallel() original := `[[containers]] From d2e8857b890824da782d2c077694f18ad2b3b478 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 12:51:56 +0200 Subject: [PATCH 05/22] Assert error from runLstk --- test/integration/emulator_select_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go index 4fedf3ba..20b3657e 100644 --- a/test/integration/emulator_select_test.go +++ b/test/integration/emulator_select_test.go @@ -37,7 +37,8 @@ port = "4566" ctx := testContext(t) // The process will fail at container.Start (no Docker / no real auth), but the // config switch happens earlier so the file should already be updated. - _, _, _ = runLstk(t, ctx, "", e.With(env.AuthToken, "test-token"), "--emulator", "snowflake", "--non-interactive") + _, _, runErr := runLstk(t, ctx, "", e.With(env.AuthToken, "test-token"), "--emulator", "snowflake", "--non-interactive") + assert.Error(t, runErr, "expected failure: no Docker available") got, err := os.ReadFile(configPath) require.NoError(t, err, "config file should still exist after the run") @@ -98,6 +99,7 @@ func TestFirstRunNonInteractiveEmitsDefaultEmulatorNote(t *testing.T) { require.NoFileExists(t, configPath) // Process fails at container.Start (no Docker), but the note is emitted before that. - stdout, _, _ := runLstk(t, testContext(t), "", e.With(env.AuthToken, "test-token"), "--non-interactive") - assert.Contains(t, stdout, "defaulting to AWS", "non-interactive first run should note the default emulator") + 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") } From 796b7ae08ad4186eefd757c25610337156110ee1 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 12:55:17 +0200 Subject: [PATCH 06/22] Move ParseEmulatorType to its domain package --- cmd/root.go | 13 +------------ internal/config/containers.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 688ce802..8b897a39 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -165,7 +165,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t } if requestedEmulator != "" { - emType, err := parseEmulatorType(requestedEmulator) + emType, err := config.ParseEmulatorType(requestedEmulator) if err != nil { return err } @@ -249,17 +249,6 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t return container.Start(ctx, rt, sink, opts, false) } -func parseEmulatorType(s string) (config.EmulatorType, error) { - switch config.EmulatorType(strings.ToLower(s)) { - case config.EmulatorAWS: - return config.EmulatorAWS, nil - case config.EmulatorSnowflake: - return config.EmulatorSnowflake, nil - default: - return "", fmt.Errorf("unsupported emulator %q: must be 'aws' or 'snowflake'", s) - } -} - // instrumentCommands walks the Cobra command tree and wraps every RunE with telemetry emission. func instrumentCommands(cmd *cobra.Command, tel *telemetry.Client) { if cmd.RunE != nil { diff --git a/internal/config/containers.go b/internal/config/containers.go index 01b7f4e2..821448db 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -25,6 +25,17 @@ var emulatorDisplayNames = map[EmulatorType]string{ EmulatorAzure: "Azure", } +func ParseEmulatorType(s string) (EmulatorType, error) { + switch EmulatorType(strings.ToLower(s)) { + case EmulatorAWS: + return EmulatorAWS, nil + case EmulatorSnowflake: + return EmulatorSnowflake, nil + default: + return "", fmt.Errorf("unsupported emulator %q: must be 'aws' or 'snowflake'", s) + } +} + func (e EmulatorType) DisplayName() string { if name, ok := emulatorDisplayNames[e]; ok { return name From 8b35fa3efe5de25c6496a895cb0638151ae4f9c2 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 12:59:43 +0200 Subject: [PATCH 07/22] Make switch_test.go tests table-driven --- internal/config/switch_test.go | 190 ++++++++++++++++----------------- 1 file changed, 95 insertions(+), 95 deletions(-) diff --git a/internal/config/switch_test.go b/internal/config/switch_test.go index 34b4d2bd..d4528f4d 100644 --- a/internal/config/switch_test.go +++ b/internal/config/switch_test.go @@ -11,69 +11,60 @@ import ( "github.com/stretchr/testify/require" ) -func TestSwitchEmulatorContent_NoOp_AlreadyAWS(t *testing.T) { +func TestSwitchEmulatorContent(t *testing.T) { t.Parallel() - content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n" - result, changed := switchEmulatorContent(content, EmulatorAWS) - assert.False(t, changed) - assert.Equal(t, content, result) -} - -func TestSwitchEmulatorContent_NoOp_AlreadySnowflake(t *testing.T) { - t.Parallel() - content := "[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n" - result, changed := switchEmulatorContent(content, EmulatorSnowflake) - assert.False(t, changed) - assert.Equal(t, content, result) -} - -func TestSwitchEmulatorContent_CommentAWSAndAppendSnowflake(t *testing.T) { - t.Parallel() - content := `[[containers]] + cases := []struct { + name string + content string + to EmulatorType + wantChanged bool + contains []string + notContains []string + }{ + { + name: "no-op when already aws", + content: "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n", + to: EmulatorAWS, + wantChanged: false, + }, + { + name: "no-op when already snowflake", + content: "[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n", + to: EmulatorSnowflake, + wantChanged: false, + }, + { + name: "comments aws block and appends snowflake", + content: `[[containers]] type = "aws" port = "4566" [cli] update_skipped_version = "" -` - result, changed := switchEmulatorContent(content, EmulatorSnowflake) - assert.True(t, changed) - - assert.Contains(t, result, "# [[containers]]") - assert.Contains(t, result, `# type = "aws"`) - assert.Contains(t, result, `# port = "4566"`) - assert.Contains(t, result, `type = "snowflake"`) - assert.Contains(t, result, "[cli]") - // aws block should not appear as active - assert.NotContains(t, result, "\n[[containers]]\ntype = \"aws\"") -} - -func TestSwitchEmulatorContent_RestoresCommentedAWS(t *testing.T) { - t.Parallel() - content := "# [[containers]]\n# type = \"aws\"\n# port = \"4566\"\n\n[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n" - result, changed := switchEmulatorContent(content, EmulatorAWS) - assert.True(t, changed) - - assert.Contains(t, result, "[[containers]]\ntype = \"aws\"") - assert.Contains(t, result, "# [[containers]]") - assert.Contains(t, result, `# type = "snowflake"`) - assert.NotContains(t, result, "\n[[containers]]\ntype = \"snowflake\"") -} - -func TestSwitchEmulatorContent_RestoresCommentedSnowflake(t *testing.T) { - t.Parallel() - content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n\n# [[containers]]\n# type = \"snowflake\"\n# port = \"4566\"\n" - result, changed := switchEmulatorContent(content, EmulatorSnowflake) - assert.True(t, changed) - - assert.Contains(t, result, "[[containers]]\ntype = \"snowflake\"") - assert.Contains(t, result, "# [[containers]]") - assert.Contains(t, result, `# type = "aws"`) -} - -func TestSwitchEmulatorContent_PreservesNonContainerContent(t *testing.T) { - t.Parallel() - content := `# lstk configuration file +`, + to: EmulatorSnowflake, + wantChanged: true, + contains: []string{"# [[containers]]", `# type = "aws"`, `# port = "4566"`, `type = "snowflake"`, "[cli]"}, + notContains: []string{"[[containers]]\ntype = \"aws\""}, + }, + { + name: "restores commented aws block", + content: "# [[containers]]\n# type = \"aws\"\n# port = \"4566\"\n\n[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n", + to: EmulatorAWS, + wantChanged: true, + contains: []string{"[[containers]]\ntype = \"aws\"", "# [[containers]]", `# type = "snowflake"`}, + notContains: []string{"[[containers]]\ntype = \"snowflake\""}, + }, + { + name: "restores commented snowflake block", + content: "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n\n# [[containers]]\n# type = \"snowflake\"\n# port = \"4566\"\n", + to: EmulatorSnowflake, + wantChanged: true, + contains: []string{"[[containers]]\ntype = \"snowflake\"", "# [[containers]]", `# type = "aws"`}, + }, + { + name: "preserves non-container content", + content: `# lstk configuration file [[containers]] type = "aws" @@ -85,44 +76,53 @@ port = "4566" [cli] update_skipped_version = "v1.2.3" -` - result, changed := switchEmulatorContent(content, EmulatorSnowflake) - assert.True(t, changed) - - assert.Contains(t, result, "# lstk configuration file") - assert.Contains(t, result, `update_skipped_version = "v1.2.3"`) - assert.Contains(t, result, "# [env.debug]") - assert.Contains(t, result, `type = "snowflake"`) -} - -func TestSwitchEmulatorContent_PreservesInlineComments(t *testing.T) { - t.Parallel() - content := "[[containers]]\ntype = \"aws\" # Emulator type\ntag = \"latest\" # Docker image tag\nport = \"4566\" # Host port\n# volume = \"\" # persistent state\n" - result, changed := switchEmulatorContent(content, EmulatorSnowflake) - assert.True(t, changed) - - // Original inline comments should be preserved in the commented-out block - assert.Contains(t, result, "# type = \"aws\" # Emulator type") - assert.Contains(t, result, "# # volume = \"\" # persistent state") -} - -func TestSwitchEmulatorContent_SingleQuotedType(t *testing.T) { - t.Parallel() - content := "[[containers]]\ntype = 'aws'\nport = \"4566\"\n" - result, changed := switchEmulatorContent(content, EmulatorSnowflake) - assert.True(t, changed) - assert.Contains(t, result, `type = "snowflake"`) -} - -func TestSwitchEmulatorContent_IgnoresCommentedTypeLine(t *testing.T) { - t.Parallel() - // A block with a commented-out type line followed by the real type line. - // detectBlockType must not match the commented line. - content := "[[containers]]\n# type = \"snowflake\"\ntype = \"aws\"\nport = \"4566\"\n" - result, changed := switchEmulatorContent(content, EmulatorSnowflake) - assert.True(t, changed) - assert.Contains(t, result, `type = "snowflake"`) - assert.NotContains(t, result, "\n[[containers]]\ntype = \"aws\"") +`, + to: EmulatorSnowflake, + wantChanged: true, + contains: []string{"# lstk configuration file", `update_skipped_version = "v1.2.3"`, "# [env.debug]", `type = "snowflake"`}, + }, + { + // Original inline comments should be preserved in the commented-out block + name: "preserves inline comments when commenting out block", + content: "[[containers]]\ntype = \"aws\" # Emulator type\ntag = \"latest\" # Docker image tag\nport = \"4566\" # Host port\n# volume = \"\" # persistent state\n", + to: EmulatorSnowflake, + wantChanged: true, + contains: []string{"# type = \"aws\" # Emulator type", "# # volume = \"\" # persistent state"}, + }, + { + name: "single-quoted type is recognized", + content: "[[containers]]\ntype = 'aws'\nport = \"4566\"\n", + to: EmulatorSnowflake, + wantChanged: true, + contains: []string{`type = "snowflake"`}, + }, + { + // detectBlockType must not match a commented-out type line inside an active block + name: "commented type line within active block is ignored", + content: "[[containers]]\n# type = \"snowflake\"\ntype = \"aws\"\nport = \"4566\"\n", + to: EmulatorSnowflake, + wantChanged: true, + contains: []string{`type = "snowflake"`}, + notContains: []string{"[[containers]]\ntype = \"aws\""}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result, changed := switchEmulatorContent(tc.content, tc.to) + assert.Equal(t, tc.wantChanged, changed) + if !tc.wantChanged { + assert.Equal(t, tc.content, result) + } + for _, s := range tc.contains { + assert.Contains(t, result, s) + } + for _, s := range tc.notContains { + assert.NotContains(t, result, s) + } + }) + } } func TestSwitchEmulatorContent_RoundTrip(t *testing.T) { From b6dc8256c93cde4a1bd83d9766fda3482f69b059 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 13:19:32 +0200 Subject: [PATCH 08/22] Strong-type --emulator, validate early --- cmd/root.go | 24 ++++++++--------- cmd/start.go | 9 +++++-- internal/config/containers.go | 11 ++++++++ internal/config/containers_test.go | 41 ++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 14 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 8b897a39..81af7c5d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -37,7 +37,11 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C Long: "lstk is the command-line interface for LocalStack.", PreRunE: initConfigCapturingFirstRun(&firstRun), RunE: func(cmd *cobra.Command, args []string) error { - emulator, err := cmd.Flags().GetString("emulator") + emulatorStr, err := cmd.Flags().GetString("emulator") + if err != nil { + return err + } + requestedEmulator, err := config.ParseOptionalEmulatorType(emulatorStr) if err != nil { return err } @@ -49,7 +53,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, firstRun, emulator) + return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun, requestedEmulator) }, } @@ -158,19 +162,15 @@ 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, firstRun bool, requestedEmulator string) error { +func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool, requestedEmulator *config.EmulatorType) error { appConfig, err := config.Get() if err != nil { return fmt.Errorf("failed to get config: %w", err) } - if requestedEmulator != "" { - emType, err := config.ParseEmulatorType(requestedEmulator) - if err != nil { - return err - } - if len(appConfig.Containers) == 0 || appConfig.Containers[0].Type != emType { - if err := config.SwitchEmulator(emType); err != nil { + if requestedEmulator != nil { + if len(appConfig.Containers) == 0 || appConfig.Containers[0].Type != *requestedEmulator { + if err := config.SwitchEmulator(*requestedEmulator); err != nil { return fmt.Errorf("failed to switch emulator: %w", err) } appConfig, err = config.Get() @@ -194,7 +194,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t logger.Info("could not resolve friendly config path: %v", err) } - needsEmulatorSelection := firstRun && requestedEmulator == "" && isInteractiveMode(cfg) + needsEmulatorSelection := firstRun && requestedEmulator == nil && isInteractiveMode(cfg) if isInteractiveMode(cfg) { labelCh := make(chan string, 1) @@ -238,7 +238,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t } sink := output.NewPlainSink(os.Stdout) - if firstRun && requestedEmulator == "" && len(appConfig.Containers) > 0 { + if firstRun && requestedEmulator == nil && len(appConfig.Containers) > 0 { emName := appConfig.Containers[0].Type.DisplayName() sink.Emit(output.MessageEvent{ Severity: output.SeverityNote, diff --git a/cmd/start.go b/cmd/start.go index d482e896..3c936157 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -1,6 +1,7 @@ package cmd import ( + "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/log" "github.com/localstack/lstk/internal/runtime" @@ -16,7 +17,11 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. Long: "Start emulator and services.", PreRunE: initConfigCapturingFirstRun(&firstRun), RunE: func(c *cobra.Command, args []string) error { - emulator, err := c.Flags().GetString("emulator") + emulatorStr, err := c.Flags().GetString("emulator") + if err != nil { + return err + } + requestedEmulator, err := config.ParseOptionalEmulatorType(emulatorStr) if err != nil { return err } @@ -28,7 +33,7 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. if err != nil { return err } - return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, emulator) + return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, requestedEmulator) }, } cmd.Flags().Bool("persist", false, "Enable local persistence (sets LOCALSTACK_PERSISTENCE=1)") diff --git a/internal/config/containers.go b/internal/config/containers.go index 821448db..95024022 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -25,6 +25,17 @@ var emulatorDisplayNames = map[EmulatorType]string{ EmulatorAzure: "Azure", } +func ParseOptionalEmulatorType(s string) (*EmulatorType, error) { + if s == "" { + return nil, nil + } + emType, err := ParseEmulatorType(s) + if err != nil { + return nil, err + } + return &emType, nil +} + func ParseEmulatorType(s string) (EmulatorType, error) { switch EmulatorType(strings.ToLower(s)) { case EmulatorAWS: diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index 3b289470..a327c156 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -91,3 +91,44 @@ func TestValidate_NegativePort(t *testing.T) { err := c.Validate() assert.ErrorContains(t, err, "out of range") } + +func TestParseEmulatorType(t *testing.T) { + t.Parallel() + cases := []struct { + input string + want EmulatorType + wantErr bool + }{ + {"aws", EmulatorAWS, false}, + {"AWS", EmulatorAWS, false}, + {"snowflake", EmulatorSnowflake, false}, + {"Snowflake", EmulatorSnowflake, false}, + {"azure", "", true}, + {"unknown", "", true}, + {"", "", true}, + } + for _, tc := range cases { + got, err := ParseEmulatorType(tc.input) + if tc.wantErr { + assert.Error(t, err, "input=%q", tc.input) + } else { + assert.NoError(t, err, "input=%q", tc.input) + assert.Equal(t, tc.want, got, "input=%q", tc.input) + } + } +} + +func TestParseOptionalEmulatorType(t *testing.T) { + t.Parallel() + + got, err := ParseOptionalEmulatorType("") + assert.NoError(t, err) + assert.Nil(t, got) + + got, err = ParseOptionalEmulatorType("aws") + assert.NoError(t, err) + assert.Equal(t, EmulatorAWS, *got) + + _, err = ParseOptionalEmulatorType("unknown") + assert.Error(t, err) +} From a18bb3bf30a56de930a6fb08c9f5202d27b2fe50 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 14:08:49 +0200 Subject: [PATCH 09/22] Remove callback in startEmulator: handle in run.go --- cmd/root.go | 29 ---------------------------- internal/ui/run.go | 48 +++++++++++++++++++++++++++++----------------- 2 files changed, 30 insertions(+), 47 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 81af7c5d..196cdb24 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -197,17 +197,6 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t needsEmulatorSelection := firstRun && requestedEmulator == nil && isInteractiveMode(cfg) if isInteractiveMode(cfg) { - labelCh := make(chan string, 1) - if !needsEmulatorSelection { - 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(), @@ -215,25 +204,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t NotifyOptions: notifyOpts, ConfigPath: configPath, EmulatorLabel: config.CachedPlanLabel(), - LabelCh: labelCh, NeedsEmulatorSelection: needsEmulatorSelection, - OnEmulatorSelected: func(emType config.EmulatorType) ([]config.ContainerConfig, error) { - if err := config.SwitchEmulator(emType); err != nil { - return nil, fmt.Errorf("failed to switch emulator: %w", err) - } - newCfg, err := config.Get() - if err != nil { - return nil, err - } - go func() { - label, ok := container.ResolveEmulatorLabel(ctx, opts.PlatformClient, newCfg.Containers, cfg.AuthToken, logger) - if ok { - config.CachePlanLabel(label) - } - labelCh <- label - }() - return newCfg.Containers, nil - }, }) } diff --git a/internal/ui/run.go b/internal/ui/run.go index 37bf9f01..38c42d2c 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -35,11 +35,7 @@ type RunOptions struct { NotifyOptions update.NotifyOptions ConfigPath string EmulatorLabel string - LabelCh <-chan string NeedsEmulatorSelection bool - // OnEmulatorSelected is called with the user's choice when NeedsEmulatorSelection is true. - // It should switch the config and return the updated container configs to use for this run. - OnEmulatorSelected func(config.EmulatorType) ([]config.ContainerConfig, error) } func Run(parentCtx context.Context, runOpts RunOptions) error { @@ -56,28 +52,33 @@ 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 resolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) + } if update.NotifyUpdate(ctx, sink, runOpts.NotifyOptions) { p.Send(runDoneMsg{}) return } if runOpts.NeedsEmulatorSelection { - newContainers, selErr := selectEmulatorInTUI(ctx, sink, runOpts.ConfigPath, runOpts.OnEmulatorSelected) + newContainers, selErr := selectEmulatorInTUI(ctx, sink, runOpts.ConfigPath) if selErr != nil { if errors.Is(selErr, context.Canceled) { return @@ -86,6 +87,7 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { return } runOpts.StartOptions.Containers = newContainers + go resolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) } err = container.Start(ctx, runOpts.Runtime, sink, runOpts.StartOptions, true) if err != nil { @@ -115,11 +117,18 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { return nil } +func resolveAndCacheLabel(ctx context.Context, opts container.StartOptions, labelCh chan<- string) { + label, ok := container.ResolveEmulatorLabel(ctx, opts.PlatformClient, opts.Containers, opts.AuthToken, opts.Logger) + if ok { + config.CachePlanLabel(label) + } + labelCh <- label +} + func selectEmulatorInTUI( ctx context.Context, sink output.Sink, configPath string, - onSelected func(config.EmulatorType) ([]config.ContainerConfig, error), ) ([]config.ContainerConfig, error) { responseCh := make(chan output.InputResponse, 1) sink.Emit(output.UserInputRequestEvent{ @@ -148,7 +157,10 @@ func selectEmulatorInTUI( selected = config.EmulatorSnowflake } - containers, err := onSelected(selected) + if err := config.SwitchEmulator(selected); err != nil { + return nil, fmt.Errorf("failed to switch emulator: %w", err) + } + newCfg, err := config.Get() if err != nil { return nil, err } @@ -159,7 +171,7 @@ func selectEmulatorInTUI( } sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: msg}) - return containers, nil + return newCfg.Containers, nil } func IsInteractive() bool { From ad2945fc6dc7b7fa1de19346eaef31e6af93c7e7 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 15:54:23 +0200 Subject: [PATCH 10/22] Remove ParseEmulatorType in favor of ParseOptionalEmulatorType --- internal/config/containers.go | 18 +++----------- internal/config/containers_test.go | 40 +++++++++++------------------- 2 files changed, 19 insertions(+), 39 deletions(-) diff --git a/internal/config/containers.go b/internal/config/containers.go index 95024022..af6d3895 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -29,21 +29,11 @@ func ParseOptionalEmulatorType(s string) (*EmulatorType, error) { if s == "" { return nil, nil } - emType, err := ParseEmulatorType(s) - if err != nil { - return nil, err - } - return &emType, nil -} - -func ParseEmulatorType(s string) (EmulatorType, error) { - switch EmulatorType(strings.ToLower(s)) { - case EmulatorAWS: - return EmulatorAWS, nil - case EmulatorSnowflake: - return EmulatorSnowflake, nil + switch emType := EmulatorType(strings.ToLower(s)); emType { + case EmulatorAWS, EmulatorSnowflake: + return &emType, nil default: - return "", fmt.Errorf("unsupported emulator %q: must be 'aws' or 'snowflake'", s) + return nil, fmt.Errorf("unsupported emulator %q: must be 'aws' or 'snowflake'", s) } } diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index a327c156..3758ab87 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -92,43 +92,33 @@ func TestValidate_NegativePort(t *testing.T) { assert.ErrorContains(t, err, "out of range") } -func TestParseEmulatorType(t *testing.T) { +func TestParseOptionalEmulatorType(t *testing.T) { t.Parallel() cases := []struct { input string want EmulatorType + wantNil bool wantErr bool }{ - {"aws", EmulatorAWS, false}, - {"AWS", EmulatorAWS, false}, - {"snowflake", EmulatorSnowflake, false}, - {"Snowflake", EmulatorSnowflake, false}, - {"azure", "", true}, - {"unknown", "", true}, - {"", "", true}, + {"aws", EmulatorAWS, false, false}, + {"AWS", EmulatorAWS, false, false}, + {"snowflake", EmulatorSnowflake, false, false}, + {"Snowflake", EmulatorSnowflake, false, false}, + {"azure", "", false, true}, + {"unknown", "", false, true}, + {"", "", true, false}, } for _, tc := range cases { - got, err := ParseEmulatorType(tc.input) + got, err := ParseOptionalEmulatorType(tc.input) if tc.wantErr { assert.Error(t, err, "input=%q", tc.input) } else { assert.NoError(t, err, "input=%q", tc.input) - assert.Equal(t, tc.want, got, "input=%q", tc.input) + if tc.wantNil { + assert.Nil(t, got, "input=%q", tc.input) + } else { + assert.Equal(t, tc.want, *got, "input=%q", tc.input) + } } } } - -func TestParseOptionalEmulatorType(t *testing.T) { - t.Parallel() - - got, err := ParseOptionalEmulatorType("") - assert.NoError(t, err) - assert.Nil(t, got) - - got, err = ParseOptionalEmulatorType("aws") - assert.NoError(t, err) - assert.Equal(t, EmulatorAWS, *got) - - _, err = ParseOptionalEmulatorType("unknown") - assert.Error(t, err) -} From b52e540e7e10d7ea1d654c012df624f17d3ac6b3 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 17:07:57 +0200 Subject: [PATCH 11/22] Gentler message on changing configuration --- internal/ui/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/run.go b/internal/ui/run.go index 38c42d2c..53065c5b 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -167,7 +167,7 @@ func selectEmulatorInTUI( msg := selected.DisplayName() + " emulator selected." if configPath != "" { - msg += fmt.Sprintf(" You can change this anytime in %s.", configPath) + msg += fmt.Sprintf(" Change configuration in %s.", configPath) } sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: msg}) From b177aa74d7aa273b8d4e932d1199a8635dc521d1 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 18:11:15 +0200 Subject: [PATCH 12/22] Avoid duplicating config content --- internal/config/switch.go | 46 ++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/internal/config/switch.go b/internal/config/switch.go index a1f7b73e..8607fae7 100644 --- a/internal/config/switch.go +++ b/internal/config/switch.go @@ -7,19 +7,6 @@ import ( "strings" ) -const awsContainerBlock = `[[containers]] -type = "aws" -tag = "latest" -port = "4566" -# volume = "" # Host directory for persistent state (default: OS cache dir) -# env = [] # Named environment profiles to apply (see [env.*] sections below)` - -const snowflakeContainerBlock = `[[containers]] -type = "snowflake" -tag = "latest" -port = "4566" -# volume = "" # Host directory for persistent state (default: OS cache dir) -# env = [] # Named environment profiles to apply (see [env.*] sections below)` // SwitchEmulator updates the config file to activate the given emulator type. // Active container blocks for other types are commented out. If a previously @@ -163,12 +150,31 @@ func detectBlockType(lines []string, isCommented bool) EmulatorType { } func containerBlockTemplate(t EmulatorType) string { - switch t { - case EmulatorAWS: - return awsContainerBlock - case EmulatorSnowflake: - return snowflakeContainerBlock - default: - return "" + lines := strings.Split(defaultConfigTemplate, "\n") + n := len(lines) + for i := 0; i < n; i++ { + if strings.TrimSpace(lines[i]) != "[[containers]]" { + continue + } + end := i + 1 + for end < n { + t2 := strings.TrimSpace(lines[end]) + if t2 == "" || t2 == "[[containers]]" || t2 == "# [[containers]]" { + break + } + end++ + } + blockLines := make([]string, end-i) + copy(blockLines, lines[i:end]) + for j, line := range blockLines { + if typeLineRe.MatchString(strings.TrimSpace(line)) { + blockLines[j] = typeLineRe.ReplaceAllStringFunc(line, func(string) string { + return `type = "` + string(t) + `"` + }) + break + } + } + return strings.Join(blockLines, "\n") } + return "" } From 4afa6fc1863a8ec427dd634a42520d9da80f4c85 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 18:32:04 +0200 Subject: [PATCH 13/22] Skip keyboard shortcuts for emulator selection --- internal/ui/run.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/run.go b/internal/ui/run.go index 53065c5b..64a5e555 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -134,8 +134,8 @@ func selectEmulatorInTUI( sink.Emit(output.UserInputRequestEvent{ Prompt: "Which emulator would you like to use?", Options: []output.InputOption{ - {Key: "a", Label: "AWS [A]"}, - {Key: "s", Label: "Snowflake [S]"}, + {Key: "a", Label: "AWS"}, + {Key: "s", Label: "Snowflake"}, }, ResponseCh: responseCh, Vertical: true, From 49c7698d143fce8fa99f165a14b45c4930dcd8ca Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 18:41:26 +0200 Subject: [PATCH 14/22] Nits --- internal/config/switch.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/config/switch.go b/internal/config/switch.go index 8607fae7..e7e5c0a6 100644 --- a/internal/config/switch.go +++ b/internal/config/switch.go @@ -149,6 +149,8 @@ func detectBlockType(lines []string, isCommented bool) EmulatorType { return "" } +// containerBlockTemplate returns the [[containers]] block from default_config.toml +// with the type field replaced by t, used when appending a fresh block to a config file. func containerBlockTemplate(t EmulatorType) string { lines := strings.Split(defaultConfigTemplate, "\n") n := len(lines) @@ -158,8 +160,8 @@ func containerBlockTemplate(t EmulatorType) string { } end := i + 1 for end < n { - t2 := strings.TrimSpace(lines[end]) - if t2 == "" || t2 == "[[containers]]" || t2 == "# [[containers]]" { + candidate := strings.TrimSpace(lines[end]) + if candidate == "" || candidate == "[[containers]]" || candidate == "# [[containers]]" { break } end++ From 31cb5b9903941cd9da7f7d4ccf6e3a9daae7ad3f Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 19:01:52 +0200 Subject: [PATCH 15/22] Split emulator-selected note into two message events: change configuration message is secondary --- internal/ui/run.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/ui/run.go b/internal/ui/run.go index 64a5e555..a08aae9d 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -165,11 +165,10 @@ func selectEmulatorInTUI( return nil, err } - msg := selected.DisplayName() + " emulator selected." + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: selected.DisplayName() + " emulator selected."}) if configPath != "" { - msg += fmt.Sprintf(" Change configuration in %s.", configPath) + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Change configuration in " + configPath + "."}) } - sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: msg}) return newCfg.Containers, nil } From d651cd83e381d8650c750fc02bef99dc45610ed2 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 6 May 2026 12:09:34 +0200 Subject: [PATCH 16/22] Simplify: Remove --emulator flag on start --- cmd/root.go | 33 +--- cmd/start.go | 12 +- internal/config/containers.go | 12 -- internal/config/containers_test.go | 31 ---- internal/config/emulator_type.go | 34 +++++ internal/config/emulator_type_test.go | 61 ++++++++ internal/config/switch.go | 182 ----------------------- internal/config/switch_test.go | 180 ---------------------- internal/ui/run.go | 6 +- test/integration/emulator_select_test.go | 29 ---- 10 files changed, 104 insertions(+), 476 deletions(-) create mode 100644 internal/config/emulator_type.go create mode 100644 internal/config/emulator_type_test.go delete mode 100644 internal/config/switch.go delete mode 100644 internal/config/switch_test.go diff --git a/cmd/root.go b/cmd/root.go index 196cdb24..cb643404 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -37,14 +37,6 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C Long: "lstk is the command-line interface for LocalStack.", PreRunE: initConfigCapturingFirstRun(&firstRun), RunE: func(cmd *cobra.Command, args []string) error { - emulatorStr, err := cmd.Flags().GetString("emulator") - if err != nil { - return err - } - requestedEmulator, err := config.ParseOptionalEmulatorType(emulatorStr) - if err != nil { - return err - } rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err @@ -53,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, firstRun, requestedEmulator) + return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun) }, } @@ -64,7 +56,6 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C root.PersistentFlags().String("config", "", "Path to config file") root.PersistentFlags().BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive mode") root.Flags().Bool("persist", false, "Enable local persistence (sets LOCALSTACK_PERSISTENCE=1)") - root.Flags().String("emulator", "", "Emulator to use (aws|snowflake)") configureHelp(root) @@ -162,24 +153,12 @@ 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, firstRun bool, requestedEmulator *config.EmulatorType) 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) } - if requestedEmulator != nil { - if len(appConfig.Containers) == 0 || appConfig.Containers[0].Type != *requestedEmulator { - if err := config.SwitchEmulator(*requestedEmulator); err != nil { - return fmt.Errorf("failed to switch emulator: %w", err) - } - appConfig, err = config.Get() - if err != nil { - return fmt.Errorf("failed to reload config: %w", err) - } - } - } - opts := buildStartOptions(cfg, appConfig, logger, tel, persist) notifyOpts := update.NotifyOptions{ @@ -194,8 +173,6 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t logger.Info("could not resolve friendly config path: %v", err) } - needsEmulatorSelection := firstRun && requestedEmulator == nil && isInteractiveMode(cfg) - if isInteractiveMode(cfg) { return ui.Run(ctx, ui.RunOptions{ Runtime: rt, @@ -204,16 +181,16 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t NotifyOptions: notifyOpts, ConfigPath: configPath, EmulatorLabel: config.CachedPlanLabel(), - NeedsEmulatorSelection: needsEmulatorSelection, + NeedsEmulatorSelection: firstRun, }) } sink := output.NewPlainSink(os.Stdout) - if firstRun && requestedEmulator == nil && len(appConfig.Containers) > 0 { + if firstRun && len(appConfig.Containers) > 0 { emName := appConfig.Containers[0].Type.DisplayName() sink.Emit(output.MessageEvent{ Severity: output.SeverityNote, - Text: fmt.Sprintf("Configured with default emulator %s. Pass --emulator to change.", emName), + Text: fmt.Sprintf("Configured with default emulator %s.", emName), }) } update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken}) diff --git a/cmd/start.go b/cmd/start.go index 3c936157..77cbb1d1 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -1,7 +1,6 @@ package cmd import ( - "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/log" "github.com/localstack/lstk/internal/runtime" @@ -17,14 +16,6 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. Long: "Start emulator and services.", PreRunE: initConfigCapturingFirstRun(&firstRun), RunE: func(c *cobra.Command, args []string) error { - emulatorStr, err := c.Flags().GetString("emulator") - if err != nil { - return err - } - requestedEmulator, err := config.ParseOptionalEmulatorType(emulatorStr) - if err != nil { - return err - } rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err @@ -33,10 +24,9 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. if err != nil { return err } - return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, requestedEmulator) + return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun) }, } cmd.Flags().Bool("persist", false, "Enable local persistence (sets LOCALSTACK_PERSISTENCE=1)") - cmd.Flags().String("emulator", "", "Emulator to use (aws|snowflake)") return cmd } diff --git a/internal/config/containers.go b/internal/config/containers.go index af6d3895..01b7f4e2 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -25,18 +25,6 @@ var emulatorDisplayNames = map[EmulatorType]string{ EmulatorAzure: "Azure", } -func ParseOptionalEmulatorType(s string) (*EmulatorType, error) { - if s == "" { - return nil, nil - } - switch emType := EmulatorType(strings.ToLower(s)); emType { - case EmulatorAWS, EmulatorSnowflake: - return &emType, nil - default: - return nil, fmt.Errorf("unsupported emulator %q: must be 'aws' or 'snowflake'", s) - } -} - func (e EmulatorType) DisplayName() string { if name, ok := emulatorDisplayNames[e]; ok { return name diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index 3758ab87..3b289470 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -91,34 +91,3 @@ func TestValidate_NegativePort(t *testing.T) { err := c.Validate() assert.ErrorContains(t, err, "out of range") } - -func TestParseOptionalEmulatorType(t *testing.T) { - t.Parallel() - cases := []struct { - input string - want EmulatorType - wantNil bool - wantErr bool - }{ - {"aws", EmulatorAWS, false, false}, - {"AWS", EmulatorAWS, false, false}, - {"snowflake", EmulatorSnowflake, false, false}, - {"Snowflake", EmulatorSnowflake, false, false}, - {"azure", "", false, true}, - {"unknown", "", false, true}, - {"", "", true, false}, - } - for _, tc := range cases { - got, err := ParseOptionalEmulatorType(tc.input) - if tc.wantErr { - assert.Error(t, err, "input=%q", tc.input) - } else { - assert.NoError(t, err, "input=%q", tc.input) - if tc.wantNil { - assert.Nil(t, got, "input=%q", tc.input) - } else { - assert.Equal(t, tc.want, *got, "input=%q", tc.input) - } - } - } -} 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/config/switch.go b/internal/config/switch.go deleted file mode 100644 index e7e5c0a6..00000000 --- a/internal/config/switch.go +++ /dev/null @@ -1,182 +0,0 @@ -package config - -import ( - "fmt" - "os" - "regexp" - "strings" -) - - -// SwitchEmulator updates the config file to activate the given emulator type. -// Active container blocks for other types are commented out. If a previously -// commented block for the target type exists it is restored; otherwise a fresh -// block is appended. No-op when the target is already the only active emulator. -func SwitchEmulator(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) - } - - updated, changed := switchEmulatorContent(string(data), to) - if !changed { - return nil - } - - if err := os.WriteFile(path, []byte(updated), 0644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - return loadConfig(path) -} - -func switchEmulatorContent(content string, to EmulatorType) (updated string, changed bool) { - lines := strings.Split(content, "\n") - blocks := parseContainerBlocks(lines) - - if isEmulatorAlreadyActive(blocks, to) { - return content, false - } - - newLines := make([]string, len(lines)) - copy(newLines, lines) - - hasActiveTarget := false - restoredCommented := false - - for _, b := range blocks { - switch { - case !b.isCommented && b.emulType == to: - hasActiveTarget = true - case !b.isCommented && b.emulType != to: - for i := b.start; i < b.end; i++ { - if newLines[i] != "" { - newLines[i] = "# " + newLines[i] - } - } - case b.isCommented && b.emulType == to && !restoredCommented: - for i := b.start; i < b.end; i++ { - newLines[i] = strings.TrimPrefix(newLines[i], "# ") - } - restoredCommented = true - } - } - - result := strings.Join(newLines, "\n") - if !hasActiveTarget && !restoredCommented { - tmpl := containerBlockTemplate(to) - result = strings.TrimRight(result, "\n") + "\n\n" + tmpl + "\n" - } - - return result, true -} - -func isEmulatorAlreadyActive(blocks []containerBlock, to EmulatorType) bool { - hasActiveTarget := false - for _, b := range blocks { - if b.isCommented { - continue - } - if b.emulType != to { - return false - } - hasActiveTarget = true - } - return hasActiveTarget -} - -type containerBlock struct { - start int - end int // exclusive - emulType EmulatorType - isCommented bool -} - -func parseContainerBlocks(lines []string) []containerBlock { - var blocks []containerBlock - n := len(lines) - - for i := 0; i < n; i++ { - trimmed := strings.TrimSpace(lines[i]) - isActive := trimmed == "[[containers]]" - isCommented := trimmed == "# [[containers]]" - if !isActive && !isCommented { - continue - } - - end := n - for j := i + 1; j < n; j++ { - t := strings.TrimSpace(lines[j]) - if t == "[[containers]]" || t == "# [[containers]]" { - end = j - break - } - if len(t) > 0 && t[0] == '[' { - end = j - break - } - } - - blocks = append(blocks, containerBlock{ - start: i, - end: end, - emulType: detectBlockType(lines[i:end], isCommented), - isCommented: isCommented, - }) - i = end - 1 - } - return blocks -} - -var typeLineRe = regexp.MustCompile(`type\s*=\s*["'](\w+)["']`) - -func detectBlockType(lines []string, isCommented bool) EmulatorType { - for _, line := range lines { - effective := strings.TrimSpace(line) - if isCommented { - effective = strings.TrimSpace(strings.TrimPrefix(effective, "#")) - } else if strings.HasPrefix(effective, "#") { - continue - } - if m := typeLineRe.FindStringSubmatch(effective); m != nil { - return EmulatorType(strings.ToLower(m[1])) - } - } - return "" -} - -// containerBlockTemplate returns the [[containers]] block from default_config.toml -// with the type field replaced by t, used when appending a fresh block to a config file. -func containerBlockTemplate(t EmulatorType) string { - lines := strings.Split(defaultConfigTemplate, "\n") - n := len(lines) - for i := 0; i < n; i++ { - if strings.TrimSpace(lines[i]) != "[[containers]]" { - continue - } - end := i + 1 - for end < n { - candidate := strings.TrimSpace(lines[end]) - if candidate == "" || candidate == "[[containers]]" || candidate == "# [[containers]]" { - break - } - end++ - } - blockLines := make([]string, end-i) - copy(blockLines, lines[i:end]) - for j, line := range blockLines { - if typeLineRe.MatchString(strings.TrimSpace(line)) { - blockLines[j] = typeLineRe.ReplaceAllStringFunc(line, func(string) string { - return `type = "` + string(t) + `"` - }) - break - } - } - return strings.Join(blockLines, "\n") - } - return "" -} diff --git a/internal/config/switch_test.go b/internal/config/switch_test.go deleted file mode 100644 index d4528f4d..00000000 --- a/internal/config/switch_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSwitchEmulatorContent(t *testing.T) { - t.Parallel() - cases := []struct { - name string - content string - to EmulatorType - wantChanged bool - contains []string - notContains []string - }{ - { - name: "no-op when already aws", - content: "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n", - to: EmulatorAWS, - wantChanged: false, - }, - { - name: "no-op when already snowflake", - content: "[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n", - to: EmulatorSnowflake, - wantChanged: false, - }, - { - name: "comments aws block and appends snowflake", - content: `[[containers]] -type = "aws" -port = "4566" - -[cli] -update_skipped_version = "" -`, - to: EmulatorSnowflake, - wantChanged: true, - contains: []string{"# [[containers]]", `# type = "aws"`, `# port = "4566"`, `type = "snowflake"`, "[cli]"}, - notContains: []string{"[[containers]]\ntype = \"aws\""}, - }, - { - name: "restores commented aws block", - content: "# [[containers]]\n# type = \"aws\"\n# port = \"4566\"\n\n[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n", - to: EmulatorAWS, - wantChanged: true, - contains: []string{"[[containers]]\ntype = \"aws\"", "# [[containers]]", `# type = "snowflake"`}, - notContains: []string{"[[containers]]\ntype = \"snowflake\""}, - }, - { - name: "restores commented snowflake block", - content: "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n\n# [[containers]]\n# type = \"snowflake\"\n# port = \"4566\"\n", - to: EmulatorSnowflake, - wantChanged: true, - contains: []string{"[[containers]]\ntype = \"snowflake\"", "# [[containers]]", `# type = "aws"`}, - }, - { - name: "preserves non-container content", - content: `# lstk configuration file - -[[containers]] -type = "aws" -port = "4566" -# volume = "" # some comment - -# [env.debug] -# DEBUG = "1" - -[cli] -update_skipped_version = "v1.2.3" -`, - to: EmulatorSnowflake, - wantChanged: true, - contains: []string{"# lstk configuration file", `update_skipped_version = "v1.2.3"`, "# [env.debug]", `type = "snowflake"`}, - }, - { - // Original inline comments should be preserved in the commented-out block - name: "preserves inline comments when commenting out block", - content: "[[containers]]\ntype = \"aws\" # Emulator type\ntag = \"latest\" # Docker image tag\nport = \"4566\" # Host port\n# volume = \"\" # persistent state\n", - to: EmulatorSnowflake, - wantChanged: true, - contains: []string{"# type = \"aws\" # Emulator type", "# # volume = \"\" # persistent state"}, - }, - { - name: "single-quoted type is recognized", - content: "[[containers]]\ntype = 'aws'\nport = \"4566\"\n", - to: EmulatorSnowflake, - wantChanged: true, - contains: []string{`type = "snowflake"`}, - }, - { - // detectBlockType must not match a commented-out type line inside an active block - name: "commented type line within active block is ignored", - content: "[[containers]]\n# type = \"snowflake\"\ntype = \"aws\"\nport = \"4566\"\n", - to: EmulatorSnowflake, - wantChanged: true, - contains: []string{`type = "snowflake"`}, - notContains: []string{"[[containers]]\ntype = \"aws\""}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - result, changed := switchEmulatorContent(tc.content, tc.to) - assert.Equal(t, tc.wantChanged, changed) - if !tc.wantChanged { - assert.Equal(t, tc.content, result) - } - for _, s := range tc.contains { - assert.Contains(t, result, s) - } - for _, s := range tc.notContains { - assert.NotContains(t, result, s) - } - }) - } -} - -func TestSwitchEmulatorContent_RoundTrip(t *testing.T) { - t.Parallel() - original := `[[containers]] -type = "aws" -port = "4566" -` - // Switch to snowflake - afterSnowflake, changed := switchEmulatorContent(original, EmulatorSnowflake) - assert.True(t, changed) - assert.Contains(t, afterSnowflake, `type = "snowflake"`) - - // Switch back to AWS — should restore the commented block - afterAWS, changed := switchEmulatorContent(afterSnowflake, EmulatorAWS) - assert.True(t, changed) - assert.Contains(t, afterAWS, "[[containers]]\ntype = \"aws\"") - assert.NotContains(t, afterAWS, "\n[[containers]]\ntype = \"snowflake\"") -} - -func TestSwitchEmulator_WritesAndReloads(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, SwitchEmulator(EmulatorSnowflake)) - - got, err := os.ReadFile(path) - require.NoError(t, err) - assert.Contains(t, string(got), `type = "snowflake"`) - assert.True(t, strings.Contains(string(got), "# [[containers]]")) - - cfg, err := Get() - require.NoError(t, err) - require.Len(t, cfg.Containers, 1) - assert.Equal(t, EmulatorSnowflake, cfg.Containers[0].Type) -} - -func TestSwitchEmulator_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, SwitchEmulator(EmulatorAWS)) - - got, err := os.ReadFile(path) - require.NoError(t, err) - assert.Equal(t, content, string(got)) -} diff --git a/internal/ui/run.go b/internal/ui/run.go index a08aae9d..7005f651 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -157,15 +157,15 @@ func selectEmulatorInTUI( selected = config.EmulatorSnowflake } - if err := config.SwitchEmulator(selected); err != nil { - return nil, fmt.Errorf("failed to switch emulator: %w", err) + 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.SeverityNote, Text: selected.DisplayName() + " emulator selected."}) + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: selected.DisplayName() + " emulator selected."}) if configPath != "" { sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Change configuration in " + configPath + "."}) } diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go index 20b3657e..70c60f89 100644 --- a/test/integration/emulator_select_test.go +++ b/test/integration/emulator_select_test.go @@ -17,35 +17,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestEmulatorFlagSwitchesConfigToSnowflake(t *testing.T) { - t.Parallel() - // config.SwitchEmulator writes the file before container.Start is called, - // so we can verify the switch even when the process ultimately fails (no Docker). - tmpHome := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) - e := env.Environ(testEnvWithHome(tmpHome, tmpHome)).With(env.DisableEvents, "1") - - configDir := filepath.Join(tmpHome, ".config", "lstk") - require.NoError(t, os.MkdirAll(configDir, 0755)) - configPath := filepath.Join(configDir, "config.toml") - require.NoError(t, os.WriteFile(configPath, []byte(`[[containers]] -type = "aws" -tag = "latest" -port = "4566" -`), 0644)) - - ctx := testContext(t) - // The process will fail at container.Start (no Docker / no real auth), but the - // config switch happens earlier so the file should already be updated. - _, _, runErr := runLstk(t, ctx, "", e.With(env.AuthToken, "test-token"), "--emulator", "snowflake", "--non-interactive") - assert.Error(t, runErr, "expected failure: no Docker available") - - got, err := os.ReadFile(configPath) - require.NoError(t, err, "config file should still exist after the run") - assert.Contains(t, string(got), `type = "snowflake"`, "config should be switched to snowflake") - assert.NotContains(t, string(got), "\n[[containers]]\ntype = \"aws\"", "original aws block should be commented out") -} - func TestFirstRunShowsEmulatorSelectionPrompt(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { From fe9352933b1f4ae7c9a15934149228a91c21fac5 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 7 May 2026 09:52:38 +0200 Subject: [PATCH 17/22] Move domain code to proper place --- CLAUDE.md | 8 +++-- internal/container/label.go | 8 +++++ internal/container/select.go | 57 ++++++++++++++++++++++++++++++++ internal/ui/run.go | 64 ++---------------------------------- 4 files changed, 73 insertions(+), 64 deletions(-) create mode 100644 internal/container/select.go 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/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..7e1ddd90 --- /dev/null +++ b/internal/container/select.go @@ -0,0 +1,57 @@ +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) { + responseCh := make(chan output.InputResponse, 1) + sink.Emit(output.UserInputRequestEvent{ + Prompt: "Which emulator would you like to use?", + Options: []output.InputOption{ + {Key: "a", Label: "AWS"}, + {Key: "s", Label: "Snowflake"}, + }, + 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.EmulatorAWS + if resp.SelectedKey == "s" { + selected = config.EmulatorSnowflake + } + + 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.DisplayName() + " emulator selected."}) + if configPath != "" { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Change configuration in " + configPath + "."}) + } + + return newCfg.Containers, nil +} diff --git a/internal/ui/run.go b/internal/ui/run.go index 7005f651..9bcc1786 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -3,11 +3,9 @@ package ui import ( "context" "errors" - "fmt" "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" @@ -71,14 +69,14 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { // 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 resolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) + go container.ResolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) } if update.NotifyUpdate(ctx, sink, runOpts.NotifyOptions) { p.Send(runDoneMsg{}) return } if runOpts.NeedsEmulatorSelection { - newContainers, selErr := selectEmulatorInTUI(ctx, sink, runOpts.ConfigPath) + newContainers, selErr := container.SelectEmulator(ctx, sink, runOpts.ConfigPath) if selErr != nil { if errors.Is(selErr, context.Canceled) { return @@ -87,7 +85,7 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { return } runOpts.StartOptions.Containers = newContainers - go resolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) + go container.ResolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) } err = container.Start(ctx, runOpts.Runtime, sink, runOpts.StartOptions, true) if err != nil { @@ -117,62 +115,6 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { return nil } -func resolveAndCacheLabel(ctx context.Context, opts container.StartOptions, labelCh chan<- string) { - label, ok := container.ResolveEmulatorLabel(ctx, opts.PlatformClient, opts.Containers, opts.AuthToken, opts.Logger) - if ok { - config.CachePlanLabel(label) - } - labelCh <- label -} - -func selectEmulatorInTUI( - ctx context.Context, - sink output.Sink, - configPath string, -) ([]config.ContainerConfig, error) { - responseCh := make(chan output.InputResponse, 1) - sink.Emit(output.UserInputRequestEvent{ - Prompt: "Which emulator would you like to use?", - Options: []output.InputOption{ - {Key: "a", Label: "AWS"}, - {Key: "s", Label: "Snowflake"}, - }, - 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.EmulatorAWS - if resp.SelectedKey == "s" { - selected = config.EmulatorSnowflake - } - - 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.DisplayName() + " emulator selected."}) - if configPath != "" { - sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Change configuration in " + configPath + "."}) - } - - return newCfg.Containers, nil -} - func IsInteractive() bool { return term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stdin.Fd())) } From 62926ca1f6ee07c6fdbfb17e5434e74bd87c1cef Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 7 May 2026 10:46:03 +0200 Subject: [PATCH 18/22] Refactor display name related functions --- cmd/root.go | 2 +- internal/config/containers.go | 15 ++++++--------- internal/container/select.go | 2 +- internal/container/start.go | 10 +++++----- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index cb643404..25034537 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -187,7 +187,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t sink := output.NewPlainSink(os.Stdout) if firstRun && len(appConfig.Containers) > 0 { - emName := appConfig.Containers[0].Type.DisplayName() + emName := appConfig.Containers[0].Type.ShortName() sink.Emit(output.MessageEvent{ Severity: output.SeverityNote, Text: fmt.Sprintf("Configured with default emulator %s.", emName), diff --git a/internal/config/containers.go b/internal/config/containers.go index 01b7f4e2..bcadd4da 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -25,12 +25,16 @@ var emulatorDisplayNames = map[EmulatorType]string{ EmulatorAzure: "Azure", } -func (e EmulatorType) DisplayName() string { +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", @@ -74,13 +78,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"` @@ -174,7 +171,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/container/select.go b/internal/container/select.go index 7e1ddd90..bc26d4c2 100644 --- a/internal/container/select.go +++ b/internal/container/select.go @@ -48,7 +48,7 @@ func SelectEmulator( return nil, err } - sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: selected.DisplayName() + " emulator selected."}) + 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 + "."}) } 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"}, From b4fc4cf797438093eda44aaa766ed8e167a972d1 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 7 May 2026 12:40:14 +0200 Subject: [PATCH 19/22] Reuse for emulator name --- internal/config/containers.go | 8 ++++++++ internal/container/select.go | 21 +++++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/internal/config/containers.go b/internal/config/containers.go index bcadd4da..6d21cbb9 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -25,6 +25,14 @@ 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 diff --git a/internal/container/select.go b/internal/container/select.go index bc26d4c2..822223db 100644 --- a/internal/container/select.go +++ b/internal/container/select.go @@ -13,13 +13,15 @@ func SelectEmulator( 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: []output.InputOption{ - {Key: "a", Label: "AWS"}, - {Key: "s", Label: "Snowflake"}, - }, + Prompt: "Which emulator would you like to use?", + Options: options, ResponseCh: responseCh, Vertical: true, }) @@ -35,9 +37,12 @@ func SelectEmulator( return nil, context.Canceled } - selected := config.EmulatorAWS - if resp.SelectedKey == "s" { - selected = config.EmulatorSnowflake + selected := config.SelectableEmulatorTypes[0] + for _, t := range config.SelectableEmulatorTypes { + if t.SelectionKey() == resp.SelectedKey { + selected = t + break + } } if err := config.SetEmulatorType(selected); err != nil { From 6ced3fe963d008d160315f3525333c9c3b3c72bf Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 7 May 2026 12:55:14 +0200 Subject: [PATCH 20/22] Get rid of initConfigCapturingFirstRun --- cmd/aws.go | 2 +- cmd/config.go | 2 +- cmd/login.go | 2 +- cmd/logout.go | 2 +- cmd/logs.go | 2 +- cmd/restart.go | 2 +- cmd/root.go | 21 ++++++--------------- cmd/setup.go | 2 +- cmd/start.go | 2 +- cmd/status.go | 2 +- cmd/stop.go | 2 +- cmd/update.go | 2 +- cmd/volume.go | 4 ++-- 13 files changed, 19 insertions(+), 28 deletions(-) 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 25034537..873728db 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -35,7 +35,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C Use: "lstk", Short: "LocalStack CLI", Long: "lstk is the command-line interface for LocalStack.", - PreRunE: initConfigCapturingFirstRun(&firstRun), + PreRunE: initConfig(&firstRun), RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { @@ -286,19 +286,7 @@ 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 { - return err - } - if path != "" { - return config.InitFromPath(path) - } - _, err = config.Init() - return err -} - -func initConfigCapturingFirstRun(firstRun *bool) func(*cobra.Command, []string) error { +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 { @@ -307,7 +295,10 @@ func initConfigCapturingFirstRun(firstRun *bool) func(*cobra.Command, []string) if path != "" { return config.InitFromPath(path) } - *firstRun, err = config.Init() + isFirstRun, err := config.Init() + if firstRun != nil { + *firstRun = isFirstRun + } return err } } 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 77cbb1d1..3ece0767 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -14,7 +14,7 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. Use: "start", Short: "Start emulator", Long: "Start emulator and services.", - PreRunE: initConfigCapturingFirstRun(&firstRun), + PreRunE: initConfig(&firstRun), RunE: func(c *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { 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 { From 238b97acbaded9b158cdba99f7e131bb8873c784 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 7 May 2026 13:01:49 +0200 Subject: [PATCH 21/22] Enhance test by capturing user input & persisting in config --- test/integration/emulator_select_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go index 70c60f89..aef036ae 100644 --- a/test/integration/emulator_select_test.go +++ b/test/integration/emulator_select_test.go @@ -54,6 +54,20 @@ func TestFirstRunShowsEmulatorSelectionPrompt(t *testing.T) { 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 } From 3d88700f0ad5f2be2a1a3ba0e9ac643a716e6eb3 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 7 May 2026 13:07:24 +0200 Subject: [PATCH 22/22] New test: emulator selection not triggered when config exists --- test/integration/emulator_select_test.go | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go index aef036ae..62c80931 100644 --- a/test/integration/emulator_select_test.go +++ b/test/integration/emulator_select_test.go @@ -17,6 +17,48 @@ import ( "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" {