Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0c17622
Select default emulator on first run
anisaoshafi Apr 30, 2026
d7fcdfb
Parallelize new tests
anisaoshafi May 4, 2026
ea7166a
Remove error & rename message for default emulator
anisaoshafi May 5, 2026
d6713e3
Match single quotes in config & handle commented section in detectBlo…
anisaoshafi May 5, 2026
d2e8857
Assert error from runLstk
anisaoshafi May 5, 2026
796b7ae
Move ParseEmulatorType to its domain package
anisaoshafi May 5, 2026
8b35fa3
Make switch_test.go tests table-driven
anisaoshafi May 5, 2026
b6dc825
Strong-type --emulator, validate early
anisaoshafi May 5, 2026
a18bb3b
Remove callback in startEmulator: handle in run.go
anisaoshafi May 5, 2026
ad2945f
Remove ParseEmulatorType in favor of ParseOptionalEmulatorType
anisaoshafi May 5, 2026
b52e540
Gentler message on changing configuration
anisaoshafi May 5, 2026
b177aa7
Avoid duplicating config content
anisaoshafi May 5, 2026
4afa6fc
Skip keyboard shortcuts for emulator selection
anisaoshafi May 5, 2026
49c7698
Nits
anisaoshafi May 5, 2026
31cb5b9
Split emulator-selected note into two message events: change configur…
anisaoshafi May 5, 2026
d651cd8
Simplify: Remove --emulator flag on start
anisaoshafi May 6, 2026
fe93529
Move domain code to proper place
anisaoshafi May 7, 2026
62926ca
Refactor display name related functions
anisaoshafi May 7, 2026
b4fc4cf
Reuse for emulator name
anisaoshafi May 7, 2026
6ced3fe
Get rid of initConfigCapturingFirstRun
anisaoshafi May 7, 2026
238b97a
Enhance test by capturing user input & persisting in config
anisaoshafi May 7, 2026
3d88700
New test: emulator selection not triggered when config exists
anisaoshafi May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Environment variables:
- Reuse `FormatEventLine(event Event)` for all line-oriented rendering so plain and TUI output stay consistent.
- Select output mode at the command boundary in `cmd/`: interactive TTY runs Bubble Tea, non-interactive mode uses `output.NewPlainSink(...)`.
- Keep non-TTY mode non-interactive (no stdin prompts or input waits).
- Domain packages must not import Bubble Tea or UI packages.
- Domain packages (`internal/` minus `internal/ui/`) must not import Bubble Tea or UI packages. A useful test: domain code should work unchanged if `internal/ui/` were swapped for a different frontend.
- Any feature/workflow package that produces user-visible progress should accept an `output.Sink` dependency and emit events through `internal/output`.
- Do not pass UI callbacks like `onProgress func(...)` through domain layers; prefer typed output events.
- Event payloads should be domain facts (phase/status/progress), not pre-rendered UI strings.
Expand All @@ -118,9 +118,11 @@ Domain code must never read from stdin or wait for user input directly. Instead:
- `SelectedKey`: which option was selected
- `Cancelled`: true if user cancelled (e.g., Ctrl+C)

3. The TUI (`internal/ui/app.go`) handles these events by showing the prompt and sending the response when the user interacts.
3. The TUI (`internal/ui/app.go`) handles these events by showing the prompt and sending the response when the user interacts. `internal/ui/` is responsible only for the interaction itself — it does not contain the logic that acts on the response.

4. In non-interactive mode, commands requiring user input should fail early with a helpful error (e.g., "set LOCALSTACK_AUTH_TOKEN or run in interactive mode").
4. The logic executed in response to the user's choice (e.g., writing config, starting a container) belongs in a domain package alongside the rest of the feature, not in `internal/ui/`.

5. In non-interactive mode, commands requiring user input should fail early with a helpful error (e.g., "set LOCALSTACK_AUTH_TOKEN or run in interactive mode").

