Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 11 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down
31 changes: 31 additions & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 4 additions & 3 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 3 additions & 2 deletions cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 9 additions & 11 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
7 changes: 4 additions & 3 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions cmd/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
129 changes: 36 additions & 93 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}
67 changes: 67 additions & 0 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading