Skip to content

Commit 8a66cef

Browse files
committed
Change config search and creation
1 parent 4565603 commit 8a66cef

6 files changed

Lines changed: 385 additions & 59 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,16 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for
3030

3131
# Configuration
3232

33-
Uses Viper with TOML format. Config file location:
34-
- Linux: `~/.config/lstk/config.toml`
35-
- macOS: `~/Library/Application Support/lstk/config.toml`
33+
Uses Viper with TOML format. Config lookup order:
34+
1. `./lstk.toml` (project-local)
35+
2. `$HOME/.config/lstk/config.toml`
36+
3. `os.UserConfigDir()/lstk/config.toml`
37+
38+
When no config file exists, lstk creates one at:
39+
- `$HOME/.config/lstk/config.toml` if `$HOME/.config` exists
40+
- otherwise `os.UserConfigDir()/lstk/config.toml`
41+
42+
Use `lstk config path` to print the resolved config file path currently in use.
3643

3744
Created automatically on first run with defaults. Supports emulator types (aws, snowflake, azure) - currently only aws is implemented.
3845

cmd/config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/localstack/lstk/internal/config"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
var configCmd = &cobra.Command{
11+
Use: "config",
12+
Short: "Manage lstk configuration",
13+
}
14+
15+
var configPathCmd = &cobra.Command{
16+
Use: "path",
17+
Short: "Show the resolved configuration file path",
18+
RunE: func(cmd *cobra.Command, args []string) error {
19+
configPath, err := config.ConfigFilePath()
20+
if err != nil {
21+
return err
22+
}
23+
_, err = fmt.Fprintln(cmd.OutOrStdout(), configPath)
24+
return err
25+
},
26+
}
27+
28+
func init() {
29+
configCmd.AddCommand(configPathCmd)
30+
rootCmd.AddCommand(configCmd)
31+
}

cmd/root.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ var rootCmd = &cobra.Command{
2323
Short: "LocalStack CLI",
2424
Long: "lstk is the command-line interface for LocalStack.",
2525
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
26-
// Version should be side-effect free and must not create/read user config.
27-
if cmd.Name() == "version" {
26+
// These commands should be side-effect free and must not create/read user config.
27+
if cmd.Name() == "version" || hasConfigPathSequence(args) || hasConfigPathSequence(os.Args[1:]) {
2828
return nil
2929
}
3030
return config.Init()
@@ -60,3 +60,12 @@ func runStart(ctx context.Context, rt runtime.Runtime) error {
6060
}
6161
return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), platformClient, false)
6262
}
63+
64+
func hasConfigPathSequence(args []string) bool {
65+
for i := 0; i < len(args)-1; i++ {
66+
if args[i] == "config" && args[i+1] == "path" {
67+
return true
68+
}
69+
}
70+
return false
71+
}

internal/config/config.go

Lines changed: 152 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ const (
1515
EmulatorSnowflake EmulatorType = "snowflake"
1616
EmulatorAzure EmulatorType = "azure"
1717

18-
dockerRegistry = "localstack"
18+
dockerRegistry = "localstack"
19+
localConfigFileName = "lstk.toml"
20+
userConfigFileName = "config.toml"
1921
)
2022