Example flow in auth login:
```go
Expand Down
2 changes: 1 addition & 1 deletion cmd/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Examples:
lstk aws sqs list-queues
lstk aws s3 mb s3://my-bucket`,
DisableFlagParsing: true,
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func newConfigProfileCmd(cfg *env.Env) *cobra.Command {
return &cobra.Command{
Use: "profile",
Short: "Deprecated: use 'lstk setup aws' instead",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
appConfig, err := config.Get()
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func newLoginCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.
Use: "login",
Short: "Manage login",
Long: "Manage login and store credentials in system keyring",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
if !isInteractiveMode(cfg) {
return fmt.Errorf("login requires an interactive terminal")
Expand Down
2 changes: 1 addition & 1 deletion cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func newLogoutCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored authentication credentials",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
platformClient := api.NewPlatformClient(cfg.APIEndpoint, logger)
appConfig, err := config.Get()
Expand Down
2 changes: 1 addition & 1 deletion cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func newLogsCmd(cfg *env.Env) *cobra.Command {
Use: "logs",
Short: "Show emulator logs",
Long: "Show logs from the emulator. Use --follow to stream in real-time.",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
follow, err := cmd.Flags().GetBool("follow")
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/restart.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func newRestartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobr
Use: "restart",
Short: "Restart emulator",
Long: "Stop and restart emulator and services.",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
Expand Down
58 changes: 31 additions & 27 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ import (
)

func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
var firstRun bool
root := &cobra.Command{
Use: "lstk",
Short: "LocalStack CLI",
Long: "lstk is the command-line interface for LocalStack.",
PreRunE: initConfig,
PreRunE: initConfig(&firstRun),
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
Expand All @@ -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)
},
}

Expand Down Expand Up @@ -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)
Expand All @@ -174,27 +174,25 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
}

if isInteractiveMode(cfg) {
labelCh := make(chan string, 1)
go func() {
label, ok := container.ResolveEmulatorLabel(ctx, opts.PlatformClient, appConfig.Containers, cfg.AuthToken, logger)
if ok {
config.CachePlanLabel(label)
}
labelCh <- label
}()

return ui.Run(ctx, ui.RunOptions{
Runtime: rt,
Version: version.Version(),
StartOptions: opts,
NotifyOptions: notifyOpts,
ConfigPath: configPath,
EmulatorLabel: config.CachedPlanLabel(),
LabelCh: labelCh,
Runtime: rt,
Version: version.Version(),
StartOptions: opts,
NotifyOptions: notifyOpts,
ConfigPath: configPath,
EmulatorLabel: config.CachedPlanLabel(),
NeedsEmulatorSelection: firstRun,
})
}

sink := output.NewPlainSink(os.Stdout)
if firstRun && len(appConfig.Containers) > 0 {
emName := appConfig.Containers[0].Type.ShortName()
sink.Emit(output.MessageEvent{
Severity: output.SeverityNote,
Text: fmt.Sprintf("Configured with default emulator %s.", emName),
})
}
update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken})
return container.Start(ctx, rt, sink, opts, false)
}
Expand Down Expand Up @@ -288,13 +286,19 @@ func newLogger() (log.Logger, func(), error) {
return log.New(f), func() { _ = f.Close() }, nil
}

