diff --git a/cmd/root.go b/cmd/root.go index c2babb05..cb643404 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -30,11 +30,12 @@ import ( ) func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command { + var firstRun bool root := &cobra.Command{ Use: "lstk", Short: "LocalStack CLI", Long: "lstk is the command-line interface for LocalStack.", - PreRunE: initConfig, + PreRunE: initConfigCapturingFirstRun(&firstRun), RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { @@ -44,7 +45,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C if err != nil { return err } - return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist) + return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun) }, } @@ -152,8 +153,7 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger } } -func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool) error { - +func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool) error { appConfig, err := config.Get() if err != nil { return fmt.Errorf("failed to get config: %w", err) @@ -174,27 +174,25 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t } if isInteractiveMode(cfg) { - labelCh := make(chan string, 1) - go func() { - label, ok := container.ResolveEmulatorLabel(ctx, opts.PlatformClient, appConfig.Containers, cfg.AuthToken, logger) - if ok { - config.CachePlanLabel(label) - } - labelCh <- label - }() - return ui.Run(ctx, ui.RunOptions{ - Runtime: rt, - Version: version.Version(), - StartOptions: opts, - NotifyOptions: notifyOpts, - ConfigPath: configPath, - EmulatorLabel: config.CachedPlanLabel(), - LabelCh: labelCh, + Runtime: rt, + Version: version.Version(), + StartOptions: opts, + NotifyOptions: notifyOpts, + ConfigPath: configPath, + EmulatorLabel: config.CachedPlanLabel(), + NeedsEmulatorSelection: firstRun, }) } sink := output.NewPlainSink(os.Stdout) + if firstRun && len(appConfig.Containers) > 0 { + emName := appConfig.Containers[0].Type.DisplayName() + sink.Emit(output.MessageEvent{ + Severity: output.SeverityNote, + Text: fmt.Sprintf("Configured with default emulator %s.", emName), + }) + } update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken}) return container.Start(ctx, rt, sink, opts, false) } @@ -296,5 +294,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..77cbb1d1 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -9,21 +9,22 @@ import ( ) func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command { + var firstRun bool cmd := &cobra.Command{ Use: "start", Short: "Start emulator", Long: "Start emulator and services.", - PreRunE: initConfig, - RunE: func(cmd *cobra.Command, args []string) error { + PreRunE: initConfigCapturingFirstRun(&firstRun), + RunE: func(c *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err } - persist, err := cmd.Flags().GetBool("persist") + persist, err := c.Flags().GetBool("persist") if err != nil { return err } - return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist) + return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun) }, } cmd.Flags().Bool("persist", false, "Enable local persistence (sets LOCALSTACK_PERSISTENCE=1)") diff --git a/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/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/ui/run.go b/internal/ui/run.go index f176b7cb..7005f651 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,13 @@ func (s programSender) Send(msg any) { // RunOptions groups the parameters for Run. Bundling them keeps the call // site readable as the UI entry point grows new concerns. type RunOptions struct { - Runtime runtime.Runtime - Version string - StartOptions container.StartOptions - NotifyOptions update.NotifyOptions - ConfigPath string - EmulatorLabel string - LabelCh <-chan string + Runtime runtime.Runtime + Version string + StartOptions container.StartOptions + NotifyOptions update.NotifyOptions + ConfigPath string + EmulatorLabel string + NeedsEmulatorSelection bool } func Run(parentCtx context.Context, runOpts RunOptions) error { @@ -50,26 +52,43 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { p := tea.NewProgram(app) runErrCh := make(chan error, 1) - if runOpts.LabelCh != nil { - go func() { - select { - case label, ok := <-runOpts.LabelCh: - if ok && label != "" { - p.Send(headerLabelMsg{label: label}) - } - case <-ctx.Done(): + labelCh := make(chan string, 1) + go func() { + select { + case label := <-labelCh: + if label != "" { + p.Send(headerLabelMsg{label: label}) } - }() - } + case <-ctx.Done(): + } + }() go func() { var err error defer func() { runErrCh <- err }() sink := output.NewTUISink(programSender{p: p}) + // Start label resolution immediately when no emulator selection is needed, so + // headerLabelMsg always arrives even if NotifyUpdate returns early (update case). + // When emulator selection is needed, resolution starts after the user picks. + if !runOpts.NeedsEmulatorSelection { + go 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) + if selErr != nil { + if errors.Is(selErr, context.Canceled) { + return + } + p.Send(runErrMsg{err: selErr}) + return + } + runOpts.StartOptions.Containers = newContainers + go resolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) + } err = container.Start(ctx, runOpts.Runtime, sink, runOpts.StartOptions, true) if err != nil { if errors.Is(err, context.Canceled) { @@ -98,6 +117,62 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { return nil } +func resolveAndCacheLabel(ctx context.Context, opts container.StartOptions, labelCh chan<- string) { + label, ok := container.ResolveEmulatorLabel(ctx, opts.PlatformClient, opts.Containers, opts.AuthToken, opts.Logger) + if ok { + config.CachePlanLabel(label) + } + labelCh <- label +} + +func selectEmulatorInTUI( + ctx context.Context, + sink output.Sink, + configPath string, +) ([]config.ContainerConfig, error) { + responseCh := make(chan output.InputResponse, 1) + sink.Emit(output.UserInputRequestEvent{ + Prompt: "Which emulator would you like to use?", + Options: []output.InputOption{ + {Key: "a", Label: "AWS"}, + {Key: "s", Label: "Snowflake"}, + }, + ResponseCh: responseCh, + Vertical: true, + }) + + var resp output.InputResponse + select { + case resp = <-responseCh: + case <-ctx.Done(): + return nil, context.Canceled + } + + if resp.Cancelled { + return nil, context.Canceled + } + + selected := config.EmulatorAWS + if resp.SelectedKey == "s" { + selected = config.EmulatorSnowflake + } + + if err := config.SetEmulatorType(selected); err != nil { + return nil, fmt.Errorf("failed to set emulator type: %w", err) + } + newCfg, err := config.Get() + if err != nil { + return nil, err + } + + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: selected.DisplayName() + " emulator selected."}) + if configPath != "" { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Change configuration in " + configPath + "."}) + } + + return newCfg.Containers, nil +} + func IsInteractive() bool { return term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stdin.Fd())) } diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go new file mode 100644 index 00000000..70c60f89 --- /dev/null +++ b/test/integration/emulator_select_test.go @@ -0,0 +1,76 @@ +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 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, _, 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") +}