feat(dolt): add gt dolt command for server management

Add `gt dolt` command with subcommands to manage the Dolt SQL server:
- start/stop/status: Control server lifecycle
- logs: View server logs
- sql: Open interactive SQL shell
- list: List available rig databases
- init-rig: Initialize new rig database
- migrate: Migrate from old .beads/dolt/ layout

The command detects both servers started via `gt dolt start` and
externally-started dolt processes by checking port 3307.

Closes: hq-05caeb

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-25 15:34:37 -08:00
committed by beads/crew/emma
parent e937717147
commit 2ee5e1c5ad
2 changed files with 988 additions and 0 deletions

394
internal/cmd/dolt.go Normal file
View File

@@ -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 <name>",
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/<rigname>/
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 <name>"))
} 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 <name>", 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 <name>"))
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
}

View File

@@ -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 <name> # 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 <name>", 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/<rigname> 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
}