207 lines
4.7 KiB
Go
207 lines
4.7 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/joho/godotenv"
|
|
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
)
|
|
|
|
type dotEnvFile struct {
|
|
Path string
|
|
Values map[string]string
|
|
Duplicates []string
|
|
}
|
|
|
|
// loadDotEnv loads Reasonix's global .env for provider credentials. The
|
|
// workspace .env values returned by loadDotEnvForRoot are ignored here because
|
|
// loadDotEnv has no Config to carry a workspace-scoped expansion environment.
|
|
func loadDotEnv() {
|
|
loadDotEnvForRoot(".")
|
|
}
|
|
|
|
// loadDotEnvForRoot returns workspace .env values for scoped plugin/MCP/proxy
|
|
// expansion, then loads Reasonix's global .env for provider credentials.
|
|
// Workspace .env values are deliberately not written into the process
|
|
// environment, so multiple desktop/ACP workspaces cannot leak tokens into each
|
|
// other and project files cannot redirect Reasonix's own config/credential
|
|
// paths.
|
|
func loadDotEnvForRoot(root string) map[string]string {
|
|
projectEnv := loadProjectDotEnvForExpansion(root)
|
|
loadCredentialStoreForRoot(root)
|
|
return projectEnv
|
|
}
|
|
|
|
func loadProjectDotEnvForExpansion(root string) map[string]string {
|
|
root = resolveRoot(root)
|
|
path := ".env"
|
|
if root != "." {
|
|
path = filepath.Join(root, ".env")
|
|
}
|
|
if current := UserCredentialsPath(); current != "" && samePath(path, current) {
|
|
return nil
|
|
}
|
|
file, ok := readDotEnvFile(path)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return file.filtered(func(key string) bool {
|
|
return !isProjectDotEnvControlKey(key)
|
|
})
|
|
}
|
|
|
|
func isProjectDotEnvControlKey(key string) bool {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return true
|
|
}
|
|
upper := strings.ToUpper(key)
|
|
if strings.HasPrefix(upper, "REASONIX_") {
|
|
return true
|
|
}
|
|
switch upper {
|
|
case "HOME", "USERPROFILE", "APPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func legacyCredentialsPaths() []string {
|
|
current := UserCredentialsPath()
|
|
seen := map[string]bool{}
|
|
var paths []string
|
|
add := func(path string) {
|
|
if path == "" {
|
|
return
|
|
}
|
|
path = filepath.Clean(path)
|
|
if current != "" && samePath(path, current) {
|
|
return
|
|
}
|
|
if seen[path] {
|
|
return
|
|
}
|
|
seen[path] = true
|
|
paths = append(paths, path)
|
|
}
|
|
if dir := legacyOSSupportDir(); dir != "" {
|
|
add(filepath.Join(dir, "credentials"))
|
|
}
|
|
if dir := userSupportDir(); dir != "" {
|
|
add(filepath.Join(dir, "credentials"))
|
|
add(filepath.Join(dir, ".env"))
|
|
}
|
|
for _, cfg := range legacyXDGConfigPaths() {
|
|
add(filepath.Join(filepath.Dir(cfg), "credentials"))
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func loadDotEnvFileAs(path string, source CredentialSource) {
|
|
file, ok := readDotEnvFile(path)
|
|
if !ok {
|
|
return
|
|
}
|
|
for key, val := range file.Values {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := os.LookupEnv(key); exists && source.Kind != CredentialSourceCredentials {
|
|
recordExistingCredentialSource(key)
|
|
continue
|
|
}
|
|
if err := os.Setenv(key, val); err == nil && source.Kind != "" {
|
|
source.Path = path
|
|
recordCredentialSource(key, val, source)
|
|
}
|
|
}
|
|
}
|
|
|
|
func readDotEnvFile(path string) (dotEnvFile, bool) {
|
|
raw, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return dotEnvFile{}, false
|
|
}
|
|
values, err := godotenv.Unmarshal(string(raw))
|
|
if err != nil {
|
|
return dotEnvFile{}, false
|
|
}
|
|
return dotEnvFile{
|
|
Path: path,
|
|
Values: values,
|
|
Duplicates: detectDotEnvDuplicateKeys(path),
|
|
}, true
|
|
}
|
|
|
|
func (f dotEnvFile) filtered(allow func(string) bool) map[string]string {
|
|
out := map[string]string{}
|
|
for key, val := range f.Values {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" || allow != nil && !allow(key) {
|
|
continue
|
|
}
|
|
out[key] = val
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (f dotEnvFile) warnings() []string {
|
|
if len(f.Duplicates) == 0 {
|
|
return nil
|
|
}
|
|
warnings := make([]string, 0, len(f.Duplicates))
|
|
for _, key := range f.Duplicates {
|
|
warnings = append(warnings, "duplicate .env key "+key+" in "+f.Path+"; last parsed value wins")
|
|
}
|
|
return warnings
|
|
}
|
|
|
|
func detectDotEnvDuplicateKeys(path string) []string {
|
|
raw, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
dups := map[string]bool{}
|
|
for _, line := range strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") {
|
|
values, err := godotenv.Unmarshal(line)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for key := range values {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if seen[key] {
|
|
dups[key] = true
|
|
}
|
|
seen[key] = true
|
|
}
|
|
}
|
|
out := make([]string, 0, len(dups))
|
|
for key := range dups {
|
|
out = append(out, key)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func envFileValue(path, wantKey string) (string, bool) {
|
|
file, ok := readDotEnvFile(path)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
val, ok := file.Values[wantKey]
|
|
return val, ok
|
|
}
|