2123
var emulatorImages = map[EmulatorType]string{
@@ -74,62 +76,188 @@ func (c *ContainerConfig) ProductName() (string, error) {
7476
return productName, nil
7577
}
7678

77-
func ConfigDir() (string, error) {
79+
func xdgConfigDir() (string, error) {
80+
homeDir, err := os.UserHomeDir()
81+
if err != nil {
82+
return "", fmt.Errorf("failed to get user home directory: %w", err)
83+
}
84+
return filepath.Join(homeDir, ".config", "lstk"), nil
85+
}
86+
87+
func osConfigDir() (string, error) {
7888
configHome, err := os.UserConfigDir()
7989
if err != nil {
8090
return "", fmt.Errorf("failed to get user config directory: %w", err)
8191
}
8292
return filepath.Join(configHome, "lstk"), nil
8393
}
8494

85-
func Init() error {
86-
dir, err := ConfigDir()
95+
func localConfigPath() string {
96+
return filepath.Join(".", localConfigFileName)
97+
}
98+
99+
func configSearchPaths() ([]string, error) {
100+
xdgDir, err := xdgConfigDir()
87101
if err != nil {
88-
return err
102+
return nil, err
89103
}
90104

91-
if err := os.MkdirAll(dir, 0755); err != nil {
92-
return fmt.Errorf("failed to create config directory: %w", err)
105+
osDir, err := osConfigDir()
106+
if err != nil {
107+
return nil, err
93108
}
94109

95-
viper.SetConfigName("config")
96-
viper.SetConfigType("toml")
97-
viper.AddConfigPath(dir)
110+
return []string{
111+
// Priority order: project-local, then XDG-style home config, then OS-specific fallback.
112+
localConfigPath(),
113+
filepath.Join(xdgDir, userConfigFileName),
114+
filepath.Join(osDir, userConfigFileName),
115+
}, nil
116+
}
117+
118+
func configCreationDir() (string, error) {
119+
homeDir, err := os.UserHomeDir()
120+
if err != nil {
121+
return "", fmt.Errorf("failed to get user home directory: %w", err)
122+
}
123+
124+
homeConfigDir := filepath.Join(homeDir, ".config")
125+
// Creation policy differs from read fallback: prefer $HOME/.config only when it already exists.
126+
info, err := os.Stat(homeConfigDir)
127+
if err == nil {
128+
if info.IsDir() {
129+
return xdgConfigDir()
130+
}
131+
} else if !os.IsNotExist(err) {
132+
return "", fmt.Errorf("failed to inspect %s: %w", homeConfigDir, err)
133+
}
134+
135+
return osConfigDir()
136+
}
98137

138+
func setDefaults() {
99139
viper.SetDefault("containers", []map[string]any{
100140
{
101141
"type": "aws",
102142
"tag": "latest",
103143
"port": "4566",
104144
},
105145
})
146+
}
106147

107-
if err := viper.ReadInConfig(); err != nil {
108-
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
109-
if err := viper.SafeWriteConfig(); err != nil {
110-
return fmt.Errorf("failed to write config file: %w", err)
111-
}
112-
return nil
148+
func firstExistingConfigPath() (string, bool, error) {
149+
paths, err := configSearchPaths()
150+
if err != nil {
151+
return "", false, err
152+
}
153+
154+
for _, path := range paths {
155+
if _, err := os.Stat(path); err == nil {
156+
return path, true, nil
157+
} else if !os.IsNotExist(err) {
158+
return "", false, fmt.Errorf("failed to inspect config path %s: %w", path, err)
113159
}
160+
}
161+
162+
return "", false, nil
163+
}
114164

165+
func loadConfig(path string) error {
166+
viper.Reset()
167+
setDefaults()
168+
viper.SetConfigFile(path)
169+
170+
if err := viper.ReadInConfig(); err != nil {
115171
return fmt.Errorf("failed to read config file: %w", err)
116172
}
117-
118173
return nil
119174
}
120175

121-
func Get() (*Config, error) {
122-
var cfg Config
123-
if err := viper.Unmarshal(&cfg); err != nil {
124-
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
176+
func Init() error {
177+
// Reuse the same ordered path resolution used by ConfigFilePath.
178+
existingPath, found, err := firstExistingConfigPath()
179+
if err != nil {
180+
return err
125181
}
126-
return &cfg, nil
182+
if found {
183+
return loadConfig(existingPath)
184+
}
185+
186+
// No config found anywhere, create one using creation policy.
187+
creationDir, err := configCreationDir()
188+
if err != nil {
189+
return err
190+
}
191+
192+
if err := os.MkdirAll(creationDir, 0755); err != nil {
193+
return fmt.Errorf("failed to create config directory: %w", err)
194+
}
195+
196+
configPath := filepath.Join(creationDir, userConfigFileName)
197+
viper.Reset()
198+
setDefaults()
199+
viper.SetConfigType("toml")
200+
viper.SetConfigFile(configPath)
201+
if err := viper.SafeWriteConfigAs(configPath); err != nil {
202+
return fmt.Errorf("failed to write config file: %w", err)
203+
}
204+
205+
return loadConfig(configPath)
206+
}
207+
208+
func ConfigDir() (string, error) {
209+
configPath, err := ConfigFilePath()
210+
if err != nil {
211+
return "", err
212+
}
213+
214+
return filepath.Dir(configPath), nil
215+
}
216+
217+
func ResolvedConfigPath() string {
218+
return viper.ConfigFileUsed()
127219
}
128220

129221
func ConfigFilePath() (string, error) {
130-
dir, err := ConfigDir()
222+
if resolved := ResolvedConfigPath(); resolved != "" {
223+
// If Init already ran, use Viper's selected file directly.
224+
absResolved, err := filepath.Abs(resolved)
225+
if err != nil {
226+
return "", fmt.Errorf("failed to resolve absolute config path: %w", err)
227+
}
228+
return absResolved, nil
229+
}
230+
231+
existingPath, found, err := firstExistingConfigPath()
232+
if err != nil {
233+
return "", err
234+
}
235+
if found {
236+
// Side-effect-free resolution for commands that skip Init (e.g. `lstk config path`).
237+
absPath, err := filepath.Abs(existingPath)
238+
if err != nil {
239+
return "", fmt.Errorf("failed to resolve absolute config path: %w", err)
240+
}
241+
return absPath, nil
242+
}
243+
244+
creationDir, err := configCreationDir()
131245
if err != nil {
132246
return "", err
133247
}
134-
return filepath.Join(dir, "config.toml"), nil
248+
249+
creationPath := filepath.Join(creationDir, userConfigFileName)
250+
absCreationPath, err := filepath.Abs(creationPath)
251+
if err != nil {
252+
return "", fmt.Errorf("failed to resolve absolute config path: %w", err)
253+
}
254+
return absCreationPath, nil
255+
}
256+
257+
func Get() (*Config, error) {
258+
var cfg Config
259+
if err := viper.Unmarshal(&cfg); err != nil {
260+
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
261+
}
262+
return &cfg, nil
135263
}

0 commit comments

Comments
 (0)