on main help sign in
fix(config): atomically update multiple fields during oauth
commit8bc4a75289efc3e819a95f9c16f351f9cd4a091f authorKieran Klukas <kieran@dunkirk.sh> dateWed 6 May 2026, 12:10 (5 months ago) parent3b9b361
2 files changed+60−17
internal/config/store.go+28−17 internal/config/store_test.go+32
internal/config/store.go+28−17
@@ -1,7 +1,6 @@
11package config
22
33import (
4− "cmp"
54 "context"
65 "encoding/json"
76 "fmt"
@@ -127,9 +126,18 @@ func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
127126// After a successful write, it automatically reloads config to keep in-memory
128127// state fresh.
129128func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
129+ return s.SetConfigFields(scope, map[string]any{key: value})
130+}
131+
132+// SetConfigFields sets multiple key/value pairs in the config file for the given
133+// scope in a single write. After a successful write, it automatically reloads
134+// config to keep in-memory state fresh. This is preferred over multiple
135+// SetConfigField calls when writing several fields atomically to avoid
136+// intermediate reloads with partial state.
137+func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
130138 path, err := s.configPath(scope)
131139 if err != nil {
132− return fmt.Errorf("%s: %w", key, err)
140+ return fmt.Errorf("%v: %w", kv, err)
133141 }
134142 data, err := os.ReadFile(path)
135143 if err != nil {
@@ -140,9 +148,12 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
140148 }
141149 }
142150
143− newValue, err := sjson.Set(string(data), key, value)
144− if err != nil {
145− return fmt.Errorf("failed to set config field %s: %w", key, err)
151+ newValue := string(data)
152+ for key, value := range kv {
153+ newValue, err = sjson.Set(newValue, key, value)
154+ if err != nil {
155+ return fmt.Errorf("failed to set config field %s: %w", key, err)
156+ }
146157 }
147158 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
148159 return fmt.Errorf("failed to create config directory %q: %w", path, err)
@@ -238,10 +249,10 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
238249 }
239250 setKeyOrToken = func() { providerConfig.APIKey = v }
240251 case *oauth.Token:
241− if err := cmp.Or(
242− s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken),
243− s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), v),
244− ); err != nil {
252+ if err := s.SetConfigFields(scope, map[string]any{
253+ fmt.Sprintf("providers.%s.api_key", providerID): v.AccessToken,
254+ fmt.Sprintf("providers.%s.oauth", providerID): v,
255+ }); err != nil {
245256 return err
246257 }
247258 setKeyOrToken = func() {
@@ -343,10 +354,10 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
343354
344355 s.config.Providers.Set(providerID, providerConfig)
345356
346− if err := cmp.Or(
347− s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), refreshedToken.AccessToken),
348− s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), refreshedToken),
349− ); err != nil {
357+ if err := s.SetConfigFields(scope, map[string]any{
358+ fmt.Sprintf("providers.%s.api_key", providerID): refreshedToken.AccessToken,
359+ fmt.Sprintf("providers.%s.oauth", providerID): refreshedToken,
360+ }); err != nil {
350361 return fmt.Errorf("failed to persist refreshed token: %w", err)
351362 }
352363
@@ -460,10 +471,10 @@ func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
460471 return token, false
461472 }
462473
463− if err := cmp.Or(
464− s.SetConfigField(ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
465− s.SetConfigField(ScopeGlobal, "providers.copilot.oauth", token),
466− ); err != nil {
474+ if err := s.SetConfigFields(ScopeGlobal, map[string]any{
475+ "providers.copilot.api_key": token.AccessToken,
476+ "providers.copilot.oauth": token,
477+ }); err != nil {
467478 slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
468479 }
469480
internal/config/store_test.go+32
@@ -513,6 +513,38 @@ func TestAutoReloadDisabledDuringReload(t *testing.T) {
513513 require.False(t, store.autoReloadDisabled, "autoReloadDisabled should be false after ReloadFromDisk")
514514}
515515
516+// TestSetConfigFields_AutoReloadsAtomically verifies that SetConfigFields writes
517+// multiple fields in a single disk write and triggers only one auto-reload,
518+// avoiding intermediate states where only some fields are persisted.
519+func TestSetConfigFields_AutoReloadsAtomically(t *testing.T) {
520+ t.Parallel()
521+
522+ dir := t.TempDir()
523+ configPath := filepath.Join(dir, "crush.json")
524+
525+ // Create initial config file.
526+ initialConfig := `{"options": {"debug": false}}`
527+ require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
528+
529+ // Load initial config.
530+ store, err := Load(dir, dir, false)
531+ require.NoError(t, err)
532+
533+ // Set globalDataPath and capture snapshot.
534+ store.globalDataPath = configPath
535+ store.CaptureStalenessSnapshot([]string{configPath})
536+
537+ // Write multiple fields atomically.
538+ err = store.SetConfigFields(ScopeGlobal, map[string]any{
539+ "options.debug": true,
540+ "options.custom": "hello",
541+ })
542+ require.NoError(t, err)
543+
544+ // Verify both fields are reflected in memory.
545+ require.True(t, store.config.Options.Debug)
546+}
547+
516548func TestLoadTokenFromDisk_ReturnsNewerToken(t *testing.T) {
517549 t.Parallel()
518550