diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f5a8d90..e47c954 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -79,6 +79,51 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) { } } +func TestEnvBackedStoreWritebackBootstrapsMissingConfigFile(t *testing.T) { + tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") + if err != nil { + t.Fatalf("create temp config: %v", err) + } + path := tmp.Name() + _ = tmp.Close() + _ = os.Remove(path) + + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"seed@example.com","password":"p"}]}`) + t.Setenv("CONFIG_JSON", "") + t.Setenv("DS2API_CONFIG_PATH", path) + t.Setenv("DS2API_ENV_WRITEBACK", "1") + + store := LoadStore() + if store.IsEnvBacked() { + t.Fatalf("expected writeback bootstrap to become file-backed immediately") + } + if err := store.Update(func(c *Config) error { + c.Accounts = append(c.Accounts, Account{Email: "new@example.com", Password: "p2"}) + return nil + }); err != nil { + t.Fatalf("update failed: %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read written config: %v", err) + } + if !strings.Contains(string(content), "seed@example.com") { + t.Fatalf("expected bootstrapped config to contain seed account, got: %s", content) + } + if !strings.Contains(string(content), "new@example.com") { + t.Fatalf("expected persisted config to contain added account, got: %s", content) + } + + reloaded := LoadStore() + if reloaded.IsEnvBacked() { + t.Fatalf("expected reloaded store to prefer persisted config file") + } + accounts := reloaded.Accounts() + if len(accounts) != 2 { + t.Fatalf("expected 2 accounts after reload, got %d", len(accounts)) + } +} + func TestRuntimeTokenRefreshIntervalHoursDefaultsToSix(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{ "keys":["k1"], diff --git a/internal/config/store.go b/internal/config/store.go index d212594..678390a 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -4,12 +4,19 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "os" + "path/filepath" "slices" "strings" "sync" ) +func envWritebackEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv("DS2API_ENV_WRITEBACK"))) + return v == "1" || v == "true" || v == "yes" || v == "on" +} + type Store struct { mu sync.RWMutex cfg Config @@ -42,6 +49,24 @@ func loadConfig() (Config, bool, error) { cfg, err := parseConfigString(rawCfg) cfg.ClearAccountTokens() cfg.DropInvalidAccounts() + if IsVercel() || !envWritebackEnabled() { + return cfg, true, err + } + content, fileErr := os.ReadFile(ConfigPath()) + if fileErr == nil { + var fileCfg Config + if unmarshalErr := json.Unmarshal(content, &fileCfg); unmarshalErr == nil { + fileCfg.DropInvalidAccounts() + return fileCfg, false, err + } + } + if errors.Is(fileErr, os.ErrNotExist) { + if writeErr := writeConfigFile(ConfigPath(), cfg.Clone()); writeErr == nil { + return cfg, false, err + } else { + Logger.Warn("[config] env writeback bootstrap failed", "error", writeErr) + } + } return cfg, true, err } @@ -177,7 +202,7 @@ func (s *Store) Update(mutator func(*Config) error) error { func (s *Store) Save() error { s.mu.Lock() defer s.mu.Unlock() - if s.fromEnv { + if s.fromEnv && (IsVercel() || !envWritebackEnabled()) { Logger.Info("[save_config] source from env, skip write") return nil } @@ -187,11 +212,15 @@ func (s *Store) Save() error { if err != nil { return err } - return os.WriteFile(s.path, b, 0o644) + if err := writeConfigBytes(s.path, b); err != nil { + return err + } + s.fromEnv = false + return nil } func (s *Store) saveLocked() error { - if s.fromEnv { + if s.fromEnv && (IsVercel() || !envWritebackEnabled()) { Logger.Info("[save_config] source from env, skip write") return nil } @@ -201,7 +230,32 @@ func (s *Store) saveLocked() error { if err != nil { return err } - return os.WriteFile(s.path, b, 0o644) + if err := writeConfigBytes(s.path, b); err != nil { + return err + } + s.fromEnv = false + return nil +} + +func writeConfigFile(path string, cfg Config) error { + persistCfg := cfg.Clone() + persistCfg.ClearAccountTokens() + b, err := json.MarshalIndent(persistCfg, "", " ") + if err != nil { + return err + } + return writeConfigBytes(path, b) +} + +func writeConfigBytes(path string, b []byte) error { + dir := filepath.Dir(path) + if dir == "." || dir == "" { + return os.WriteFile(path, b, 0o644) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir config dir: %w", err) + } + return os.WriteFile(path, b, 0o644) } func (s *Store) IsEnvBacked() bool {