From b8ca8a818b44566460e50dfdafdc9178dff0fd01 Mon Sep 17 00:00:00 2001 From: jcnnll <201615143+jcnnll@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:42:59 +0200 Subject: [PATCH] feat(config): add LogDir and move diagnostic logs to XDG state - Add LogDir() to resolve to XDG and Windows standard logging paths - Decoupled logging from configuration - Updated CLAUDE.md to follow the logging standard - Added integration test to cover this feature --- CLAUDE.md | 10 ++++++++- cmd/root.go | 13 +++++------ internal/config/paths.go | 27 ++++++++++++++++++++++ internal/config/paths_test.go | 42 +++++++++++++++++++++++++++++++---- 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8bc9a63..e68e8eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ make clean # Remove build artifacts ``` Run a single integration test: + ```bash make test-integration RUN=TestStartCommandSucceedsWithValidToken ``` @@ -36,7 +37,7 @@ 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. @@ -44,6 +45,7 @@ lstk always writes diagnostic logs to `$CONFIG_DIR/lstk.log` (appends across run # 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` @@ -58,12 +60,14 @@ Created automatically on first run with defaults. Supports emulator types (aws, # Emulator Setup Commands Use `lstk setup ` 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 @@ -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{ @@ -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. @@ -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. diff --git a/cmd/root.go b/cmd/root.go index bf90c01..31a606a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) @@ -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) } diff --git a/internal/config/paths.go b/internal/config/paths.go index 417e5ca..b4b4e97 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" ) @@ -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) diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go index 612caa4..7caa1b9 100644 --- a/internal/config/paths_test.go +++ b/internal/config/paths_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "runtime" "testing" "github.com/spf13/viper" @@ -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) @@ -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) @@ -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) + } +}