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:
594
internal/doltserver/doltserver.go
Normal file
594
internal/doltserver/doltserver.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user