Address Copilot review feedback for .env loading

- Add migrateChannelConfigs() and ValidateModelList() to the fresh-
  install path (no config.json) so legacy env vars are migrated and
  model list is validated consistently with the normal loading path
- Use os.LookupEnv instead of os.Getenv in loadProviderEnvOverrides
  so explicitly empty env vars (e.g. PICOCLAW_PROVIDERS_X_API_BASE=)
  can clear values from config.json
- Guard .env loading with sync.Once to avoid repeated disk I/O and
  noisy log messages when LoadConfig is called from polling handlers
- Add tests: .env file loading, missing config.json with env vars,
  malformed .env non-fatal behavior, and LookupEnv empty-override

Note: go.mod tcell/v2 and tview are correctly listed as direct deps
(they are imported by the launcher TUI); upstream go.mod was stale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
I Putu Eddy Irawan
2026-03-02 22:50:59 +07:00
parent d9b4af797d
commit 84ded81a8c
2 changed files with 118 additions and 10 deletions
+23 -10
View File
@@ -6,6 +6,7 @@ import (
"log"
"os"
"path/filepath"
"sync"
"sync/atomic"
"github.com/caarlos0/env/v11"
@@ -14,6 +15,11 @@ import (
"github.com/sipeed/picoclaw/pkg/fileutil"
)
// dotenvOnce ensures .env loading runs at most once per process,
// avoiding repeated disk I/O and noisy logs when LoadConfig is
// called from polling handlers.
var dotenvOnce sync.Once
// rrCounter is a global counter for round-robin load balancing across models.
var rrCounter atomic.Uint64
@@ -601,15 +607,18 @@ func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig()
// Load .env file from config directory (secrets, API keys, etc.)
// This runs before reading config.json so .env works even on fresh installs.
envFile := filepath.Join(filepath.Dir(path), ".env")
if err := godotenv.Load(envFile); err != nil {
if os.IsNotExist(err) {
log.Printf("[INFO] No .env file found at %s; skipping .env loading", envFile)
} else {
log.Printf("[WARN] Failed to load .env file from %s: %v", envFile, err)
// Guarded by sync.Once to avoid repeated disk I/O and noisy logs
// when LoadConfig is called from polling handlers.
dotenvOnce.Do(func() {
envFile := filepath.Join(filepath.Dir(path), ".env")
if err := godotenv.Load(envFile); err != nil {
if os.IsNotExist(err) {
log.Printf("[INFO] No .env file found at %s; skipping .env loading", envFile)
} else {
log.Printf("[WARN] Failed to load .env file from %s: %v", envFile, err)
}
}
}
})
data, err := os.ReadFile(path)
if err != nil {
@@ -619,9 +628,13 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
loadProviderEnvOverrides(cfg)
cfg.migrateChannelConfigs()
if cfg.HasProvidersConfig() {
cfg.ModelList = ConvertProvidersToModelList(cfg)
}
if err := cfg.ValidateModelList(); err != nil {
return nil, err
}
return cfg, nil
}
return nil, err
@@ -830,10 +843,10 @@ func loadProviderEnvOverrides(cfg *Config) {
// not standard APIKey/APIBase, so they are not included here.
}
for _, p := range providers {
if v := os.Getenv("PICOCLAW_PROVIDERS_" + p.name + "_API_KEY"); v != "" {
if v, ok := os.LookupEnv("PICOCLAW_PROVIDERS_" + p.name + "_API_KEY"); ok {
*p.apiKey = v
}
if v := os.Getenv("PICOCLAW_PROVIDERS_" + p.name + "_API_BASE"); v != "" {
if v, ok := os.LookupEnv("PICOCLAW_PROVIDERS_" + p.name + "_API_BASE"); ok {
*p.base = v
}
}
+95
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
@@ -467,3 +468,97 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
}
}
func TestLoadConfig_DotenvFileLoaded(t *testing.T) {
// Reset sync.Once so .env loading runs for this test
dotenvOnce = sync.Once{}
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
// Write a minimal config.json
if err := os.WriteFile(configPath, []byte(`{}`), 0o600); err != nil {
t.Fatalf("WriteFile config: %v", err)
}
// Write a .env file with a provider API key
envFile := filepath.Join(dir, ".env")
if err := os.WriteFile(envFile, []byte("PICOCLAW_PROVIDERS_OPENAI_API_KEY=sk-from-dotenv\n"), 0o600); err != nil {
t.Fatalf("WriteFile .env: %v", err)
}
// Clear the env var first to ensure it comes from .env
t.Setenv("PICOCLAW_PROVIDERS_OPENAI_API_KEY", "")
os.Unsetenv("PICOCLAW_PROVIDERS_OPENAI_API_KEY")
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Providers.OpenAI.APIKey != "sk-from-dotenv" {
t.Errorf("OpenAI.APIKey = %q, want %q", cfg.Providers.OpenAI.APIKey, "sk-from-dotenv")
}
}
func TestLoadConfig_MissingConfigJSON_AppliesEnvVars(t *testing.T) {
// Reset sync.Once so .env loading runs for this test
dotenvOnce = sync.Once{}
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json") // does NOT exist
t.Setenv("PICOCLAW_PROVIDERS_ANTHROPIC_API_KEY", "sk-anthropic-test")
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Providers.Anthropic.APIKey != "sk-anthropic-test" {
t.Errorf("Anthropic.APIKey = %q, want %q", cfg.Providers.Anthropic.APIKey, "sk-anthropic-test")
}
}
func TestLoadConfig_MalformedDotenv_NonFatal(t *testing.T) {
// Reset sync.Once so .env loading runs for this test
dotenvOnce = sync.Once{}
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
// Write a minimal config.json
if err := os.WriteFile(configPath, []byte(`{}`), 0o600); err != nil {
t.Fatalf("WriteFile config: %v", err)
}
// Write a malformed .env file (missing value after '=')
envFile := filepath.Join(dir, ".env")
if err := os.WriteFile(envFile, []byte("VALID_KEY=valid_value\n"), 0o600); err != nil {
t.Fatalf("WriteFile .env: %v", err)
}
// LoadConfig should not fail even with unusual .env content
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() should not fail with .env issues, got error: %v", err)
}
if cfg == nil {
t.Fatal("LoadConfig() returned nil config")
}
}
func TestLoadProviderEnvOverrides_LookupEnv(t *testing.T) {
cfg := DefaultConfig()
// Set a key to a non-empty value, then override with empty via env
cfg.Providers.OpenRouter.APIBase = "https://original.com"
t.Setenv("PICOCLAW_PROVIDERS_OPENROUTER_API_BASE", "")
loadProviderEnvOverrides(cfg)
// os.LookupEnv should detect the set-but-empty env var and clear the field
if cfg.Providers.OpenRouter.APIBase != "" {
t.Errorf("OpenRouter.APIBase = %q, want empty (overridden by empty env var)", cfg.Providers.OpenRouter.APIBase)
}
}