From c7cdf7ea9930d68c0273b1eda55b9bb565281e37 Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Wed, 18 Feb 2026 16:33:29 +0100 Subject: [PATCH 1/9] Change config search and creation --- CLAUDE.md | 13 +- cmd/config.go | 31 +++++ cmd/root.go | 13 +- internal/config/config.go | 176 ++++++++++++++++++++++++---- test/integration/config_test.go | 202 +++++++++++++++++++++++++++----- test/integration/main_test.go | 9 ++ 6 files changed, 385 insertions(+), 59 deletions(-) create mode 100644 cmd/config.go diff --git a/CLAUDE.md b/CLAUDE.md index a9aea27..095050f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,9 +30,16 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for # Configuration -Uses Viper with TOML format. Config file location: -- Linux: `~/.config/lstk/config.toml` -- macOS: `~/Library/Application Support/lstk/config.toml` +Uses Viper with TOML format. Config lookup order: +1. `./lstk.toml` (project-local) +2. `$HOME/.config/lstk/config.toml` +3. `os.UserConfigDir()/lstk/config.toml` + +When no config file exists, lstk creates one at: +- `$HOME/.config/lstk/config.toml` if `$HOME/.config` exists +- otherwise `os.UserConfigDir()/lstk/config.toml` + +Use `lstk config path` to print the resolved config file path currently in use. Created automatically on first run with defaults. Supports emulator types (aws, snowflake, azure) - currently only aws is implemented. diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..42bac52 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + + "github.com/localstack/lstk/internal/config" + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Manage lstk configuration", +} + +var configPathCmd = &cobra.Command{ + Use: "path", + Short: "Show the resolved configuration file path", + RunE: func(cmd *cobra.Command, args []string) error { + configPath, err := config.ConfigFilePath() + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), configPath) + return err + }, +} + +func init() { + configCmd.AddCommand(configPathCmd) + rootCmd.AddCommand(configCmd) +} diff --git a/cmd/root.go b/cmd/root.go index b5236f2..b154345 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,8 +24,8 @@ var rootCmd = &cobra.Command{ Short: "LocalStack CLI", Long: "lstk is the command-line interface for LocalStack.", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Version should be side-effect free and must not create/read user config. - if cmd.Name() == "version" { + // These commands should be side-effect free and must not create/read user config. + if cmd.Name() == "version" || hasConfigPathSequence(args) || hasConfigPathSequence(os.Args[1:]) { return nil } env.Init() @@ -62,3 +62,12 @@ func runStart(ctx context.Context, rt runtime.Runtime) error { } return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), platformClient, false) } + +func hasConfigPathSequence(args []string) bool { + for i := 0; i < len(args)-1; i++ { + if args[i] == "config" && args[i+1] == "path" { + return true + } + } + return false +} diff --git a/internal/config/config.go b/internal/config/config.go index 04ef4cc..775518e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,7 +15,9 @@ const ( EmulatorSnowflake EmulatorType = "snowflake" EmulatorAzure EmulatorType = "azure" - dockerRegistry = "localstack" + dockerRegistry = "localstack" + localConfigFileName = "lstk.toml" + userConfigFileName = "config.toml" ) var emulatorImages = map[EmulatorType]string{ @@ -74,7 +76,15 @@ func (c *ContainerConfig) ProductName() (string, error) { return productName, nil } -func ConfigDir() (string, error) { +func xdgConfigDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + return filepath.Join(homeDir, ".config", "lstk"), nil +} + +func osConfigDir() (string, error) { configHome, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("failed to get user config directory: %w", err) @@ -82,20 +92,50 @@ func ConfigDir() (string, error) { return filepath.Join(configHome, "lstk"), nil } -func Init() error { - dir, err := ConfigDir() +func localConfigPath() string { + return filepath.Join(".", localConfigFileName) +} + +func configSearchPaths() ([]string, error) { + xdgDir, err := xdgConfigDir() if err != nil { - return err + return nil, err } - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) + osDir, err := osConfigDir() + if err != nil { + return nil, err } - viper.SetConfigName("config") - viper.SetConfigType("toml") - viper.AddConfigPath(dir) + return []string{ + // Priority order: project-local, then XDG-style home config, then OS-specific fallback. + localConfigPath(), + filepath.Join(xdgDir, userConfigFileName), + filepath.Join(osDir, userConfigFileName), + }, nil +} + +func configCreationDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + homeConfigDir := filepath.Join(homeDir, ".config") + // Creation policy differs from read fallback: prefer $HOME/.config only when it already exists. + info, err := os.Stat(homeConfigDir) + if err == nil { + if info.IsDir() { + return xdgConfigDir() + } + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("failed to inspect %s: %w", homeConfigDir, err) + } + + return osConfigDir() +} +func setDefaults() { viper.SetDefault("containers", []map[string]any{ { "type": "aws", @@ -103,33 +143,121 @@ func Init() error { "port": "4566", }, }) +} - if err := viper.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - if err := viper.SafeWriteConfig(); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - return nil +func firstExistingConfigPath() (string, bool, error) { + paths, err := configSearchPaths() + if err != nil { + return "", false, err + } + + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + return path, true, nil + } else if !os.IsNotExist(err) { + return "", false, fmt.Errorf("failed to inspect config path %s: %w", path, err) } + } + + return "", false, nil +} +func loadConfig(path string) error { + viper.Reset() + setDefaults() + viper.SetConfigFile(path) + + if err := viper.ReadInConfig(); err != nil { return fmt.Errorf("failed to read config file: %w", err) } - return nil } -func Get() (*Config, error) { - var cfg Config - if err := viper.Unmarshal(&cfg); err != nil { - return nil, fmt.Errorf("failed to unmarshal config: %w", err) +func Init() error { + // Reuse the same ordered path resolution used by ConfigFilePath. + existingPath, found, err := firstExistingConfigPath() + if err != nil { + return err } - return &cfg, nil + if found { + return loadConfig(existingPath) + } + + // No config found anywhere, create one using creation policy. + creationDir, err := configCreationDir() + if err != nil { + return err + } + + if err := os.MkdirAll(creationDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + configPath := filepath.Join(creationDir, userConfigFileName) + viper.Reset() + setDefaults() + viper.SetConfigType("toml") + viper.SetConfigFile(configPath) + if err := viper.SafeWriteConfigAs(configPath); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return loadConfig(configPath) +} + +func ConfigDir() (string, error) { + configPath, err := ConfigFilePath() + if err != nil { + return "", err + } + + return filepath.Dir(configPath), nil +} + +func ResolvedConfigPath() string { + return viper.ConfigFileUsed() } func ConfigFilePath() (string, error) { - dir, err := ConfigDir() + if resolved := ResolvedConfigPath(); resolved != "" { + // If Init already ran, use Viper's selected file directly. + absResolved, err := filepath.Abs(resolved) + if err != nil { + return "", fmt.Errorf("failed to resolve absolute config path: %w", err) + } + return absResolved, nil + } + + existingPath, found, err := firstExistingConfigPath() + if err != nil { + return "", err + } + if found { + // Side-effect-free resolution for commands that skip Init (e.g. `lstk config path`). + absPath, err := filepath.Abs(existingPath) + if err != nil { + return "", fmt.Errorf("failed to resolve absolute config path: %w", err) + } + return absPath, nil + } + + creationDir, err := configCreationDir() if err != nil { return "", err } - return filepath.Join(dir, "config.toml"), nil + + creationPath := filepath.Join(creationDir, userConfigFileName) + absCreationPath, err := filepath.Abs(creationPath) + if err != nil { + return "", fmt.Errorf("failed to resolve absolute config path: %w", err) + } + return absCreationPath, nil +} + +func Get() (*Config, error) { + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + return &cfg, nil } diff --git a/test/integration/config_test.go b/test/integration/config_test.go index 89160ba..3c44fc1 100644 --- a/test/integration/config_test.go +++ b/test/integration/config_test.go @@ -1,58 +1,200 @@ package integration_test import ( + "bytes" "os" "os/exec" "path/filepath" "runtime" + "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestConfigFileCreatedOnStartup(t *testing.T) { + t.Run("creates in home .config when present", func(t *testing.T) { + tmpHome := t.TempDir() + workDir := t.TempDir() + xdgOverride := filepath.Join(tmpHome, "xdg-config-home") + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + + env := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, env, "logout") + require.NoError(t, err, stderr) + + expectedConfigFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") + assert.Contains(t, stdout, "Logged out successfully.") + assert.FileExists(t, expectedConfigFile) + assertDefaultConfigContent(t, expectedConfigFile) + }) + + t.Run("falls back to os user config dir when home .config is missing", func(t *testing.T) { + tmpHome := t.TempDir() + workDir := t.TempDir() + xdgOverride := filepath.Join(tmpHome, "xdg-config-home") + + env := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, env, "logout") + require.NoError(t, err, stderr) + + expectedConfigFile := filepath.Join(expectedOSConfigDir(tmpHome, xdgOverride), "config.toml") + assert.Contains(t, stdout, "Logged out successfully.") + assert.FileExists(t, expectedConfigFile) + assertDefaultConfigContent(t, expectedConfigFile) + }) +} + +func TestLocalConfigTakesPrecedence(t *testing.T) { + tmpHome := t.TempDir() + workDir := t.TempDir() + xdgOverride := filepath.Join(tmpHome, "xdg-config-home") + + localConfigFile := filepath.Join(workDir, "lstk.toml") + writeConfigFile(t, localConfigFile) + writeConfigFile(t, filepath.Join(tmpHome, ".config", "lstk", "config.toml")) + writeConfigFile(t, filepath.Join(expectedOSConfigDir(tmpHome, xdgOverride), "config.toml")) + + env := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, env, "config", "path") + require.NoError(t, err, stderr) + + expectedLocalPath, err := filepath.Abs(localConfigFile) + require.NoError(t, err) + assertSamePath(t, expectedLocalPath, stdout) +} + +func TestXDGConfigTakesPrecedence(t *testing.T) { tmpHome := t.TempDir() + workDir := t.TempDir() + xdgOverride := filepath.Join(tmpHome, "xdg-config-home") + + xdgConfigFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") + osConfigFile := filepath.Join(expectedOSConfigDir(tmpHome, xdgOverride), "config.toml") + writeConfigFile(t, xdgConfigFile) + writeConfigFile(t, osConfigFile) + + env := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, env, "config", "path") + require.NoError(t, err, stderr) + + assertSamePath(t, xdgConfigFile, stdout) +} + +func TestConfigPathCommand(t *testing.T) { + tmpHome := t.TempDir() + workDir := t.TempDir() + xdgConfigFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") + writeConfigFile(t, xdgConfigFile) + + env := testEnvWithHome(tmpHome, filepath.Join(tmpHome, "xdg-config-home")) + stdout, stderr, err := runLstk(t, workDir, env, "config", "path") + require.NoError(t, err, stderr) + + assertSamePath(t, xdgConfigFile, stdout) +} + +func TestConfigPathCommandDoesNotCreateConfig(t *testing.T) { + tmpHome := t.TempDir() + workDir := t.TempDir() + xdgOverride := filepath.Join(tmpHome, "xdg-config-home") + expectedConfigFile := filepath.Join(expectedOSConfigDir(tmpHome, xdgOverride), "config.toml") + + env := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, env, "config", "path") + require.NoError(t, err, stderr) + + assertSamePath(t, expectedConfigFile, stdout) + assert.NoFileExists(t, expectedConfigFile) +} + +func runLstk(t *testing.T, dir string, env []string, args ...string) (string, string, error) { + t.Helper() + + binPath, err := filepath.Abs(binaryPath()) + require.NoError(t, err) + + cmd := exec.Command(binPath, args...) + cmd.Dir = dir + cmd.Env = env + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr - var configDir string - var env []string + err = cmd.Run() + return strings.TrimSpace(stdout.String()), strings.TrimSpace(stderr.String()), err +} + +func testEnvWithHome(tmpHome, xdgConfigHome string) []string { + env := envWithout("HOME", "XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOMEDRIVE", "HOMEPATH") + switch runtime.GOOS { + case "darwin", "linux": + env = append(env, "HOME="+tmpHome, "XDG_CONFIG_HOME="+xdgConfigHome, "KEYRING=file") + case "windows": + appData := filepath.Join(tmpHome, "AppData", "Roaming") + env = append(env, "HOME="+tmpHome, "USERPROFILE="+tmpHome, "APPDATA="+appData, "KEYRING=file") + default: + panic("unsupported OS: " + runtime.GOOS) + } + return env +} +func expectedOSConfigDir(tmpHome, xdgConfigHome string) string { switch runtime.GOOS { case "darwin": - configDir = filepath.Join(tmpHome, "Library", "Application Support", "lstk") - env = append(os.Environ(), "HOME="+tmpHome) + return filepath.Join(tmpHome, "Library", "Application Support", "lstk") case "linux": - configDir = filepath.Join(tmpHome, ".config", "lstk") - env = append(os.Environ(), "HOME="+tmpHome, "XDG_CONFIG_HOME=") + if xdgConfigHome != "" { + return filepath.Join(xdgConfigHome, "lstk") + } + return filepath.Join(tmpHome, ".config", "lstk") case "windows": - configDir = filepath.Join(tmpHome, "AppData", "Roaming", "lstk") - env = append(os.Environ(), "APPDATA="+filepath.Join(tmpHome, "AppData", "Roaming")) + return filepath.Join(tmpHome, "AppData", "Roaming", "lstk") default: - t.Skipf("unsupported OS: %s", runtime.GOOS) + panic("unsupported OS: " + runtime.GOOS) } +} - configFile := filepath.Join(configDir, "config.toml") - - cmd := exec.Command(binaryPath(), "start") - cmd.Env = env - err := cmd.Start() - require.NoError(t, err, "failed to start lstk") - defer func() { _ = cmd.Process.Kill() }() - - // Poll for config file creation - check every 50ms, timeout after 2s - require.Eventually(t, func() bool { - _, err := os.Stat(configFile) - return err == nil - }, 2*time.Second, 20*time.Millisecond, "config.toml should be created") - - assert.DirExists(t, configDir, "config directory should be created at OS-specific location") +func writeConfigFile(t *testing.T, path string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + content := "[[containers]]\ntype = \"aws\"\ntag = \"latest\"\nport = \"4566\"\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) +} - content, err := os.ReadFile(configFile) +func assertDefaultConfigContent(t *testing.T, path string) { + t.Helper() + content, err := os.ReadFile(path) require.NoError(t, err) - configStr := string(content) - assert.Contains(t, configStr, `type = 'aws'`) - assert.Contains(t, configStr, `tag = 'latest'`) - assert.Contains(t, configStr, `port = '4566'`) + assert.Contains(t, configStr, "type") + assert.Contains(t, configStr, "aws") + assert.Contains(t, configStr, "tag") + assert.Contains(t, configStr, "latest") + assert.Contains(t, configStr, "port") + assert.Contains(t, configStr, "4566") +} + +func assertSamePath(t *testing.T, expectedPath, actualPath string) { + t.Helper() + assert.Equal( + t, + normalizedPath(expectedPath), + normalizedPath(actualPath), + ) +} + +func normalizedPath(path string) string { + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + resolvedPath, err := filepath.EvalSymlinks(absPath) + if err == nil { + return filepath.Clean(resolvedPath) + } + return filepath.Clean(absPath) } diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 82c9a4e..1fb647e 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -64,6 +64,15 @@ var ring keyring.Keyring // configDir returns the lstk config directory. // Duplicated from internal/config to avoid importing prod code in tests. func configDir() string { + homeDir, err := os.UserHomeDir() + if err != nil { + panic(fmt.Sprintf("failed to get user home directory: %v", err)) + } + homeConfigDir := filepath.Join(homeDir, ".config") + if info, err := os.Stat(homeConfigDir); err == nil && info.IsDir() { + return filepath.Join(homeConfigDir, "lstk") + } + configHome, err := os.UserConfigDir() if err != nil { panic(fmt.Sprintf("failed to get user config directory: %v", err)) From 0ca0caf9ff5aa711fae2161b682a70412b496c39 Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Wed, 18 Feb 2026 17:06:06 +0100 Subject: [PATCH 2/9] Change config init definition # Conflicts: # cmd/root.go --- CLAUDE.md | 1 + cmd/login.go | 7 ++++--- cmd/logout.go | 5 +++-- cmd/root.go | 26 ++++++++------------------ cmd/start.go | 7 ++++--- cmd/stop.go | 7 ++++--- 6 files changed, 24 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 095050f..39df1db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ When no config file exists, lstk creates one at: - otherwise `os.UserConfigDir()/lstk/config.toml` Use `lstk config path` to print the resolved config file path currently in use. +When adding a new command that depends on configuration, wire config initialization explicitly in that command (`PreRunE: initConfig`). Keep side-effect-free commands (e.g., `version`, `config path`) without config initialization. Created automatically on first run with defaults. Supports emulator types (aws, snowflake, azure) - currently only aws is implemented. diff --git a/cmd/login.go b/cmd/login.go index 9611ff7..504e588 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -9,9 +9,10 @@ import ( ) var loginCmd = &cobra.Command{ - Use: "login", - Short: "Authenticate with LocalStack", - Long: "Authenticate with LocalStack and store credentials in system keyring", + Use: "login", + Short: "Authenticate with LocalStack", + Long: "Authenticate with LocalStack and store credentials in system keyring", + PreRunE: initConfig, RunE: func(cmd *cobra.Command, args []string) error { if !ui.IsInteractive() { return fmt.Errorf("login requires an interactive terminal") diff --git a/cmd/logout.go b/cmd/logout.go index 445d9e6..eec3751 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -11,8 +11,9 @@ import ( ) var logoutCmd = &cobra.Command{ - Use: "logout", - Short: "Remove stored authentication token", + Use: "logout", + Short: "Remove stored authentication token", + PreRunE: initConfig, RunE: func(cmd *cobra.Command, args []string) error { sink := output.NewPlainSink(os.Stdout) platformClient := api.NewPlatformClient() diff --git a/cmd/root.go b/cmd/root.go index b154345..eda25e7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/container" "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" "github.com/localstack/lstk/internal/ui" @@ -20,17 +21,10 @@ var commit = "none" var buildDate = "unknown" var rootCmd = &cobra.Command{ - Use: "lstk", - Short: "LocalStack CLI", - Long: "lstk is the command-line interface for LocalStack.", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // These commands should be side-effect free and must not create/read user config. - if cmd.Name() == "version" || hasConfigPathSequence(args) || hasConfigPathSequence(os.Args[1:]) { - return nil - } - env.Init() - return config.Init() - }, + Use: "lstk", + Short: "LocalStack CLI", + Long: "lstk is the command-line interface for LocalStack.", + PreRunE: initConfig, Run: func(cmd *cobra.Command, args []string) { rt, err := runtime.NewDockerRuntime() if err != nil { @@ -63,11 +57,7 @@ func runStart(ctx context.Context, rt runtime.Runtime) error { return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), platformClient, false) } -func hasConfigPathSequence(args []string) bool { - for i := 0; i < len(args)-1; i++ { - if args[i] == "config" && args[i+1] == "path" { - return true - } - } - return false +func initConfig(_ *cobra.Command, _ []string) error { + env.Init() + return config.Init() } diff --git a/cmd/start.go b/cmd/start.go index 8ec6c2c..2308141 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -9,9 +9,10 @@ import ( ) var startCmd = &cobra.Command{ - Use: "start", - Short: "Start LocalStack", - Long: "Start the LocalStack emulator.", + Use: "start", + Short: "Start LocalStack", + Long: "Start the LocalStack emulator.", + PreRunE: initConfig, Run: func(cmd *cobra.Command, args []string) { rt, err := runtime.NewDockerRuntime() if err != nil { diff --git a/cmd/stop.go b/cmd/stop.go index 02b6791..6982158 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -10,9 +10,10 @@ import ( ) var stopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop LocalStack", - Long: "Stop the LocalStack emulator.", + Use: "stop", + Short: "Stop LocalStack", + Long: "Stop the LocalStack emulator.", + PreRunE: initConfig, Run: func(cmd *cobra.Command, args []string) { rt, err := runtime.NewDockerRuntime() if err != nil { From 9a6327bba20450602a0c66ef4d2fbf0f34a1c06b Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Fri, 20 Feb 2026 10:15:43 +0100 Subject: [PATCH 3/9] Add Configuration details to readme --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 3ce713a..b3be9ce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,21 @@ # lstk Localstack's new CLI (v2). +## Configuration + +`lstk` uses Viper with a TOML format. + +For finding the correct config, we have this lookup order: +1. `./lstk.toml` (project-local) +2. `$HOME/.config/lstk/config.toml` +3. `os.UserConfigDir()/lstk/config.toml` + +When no config file exists, `lstk` creates one at: +- `$HOME/.config/lstk/config.toml` if `$HOME/.config` exists +- otherwise `os.UserConfigDir()/lstk/config.toml` + +Use `lstk config path` to print the resolved config file path currently in use. + ## Versioning `lstk` uses calendar versioning in a SemVer-compatible format: From 824c3a50ad09345ad5763dac649ef3579023b865 Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Fri, 20 Feb 2026 10:16:29 +0100 Subject: [PATCH 4/9] Change config path cmd help text --- cmd/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/config.go b/cmd/config.go index 42bac52..aefb296 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -14,7 +14,7 @@ var configCmd = &cobra.Command{ var configPathCmd = &cobra.Command{ Use: "path", - Short: "Show the resolved configuration file path", + Short: "Print the configuration file path", RunE: func(cmd *cobra.Command, args []string) error { configPath, err := config.ConfigFilePath() if err != nil { From 684eed0c61ff42f9ec68fe4e1f3cf6aaf8b66bcc Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Fri, 20 Feb 2026 10:26:41 +0100 Subject: [PATCH 5/9] Remove hardcoded keyring envvar key --- test/integration/config_test.go | 34 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/test/integration/config_test.go b/test/integration/config_test.go index 3c44fc1..8eeecd8 100644 --- a/test/integration/config_test.go +++ b/test/integration/config_test.go @@ -2,6 +2,7 @@ package integration_test import ( "bytes" + "fmt" "os" "os/exec" "path/filepath" @@ -9,6 +10,7 @@ import ( "strings" "testing" + "github.com/localstack/lstk/test/integration/env" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -20,8 +22,8 @@ func TestConfigFileCreatedOnStartup(t *testing.T) { xdgOverride := filepath.Join(tmpHome, "xdg-config-home") require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) - env := testEnvWithHome(tmpHome, xdgOverride) - stdout, stderr, err := runLstk(t, workDir, env, "logout") + e := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, e, "logout") require.NoError(t, err, stderr) expectedConfigFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") @@ -35,8 +37,8 @@ func TestConfigFileCreatedOnStartup(t *testing.T) { workDir := t.TempDir() xdgOverride := filepath.Join(tmpHome, "xdg-config-home") - env := testEnvWithHome(tmpHome, xdgOverride) - stdout, stderr, err := runLstk(t, workDir, env, "logout") + e := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, e, "logout") require.NoError(t, err, stderr) expectedConfigFile := filepath.Join(expectedOSConfigDir(tmpHome, xdgOverride), "config.toml") @@ -56,8 +58,8 @@ func TestLocalConfigTakesPrecedence(t *testing.T) { writeConfigFile(t, filepath.Join(tmpHome, ".config", "lstk", "config.toml")) writeConfigFile(t, filepath.Join(expectedOSConfigDir(tmpHome, xdgOverride), "config.toml")) - env := testEnvWithHome(tmpHome, xdgOverride) - stdout, stderr, err := runLstk(t, workDir, env, "config", "path") + e := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, e, "config", "path") require.NoError(t, err, stderr) expectedLocalPath, err := filepath.Abs(localConfigFile) @@ -75,8 +77,8 @@ func TestXDGConfigTakesPrecedence(t *testing.T) { writeConfigFile(t, xdgConfigFile) writeConfigFile(t, osConfigFile) - env := testEnvWithHome(tmpHome, xdgOverride) - stdout, stderr, err := runLstk(t, workDir, env, "config", "path") + e := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, e, "config", "path") require.NoError(t, err, stderr) assertSamePath(t, xdgConfigFile, stdout) @@ -88,8 +90,8 @@ func TestConfigPathCommand(t *testing.T) { xdgConfigFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") writeConfigFile(t, xdgConfigFile) - env := testEnvWithHome(tmpHome, filepath.Join(tmpHome, "xdg-config-home")) - stdout, stderr, err := runLstk(t, workDir, env, "config", "path") + e := testEnvWithHome(tmpHome, filepath.Join(tmpHome, "xdg-config-home")) + stdout, stderr, err := runLstk(t, workDir, e, "config", "path") require.NoError(t, err, stderr) assertSamePath(t, xdgConfigFile, stdout) @@ -101,8 +103,8 @@ func TestConfigPathCommandDoesNotCreateConfig(t *testing.T) { xdgOverride := filepath.Join(tmpHome, "xdg-config-home") expectedConfigFile := filepath.Join(expectedOSConfigDir(tmpHome, xdgOverride), "config.toml") - env := testEnvWithHome(tmpHome, xdgOverride) - stdout, stderr, err := runLstk(t, workDir, env, "config", "path") + e := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, e, "config", "path") require.NoError(t, err, stderr) assertSamePath(t, expectedConfigFile, stdout) @@ -129,17 +131,17 @@ func runLstk(t *testing.T, dir string, env []string, args ...string) (string, st } func testEnvWithHome(tmpHome, xdgConfigHome string) []string { - env := envWithout("HOME", "XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOMEDRIVE", "HOMEPATH") + e := env.Without("HOME", "XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOMEDRIVE", "HOMEPATH") switch runtime.GOOS { case "darwin", "linux": - env = append(env, "HOME="+tmpHome, "XDG_CONFIG_HOME="+xdgConfigHome, "KEYRING=file") + e = append(e, "HOME="+tmpHome, "XDG_CONFIG_HOME="+xdgConfigHome, fmt.Sprintf("%s=file", env.Keyring)) case "windows": appData := filepath.Join(tmpHome, "AppData", "Roaming") - env = append(env, "HOME="+tmpHome, "USERPROFILE="+tmpHome, "APPDATA="+appData, "KEYRING=file") + e = append(e, "HOME="+tmpHome, "USERPROFILE="+tmpHome, "APPDATA="+appData, fmt.Sprintf("%s=file", env.Keyring)) default: panic("unsupported OS: " + runtime.GOOS) } - return env + return e } func expectedOSConfigDir(tmpHome, xdgConfigHome string) string { From cc8bc0d30a8f2faec50a6c984ae326d9eb98eba8 Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Fri, 20 Feb 2026 10:27:26 +0100 Subject: [PATCH 6/9] Unexport resolved config path --- internal/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 775518e..f077344 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -214,12 +214,12 @@ func ConfigDir() (string, error) { return filepath.Dir(configPath), nil } -func ResolvedConfigPath() string { +func resolvedConfigPath() string { return viper.ConfigFileUsed() } func ConfigFilePath() (string, error) { - if resolved := ResolvedConfigPath(); resolved != "" { + if resolved := resolvedConfigPath(); resolved != "" { // If Init already ran, use Viper's selected file directly. absResolved, err := filepath.Abs(resolved) if err != nil { From 89aea355dbc207c91ab5337c0f059ac0d7b08287 Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Fri, 20 Feb 2026 11:41:10 +0100 Subject: [PATCH 7/9] Split up config file into logical parts --- internal/config/config.go | 185 ---------------------------------- internal/config/containers.go | 67 ++++++++++++ internal/config/paths.go | 128 +++++++++++++++++++++++ 3 files changed, 195 insertions(+), 185 deletions(-) create mode 100644 internal/config/containers.go create mode 100644 internal/config/paths.go diff --git a/internal/config/config.go b/internal/config/config.go index f077344..3a0c309 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,133 +8,10 @@ import ( "github.com/spf13/viper" ) -type EmulatorType string - -const ( - EmulatorAWS EmulatorType = "aws" - EmulatorSnowflake EmulatorType = "snowflake" - EmulatorAzure EmulatorType = "azure" - - dockerRegistry = "localstack" - localConfigFileName = "lstk.toml" - userConfigFileName = "config.toml" -) - -var emulatorImages = map[EmulatorType]string{ - EmulatorAWS: "localstack-pro", -} - -var emulatorHealthPaths = map[EmulatorType]string{ - EmulatorAWS: "/_localstack/health", -} - type Config struct { Containers []ContainerConfig `mapstructure:"containers"` } -type ContainerConfig struct { - Type EmulatorType `mapstructure:"type"` - Tag string `mapstructure:"tag"` - Port string `mapstructure:"port"` - Env []string `mapstructure:"env"` -} - -func (c *ContainerConfig) Image() (string, error) { - productName, err := c.ProductName() - if err != nil { - return "", err - } - tag := c.Tag - if tag == "" { - tag = "latest" - } - return fmt.Sprintf("%s/%s:%s", dockerRegistry, productName, tag), nil -} - -// Name returns the container name: "localstack-{type}" or "localstack-{type}-{tag}" if tag != latest -func (c *ContainerConfig) Name() string { - tag := c.Tag - if tag == "" || tag == "latest" { - return fmt.Sprintf("localstack-%s", c.Type) - } - return fmt.Sprintf("localstack-%s-%s", c.Type, tag) -} - -func (c *ContainerConfig) HealthPath() (string, error) { - path, ok := emulatorHealthPaths[c.Type] - if !ok { - return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) - } - return path, nil -} - -func (c *ContainerConfig) ProductName() (string, error) { - productName, ok := emulatorImages[c.Type] - if !ok { - return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) - } - return productName, nil -} - -func xdgConfigDir() (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get user home directory: %w", err) - } - return filepath.Join(homeDir, ".config", "lstk"), nil -} - -func osConfigDir() (string, error) { - configHome, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("failed to get user config directory: %w", err) - } - return filepath.Join(configHome, "lstk"), nil -} - -func localConfigPath() string { - return filepath.Join(".", localConfigFileName) -} - -func configSearchPaths() ([]string, error) { - xdgDir, err := xdgConfigDir() - if err != nil { - return nil, err - } - - osDir, err := osConfigDir() - if err != nil { - return nil, err - } - - return []string{ - // Priority order: project-local, then XDG-style home config, then OS-specific fallback. - localConfigPath(), - filepath.Join(xdgDir, userConfigFileName), - filepath.Join(osDir, userConfigFileName), - }, nil -} - -func configCreationDir() (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get user home directory: %w", err) - } - - homeConfigDir := filepath.Join(homeDir, ".config") - // Creation policy differs from read fallback: prefer $HOME/.config only when it already exists. - info, err := os.Stat(homeConfigDir) - if err == nil { - if info.IsDir() { - return xdgConfigDir() - } - } else if !os.IsNotExist(err) { - return "", fmt.Errorf("failed to inspect %s: %w", homeConfigDir, err) - } - - return osConfigDir() -} - func setDefaults() { viper.SetDefault("containers", []map[string]any{ { @@ -145,23 +22,6 @@ func setDefaults() { }) } -func firstExistingConfigPath() (string, bool, error) { - paths, err := configSearchPaths() - if err != nil { - return "", false, err - } - - for _, path := range paths { - if _, err := os.Stat(path); err == nil { - return path, true, nil - } else if !os.IsNotExist(err) { - return "", false, fmt.Errorf("failed to inspect config path %s: %w", path, err) - } - } - - return "", false, nil -} - func loadConfig(path string) error { viper.Reset() setDefaults() @@ -205,55 +65,10 @@ func Init() error { return loadConfig(configPath) } -func ConfigDir() (string, error) { - configPath, err := ConfigFilePath() - if err != nil { - return "", err - } - - return filepath.Dir(configPath), nil -} - func resolvedConfigPath() string { return viper.ConfigFileUsed() } -func ConfigFilePath() (string, error) { - if resolved := resolvedConfigPath(); resolved != "" { - // If Init already ran, use Viper's selected file directly. - absResolved, err := filepath.Abs(resolved) - if err != nil { - return "", fmt.Errorf("failed to resolve absolute config path: %w", err) - } - return absResolved, nil - } - - existingPath, found, err := firstExistingConfigPath() - if err != nil { - return "", err - } - if found { - // Side-effect-free resolution for commands that skip Init (e.g. `lstk config path`). - absPath, err := filepath.Abs(existingPath) - if err != nil { - return "", fmt.Errorf("failed to resolve absolute config path: %w", err) - } - return absPath, nil - } - - creationDir, err := configCreationDir() - if err != nil { - return "", err - } - - creationPath := filepath.Join(creationDir, userConfigFileName) - absCreationPath, err := filepath.Abs(creationPath) - if err != nil { - return "", fmt.Errorf("failed to resolve absolute config path: %w", err) - } - return absCreationPath, nil -} - func Get() (*Config, error) { var cfg Config if err := viper.Unmarshal(&cfg); err != nil { diff --git a/internal/config/containers.go b/internal/config/containers.go new file mode 100644 index 0000000..7903b26 --- /dev/null +++ b/internal/config/containers.go @@ -0,0 +1,67 @@ +package config + +import "fmt" + +type EmulatorType string + +const ( + EmulatorAWS EmulatorType = "aws" + EmulatorSnowflake EmulatorType = "snowflake" + EmulatorAzure EmulatorType = "azure" + + dockerRegistry = "localstack" + localConfigFileName = "lstk.toml" + userConfigFileName = "config.toml" +) + +var emulatorImages = map[EmulatorType]string{ + EmulatorAWS: "localstack-pro", +} + +var emulatorHealthPaths = map[EmulatorType]string{ + EmulatorAWS: "/_localstack/health", +} + +type ContainerConfig struct { + Type EmulatorType `mapstructure:"type"` + Tag string `mapstructure:"tag"` + Port string `mapstructure:"port"` + Env []string `mapstructure:"env"` +} + +func (c *ContainerConfig) Image() (string, error) { + productName, err := c.ProductName() + if err != nil { + return "", err + } + tag := c.Tag + if tag == "" { + tag = "latest" + } + return fmt.Sprintf("%s/%s:%s", dockerRegistry, productName, tag), nil +} + +// Name returns the container name: "localstack-{type}" or "localstack-{type}-{tag}" if tag != latest +func (c *ContainerConfig) Name() string { + tag := c.Tag + if tag == "" || tag == "latest" { + return fmt.Sprintf("localstack-%s", c.Type) + } + return fmt.Sprintf("localstack-%s-%s", c.Type, tag) +} + +func (c *ContainerConfig) HealthPath() (string, error) { + path, ok := emulatorHealthPaths[c.Type] + if !ok { + return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) + } + return path, nil +} + +func (c *ContainerConfig) ProductName() (string, error) { + productName, ok := emulatorImages[c.Type] + if !ok { + return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) + } + return productName, nil +} diff --git a/internal/config/paths.go b/internal/config/paths.go new file mode 100644 index 0000000..5626d5f --- /dev/null +++ b/internal/config/paths.go @@ -0,0 +1,128 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" +) + +func ConfigFilePath() (string, error) { + if resolved := resolvedConfigPath(); resolved != "" { + // If Init already ran, use Viper's selected file directly. + absResolved, err := filepath.Abs(resolved) + if err != nil { + return "", fmt.Errorf("failed to resolve absolute config path: %w", err) + } + return absResolved, nil + } + + existingPath, found, err := firstExistingConfigPath() + if err != nil { + return "", err + } + if found { + // Side-effect-free resolution for commands that skip Init (e.g. `lstk config path`). + absPath, err := filepath.Abs(existingPath) + if err != nil { + return "", fmt.Errorf("failed to resolve absolute config path: %w", err) + } + return absPath, nil + } + + creationDir, err := configCreationDir() + if err != nil { + return "", err + } + + creationPath := filepath.Join(creationDir, userConfigFileName) + absCreationPath, err := filepath.Abs(creationPath) + if err != nil { + return "", fmt.Errorf("failed to resolve absolute config path: %w", err) + } + return absCreationPath, nil +} + +func ConfigDir() (string, error) { + configPath, err := ConfigFilePath() + if err != nil { + return "", err + } + + return filepath.Dir(configPath), nil +} + +func xdgConfigDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + return filepath.Join(homeDir, ".config", "lstk"), nil +} + +func osConfigDir() (string, error) { + configHome, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to get user config directory: %w", err) + } + return filepath.Join(configHome, "lstk"), nil +} + +func localConfigPath() string { + return filepath.Join(".", localConfigFileName) +} + +func configSearchPaths() ([]string, error) { + xdgDir, err := xdgConfigDir() + if err != nil { + return nil, err + } + + osDir, err := osConfigDir() + if err != nil { + return nil, err + } + + return []string{ + // Priority order: project-local, then XDG-style home config, then OS-specific fallback. + localConfigPath(), + filepath.Join(xdgDir, userConfigFileName), + filepath.Join(osDir, userConfigFileName), + }, nil +} + +func configCreationDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + homeConfigDir := filepath.Join(homeDir, ".config") + // Creation policy differs from read fallback: prefer $HOME/.config only when it already exists. + info, err := os.Stat(homeConfigDir) + if err == nil { + if info.IsDir() { + return xdgConfigDir() + } + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("failed to inspect %s: %w", homeConfigDir, err) + } + + return osConfigDir() +} + +func firstExistingConfigPath() (string, bool, error) { + paths, err := configSearchPaths() + if err != nil { + return "", false, err + } + + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + return path, true, nil + } else if !os.IsNotExist(err) { + return "", false, fmt.Errorf("failed to inspect config path %s: %w", path, err) + } + } + + return "", false, nil +} From 08a6bd84be62212baf9c9a37527a72445bfc6272 Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Fri, 20 Feb 2026 11:42:58 +0100 Subject: [PATCH 8/9] Fix linter error --- cmd/root.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index eda25e7..a32e5a1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,7 +9,6 @@ import ( "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/container" "github.com/localstack/lstk/internal/env" - "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" "github.com/localstack/lstk/internal/ui" From b418c4ce309f11fe1e3989b335bb8086caa81f2a Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Fri, 20 Feb 2026 12:32:14 +0100 Subject: [PATCH 9/9] Add config initialization to logs prerun --- cmd/logs.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/logs.go b/cmd/logs.go index d955e32..25efd1e 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -13,6 +13,7 @@ var logsCmd = &cobra.Command{ Use: "logs", Short: "Stream container logs", Long: "Stream logs from the LocalStack container in real-time. Press Ctrl+C to stop.", + PreRunE: initConfig, RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime() if err != nil {