323 lines
7.7 KiB
Go
323 lines
7.7 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Store loads and persists the configuration file. All methods are safe
|
|
// for concurrent use.
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
path string
|
|
cfg *Config
|
|
}
|
|
|
|
// Dir returns the CrossRelay data directory. CROSSRELAY_HOME overrides
|
|
// the default location (~/.crossrelay), which tests rely on.
|
|
func Dir() string {
|
|
if v := strings.TrimSpace(os.Getenv("CROSSRELAY_HOME")); v != "" {
|
|
return v
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ".crossrelay"
|
|
}
|
|
return filepath.Join(home, ".crossrelay")
|
|
}
|
|
|
|
// Path returns the config file location.
|
|
func Path() string { return filepath.Join(Dir(), "config.json") }
|
|
|
|
// Load reads the config file, creating defaults when it does not exist.
|
|
func Load() (*Store, error) {
|
|
s := &Store{path: Path()}
|
|
data, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
s.cfg = DefaultConfig()
|
|
return s, s.save()
|
|
}
|
|
var cfg Config
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", s.path, err)
|
|
}
|
|
cfg.normalize()
|
|
s.cfg = &cfg
|
|
return s, nil
|
|
}
|
|
|
|
// Config returns a snapshot of the configuration.
|
|
func (s *Store) Config() *Config {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.cfg.clone()
|
|
}
|
|
|
|
// FindProvider returns a copy of the provider with the given id.
|
|
func (s *Store) FindProvider(id string) *Provider {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.cfg.Providers {
|
|
if s.cfg.Providers[i].ID == id {
|
|
return s.cfg.Providers[i].clone()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ActiveClaude returns the provider currently applied to Claude Code.
|
|
func (s *Store) ActiveClaude() *Provider {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.cfg.ActiveClaudeID == "" {
|
|
return nil
|
|
}
|
|
for i := range s.cfg.Providers {
|
|
if s.cfg.Providers[i].ID == s.cfg.ActiveClaudeID {
|
|
return s.cfg.Providers[i].clone()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ActiveCodex returns the provider currently applied to Codex.
|
|
func (s *Store) ActiveCodex() *Provider {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.cfg.ActiveCodexID == "" {
|
|
return nil
|
|
}
|
|
for i := range s.cfg.Providers {
|
|
if s.cfg.Providers[i].ID == s.cfg.ActiveCodexID {
|
|
return s.cfg.Providers[i].clone()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddProvider validates and appends a provider for the given target.
|
|
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
p, err := newProvider(name, preset, baseURL, apiKey, models, target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.cfg.Providers = append(s.cfg.Providers, *p)
|
|
if err := s.saveLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
return p.clone(), nil
|
|
}
|
|
|
|
// UpdateProvider replaces a provider's editable fields. An empty apiKey
|
|
// keeps the stored key so edits never wipe it by accident; an empty
|
|
// target keeps the stored one.
|
|
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx := -1
|
|
for i := range s.cfg.Providers {
|
|
if s.cfg.Providers[i].ID == id {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return nil, fmt.Errorf("provider %q not found", id)
|
|
}
|
|
existing := s.cfg.Providers[idx]
|
|
if apiKey == "" {
|
|
apiKey = existing.APIKey
|
|
}
|
|
if target == "" {
|
|
target = existing.Target
|
|
}
|
|
updated, err := newProvider(name, preset, baseURL, apiKey, models, target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
updated.ID = existing.ID
|
|
updated.EnabledForClaude = existing.EnabledForClaude
|
|
updated.EnabledForCodex = existing.EnabledForCodex
|
|
updated.CreatedAt = existing.CreatedAt
|
|
s.cfg.Providers[idx] = *updated
|
|
if err := s.saveLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
return updated.clone(), nil
|
|
}
|
|
|
|
// DeleteProvider removes a provider and clears it when active.
|
|
func (s *Store) DeleteProvider(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx := -1
|
|
for i := range s.cfg.Providers {
|
|
if s.cfg.Providers[i].ID == id {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return fmt.Errorf("provider %q not found", id)
|
|
}
|
|
s.cfg.Providers = append(s.cfg.Providers[:idx], s.cfg.Providers[idx+1:]...)
|
|
if s.cfg.ActiveClaudeID == id {
|
|
s.cfg.ActiveClaudeID = ""
|
|
}
|
|
if s.cfg.ActiveCodexID == id {
|
|
s.cfg.ActiveCodexID = ""
|
|
}
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// ActivateClaude marks a provider as the one written into Claude Code's
|
|
// settings and records it as active.
|
|
func (s *Store) ActivateClaude(id string) (*Provider, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx := -1
|
|
for i := range s.cfg.Providers {
|
|
if s.cfg.Providers[i].ID == id {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return nil, fmt.Errorf("provider %q not found", id)
|
|
}
|
|
if s.cfg.Providers[idx].Target != TargetClaude {
|
|
return nil, fmt.Errorf("provider %q is not a Claude provider", id)
|
|
}
|
|
s.cfg.Providers[idx].EnabledForClaude = true
|
|
s.cfg.ActiveClaudeID = id
|
|
if err := s.saveLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.cfg.Providers[idx].clone(), nil
|
|
}
|
|
|
|
// ClearActiveClaude forgets the active provider without touching the
|
|
// provider list (used after restoring the official settings).
|
|
func (s *Store) ClearActiveClaude() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.cfg.ActiveClaudeID == "" {
|
|
return nil
|
|
}
|
|
s.cfg.ActiveClaudeID = ""
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// ActivateCodex marks a provider as the one written into Codex's
|
|
// config.toml and records it as active.
|
|
func (s *Store) ActivateCodex(id string) (*Provider, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx := -1
|
|
for i := range s.cfg.Providers {
|
|
if s.cfg.Providers[i].ID == id {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return nil, fmt.Errorf("provider %q not found", id)
|
|
}
|
|
if s.cfg.Providers[idx].Target != TargetCodex {
|
|
return nil, fmt.Errorf("provider %q is not a Codex provider", id)
|
|
}
|
|
s.cfg.Providers[idx].EnabledForCodex = true
|
|
s.cfg.ActiveCodexID = id
|
|
if err := s.saveLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.cfg.Providers[idx].clone(), nil
|
|
}
|
|
|
|
// ClearActiveCodex forgets the active Codex provider without touching
|
|
// the provider list (used after restoring the official config).
|
|
func (s *Store) ClearActiveCodex() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.cfg.ActiveCodexID == "" {
|
|
return nil
|
|
}
|
|
s.cfg.ActiveCodexID = ""
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// SetCodexPriorProvider records (or clears) the model_provider value
|
|
// that config.toml carried before CrossRelay took it over.
|
|
func (s *Store) SetCodexPriorProvider(prior string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cfg.CodexPriorProvider = strings.TrimSpace(prior)
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// SetGateway updates listener settings.
|
|
func (s *Store) SetGateway(g Gateway) error {
|
|
g.PortMode = strings.TrimSpace(g.PortMode)
|
|
switch g.PortMode {
|
|
case PortModeAuto:
|
|
g.FixedPort = 0
|
|
case PortModeFixed:
|
|
if g.FixedPort < 1 || g.FixedPort > 65535 {
|
|
return errors.New("fixed port must be between 1 and 65535")
|
|
}
|
|
default:
|
|
return errors.New("port mode must be \"auto\" or \"fixed\"")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cfg.Gateway = g
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// SetLanguage updates the UI language.
|
|
func (s *Store) SetLanguage(lang string) error {
|
|
if lang != LanguageChinese && lang != LanguageEnglish {
|
|
return errors.New("language must be zh-CN or en-US")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cfg.Language = lang
|
|
return s.saveLocked()
|
|
}
|
|
|
|
func (s *Store) save() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.saveLocked()
|
|
}
|
|
|
|
func (s *Store) saveLocked() error {
|
|
data, err := marshalConfig(s.cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, s.path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|