package configfile import ( "encoding/json" "fmt" "os" "path/filepath" "strings" ) const ConfigFileName = "metadata.json" type Config struct { Database string `json:"database"` JSONLExport string `json:"jsonl_export,omitempty"` Backend string `json:"backend,omitempty"` // "sqlite" (default) or "dolt" // Deletions configuration DeletionsRetentionDays int `json:"deletions_retention_days,omitempty"` // 0 means use default (3 days) // Dolt server mode configuration (bd-dolt.2.2) // When Mode is "server", connects to external dolt sql-server instead of embedded. // This enables multi-writer access for multi-agent environments. DoltMode string `json:"dolt_mode,omitempty"` // "embedded" (default) or "server" DoltServerHost string `json:"dolt_server_host,omitempty"` // Server host (default: 127.0.0.1) DoltServerPort int `json:"dolt_server_port,omitempty"` // Server port (default: 3306) DoltServerUser string `json:"dolt_server_user,omitempty"` // MySQL user (default: root) // Note: Password should be set via BEADS_DOLT_PASSWORD env var for security // Deprecated: LastBdVersion is no longer used for version tracking. // Version is now stored in .local_version (gitignored) to prevent // upgrade notifications firing after git operations reset metadata.json. // bd-tok: This field is kept for backwards compatibility when reading old configs. LastBdVersion string `json:"last_bd_version,omitempty"` } func DefaultConfig() *Config { return &Config{ Database: "beads.db", JSONLExport: "issues.jsonl", // Canonical name (bd-6xd) } } func ConfigPath(beadsDir string) string { return filepath.Join(beadsDir, ConfigFileName) } func Load(beadsDir string) (*Config, error) { configPath := ConfigPath(beadsDir) data, err := os.ReadFile(configPath) // #nosec G304 - controlled path from config if os.IsNotExist(err) { // Try legacy config.json location (migration path) legacyPath := filepath.Join(beadsDir, "config.json") data, err = os.ReadFile(legacyPath) // #nosec G304 - controlled path from config if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, fmt.Errorf("reading legacy config: %w", err) } // Migrate: parse legacy config, save as metadata.json, remove old file var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing legacy config: %w", err) } // Save to new location if err := cfg.Save(beadsDir); err != nil { return nil, fmt.Errorf("migrating config to metadata.json: %w", err) } // Remove legacy file (best effort) _ = os.Remove(legacyPath) return &cfg, nil } if err != nil { return nil, fmt.Errorf("reading config: %w", err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing config: %w", err) } return &cfg, nil } func (c *Config) Save(beadsDir string) error { configPath := ConfigPath(beadsDir) data, err := json.MarshalIndent(c, "", " ") if err != nil { return fmt.Errorf("marshaling config: %w", err) } if err := os.WriteFile(configPath, data, 0600); err != nil { return fmt.Errorf("writing config: %w", err) } return nil } func (c *Config) DatabasePath(beadsDir string) string { backend := c.GetBackend() // Treat Database as the on-disk storage location: // - SQLite: filename (default: beads.db) // - Dolt: directory name (default: dolt) // // Backward-compat: early dolt configs wrote "beads.db" even when Backend=dolt. // In that case, treat it as "dolt". if backend == BackendDolt { db := strings.TrimSpace(c.Database) if db == "" || db == "beads.db" { db = "dolt" } if filepath.IsAbs(db) { return db } return filepath.Join(beadsDir, db) } // SQLite (default) db := strings.TrimSpace(c.Database) if db == "" { db = "beads.db" } if filepath.IsAbs(db) { return db } return filepath.Join(beadsDir, db) } func (c *Config) JSONLPath(beadsDir string) string { if c.JSONLExport == "" { return filepath.Join(beadsDir, "issues.jsonl") } return filepath.Join(beadsDir, c.JSONLExport) } // DefaultDeletionsRetentionDays is the default retention period for deletion records. const DefaultDeletionsRetentionDays = 3 // GetDeletionsRetentionDays returns the configured retention days, or the default if not set. func (c *Config) GetDeletionsRetentionDays() int { if c.DeletionsRetentionDays <= 0 { return DefaultDeletionsRetentionDays } return c.DeletionsRetentionDays } // Backend constants const ( BackendSQLite = "sqlite" BackendDolt = "dolt" ) // BackendCapabilities describes behavioral constraints for a storage backend. // // This is intentionally small and stable: callers should use these flags to decide // whether to enable features like daemon/RPC/autostart and process spawning. // // NOTE: The embedded Dolt driver is effectively single-writer at the OS-process level. // Even if multiple goroutines are safe within one process, multiple processes opening // the same Dolt directory concurrently can cause lock contention and transient // "read-only" failures. Therefore, Dolt is treated as single-process-only. type BackendCapabilities struct { // SingleProcessOnly indicates the backend must not be accessed from multiple // Beads OS processes concurrently (no daemon mode, no RPC client/server split, // no helper-process spawning). SingleProcessOnly bool } // CapabilitiesForBackend returns capabilities for a backend string. // Unknown backends are treated conservatively as single-process-only. func CapabilitiesForBackend(backend string) BackendCapabilities { switch strings.TrimSpace(strings.ToLower(backend)) { case "", BackendSQLite: return BackendCapabilities{SingleProcessOnly: false} case BackendDolt: return BackendCapabilities{SingleProcessOnly: true} default: return BackendCapabilities{SingleProcessOnly: true} } } // GetBackend returns the configured backend type, defaulting to SQLite. func (c *Config) GetBackend() string { if c.Backend == "" { return BackendSQLite } return c.Backend } // Dolt mode constants const ( DoltModeEmbedded = "embedded" DoltModeServer = "server" ) // Default Dolt server settings const ( DefaultDoltServerHost = "127.0.0.1" DefaultDoltServerPort = 3306 DefaultDoltServerUser = "root" ) // IsDoltServerMode returns true if Dolt is configured for server mode. func (c *Config) IsDoltServerMode() bool { return c.GetBackend() == BackendDolt && c.DoltMode == DoltModeServer } // GetDoltMode returns the Dolt connection mode, defaulting to embedded. func (c *Config) GetDoltMode() string { if c.DoltMode == "" { return DoltModeEmbedded } return c.DoltMode } // GetDoltServerHost returns the Dolt server host, defaulting to 127.0.0.1. func (c *Config) GetDoltServerHost() string { if c.DoltServerHost == "" { return DefaultDoltServerHost } return c.DoltServerHost } // GetDoltServerPort returns the Dolt server port, defaulting to 3306. func (c *Config) GetDoltServerPort() int { if c.DoltServerPort == 0 { return DefaultDoltServerPort } return c.DoltServerPort } // GetDoltServerUser returns the Dolt server user, defaulting to root. func (c *Config) GetDoltServerUser() string { if c.DoltServerUser == "" { return DefaultDoltServerUser } return c.DoltServerUser }