diff --git a/internal/config/keyring.go b/internal/config/keyring.go index 4bcbb01..4194cde 100644 --- a/internal/config/keyring.go +++ b/internal/config/keyring.go @@ -68,17 +68,16 @@ func (c Credential) RequestOptions() []option.RequestOption { return opts } -// ResolveEnvVar checks the environment and .env files for a variable. +// ResolveEnvVar checks the environment and known .env files for a variable. // Returns the value and true if found, or empty string and false. func ResolveEnvVar(name string) (string, bool) { if v := os.Getenv(name); v != "" { return v, true } - if v := loadKeyFromEnvFile(loadExeEnvFilePath(), name); v != "" { - return v, true - } - if v := loadKeyFromEnvFile(".env", name); v != "" { - return v, true + for _, path := range envFileSearchPaths() { + if v := loadKeyFromEnvFile(path, name); v != "" { + return v, true + } } return "", false } @@ -113,14 +112,9 @@ func resolveKey(envVar, keyringUser string) (string, KeySource) { } } - // 3. .env next to executable + // 3. Known .env files (executable-adjacent, ~/.vix/.env, then CWD). if envVar != "" { - if key := loadKeyFromEnvFile(loadExeEnvFilePath(), envVar); key != "" { - return key, KeySourceEnvFile - } - - // 4. .env in CWD - if key := loadKeyFromEnvFile(".env", envVar); key != "" { + if key, ok := ResolveEnvVar(envVar); ok { return key, KeySourceEnvFile } } @@ -233,7 +227,7 @@ func reorderAuthMethods(methods []AuthMethod, pref string) []AuthMethod { // or env-provided base URL overrides the method's static BaseURL. func buildCredential(value string, src KeySource, m AuthMethod) Credential { baseURL := m.BaseURL - if m.RequiresBaseURL { + if m.RequiresBaseURL || m.BaseURLEnv != "" { if u := resolveMethodBaseURL(m); u != "" { baseURL = u } @@ -246,15 +240,15 @@ func buildCredential(value string, src KeySource, m AuthMethod) Credential { } // resolveMethodBaseURL returns the user-supplied endpoint for a method: the -// BaseURLEnv environment variable first, then the keychain entry stored next to -// the key. Returns "" when neither is set. +// BaseURLEnv value first, then (for RequiresBaseURL methods) the keychain entry +// stored next to the key. Returns "" when neither is set. func resolveMethodBaseURL(m AuthMethod) string { if m.BaseURLEnv != "" { - if v := os.Getenv(m.BaseURLEnv); v != "" { + if v, ok := ResolveEnvVar(m.BaseURLEnv); ok { return v } } - if m.Keyring != "" { + if m.RequiresBaseURL && m.Keyring != "" { if v, err := defaultStore().Get(methodBaseURLKeyringUser(m.Keyring)); err == nil && v != "" { return v } @@ -583,6 +577,15 @@ func loadExeEnvFilePath() string { return filepath.Join(filepath.Dir(exe), "..", "..", ".env") } +func envFileSearchPaths() []string { + paths := []string{loadExeEnvFilePath()} + if home := HomeVixDir(); home != "" { + paths = append(paths, filepath.Join(home, ".env")) + } + paths = append(paths, ".env") + return paths +} + // loadKeyFromEnvFile reads a .env file and extracts the value of the given variable name. func loadKeyFromEnvFile(path, varName string) string { if path == "" { diff --git a/internal/config/keyring_test.go b/internal/config/keyring_test.go index 7492ea7..d2aec35 100644 --- a/internal/config/keyring_test.go +++ b/internal/config/keyring_test.go @@ -1,6 +1,8 @@ package config import ( + "os" + "path/filepath" "testing" "github.com/zalando/go-keyring" @@ -184,6 +186,43 @@ func TestListStoredProviderKeys(t *testing.T) { } } +func TestResolveEnvVarSearchesHomeVixDotEnv(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("ANTHROPIC_API_KEY", "") + if err := os.MkdirAll(HomeVixDir(), 0o755); err != nil { + t.Fatalf("mkdir home .vix: %v", err) + } + if err := os.WriteFile(filepath.Join(HomeVixDir(), ".env"), []byte("ANTHROPIC_API_KEY=home-file-key\n"), 0o600); err != nil { + t.Fatalf("write home .env: %v", err) + } + + got, ok := ResolveEnvVar("ANTHROPIC_API_KEY") + if !ok || got != "home-file-key" { + t.Fatalf("ResolveEnvVar = %q, %v; want home-file-key, true", got, ok) + } +} + +func TestResolveProviderCredentialBaseURLFromDotEnv(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("ANTHROPIC_BASE_URL", "") + DeleteProviderKey("anthropic") + + cwd := t.TempDir() + t.Chdir(cwd) + if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte("ANTHROPIC_API_KEY=dotenv-key\nANTHROPIC_BASE_URL=https://mock.example/v1\n"), 0o600); err != nil { + t.Fatalf("write cwd .env: %v", err) + } + + cred := ResolveProviderCredential("anthropic") + if cred.Value != "dotenv-key" || cred.Source != KeySourceEnvFile { + t.Fatalf("credential = %+v; want key from .env", cred) + } + if cred.BaseURL != "https://mock.example/v1" { + t.Fatalf("baseURL = %q, want .env override", cred.BaseURL) + } +} + // TestMiMoTokenPlanCredential covers the Token Plan credential method: a key and // a user-supplied base URL are stored together, resolution returns both, and the // BaseURLEnv override wins over the stored endpoint. diff --git a/internal/config/providers.go b/internal/config/providers.go index 870e243..8b4ea0b 100644 --- a/internal/config/providers.go +++ b/internal/config/providers.go @@ -1,6 +1,8 @@ package config import ( + "strings" + "github.com/get-vix/vix/internal/auth" "github.com/get-vix/vix/internal/providers" ) @@ -111,6 +113,10 @@ func credKindToAuthKind(kind string) AuthKind { // authMethodFromSpec projects a registry credential method into an AuthMethod. func authMethodFromSpec(m providers.CredentialMethod) AuthMethod { + baseURLEnv := m.BaseURLEnv + if baseURLEnv == "" && strings.HasSuffix(m.EnvVar, "_API_KEY") { + baseURLEnv = strings.TrimSuffix(m.EnvVar, "_API_KEY") + "_BASE_URL" + } am := AuthMethod{ Kind: credKindToAuthKind(m.Kind), EnvVar: m.EnvVar, @@ -119,7 +125,7 @@ func authMethodFromSpec(m providers.CredentialMethod) AuthMethod { BaseURL: m.BaseURL, Label: m.Label, RequiresBaseURL: m.RequiresBaseURL, - BaseURLEnv: m.BaseURLEnv, + BaseURLEnv: baseURLEnv, } if m.HeaderStyle == providers.AuthSchemeBearer { am.HeaderStyle = BearerHeader