From af4f7f37f8187c692f88bc1113a100a1e7a60cb6 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 30 Apr 2026 17:01:02 +0200 Subject: [PATCH 01/16] 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 5ce5a73de6446f0e466ff63645e9d51d937d2911 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 4 May 2026 17:41:38 +0200 Subject: [PATCH 02/16] 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 fba3335d36220956a81bafdd7830d76e93b6a449 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 12:37:27 +0200 Subject: [PATCH 03/16] 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 cb621fcf678c7a8f1677e92284d04cfc7b7e6c95 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 12:47:43 +0200 Subject: [PATCH 04/16] 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 701842169033985e97519e658078fe125a2ab2ff Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 12:51:56 +0200 Subject: [PATCH 05/16] 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 cfe2273ff21274465253a5fca3ac1f1766ee3e74 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 12:55:17 +0200 Subject: [PATCH 06/16] 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 762025727340114d22de26030829b2f8c2e576f4 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 12:59:43 +0200 Subject: [PATCH 07/16] 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 973990234ff8e9bb1b7788a8eb2e2657bb1271e7 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 13:19:32 +0200 Subject: [PATCH 08/16] 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 b7d78013f9df6ad2177cce8e634ce117fb65e5c7 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 14:08:49 +0200 Subject: [PATCH 09/16] 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 0f681da6eca930ecc79dbaefb98f22d7cfb3543e Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 15:54:23 +0200 Subject: [PATCH 10/16] 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 1d627a9a847be498fa369ddb9c49a0ae342de91d Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 17:07:57 +0200 Subject: [PATCH 11/16] 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 2aa3fff757467bb0527b81a387247dbd6979eb10 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 18:11:15 +0200 Subject: [PATCH 12/16] 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 bbd7f69491dffc3f4b126274b1ce8d9db764f3ce Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 18:32:04 +0200 Subject: [PATCH 13/16] 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 96aa343328009bdee520f40073538b4f98563f4b Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 18:41:26 +0200 Subject: [PATCH 14/16] 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 ef8a3a5738925789bf2df9321f295a785cca03cd Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 5 May 2026 19:01:52 +0200 Subject: [PATCH 15/16] 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 9b1d670dfca7e74966c0323905713dd95d633804 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 6 May 2026 12:09:34 +0200 Subject: [PATCH 16/16] 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" {