diff --git a/internal/cmd/dolt.go b/internal/cmd/dolt.go new file mode 100644 index 00000000..a59df3d3 --- /dev/null +++ b/internal/cmd/dolt.go @@ -0,0 +1,394 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/doltserver" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +var doltCmd = &cobra.Command{ + Use: "dolt", + GroupID: GroupServices, + Short: "Manage the Dolt SQL server", + RunE: requireSubcommand, + Long: `Manage the Dolt SQL server for Gas Town beads. + +The Dolt server provides multi-client access to all rig databases, +avoiding the single-writer limitation of embedded Dolt mode. + +Server configuration: + - Port: 3307 (avoids conflict with MySQL on 3306) + - User: root (default Dolt user, no password for localhost) + - Data directory: .dolt-data/ (contains all rig databases) + +Each rig (hq, gastown, beads) has its own database subdirectory.`, +} + +var doltStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the Dolt server", + Long: `Start the Dolt SQL server in the background. + +The server will run until stopped with 'gt dolt stop'.`, + RunE: runDoltStart, +} + +var doltStopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the Dolt server", + Long: `Stop the running Dolt SQL server.`, + RunE: runDoltStop, +} + +var doltStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show Dolt server status", + Long: `Show the current status of the Dolt SQL server.`, + RunE: runDoltStatus, +} + +var doltLogsCmd = &cobra.Command{ + Use: "logs", + Short: "View Dolt server logs", + Long: `View the Dolt server log file.`, + RunE: runDoltLogs, +} + +var doltSQLCmd = &cobra.Command{ + Use: "sql", + Short: "Open Dolt SQL shell", + Long: `Open an interactive SQL shell to the Dolt database. + +Works in both embedded mode (no server) and server mode. +For multi-client access, start the server first with 'gt dolt start'.`, + RunE: runDoltSQL, +} + +var doltInitRigCmd = &cobra.Command{ + Use: "init-rig ", + Short: "Initialize a new rig database", + Long: `Initialize a new rig database in the Dolt data directory. + +Each rig (e.g., gastown, beads) gets its own database that will be +served by the Dolt server. The rig name becomes the database name +when connecting via MySQL protocol. + +Example: + gt dolt init-rig gastown + gt dolt init-rig beads`, + Args: cobra.ExactArgs(1), + RunE: runDoltInitRig, +} + +var doltListCmd = &cobra.Command{ + Use: "list", + Short: "List available rig databases", + Long: `List all rig databases in the Dolt data directory.`, + RunE: runDoltList, +} + +var doltMigrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Migrate existing dolt databases to centralized data directory", + Long: `Migrate existing dolt databases from .beads/dolt/ locations to the +centralized .dolt-data/ directory structure. + +This command will: +1. Detect existing dolt databases in .beads/dolt/ directories +2. Move them to .dolt-data// +3. Remove the old empty directories + +After migration, start the server with 'gt dolt start'.`, + RunE: runDoltMigrate, +} + +var ( + doltLogLines int + doltLogFollow bool +) + +func init() { + doltCmd.AddCommand(doltStartCmd) + doltCmd.AddCommand(doltStopCmd) + doltCmd.AddCommand(doltStatusCmd) + doltCmd.AddCommand(doltLogsCmd) + doltCmd.AddCommand(doltSQLCmd) + doltCmd.AddCommand(doltInitRigCmd) + doltCmd.AddCommand(doltListCmd) + doltCmd.AddCommand(doltMigrateCmd) + + doltLogsCmd.Flags().IntVarP(&doltLogLines, "lines", "n", 50, "Number of lines to show") + doltLogsCmd.Flags().BoolVarP(&doltLogFollow, "follow", "f", false, "Follow log output") + + rootCmd.AddCommand(doltCmd) +} + +func runDoltStart(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + if err := doltserver.Start(townRoot); err != nil { + return err + } + + // Get state for display + state, _ := doltserver.LoadState(townRoot) + config := doltserver.DefaultConfig(townRoot) + + fmt.Printf("%s Dolt server started (PID %d, port %d)\n", + style.Bold.Render("✓"), state.PID, config.Port) + fmt.Printf(" Data dir: %s\n", state.DataDir) + fmt.Printf(" Databases: %s\n", style.Dim.Render(strings.Join(state.Databases, ", "))) + fmt.Printf(" Connection: %s\n", style.Dim.Render(doltserver.GetConnectionString(townRoot))) + + return nil +} + +func runDoltStop(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + _, pid, _ := doltserver.IsRunning(townRoot) + + if err := doltserver.Stop(townRoot); err != nil { + return err + } + + fmt.Printf("%s Dolt server stopped (was PID %d)\n", style.Bold.Render("✓"), pid) + return nil +} + +func runDoltStatus(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + running, pid, err := doltserver.IsRunning(townRoot) + if err != nil { + return fmt.Errorf("checking server status: %w", err) + } + + config := doltserver.DefaultConfig(townRoot) + + if running { + fmt.Printf("%s Dolt server is %s (PID %d)\n", + style.Bold.Render("●"), + style.Bold.Render("running"), + pid) + + // Load state for more details + state, err := doltserver.LoadState(townRoot) + if err == nil && !state.StartedAt.IsZero() { + fmt.Printf(" Started: %s\n", state.StartedAt.Format("2006-01-02 15:04:05")) + fmt.Printf(" Port: %d\n", state.Port) + fmt.Printf(" Data dir: %s\n", state.DataDir) + if len(state.Databases) > 0 { + fmt.Printf(" Databases:\n") + for _, db := range state.Databases { + fmt.Printf(" - %s\n", db) + } + } + fmt.Printf(" Connection: %s\n", doltserver.GetConnectionString(townRoot)) + } + } else { + fmt.Printf("%s Dolt server is %s\n", + style.Dim.Render("○"), + "not running") + + // List available databases + databases, _ := doltserver.ListDatabases(townRoot) + if len(databases) == 0 { + fmt.Printf("\n%s No rig databases found in %s\n", + style.Bold.Render("!"), + config.DataDir) + fmt.Printf(" Initialize with: %s\n", style.Dim.Render("gt dolt init-rig ")) + } else { + fmt.Printf("\nAvailable databases in %s:\n", config.DataDir) + for _, db := range databases { + fmt.Printf(" - %s\n", db) + } + fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt dolt start")) + } + } + + return nil +} + +func runDoltLogs(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + config := doltserver.DefaultConfig(townRoot) + + if _, err := os.Stat(config.LogFile); os.IsNotExist(err) { + return fmt.Errorf("no log file found at %s", config.LogFile) + } + + if doltLogFollow { + // Use tail -f for following + tailCmd := exec.Command("tail", "-f", config.LogFile) + tailCmd.Stdout = os.Stdout + tailCmd.Stderr = os.Stderr + return tailCmd.Run() + } + + // Use tail -n for last N lines + tailCmd := exec.Command("tail", "-n", strconv.Itoa(doltLogLines), config.LogFile) + tailCmd.Stdout = os.Stdout + tailCmd.Stderr = os.Stderr + return tailCmd.Run() +} + +func runDoltSQL(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + config := doltserver.DefaultConfig(townRoot) + + // Check if server is running - if so, connect via Dolt SQL client + running, _, _ := doltserver.IsRunning(townRoot) + if running { + // Connect to running server using dolt sql client + // Using --no-tls since local server doesn't have TLS configured + sqlCmd := exec.Command("dolt", + "--host", "127.0.0.1", + "--port", strconv.Itoa(config.Port), + "--user", config.User, + "--password", "", + "--no-tls", + "sql", + ) + sqlCmd.Stdin = os.Stdin + sqlCmd.Stdout = os.Stdout + sqlCmd.Stderr = os.Stderr + return sqlCmd.Run() + } + + // Server not running - list databases and pick first one for embedded mode + databases, err := doltserver.ListDatabases(townRoot) + if err != nil { + return fmt.Errorf("listing databases: %w", err) + } + + if len(databases) == 0 { + return fmt.Errorf("no databases found in %s\nInitialize with: gt dolt init-rig ", config.DataDir) + } + + // Use first database for embedded SQL shell + dbDir := doltserver.RigDatabaseDir(townRoot, databases[0]) + fmt.Printf("Using database: %s (start server with 'gt dolt start' for multi-database access)\n\n", databases[0]) + + sqlCmd := exec.Command("dolt", "sql") + sqlCmd.Dir = dbDir + sqlCmd.Stdin = os.Stdin + sqlCmd.Stdout = os.Stdout + sqlCmd.Stderr = os.Stderr + + return sqlCmd.Run() +} + +func runDoltInitRig(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + rigName := args[0] + + if err := doltserver.InitRig(townRoot, rigName); err != nil { + return err + } + + config := doltserver.DefaultConfig(townRoot) + rigDir := doltserver.RigDatabaseDir(townRoot, rigName) + + fmt.Printf("%s Initialized rig database %q\n", style.Bold.Render("✓"), rigName) + fmt.Printf(" Location: %s\n", rigDir) + fmt.Printf(" Data dir: %s\n", config.DataDir) + fmt.Printf("\nStart server with: %s\n", style.Dim.Render("gt dolt start")) + + return nil +} + +func runDoltList(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + config := doltserver.DefaultConfig(townRoot) + databases, err := doltserver.ListDatabases(townRoot) + if err != nil { + return fmt.Errorf("listing databases: %w", err) + } + + if len(databases) == 0 { + fmt.Printf("No rig databases found in %s\n", config.DataDir) + fmt.Printf("\nInitialize with: %s\n", style.Dim.Render("gt dolt init-rig ")) + return nil + } + + fmt.Printf("Rig databases in %s:\n\n", config.DataDir) + for _, db := range databases { + dbDir := doltserver.RigDatabaseDir(townRoot, db) + fmt.Printf(" %s\n %s\n", style.Bold.Render(db), style.Dim.Render(dbDir)) + } + + return nil +} + +func runDoltMigrate(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Check if server is running - must stop first + running, _, _ := doltserver.IsRunning(townRoot) + if running { + return fmt.Errorf("Dolt server is running. Stop it first with: gt dolt stop") + } + + // Find databases to migrate + migrations := doltserver.FindMigratableDatabases(townRoot) + if len(migrations) == 0 { + fmt.Println("No databases found to migrate.") + return nil + } + + fmt.Printf("Found %d database(s) to migrate:\n\n", len(migrations)) + for _, m := range migrations { + fmt.Printf(" %s\n", m.SourcePath) + fmt.Printf(" → %s\n\n", m.TargetPath) + } + + // Perform migrations + for _, m := range migrations { + fmt.Printf("Migrating %s...\n", m.RigName) + if err := doltserver.MigrateRigFromBeads(townRoot, m.RigName, m.SourcePath); err != nil { + return fmt.Errorf("migrating %s: %w", m.RigName, err) + } + fmt.Printf(" %s Migrated to %s\n", style.Bold.Render("✓"), m.TargetPath) + } + + fmt.Printf("\n%s Migration complete.\n", style.Bold.Render("✓")) + fmt.Printf("\nStart server with: %s\n", style.Dim.Render("gt dolt start")) + + return nil +} diff --git a/internal/doltserver/doltserver.go b/internal/doltserver/doltserver.go new file mode 100644 index 00000000..2eccf38b --- /dev/null +++ b/internal/doltserver/doltserver.go @@ -0,0 +1,594 @@ +// Package doltserver manages the Dolt SQL server for Gas Town. +// +// The Dolt server provides multi-client access to beads databases, +// avoiding the single-writer limitation of embedded Dolt mode. +// +// Server configuration: +// - Port: 3307 (avoids conflict with MySQL on 3306) +// - User: root (default Dolt user, no password for localhost) +// - Data directory: ~/gt/.dolt-data/ (contains all rig databases) +// +// Each rig (hq, gastown, beads) has its own database subdirectory: +// +// ~/gt/.dolt-data/ +// ├── hq/ # Town beads (hq-*) +// ├── gastown/ # Gastown rig (gt-*) +// ├── beads/ # Beads rig (bd-*) +// └── ... # Other rigs +// +// Usage: +// +// gt dolt start # Start the server +// gt dolt stop # Stop the server +// gt dolt status # Check server status +// gt dolt logs # View server logs +// gt dolt sql # Open SQL shell +// gt dolt init-rig # Initialize a new rig database +package doltserver + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/gofrs/flock" + "github.com/steveyegge/gastown/internal/util" +) + +// Default configuration +const ( + DefaultPort = 3307 + DefaultUser = "root" // Default Dolt user (no password for local access) +) + +// Config holds Dolt server configuration. +type Config struct { + // TownRoot is the Gas Town workspace root. + TownRoot string + + // Port is the MySQL protocol port. + Port int + + // User is the MySQL user name. + User string + + // DataDir is the root directory containing all rig databases. + // Each subdirectory is a separate database that will be served. + DataDir string + + // LogFile is the path to the server log file. + LogFile string + + // PidFile is the path to the PID file. + PidFile string +} + +// DefaultConfig returns the default Dolt server configuration. +func DefaultConfig(townRoot string) *Config { + daemonDir := filepath.Join(townRoot, "daemon") + return &Config{ + TownRoot: townRoot, + Port: DefaultPort, + User: DefaultUser, + DataDir: filepath.Join(townRoot, ".dolt-data"), + LogFile: filepath.Join(daemonDir, "dolt.log"), + PidFile: filepath.Join(daemonDir, "dolt.pid"), + } +} + +// RigDatabaseDir returns the database directory for a specific rig. +func RigDatabaseDir(townRoot, rigName string) string { + config := DefaultConfig(townRoot) + return filepath.Join(config.DataDir, rigName) +} + +// State represents the Dolt server's runtime state. +type State struct { + // Running indicates if the server is running. + Running bool `json:"running"` + + // PID is the process ID of the server. + PID int `json:"pid"` + + // Port is the port the server is listening on. + Port int `json:"port"` + + // StartedAt is when the server started. + StartedAt time.Time `json:"started_at"` + + // DataDir is the data directory containing all rig databases. + DataDir string `json:"data_dir"` + + // Databases is the list of available databases (rig names). + Databases []string `json:"databases,omitempty"` +} + +// StateFile returns the path to the state file. +func StateFile(townRoot string) string { + return filepath.Join(townRoot, "daemon", "dolt-state.json") +} + +// LoadState loads Dolt server state from disk. +func LoadState(townRoot string) (*State, error) { + stateFile := StateFile(townRoot) + data, err := os.ReadFile(stateFile) + if err != nil { + if os.IsNotExist(err) { + return &State{}, nil + } + return nil, err + } + + var state State + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return &state, nil +} + +// SaveState saves Dolt server state to disk using atomic write. +func SaveState(townRoot string, state *State) error { + stateFile := StateFile(townRoot) + + // Ensure daemon directory exists + if err := os.MkdirAll(filepath.Dir(stateFile), 0755); err != nil { + return err + } + + return util.AtomicWriteJSON(stateFile, state) +} + +// IsRunning checks if a Dolt server is running for the given town. +// Returns (running, pid, error). +// Checks both PID file AND port to detect externally-started servers. +func IsRunning(townRoot string) (bool, int, error) { + config := DefaultConfig(townRoot) + + // First check PID file + data, err := os.ReadFile(config.PidFile) + if err == nil { + pidStr := strings.TrimSpace(string(data)) + pid, err := strconv.Atoi(pidStr) + if err == nil { + // Check if process is alive + process, err := os.FindProcess(pid) + if err == nil { + // On Unix, FindProcess always succeeds. Send signal 0 to check if alive. + if err := process.Signal(syscall.Signal(0)); err == nil { + // Verify it's actually a dolt process + if isDoltProcess(pid) { + return true, pid, nil + } + } + } + } + // PID file is stale, clean it up + _ = os.Remove(config.PidFile) + } + + // No valid PID file - check if port is in use by dolt anyway + // This catches externally-started dolt servers + pid := findDoltServerOnPort(config.Port) + if pid > 0 { + return true, pid, nil + } + + return false, 0, nil +} + +// findDoltServerOnPort finds a dolt sql-server process listening on the given port. +// Returns the PID or 0 if not found. +func findDoltServerOnPort(port int) int { + // Use lsof to find process on port + cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-t") + output, err := cmd.Output() + if err != nil { + return 0 + } + + // Parse first PID from output + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) == 0 || lines[0] == "" { + return 0 + } + + pid, err := strconv.Atoi(lines[0]) + if err != nil { + return 0 + } + + // Verify it's a dolt process + if isDoltProcess(pid) { + return pid + } + + return 0 +} + +// isDoltProcess checks if a PID is actually a dolt sql-server process. +func isDoltProcess(pid int) bool { + cmd := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=") + output, err := cmd.Output() + if err != nil { + return false + } + + cmdline := strings.TrimSpace(string(output)) + return strings.Contains(cmdline, "dolt") && strings.Contains(cmdline, "sql-server") +} + +// Start starts the Dolt SQL server. +func Start(townRoot string) error { + config := DefaultConfig(townRoot) + + // Ensure daemon directory exists + daemonDir := filepath.Dir(config.LogFile) + if err := os.MkdirAll(daemonDir, 0755); err != nil { + return fmt.Errorf("creating daemon directory: %w", err) + } + + // Acquire exclusive lock to prevent concurrent starts (same pattern as gt daemon) + lockFile := filepath.Join(daemonDir, "dolt.lock") + fileLock := flock.New(lockFile) + locked, err := fileLock.TryLock() + if err != nil { + return fmt.Errorf("acquiring lock: %w", err) + } + if !locked { + return fmt.Errorf("another gt dolt start is in progress") + } + defer func() { _ = fileLock.Unlock() }() + + // Check if already running + running, pid, err := IsRunning(townRoot) + if err != nil { + return fmt.Errorf("checking server status: %w", err) + } + if running { + return fmt.Errorf("Dolt server already running (PID %d)", pid) + } + + // Ensure data directory exists + if err := os.MkdirAll(config.DataDir, 0755); err != nil { + return fmt.Errorf("creating data directory: %w", err) + } + + // List available databases + databases, err := ListDatabases(townRoot) + if err != nil { + return fmt.Errorf("listing databases: %w", err) + } + + if len(databases) == 0 { + return fmt.Errorf("no databases found in %s\nInitialize with: gt dolt init-rig ", config.DataDir) + } + + // Clean up stale Dolt LOCK files in all database directories + for _, db := range databases { + dbDir := filepath.Join(config.DataDir, db) + if err := cleanupStaleDoltLock(dbDir); err != nil { + // Non-fatal warning + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + } + } + + // Open log file + logFile, err := os.OpenFile(config.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("opening log file: %w", err) + } + + // Start dolt sql-server with --data-dir to serve all databases + // Note: --user flag is deprecated in newer Dolt; authentication is handled + // via privilege system. Default is root user with no password for localhost. + cmd := exec.Command("dolt", "sql-server", + "--port", strconv.Itoa(config.Port), + "--data-dir", config.DataDir, + ) + cmd.Stdout = logFile + cmd.Stderr = logFile + + // Detach from terminal + cmd.Stdin = nil + + if err := cmd.Start(); err != nil { + logFile.Close() + return fmt.Errorf("starting Dolt server: %w", err) + } + + // Close log file in parent (child has its own handle) + logFile.Close() + + // Write PID file + if err := os.WriteFile(config.PidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil { + // Try to kill the process we just started + _ = cmd.Process.Kill() + return fmt.Errorf("writing PID file: %w", err) + } + + // Save state + state := &State{ + Running: true, + PID: cmd.Process.Pid, + Port: config.Port, + StartedAt: time.Now(), + DataDir: config.DataDir, + Databases: databases, + } + if err := SaveState(townRoot, state); err != nil { + // Non-fatal - server is still running + fmt.Fprintf(os.Stderr, "Warning: failed to save state: %v\n", err) + } + + // Wait briefly and verify it started + time.Sleep(500 * time.Millisecond) + + running, _, err = IsRunning(townRoot) + if err != nil { + return fmt.Errorf("verifying server started: %w", err) + } + if !running { + return fmt.Errorf("Dolt server failed to start (check logs with 'gt dolt logs')") + } + + return nil +} + +// cleanupStaleDoltLock removes a stale Dolt LOCK file if no process holds it. +// Dolt's embedded mode uses a file lock at .dolt/noms/LOCK that can become stale +// after crashes. This checks if any process holds the lock before removing. +// Returns nil if lock is held by active processes (this is expected if bd is running). +func cleanupStaleDoltLock(databaseDir string) error { + lockPath := filepath.Join(databaseDir, ".dolt", "noms", "LOCK") + + // Check if lock file exists + if _, err := os.Stat(lockPath); os.IsNotExist(err) { + return nil // No lock file, nothing to clean + } + + // Check if any process holds this file open using lsof + cmd := exec.Command("lsof", lockPath) + _, err := cmd.Output() + if err != nil { + // lsof returns exit code 1 when no process has the file open + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + // No process holds the lock - safe to remove stale lock + if err := os.Remove(lockPath); err != nil { + return fmt.Errorf("failed to remove stale LOCK file: %w", err) + } + return nil + } + // Other error - ignore, let dolt handle it + return nil + } + + // lsof found processes - lock is legitimately held (likely by bd) + // This is not an error condition; dolt server will handle the conflict + return nil +} + +// Stop stops the Dolt SQL server. +// Works for both servers started via gt dolt start AND externally-started servers. +func Stop(townRoot string) error { + config := DefaultConfig(townRoot) + + running, pid, err := IsRunning(townRoot) + if err != nil { + return err + } + if !running { + return fmt.Errorf("Dolt server is not running") + } + + process, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("finding process: %w", err) + } + + // Send SIGTERM for graceful shutdown + if err := process.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("sending SIGTERM: %w", err) + } + + // Wait for graceful shutdown (dolt needs more time) + for i := 0; i < 10; i++ { + time.Sleep(500 * time.Millisecond) + if err := process.Signal(syscall.Signal(0)); err != nil { + // Process has exited + break + } + } + + // Check if still running + if err := process.Signal(syscall.Signal(0)); err == nil { + // Still running, force kill + _ = process.Signal(syscall.SIGKILL) + time.Sleep(100 * time.Millisecond) + } + + // Clean up PID file + _ = os.Remove(config.PidFile) + + // Update state - preserve historical info + state, _ := LoadState(townRoot) + if state == nil { + state = &State{} + } + state.Running = false + state.PID = 0 + _ = SaveState(townRoot, state) + + return nil +} + +// GetConnectionString returns the MySQL connection string for the server. +// Use GetConnectionStringForRig for a specific database. +func GetConnectionString(townRoot string) string { + config := DefaultConfig(townRoot) + return fmt.Sprintf("%s@tcp(127.0.0.1:%d)/", config.User, config.Port) +} + +// GetConnectionStringForRig returns the MySQL connection string for a specific rig database. +func GetConnectionStringForRig(townRoot, rigName string) string { + config := DefaultConfig(townRoot) + return fmt.Sprintf("%s@tcp(127.0.0.1:%d)/%s", config.User, config.Port, rigName) +} + +// ListDatabases returns the list of available rig databases in the data directory. +func ListDatabases(townRoot string) ([]string, error) { + config := DefaultConfig(townRoot) + + entries, err := os.ReadDir(config.DataDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var databases []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + // Check if this directory is a valid Dolt database + doltDir := filepath.Join(config.DataDir, entry.Name(), ".dolt") + if _, err := os.Stat(doltDir); err == nil { + databases = append(databases, entry.Name()) + } + } + + return databases, nil +} + +// InitRig initializes a new rig database in the data directory. +func InitRig(townRoot, rigName string) error { + if rigName == "" { + return fmt.Errorf("rig name cannot be empty") + } + + config := DefaultConfig(townRoot) + + // Validate rig name (simple alphanumeric + underscore/dash) + for _, r := range rigName { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-') { + return fmt.Errorf("invalid rig name %q: must contain only alphanumeric, underscore, or dash", rigName) + } + } + + rigDir := filepath.Join(config.DataDir, rigName) + + // Check if already exists + if _, err := os.Stat(filepath.Join(rigDir, ".dolt")); err == nil { + return fmt.Errorf("rig database %q already exists at %s", rigName, rigDir) + } + + // Create the rig directory + if err := os.MkdirAll(rigDir, 0755); err != nil { + return fmt.Errorf("creating rig directory: %w", err) + } + + // Initialize Dolt database + cmd := exec.Command("dolt", "init") + cmd.Dir = rigDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("initializing Dolt database: %w\n%s", err, output) + } + + return nil +} + +// Migration represents a database migration from old to new location. +type Migration struct { + RigName string + SourcePath string + TargetPath string +} + +// FindMigratableDatabases finds existing dolt databases that can be migrated. +func FindMigratableDatabases(townRoot string) []Migration { + var migrations []Migration + config := DefaultConfig(townRoot) + + // Check town-level beads database: .beads/dolt/beads -> .dolt-data/hq + townSource := filepath.Join(townRoot, ".beads", "dolt", "beads") + if _, err := os.Stat(filepath.Join(townSource, ".dolt")); err == nil { + // Check target doesn't already have data + targetDir := filepath.Join(config.DataDir, "hq") + if _, err := os.Stat(filepath.Join(targetDir, ".dolt")); os.IsNotExist(err) { + migrations = append(migrations, Migration{ + RigName: "hq", + SourcePath: townSource, + TargetPath: targetDir, + }) + } + } + + // Check rig-level beads databases + // Look for directories in townRoot that have .beads/dolt/beads + entries, err := os.ReadDir(townRoot) + if err != nil { + return migrations + } + + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + + rigName := entry.Name() + rigSource := filepath.Join(townRoot, rigName, ".beads", "dolt", "beads") + + if _, err := os.Stat(filepath.Join(rigSource, ".dolt")); err == nil { + // Check target doesn't already have data + targetDir := filepath.Join(config.DataDir, rigName) + if _, err := os.Stat(filepath.Join(targetDir, ".dolt")); os.IsNotExist(err) { + migrations = append(migrations, Migration{ + RigName: rigName, + SourcePath: rigSource, + TargetPath: targetDir, + }) + } + } + } + + return migrations +} + +// MigrateRigFromBeads migrates an existing beads Dolt database to the data directory. +// This is used to migrate from the old per-rig .beads/dolt/beads layout to the new +// centralized .dolt-data/ layout. +func MigrateRigFromBeads(townRoot, rigName, sourcePath string) error { + config := DefaultConfig(townRoot) + + targetDir := filepath.Join(config.DataDir, rigName) + + // Check if target already exists + if _, err := os.Stat(filepath.Join(targetDir, ".dolt")); err == nil { + return fmt.Errorf("rig database %q already exists at %s", rigName, targetDir) + } + + // Check if source exists + if _, err := os.Stat(filepath.Join(sourcePath, ".dolt")); os.IsNotExist(err) { + return fmt.Errorf("source database not found at %s", sourcePath) + } + + // Ensure data directory exists + if err := os.MkdirAll(config.DataDir, 0755); err != nil { + return fmt.Errorf("creating data directory: %w", err) + } + + // Move the database directory + if err := os.Rename(sourcePath, targetDir); err != nil { + return fmt.Errorf("moving database: %w", err) + } + + return nil +}