package main import ( "bytes" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" ) const ( defaultBaseURL = "http://localhost:8080" configEnv = "CMSCTL_CONFIG" ) // Config is deliberately small and JSON-compatible so operators can inspect // or move it between machines. The file is written with mode 0600. type Config struct { BaseURL string `json:"base_url,omitempty"` FinderURL string `json:"finder_url,omitempty"` AccessToken string `json:"access_token,omitempty"` TokenType string `json:"token_type,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"` // APIKey is a minted scoped API key (AUTH_APIKEYS_FILE). Prefer over a // device access token for scripts: it has no expiry. Sent as X-API-Key. APIKey string `json:"api_key,omitempty"` } func defaultConfig() Config { return Config{BaseURL: defaultBaseURL, TokenType: "Bearer"} } func configPath(override string) (string, error) { if override != "" { return override, nil } if p := os.Getenv(configEnv); p != "" { return p, nil } // UserConfigDir follows the platform convention: $XDG_CONFIG_HOME or // ~/.config on Unix, %AppData% on Windows, and ~/Library/Application // Support on macOS. Keep the explicit XDG check for callers that set it // in environments where UserConfigDir cannot resolve it yet. if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { return filepath.Join(dir, "cmsctl", "config.json"), nil } if dir, err := os.UserConfigDir(); err == nil && dir != "" { return filepath.Join(dir, "cmsctl", "config.json"), nil } // Last-resort Unix-compatible fallback for minimal environments without a // discoverable platform config directory. home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("find user config directory: %w", err) } return filepath.Join(home, ".config", "cmsctl", "config.json"), nil } func loadConfig(path string) (Config, error) { cfg := defaultConfig() body, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return cfg, nil } if err != nil { return Config{}, fmt.Errorf("read config %s: %w", path, err) } // A zero-byte or whitespace-only file (e.g. created by `touch` or a // botched write) is not a corrupt config; fall back to defaults. if len(bytes.TrimSpace(body)) == 0 { return cfg, nil } if err := json.Unmarshal(body, &cfg); err != nil { return Config{}, fmt.Errorf("parse config %s: %w", path, err) } if cfg.BaseURL == "" { cfg.BaseURL = defaultBaseURL } if cfg.TokenType == "" { cfg.TokenType = "Bearer" } return cfg, nil } func saveConfig(path string, cfg Config) error { if cfg.BaseURL == "" { cfg.BaseURL = defaultBaseURL } if cfg.TokenType == "" { cfg.TokenType = "Bearer" } body, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("encode config: %w", err) } body = append(body, '\n') if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("create config directory: %w", err) } tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*") if err != nil { return fmt.Errorf("create config temp file: %w", err) } tmpName := tmp.Name() defer os.Remove(tmpName) if err := tmp.Chmod(0o600); err != nil { tmp.Close() return fmt.Errorf("protect config temp file: %w", err) } if _, err := tmp.Write(body); err != nil { tmp.Close() return fmt.Errorf("write config: %w", err) } if err := tmp.Sync(); err != nil { tmp.Close() return fmt.Errorf("sync config: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close config: %w", err) } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("replace config: %w", err) } return nil } func clearCredentials(cfg *Config) { cfg.AccessToken = "" cfg.TokenType = "Bearer" cfg.ExpiresAt = time.Time{} cfg.APIKey = "" } func effectiveBaseURL(cfg Config) string { base := strings.TrimRight(cfg.BaseURL, "/") if base == "" { return defaultBaseURL } return base } func effectiveFinderURL(cfg Config) string { if cfg.FinderURL != "" { return strings.TrimRight(cfg.FinderURL, "/") } return effectiveBaseURL(cfg) + "/find" }