diff --git a/cmd/root.go b/cmd/root.go index ba8fe93..a59a29d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -30,17 +30,22 @@ 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 } - return startEmulator(cmd.Context(), rt, cfg, tel, logger) + return startEmulator(cmd.Context(), rt, cfg, tel, logger, firstRun, emulator) }, } @@ -50,6 +55,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().String("emulator", "", "Emulator to use (aws|snowflake)") configureHelp(root) @@ -146,13 +152,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) error { - +func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, 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) notifyOpts := update.NotifyOptions{ @@ -167,24 +188,46 @@ 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 + }, }) } @@ -193,6 +236,17 @@ 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 { @@ -291,5 +345,22 @@ func initConfig(cmd *cobra.Command, _ []string) error { if path != "" { return config.InitFromPath(path) } - return config.Init() + _, err = config.Init() + return err +} + +// initConfigCapturingFirstRun returns a PreRunE that initialises config and +// writes whether this is the first run into the provided pointer. +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 f4217e5..ca72eb9 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -9,17 +9,24 @@ import ( ) func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command { - return &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 } - return startEmulator(cmd.Context(), rt, cfg, tel, logger) + return startEmulator(c.Context(), rt, cfg, tel, logger, firstRun, emulator) }, } + cmd.Flags().String("emulator", "", "Emulator to use (aws|snowflake)") + return cmd } diff --git a/internal/config/config.go b/internal/config/config.go index 4221095..14fdbff 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 2fe6a32..e65c659 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -25,6 +25,13 @@ var emulatorDisplayNames = map[EmulatorType]string{ EmulatorAzure: "Azure", } +func (e EmulatorType) DisplayName() string { + if name, ok := emulatorDisplayNames[e]; ok { + return name + } + return string(e) +} + var emulatorImages = map[EmulatorType]string{ EmulatorAWS: "localstack-pro", EmulatorSnowflake: "snowflake", diff --git a/internal/config/switch.go b/internal/config/switch.go new file mode 100644 index 0000000..325f268 --- /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 0000000..c705ad4 --- /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 bbec568..1441915 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 { @@ -68,6 +74,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) { @@ -96,6 +113,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 0000000..671e591 --- /dev/null +++ b/test/integration/emulator_select_test.go @@ -0,0 +1,107 @@ +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" +) + +// freshConfigEnv returns an Environ pointing HOME and USERPROFILE at a new +// temp directory (with .config/ pre-created) so lstk uses an isolated config +// dir on both Unix (HOME) and Windows (USERPROFILE). +func freshConfigEnv(t *testing.T) (env.Environ, string) { + t.Helper() + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + return env.Without(env.Home). + With(env.Home, tmpHome). + With("USERPROFILE", tmpHome). + With(env.Keyring, "file"). + With(env.DisableEvents, "1"), tmpHome +} + +func TestEmulatorFlagSwitchesConfigToSnowflake(t *testing.T) { + // config.SwitchEmulator writes the file before container.Start is called, + // so we can verify the switch even when the process ultimately fails (no Docker). + e, tmpHome := freshConfigEnv(t) + + 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) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + // Fresh HOME with no lstk config — triggers first-run behaviour. + e, tmpHome := freshConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binaryPath(), "start") + cmd.Env = e.With(env.AuthToken, "test-token") + + 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) + }() + + if !assert.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Which emulator would you like to use?")) + }, 100*time.Second, 100*time.Millisecond, "emulator selection prompt should appear on first run") { + t.Logf("process output so far:\n%s", out.String()) + t.FailNow() + } + + // Accept the default (AWS) by pressing Enter. + _, err = ptmx.Write([]byte("\r")) + require.NoError(t, err) + + // The config is written inside onSelected, before container.Start is attempted. + // Polling the file directly is more reliable than waiting for the TUI confirmation + // message, which may not render if container.Start fails fast (e.g. license check). + configPath := filepath.Join(tmpHome, ".config", "lstk", "config.toml") + require.Eventually(t, func() bool { + got, readErr := os.ReadFile(configPath) + return readErr == nil && bytes.Contains(got, []byte(`type = "aws"`)) + }, 10*time.Second, 100*time.Millisecond, "config file should be written with aws emulator on first run") + + // Kill the process — we do not need the emulator to actually start. + cancel() + <-outputCh +}