func initConfig(cmd *cobra.Command, _ []string) error {
path, err := cmd.Flags().GetString("config")
if err != nil {
func initConfig(firstRun *bool) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, _ []string) error {
path, err := cmd.Flags().GetString("config")
if err != nil {
return err
}
if path != "" {
return config.InitFromPath(path)
}
isFirstRun, err := config.Init()
if firstRun != nil {
*firstRun = isFirstRun
}
return err
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: do we really need 2 functions initConfig and initConfigCapturingFirstRun? They look quite similar

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, tried here: 6ced3fe

if path != "" {
return config.InitFromPath(path)
}
return config.Init()
}
2 changes: 1 addition & 1 deletion cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func newSetupAWSCmd(cfg *env.Env) *cobra.Command {
Use: "aws",
Short: "Set up the LocalStack AWS profile",
Long: "Set up the LocalStack AWS profile in ~/.aws/config and ~/.aws/credentials for use with AWS CLI and SDKs.",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
appConfig, err := config.Get()
if err != nil {
Expand Down
9 changes: 5 additions & 4 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,22 @@ import (
)

func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
var firstRun bool
cmd := &cobra.Command{
Use: "start",
Short: "Start emulator",
Long: "Start emulator and services.",
PreRunE: initConfig,
RunE: func(cmd *cobra.Command, args []string) error {
PreRunE: initConfig(&firstRun),
RunE: func(c *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
}
persist, err := cmd.Flags().GetBool("persist")
persist, err := c.Flags().GetBool("persist")
if err != nil {
return err
}
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist)
return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun)
},
}
cmd.Flags().Bool("persist", false, "Enable local persistence (sets LOCALSTACK_PERSISTENCE=1)")
Expand Down
2 changes: 1 addition & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func newStatusCmd(cfg *env.Env) *cobra.Command {
Use: "status",
Short: "Show emulator status and deployed resources",
Long: "Show the status of a running emulator and its deployed resources",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func newStopCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
Use: "stop",
Short: "Stop emulator",
Long: "Stop emulator and services",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func newUpdateCmd(cfg *env.Env) *cobra.Command {
Use: "update",
Short: "Update lstk to the latest version",
Long: "Check for and apply updates to the lstk CLI. Respects the original installation method (Homebrew, npm, or direct binary).",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
if isInteractiveMode(cfg) {
return ui.RunUpdate(cmd.Context(), checkOnly, cfg.GitHubToken)
Expand Down
4 changes: 2 additions & 2 deletions cmd/volume.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func newVolumePathCmd(cfg *env.Env) *cobra.Command {
return &cobra.Command{
Use: "path",
Short: "Print the volume directory path",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
appConfig, err := config.Get()
if err != nil {
Expand Down Expand Up @@ -56,7 +56,7 @@ func newVolumeClearCmd(cfg *env.Env) *cobra.Command {
Use: "clear",
Short: "Clear emulator volume data",
Long: "Remove all data from the emulator volume directory. This resets cached state such as certificates, downloaded tools, and persistence data.",
PreRunE: initConfig,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
appConfig, err := config.Get()
if err != nil {
Expand Down
26 changes: 14 additions & 12 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,17 @@ 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)
viper.SetConfigType(configType)

dirs, err := configSearchDirs()
if err != nil {
return err
return false, err
}
for _, dir := range dirs {
viper.AddConfigPath(dir)
Expand All @@ -70,43 +72,43 @@ func Init() error {
var notFoundErr viper.ConfigFileNotFoundError
if !errors.As(err, &notFoundErr) {
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 {
Expand Down
27 changes: 19 additions & 8 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ var emulatorDisplayNames = map[EmulatorType]string{
EmulatorAzure: "Azure",
}

// SelectableEmulatorTypes lists the emulator types available for interactive selection,
// in the order they should be presented. The selection key for each type is its first character.
var SelectableEmulatorTypes = []EmulatorType{EmulatorAWS, EmulatorSnowflake}

func (e EmulatorType) SelectionKey() string {
return string(e)[0:1]
}

func (e EmulatorType) ShortName() string {
if name, ok := emulatorDisplayNames[e]; ok {
return name
}
return string(e)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: we already have DisplayNameForType line 77.
Can we

  • refactor to make them one?
  • or rename to make more explicit?
  • and/or change the signatures so that they are both methods?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried here: 62926ca


func (e EmulatorType) DisplayName() string {
return fmt.Sprintf("LocalStack %s Emulator", e.ShortName())
}
var emulatorHealthPaths = map[EmulatorType]string{
EmulatorAWS: "/_localstack/health",
EmulatorSnowflake: "/_localstack/health",
Expand Down Expand Up @@ -68,13 +86,6 @@ func KnownImageReposForType(t EmulatorType) []string {
return repos
}

func DisplayNameForType(t EmulatorType) string {
name, ok := emulatorDisplayNames[t]
if !ok {
return fmt.Sprintf("LocalStack %s Emulator", t)
}
return fmt.Sprintf("LocalStack %s Emulator", name)
}

type ContainerConfig struct {
Type EmulatorType `mapstructure:"type"`
Expand Down Expand Up @@ -168,7 +179,7 @@ func (c *ContainerConfig) ContainerPort() (string, error) {
}

func (c *ContainerConfig) DisplayName() string {
return DisplayNameForType(c.Type)
return c.Type.DisplayName()
}

func (c *ContainerConfig) ProductName() (string, error) {
Expand Down
Loading
Loading