diff --git a/CLAUDE.md b/CLAUDE.md index a9aea27..39df1db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,9 +30,17 @@ 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. +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/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: diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..aefb296 --- /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: "Print the 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/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/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 { diff --git a/cmd/root.go b/cmd/root.go index b5236f2..a32e5a1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,17 +20,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 { - // Version should be side-effect free and must not create/read user config. - if cmd.Name() == "version" { - 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 { @@ -62,3 +55,8 @@ func runStart(ctx context.Context, rt runtime.Runtime) error { } return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), platformClient, 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 { diff --git a/internal/config/config.go b/internal/config/config.go index 04ef4cc..3a0c309 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,114 +8,65 @@ import ( "github.com/spf13/viper" ) -type EmulatorType string - -const ( - EmulatorAWS EmulatorType = "aws" - EmulatorSnowflake EmulatorType = "snowflake" - EmulatorAzure EmulatorType = "azure" - - dockerRegistry = "localstack" -) - -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 setDefaults() { + viper.SetDefault("containers", []map[string]any{ + { + "type": "aws", + "tag": "latest", + "port": "4566", + }, + }) } -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 loadConfig(path string) error { + viper.Reset() + setDefaults() + viper.SetConfigFile(path) -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) + if err := viper.ReadInConfig(); err != nil { + return fmt.Errorf("failed to read config file: %w", err) } - return productName, nil + return nil } -func ConfigDir() (string, error) { - configHome, err := os.UserConfigDir() +func Init() error { + // Reuse the same ordered path resolution used by ConfigFilePath. + existingPath, found, err := firstExistingConfigPath() if err != nil { - return "", fmt.Errorf("failed to get user config directory: %w", err) + return err + } + if found { + return loadConfig(existingPath) } - return filepath.Join(configHome, "lstk"), nil -} -func Init() error { - dir, err := ConfigDir() + // No config found anywhere, create one using creation policy. + creationDir, err := configCreationDir() if err != nil { return err } - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(creationDir, 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } - viper.SetConfigName("config") + configPath := filepath.Join(creationDir, userConfigFileName) + viper.Reset() + setDefaults() viper.SetConfigType("toml") - viper.AddConfigPath(dir) - - viper.SetDefault("containers", []map[string]any{ - { - "type": "aws", - "tag": "latest", - "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 - } - - return fmt.Errorf("failed to read config file: %w", err) + viper.SetConfigFile(configPath) + if err := viper.SafeWriteConfigAs(configPath); err != nil { + return fmt.Errorf("failed to write config file: %w", err) } - return nil + return loadConfig(configPath) +} + +func resolvedConfigPath() string { + return viper.ConfigFileUsed() } func Get() (*Config, error) { @@ -125,11 +76,3 @@ func Get() (*Config, error) { } return &cfg, nil } - -func ConfigFilePath() (string, error) { - dir, err := ConfigDir() - if err != nil { - return "", err - } - return filepath.Join(dir, "config.toml"), 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 +} diff --git a/test/integration/config_test.go b/test/integration/config_test.go index 89160ba..8eeecd8 100644 --- a/test/integration/config_test.go +++ b/test/integration/config_test.go @@ -1,58 +1,202 @@ package integration_test import ( + "bytes" + "fmt" "os" "os/exec" "path/filepath" "runtime" + "strings" "testing" - "time" + "github.com/localstack/lstk/test/integration/env" "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)) + + 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") + 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") + + 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") + 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")) + + e := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, e, "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) + + e := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, e, "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) + + 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) +} + +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") + + e := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, workDir, e, "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 { + e := env.Without("HOME", "XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOMEDRIVE", "HOMEPATH") + switch runtime.GOOS { + case "darwin", "linux": + e = append(e, "HOME="+tmpHome, "XDG_CONFIG_HOME="+xdgConfigHome, fmt.Sprintf("%s=file", env.Keyring)) + case "windows": + appData := filepath.Join(tmpHome, "AppData", "Roaming") + e = append(e, "HOME="+tmpHome, "USERPROFILE="+tmpHome, "APPDATA="+appData, fmt.Sprintf("%s=file", env.Keyring)) + default: + panic("unsupported OS: " + runtime.GOOS) + } + return e +} +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))