Skip to content
Open
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
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ make clean # Remove build artifacts
```

Run a single integration test:

```bash
make test-integration RUN=TestStartCommandSucceedsWithValidToken
```
Expand All @@ -36,14 +37,15 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for

# Logging

lstk always writes diagnostic logs to `$CONFIG_DIR/lstk.log` (appends across runs, cleared at 1 MB). Two log levels: `Info` and `Error`.
lstk always writes diagnostic logs to `$LOG_DIR/lstk.log` (appends across runs, cleared at 1 MB). Two log levels: `Info` and `Error`.

- `log.Logger` is injected as a dependency (via `StartOptions` or constructor params). Use `log.Nop()` in tests.
- This is separate from `output.Sink` — the logger is for internal diagnostics, the sink is for user-facing output.

# Configuration

Uses Viper with TOML format. lstk uses the first `config.toml` found in this order:

1. `./.lstk/config.toml` (project-local)
2. `$HOME/.config/lstk/config.toml`
3. **macOS**: `$HOME/Library/Application Support/lstk/config.toml` / **Windows**: `%AppData%\lstk\config.toml`
Expand All @@ -58,12 +60,14 @@ Created automatically on first run with defaults. Supports emulator types (aws,
# Emulator Setup Commands

Use `lstk setup <emulator>` to set up CLI integration for an emulator type:

- `lstk setup aws` — Sets up AWS CLI profile in `~/.aws/config` and `~/.aws/credentials`

This naming avoids AWS-specific "profile" terminology and uses a clear verb for mutation operations.
The deprecated `lstk config profile` command still works but points users to `lstk setup aws`.

Environment variables:

- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set)

# Code Style
Expand Down Expand Up @@ -116,6 +120,7 @@ Domain code must never read from stdin or wait for user input directly. Instead:
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").

Example flow in auth login:

```go
responseCh := make(chan output.InputResponse, 1)
output.EmitUserInputRequest(sink, output.UserInputRequestEvent{
Expand All @@ -138,11 +143,13 @@ case <-ctx.Done():
# UI Development (Bubble Tea TUI)

## Structure

- `internal/ui/` - Bubble Tea app model and run orchestration
- `internal/ui/components/` - Reusable presentational components
- `internal/ui/styles/` - Lipgloss style definitions and palette constants

## Component and Model Rules

1. Keep components small and focused (single concern each).
2. Keep UI as presentation/orchestration only; business logic stays in domain packages.
3. Long-running work must run outside `Update()` (goroutine or command path), with UI updates sent asynchronously.
Expand All @@ -152,6 +159,7 @@ case <-ctx.Done():
7. Keep message/history state bounded (for example, capped line buffer).

## Styling Rules

- Define styles with semantic names in `internal/ui/styles/styles.go`.
- Preserve the Nimbo palette constants (`#3F51C7`, `#5E6AD2`, `#7E88EC`) unless intentionally changing branding.
- If changing palette constants, update/add tests to guard against accidental drift.
Expand Down
13 changes: 6 additions & 7 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ func Execute(ctx context.Context) error {
}

func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger) error {

appConfig, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
Expand Down Expand Up @@ -211,20 +210,20 @@ func isInteractiveMode(cfg *env.Env) bool {
const maxLogSize = 1 << 20 // 1 MB

func newLogger() (log.Logger, func(), error) {
configDir, err := config.ConfigDir()
logDir, err := config.LogDir()
if err != nil {
return nil, func() {}, fmt.Errorf("resolve config directory: %w", err)
return nil, func() {}, fmt.Errorf("resolve log directory: %w", err)
}
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, func() {}, fmt.Errorf("create config directory %s: %w", configDir, err)
if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, func() {}, fmt.Errorf("create log directory %s: %w", logDir, err)
}
path := filepath.Join(configDir, "lstk.log")
path := filepath.Join(logDir, "lstk.log")
if info, err := os.Stat(path); err == nil && info.Size() > maxLogSize {
if err := os.Truncate(path, 0); err != nil {
return nil, func() {}, fmt.Errorf("truncate log file %s: %w", path, err)
}
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, func() {}, fmt.Errorf("open log file %s: %w", path, err)
}
Expand Down
27 changes: 27 additions & 0 deletions internal/config/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)

Expand All @@ -14,6 +15,32 @@ const (
configFileName = configName + "." + configType
)

// LogDir returns the standard path for diagnostic logs.
func LogDir() (string, error) {
return logDirForOS(runtime.GOOS)
}

func logDirForOS(goos string) (string, error) {
if goos == "windows" {
dir := os.Getenv("LOCALAPPDATA")
if dir == "" {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, "AppData", "Local")
}
return filepath.Join(dir, "lstk"), nil
}

dir := os.Getenv("XDG_STATE_HOME")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir = filepath.Join(home, ".local", "state")
}
return filepath.Join(dir, "lstk"), nil
}

