Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 37 additions & 44 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,53 +53,46 @@ func init() {

func runConfigSet(_ *cobra.Command, args []string) error {
key := strings.ToLower(strings.TrimSpace(args[0]))
val := args[1]
c, err := config.Load()
if err != nil {
return err
}
// `<provider>.<field>` routes to per-provider credentials (datadog.api_key,
// sentry.auth_token, …) — the scriptable equivalent of `codag setup`.
if provider, field, ok := strings.Cut(key, "."); ok {
if err := setup.SetProviderField(c, provider, field, val); err != nil {
return err
}
if err := config.Save(c); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "saved %s -> %s\n", key, config.ConfigPath())
return nil
}
switch key {
case "server":
if err := validateServerURL(val); err != nil {
return err
}
c.Server = val
case "telemetry":
switch strings.ToLower(val) {
case "on", "true", "1", "yes":
c.TelemetryOptOut = false
case "off", "false", "0", "no":
c.TelemetryOptOut = true
default:
return fmt.Errorf("telemetry: expected on|off, got %q", val)
val := strings.TrimSpace(args[1])

err := config.Modify(func(c *config.Config) error {
// `<provider>.<field>` routes to per-provider credentials (datadog.api_key,
// sentry.auth_token, …) — the scriptable equivalent of `codag setup`.
if provider, field, ok := strings.Cut(key, "."); ok {
return setup.SetProviderField(c, provider, field, val)
}
case "updates", "update-check", "update_check":
switch strings.ToLower(val) {
case "on", "true", "1", "yes":
c.UpdateCheckOptOut = false
case "off", "false", "0", "no":
c.UpdateCheckOptOut = true
switch key {
case "server":
if err := validateServerURL(val); err != nil {
return err
}
c.Server = val
case "telemetry":
switch strings.ToLower(val) {
case "on", "true", "1", "yes":
c.TelemetryOptOut = false
case "off", "false", "0", "no":
c.TelemetryOptOut = true
default:
return fmt.Errorf("telemetry: expected on|off, got %q", val)
}
case "updates", "update-check", "update_check":
switch strings.ToLower(val) {
case "on", "true", "1", "yes":
c.UpdateCheckOptOut = false
case "off", "false", "0", "no":
c.UpdateCheckOptOut = true
default:
return fmt.Errorf("updates: expected on|off, got %q", val)
}
case "api-key", "api_key":
return fmt.Errorf("api-key is no longer supported; sign in with `codag auth login`")
default:
return fmt.Errorf("updates: expected on|off, got %q", val)
return fmt.Errorf("unknown key %q (valid: server, telemetry, updates, or <provider>.<field> such as datadog.api_key)", key)
}
case "api-key", "api_key":
return fmt.Errorf("api-key is no longer supported; sign in with `codag auth login`")
default:
return fmt.Errorf("unknown key %q (valid: server, telemetry, updates, or <provider>.<field> such as datadog.api_key)", key)
}
if err := config.Save(c); err != nil {
return nil
})
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "saved %s -> %s\n", key, config.ConfigPath())
Expand Down
12 changes: 8 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,19 @@ func maybeReportUpdate(cmd *cobra.Command) {
return
}

cfg.LastUpdateCheckAt = now.Unix()
_ = config.Save(cfg)
_ = config.Modify(func(c *config.Config) error {
c.LastUpdateCheckAt = now.Unix()
return nil
})

info, err := updatecheck.New(autoUpdateTimeout).Check(Version)
if err != nil {
return
}
cfg.LastUpdateLatest = info.Latest
_ = config.Save(cfg)
_ = config.Modify(func(c *config.Config) error {
c.LastUpdateLatest = info.Latest
return nil
})
if !info.Available {
return
}
Expand Down
16 changes: 7 additions & 9 deletions cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,11 @@ func runUpdate(cmd *cobra.Command, _ []string) error {
}

func rememberUpdateCheck(info *updatecheck.Info) {
cfg, err := config.Load()
if err != nil || cfg == nil {
return
}
cfg.LastUpdateCheckAt = time.Now().Unix()
if info != nil {
cfg.LastUpdateLatest = info.Latest
}
_ = config.Save(cfg)
_ = config.Modify(func(cfg *config.Config) error {
cfg.LastUpdateCheckAt = time.Now().Unix()
if info != nil {
cfg.LastUpdateLatest = info.Latest
}
return nil
})
}
14 changes: 11 additions & 3 deletions internal/analytics/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,18 @@ func Init(cliVersion string) {
return
}
if cfg.CLIDistinctID == "" {
cfg.CLIDistinctID = newID()
_ = config.Save(cfg) // best-effort; if we can't persist, fall through
newIDStr := newID()
_ = config.Modify(func(c *config.Config) error {
if c.CLIDistinctID == "" {
c.CLIDistinctID = newIDStr
}
return nil
})
cfg, _ = config.Load()
}
if cfg != nil {
distinctID = cfg.CLIDistinctID
}
distinctID = cfg.CLIDistinctID

c, err := posthog.NewWithConfig(projectKey, posthog.Config{
Endpoint: apiHost,
Expand Down
51 changes: 33 additions & 18 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/codag-megalith/codag-cli/internal/auth"
"github.com/codag-megalith/codag-cli/internal/config"
"golang.org/x/sync/singleflight"
)

const maxResponseBody = 16 << 20
Expand Down Expand Up @@ -383,6 +384,8 @@ func (c *Client) do(ctx context.Context, method, path string, body any, out any)
return nil
}

var anonTokenGroup singleflight.Group

func (c *Client) ensureAnonymousToken(ctx context.Context) (string, error) {
cfg, err := config.Load()
if err != nil {
Expand All @@ -391,30 +394,42 @@ func (c *Client) ensureAnonymousToken(ctx context.Context) (string, error) {
if cfg != nil && cfg.AnonymousToken != "" {
return cfg.AnonymousToken, nil
}
resp, err := c.AnonymousToken(ctx)

v, err, _ := anonTokenGroup.Do("mint", func() (any, error) {
// Double-check under lock
freshCfg, lErr := config.Load()
if lErr == nil && freshCfg != nil && freshCfg.AnonymousToken != "" {
return freshCfg.AnonymousToken, nil
}

resp, mErr := c.AnonymousToken(ctx)
if mErr != nil {
return "", mErr
}
if resp.Token == "" {
return "", fmt.Errorf("anonymous token endpoint returned an empty token")
}

sErr := config.Modify(func(cfg *config.Config) error {
cfg.AnonymousToken = resp.Token
return nil
})
if sErr != nil {
return "", sErr
}
return resp.Token, nil
})
if err != nil {
return "", err
}
if resp.Token == "" {
return "", fmt.Errorf("anonymous token endpoint returned an empty token")
}
if cfg == nil {
cfg = &config.Config{}
}
cfg.AnonymousToken = resp.Token
if err := config.Save(cfg); err != nil {
return "", err
}
return resp.Token, nil
return v.(string), nil
}

func clearAnonymousToken() error {
cfg, err := config.Load()
if err != nil || cfg == nil {
return err
}
cfg.AnonymousToken = ""
return config.Save(cfg)
return config.Modify(func(cfg *config.Config) error {
cfg.AnonymousToken = ""
return nil
})
}

func (c *Client) doStaticBearer(ctx context.Context, method, path string, body any, out any, bearer string) error {
Expand Down
44 changes: 17 additions & 27 deletions internal/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,40 +199,30 @@ func (e Endpoints) Whoami(accessToken string) (map[string]any, error) {
return out, nil
}

// SaveTokens persists a TokenResponse into the on-disk config. Wraps
// config.Save so we don't lose other fields on disk.
// SaveTokens persists a TokenResponse into the on-disk config. Uses
// config.Modify so concurrent writes don't clobber rotated tokens.
func SaveTokens(tr *TokenResponse) error {
cfg, err := config.Load()
if err != nil {
return err
}
if cfg == nil {
cfg = &config.Config{}
}
cfg.AccessToken = tr.AccessToken
if tr.RefreshToken != "" {
cfg.RefreshToken = tr.RefreshToken
}
if tr.ExpiresIn > 0 {
cfg.ExpiresAt = time.Now().Unix() + int64(tr.ExpiresIn)
}
return config.Save(cfg)
return config.Modify(func(cfg *config.Config) error {
cfg.AccessToken = tr.AccessToken
if tr.RefreshToken != "" {
cfg.RefreshToken = tr.RefreshToken
}
if tr.ExpiresIn > 0 {
cfg.ExpiresAt = time.Now().Unix() + int64(tr.ExpiresIn)
}
return nil
})
}

// ClearTokens wipes only the auth-related fields, preserving server URL
// and provider auth.
func ClearTokens() error {
cfg, err := config.Load()
if err != nil {
return err
}
if cfg == nil {
return config.Modify(func(cfg *config.Config) error {
cfg.AccessToken = ""
cfg.RefreshToken = ""
cfg.ExpiresAt = 0
return nil
}
cfg.AccessToken = ""
cfg.RefreshToken = ""
cfg.ExpiresAt = 0
return config.Save(cfg)
})
}

// OpenBrowser tries to launch the user's default browser. Returns no
Expand Down
85 changes: 73 additions & 12 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,8 @@ func path() (string, error) {
return filepath.Join(dir, "codag", "config.json"), nil
}

// Load reads the config file. Returns an empty Config (no error) if missing.
// Unknown JSON keys (e.g. legacy `api_key`) are silently ignored.
func Load() (*Config, error) {
fileMu.Lock()
defer fileMu.Unlock()
// loadLocked reads the config file without acquiring fileMu (caller must hold fileMu).
func loadLocked() (*Config, error) {
p, err := path()
if err != nil {
return nil, err
Expand All @@ -124,13 +121,8 @@ func Load() (*Config, error) {
return &c, nil
}

// Save writes the config to disk (creating the directory if needed).
// File mode 0600 because it holds the OAuth refresh token. The write is
// atomic (temp file + rename) so a concurrent reader never sees a
// truncated file.
func Save(c *Config) error {
fileMu.Lock()
defer fileMu.Unlock()
// saveLocked writes the config file without acquiring fileMu (caller must hold fileMu).
func saveLocked(c *Config) error {
p, err := path()
if err != nil {
return err
Expand Down Expand Up @@ -161,6 +153,75 @@ func Save(c *Config) error {
return os.Rename(tmp.Name(), p)
}

// withFileLock executes fn while holding a cross-process file lock on config.json.lock.
func withFileLock(fn func() error) error {
p, err := path()
if err != nil {
return err
}
lockPath := p + ".lock"
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return err
}
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return err
}
defer lockFile.Close()

if err := lockFileFLock(lockFile); err != nil {
return err
}
defer unlockFileFLock(lockFile)

return fn()
}

// Load reads the config file under a cross-process file lock. Returns an empty Config (no error) if missing.
func Load() (*Config, error) {
fileMu.Lock()
defer fileMu.Unlock()
var c *Config
err := withFileLock(func() error {
var lErr error
c, lErr = loadLocked()
return lErr
})
if err != nil {
return nil, err
}
return c, nil
}

// Save writes the config to disk under a cross-process file lock.
func Save(c *Config) error {
fileMu.Lock()
defer fileMu.Unlock()
return withFileLock(func() error {
return saveLocked(c)
})
}

// Modify atomically reloads the latest config from disk under a cross-process lock,
// applies the mutate function, and saves it back to disk.
func Modify(mutate func(cfg *Config) error) error {
fileMu.Lock()
defer fileMu.Unlock()
return withFileLock(func() error {
cfg, err := loadLocked()
if err != nil {
return err
}
if cfg == nil {
cfg = &Config{}
}
if err := mutate(cfg); err != nil {
return err
}
return saveLocked(cfg)
})
}

// ResolveServer: flag > env > config > default.
func ResolveServer(flagVal string) string {
if flagVal != "" {
Expand Down
Loading