func ConfigFilePath() (string, error) {
if resolved := resolvedConfigPath(); resolved != "" {
absResolved, err := filepath.Abs(resolved)
Expand Down
42 changes: 38 additions & 4 deletions internal/config/paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"runtime"
"testing"

"github.com/spf13/viper"
Expand All @@ -16,10 +17,10 @@ func TestFriendlyConfigPathRelativeForProjectLocal(t *testing.T) {
dir, err := filepath.EvalSymlinks(tmpDir)
require.NoError(t, err)
configDir := filepath.Join(dir, ".lstk")
require.NoError(t, os.MkdirAll(configDir, 0755))
require.NoError(t, os.MkdirAll(configDir, 0o755))

configFile := filepath.Join(configDir, "config.toml")
require.NoError(t, os.WriteFile(configFile, []byte("[aws]\n"), 0644))
require.NoError(t, os.WriteFile(configFile, []byte("[aws]\n"), 0o644))

origDir, err := os.Getwd()
require.NoError(t, err)
Expand All @@ -45,10 +46,10 @@ func TestFriendlyConfigPathTildeForHomeDir(t *testing.T) {
require.NoError(t, err)

configDir := filepath.Join(resolvedHome, ".config", "lstk")
require.NoError(t, os.MkdirAll(configDir, 0755))
require.NoError(t, os.MkdirAll(configDir, 0o755))

configFile := filepath.Join(configDir, "config.toml")
require.NoError(t, os.WriteFile(configFile, []byte("[aws]\n"), 0644))
require.NoError(t, os.WriteFile(configFile, []byte("[aws]\n"), 0o644))

t.Setenv("HOME", resolvedHome)

Expand All @@ -61,3 +62,36 @@ func TestFriendlyConfigPathTildeForHomeDir(t *testing.T) {
require.NoError(t, err)
require.Equal(t, filepath.Join("~", ".config", "lstk", "config.toml"), friendly)
}

func TestLogDir(t *testing.T) {
// Cannot run in parallel: mutates process-wide environment variables.

tmp := t.TempDir()
resolvedTmp, err := filepath.EvalSymlinks(tmp)
require.NoError(t, err)

if runtime.GOOS == "windows" {
t.Setenv("LOCALAPPDATA", resolvedTmp)

path, err := LogDir()
require.NoError(t, err)
require.Equal(t, filepath.Join(resolvedTmp, "lstk"), path)
} else {
// Test XDG_STATE_HOME preference
t.Setenv("XDG_STATE_HOME", resolvedTmp)

path, err := LogDir()
require.NoError(t, err)
require.Equal(t, filepath.Join(resolvedTmp, "lstk"), path)

// Test fallback to HOME
t.Setenv("XDG_STATE_HOME", "")
fakeHome := t.TempDir()
resolvedHome, _ := filepath.EvalSymlinks(fakeHome)
t.Setenv("HOME", resolvedHome)

path, err = LogDir()
require.NoError(t, err)
require.Equal(t, filepath.Join(resolvedHome, ".local", "state", "lstk"), path)
}
}