v0.7.0 cli client with readline added, directory structure updated

This commit is contained in:
2025-11-13 08:55:06 -05:00
parent 52868af4ea
commit 6bdc061508
52 changed files with 2260 additions and 157 deletions

561
cmd/chess-server/cli/cli.go Normal file
View File

@ -0,0 +1,561 @@
// FILE: lixenwraith/chess/cmd/chess-server/cli/cli.go
package cli
import (
"flag"
"fmt"
"os"
"strings"
"syscall"
"text/tabwriter"
"time"
"chess/internal/server/storage"
"github.com/google/uuid"
"github.com/lixenwraith/auth"
"golang.org/x/term"
)
// Run is the entry point for the CLI mini-app
func Run(args []string) error {
if len(args) == 0 {
return fmt.Errorf("subcommand required: init, delete, query, user")
}
switch args[0] {
case "init":
return runInit(args[1:])
case "delete":
return runDelete(args[1:])
case "query":
return runQuery(args[1:])
case "user":
if len(args) < 2 {
return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, list")
}
return runUser(args[1], args[2:])
default:
return fmt.Errorf("unknown subcommand: %s", args[0])
}
}
func runInit(args []string) error {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to create store: %w", err)
}
defer store.Close()
if err := store.InitDB(); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
fmt.Printf("Database initialized at: %s\n", *path)
return nil
}
func runDelete(args []string) error {
fs := flag.NewFlagSet("delete", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
if err := store.DeleteDB(); err != nil {
return fmt.Errorf("failed to delete database: %w", err)
}
fmt.Printf("Database deleted: %s\n", *path)
return nil
}
func runQuery(args []string) error {
fs := flag.NewFlagSet("query", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
gameID := fs.String("gameId", "", "Game ID to filter (optional, * for all)")
playerID := fs.String("playerId", "", "Player ID to filter (optional, * for all)")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
defer store.Close()
games, err := store.QueryGames(*gameID, *playerID)
if err != nil {
return fmt.Errorf("query failed: %w", err)
}
if len(games) == 0 {
fmt.Println("No games found")
return nil
}
// Print results in tabular format
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Game ID\tWhite Player\tBlack Player\tStart Time")
fmt.Fprintln(w, strings.Repeat("-", 80))
for _, g := range games {
whiteInfo := fmt.Sprintf("%s (T%d)", g.WhitePlayerID[:8], g.WhiteType)
blackInfo := fmt.Sprintf("%s (T%d)", g.BlackPlayerID[:8], g.BlackType)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
g.GameID[:8]+"...",
whiteInfo,
blackInfo,
g.StartTimeUTC.Format("2006-01-02 15:04:05"),
)
}
w.Flush()
fmt.Printf("\nFound %d game(s)\n", len(games))
return nil
}
func runUser(subcommand string, args []string) error {
switch subcommand {
case "add":
return runUserAdd(args)
case "delete":
return runUserDelete(args)
case "set-password":
return runUserSetPassword(args)
case "set-hash":
return runUserSetHash(args)
case "set-email":
return runUserSetEmail(args)
case "set-username":
return runUserSetUsername(args)
case "list":
return runUserList(args)
default:
return fmt.Errorf("unknown user subcommand: %s", subcommand)
}
}
func runUserAdd(args []string) error {
fs := flag.NewFlagSet("user add", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
username := fs.String("username", "", "Username (required)")
email := fs.String("email", "", "Email address (optional)")
password := fs.String("password", "", "Password (use this or -hash, not both)")
hash := fs.String("hash", "", "Password hash (use this or -password, not both)")
interactive := fs.Bool("interactive", false, "Interactive password prompt")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
if *username == "" {
return fmt.Errorf("username required")
}
// Validate password/hash options
if *password != "" && *hash != "" {
return fmt.Errorf("cannot specify both -password and -hash")
}
var passwordHash string
if *interactive {
if *password != "" || *hash != "" {
return fmt.Errorf("cannot use -interactive with -password or -hash")
}
fmt.Print("Enter password: ")
pwBytes, err := term.ReadPassword(syscall.Stdin)
fmt.Println()
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
if len(pwBytes) < 8 {
return fmt.Errorf("password must be at least 8 characters")
}
// Hash password (Argon2)
passwordHash, err = auth.HashPassword(string(pwBytes))
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
} else if *hash != "" {
passwordHash = *hash
} else if *password != "" {
if len(*password) < 8 {
return fmt.Errorf("password must be at least 8 characters")
}
// Hash password (Argon2)
var err error
passwordHash, err = auth.HashPassword(*password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
} else {
return fmt.Errorf("password required: use -password, -hash, or -interactive")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
defer store.Close()
// Generate user ID with conflict check
var userID string
for attempts := 0; attempts < 10; attempts++ {
userID = uuid.New().String()
if _, err := store.GetUserByID(userID); err != nil {
// User doesn't exist, ID is unique
break
}
if attempts == 9 {
return fmt.Errorf("failed to generate unique user ID after 10 attempts")
}
}
record := storage.UserRecord{
UserID: userID,
Username: strings.ToLower(*username),
Email: strings.ToLower(*email),
PasswordHash: passwordHash,
CreatedAt: time.Now().UTC(),
}
if err := store.CreateUser(record); err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
fmt.Printf("User created successfully:\n")
fmt.Printf(" ID: %s\n", userID)
fmt.Printf(" Username: %s\n", *username)
if *email != "" {
fmt.Printf(" Email: %s\n", *email)
}
return nil
}
func runUserDelete(args []string) error {
fs := flag.NewFlagSet("user delete", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
username := fs.String("username", "", "Username to delete")
userID := fs.String("id", "", "User ID to delete")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
if *username == "" && *userID == "" {
return fmt.Errorf("either -username or -id required")
}
if *username != "" && *userID != "" {
return fmt.Errorf("specify either -username or -id, not both")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
defer store.Close()
var targetID string
if *userID != "" {
targetID = *userID
} else {
user, err := store.GetUserByUsername(*username)
if err != nil {
return fmt.Errorf("user not found: %s", *username)
}
targetID = user.UserID
}
if err := store.DeleteUser(targetID); err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
fmt.Printf("User deleted: %s\n", targetID)
return nil
}
func runUserSetPassword(args []string) error {
fs := flag.NewFlagSet("user set-password", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
username := fs.String("username", "", "Username (required)")
password := fs.String("password", "", "New password")
interactive := fs.Bool("interactive", false, "Interactive password prompt")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
if *username == "" {
return fmt.Errorf("username required")
}
var newPassword string
if *interactive {
if *password != "" {
return fmt.Errorf("cannot use -interactive with -password")
}
fmt.Print("Enter new password: ")
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
newPassword = string(pwBytes)
} else if *password != "" {
newPassword = *password
} else {
return fmt.Errorf("password required: use -password or -interactive")
}
if len(newPassword) < 8 {
return fmt.Errorf("password must be at least 8 characters")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
defer store.Close()
// Get user
user, err := store.GetUserByUsername(*username)
if err != nil {
return fmt.Errorf("user not found: %s", *username)
}
// Hash password (Argon2)
passwordHash, err := auth.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// Update password
if err := store.UpdateUserPassword(user.UserID, passwordHash); err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
fmt.Printf("Password updated for user: %s\n", *username)
return nil
}
func runUserSetHash(args []string) error {
fs := flag.NewFlagSet("user set-hash", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
username := fs.String("username", "", "Username (required)")
hash := fs.String("hash", "", "Password hash (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
if *username == "" {
return fmt.Errorf("username required")
}
if *hash == "" {
return fmt.Errorf("password hash required")
}
if err := auth.ValidatePHCHashFormat(*hash); err != nil {
return fmt.Errorf("invalid hash format: %w", err)
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
defer store.Close()
// Get user
user, err := store.GetUserByUsername(*username)
if err != nil {
return fmt.Errorf("user not found: %s", *username)
}
// Update password hash directly
if err := store.UpdateUserPassword(user.UserID, *hash); err != nil {
return fmt.Errorf("failed to update password hash: %w", err)
}
fmt.Printf("Password hash updated for user: %s\n", *username)
return nil
}
func runUserSetEmail(args []string) error {
fs := flag.NewFlagSet("user set-email", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
username := fs.String("username", "", "Username (required)")
email := fs.String("email", "", "New email address (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
if *username == "" {
return fmt.Errorf("username required")
}
if *email == "" {
return fmt.Errorf("email required")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
defer store.Close()
// Get user
user, err := store.GetUserByUsername(*username)
if err != nil {
return fmt.Errorf("user not found: %s", *username)
}
// Update email
if err := store.UpdateUserEmail(user.UserID, strings.ToLower(*email)); err != nil {
return fmt.Errorf("failed to update email: %w", err)
}
fmt.Printf("Email updated for user: %s\n", *username)
return nil
}
func runUserSetUsername(args []string) error {
fs := flag.NewFlagSet("user set-username", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
current := fs.String("current", "", "Current username (required)")
new := fs.String("new", "", "New username (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
if *current == "" {
return fmt.Errorf("current username required")
}
if *new == "" {
return fmt.Errorf("new username required")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
defer store.Close()
// Get user
user, err := store.GetUserByUsername(*current)
if err != nil {
return fmt.Errorf("user not found: %s", *current)
}
// Update username
if err := store.UpdateUserUsername(user.UserID, strings.ToLower(*new)); err != nil {
return fmt.Errorf("failed to update username: %w", err)
}
fmt.Printf("Username updated: %s -> %s\n", *current, *new)
return nil
}
func runUserList(args []string) error {
fs := flag.NewFlagSet("user list", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
defer store.Close()
users, err := store.GetAllUsers()
if err != nil {
return fmt.Errorf("failed to list users: %w", err)
}
if len(users) == 0 {
fmt.Println("No users found")
return nil
}
// Print results in tabular format
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "User ID\tUsername\tEmail\tCreated\tLast Login")
fmt.Fprintln(w, strings.Repeat("-", 100))
for _, u := range users {
lastLogin := "never"
if u.LastLoginAt != nil {
lastLogin = u.LastLoginAt.Format("2006-01-02 15:04:05")
}
email := u.Email
if email == "" {
email = "(none)"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
u.UserID[:8]+"...",
u.Username,
email,
u.CreatedAt.Format("2006-01-02 15:04:05"),
lastLogin,
)
}
w.Flush()
fmt.Printf("\nTotal users: %d\n", len(users))
return nil
}

187
cmd/chess-server/main.go Normal file
View File

@ -0,0 +1,187 @@
// FILE: lixenwraith/chess/cmd/chess-server/main.go
// Package main implements the chess server application with RESTful API,
// user authentication, and optional web UI serving capabilities.
package main
import (
"context"
"crypto/rand"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"chess/cmd/chess-server/cli"
"chess/internal/server/http"
"chess/internal/server/processor"
"chess/internal/server/service"
"chess/internal/server/storage"
"chess/internal/server/webserver"
)
const (
gracefulShutdownTimeout = time.Second * 5
)
func main() {
// Check for CLI database commands
if len(os.Args) > 1 && os.Args[1] == "db" {
if err := cli.Run(os.Args[2:]); err != nil {
log.Fatalf("CLI error: %v", err)
}
os.Exit(0)
}
// Command-line flags
var (
// API server flags (renamed)
apiHost = flag.String("api-host", "localhost", "API server host")
apiPort = flag.Int("api-port", 8080, "API server port")
dev = flag.Bool("dev", false, "Development mode (relaxed rate limits)")
storagePath = flag.String("storage-path", "", "Path to SQLite database file (disables persistence if empty)")
pidPath = flag.String("pid", "", "Optional path to write PID file")
pidLock = flag.Bool("pid-lock", false, "Lock PID file to allow only one instance (requires -pid)")
// Web UI server flags
serve = flag.Bool("serve", false, "Enable web UI server")
webHost = flag.String("web-host", "localhost", "Web UI server host")
webPort = flag.Int("web-port", 9090, "Web UI server port")
)
flag.Parse()
// Validate PID flags
if *pidLock && *pidPath == "" {
log.Fatal("Error: -pid-lock flag requires the -pid flag to be set")
}
// Manage PID file if requested
if *pidPath != "" {
cleanup, err := managePIDFile(*pidPath, *pidLock)
if err != nil {
log.Fatalf("Failed to manage PID file: %v", err)
}
defer cleanup()
log.Printf("PID file created at: %s (lock: %v)", *pidPath, *pidLock)
}
// 1. Initialize Storage (optional)
var store *storage.Store
if *storagePath != "" {
log.Printf("Initializing persistent storage at: %s", *storagePath)
var err error
store, err = storage.NewStore(*storagePath, *dev)
if err != nil {
log.Fatalf("Failed to initialize storage: %v", err)
}
defer func() {
if err := store.Close(); err != nil {
log.Printf("Warning: failed to close storage cleanly: %v", err)
}
}()
} else {
log.Printf("Persistent storage disabled (use -storage-path to enable)")
}
// JWT secret management
var jwtSecret []byte
if *dev {
// Fixed secret in dev mode for testing consistency
jwtSecret = []byte("dev-secret-minimum-32-characters-long")
log.Printf("Using fixed JWT secret (dev mode)")
} else {
// Generate cryptographically secure secret
jwtSecret = make([]byte, 32)
if _, err := rand.Read(jwtSecret); err != nil {
log.Fatalf("Failed to generate JWT secret: %v", err)
}
log.Printf("JWT secret generated (sessions valid until restart)")
}
// 2. Initialize the Service with optional storage and auth
svc := service.New(store, jwtSecret)
// 3. Initialize the Processor (Orchestrator), injecting the service
proc, err := processor.New(svc)
if err != nil {
svc.Shutdown(gracefulShutdownTimeout)
log.Fatalf("Failed to initialize processor: %v", err)
}
// 4. Initialize the Fiber App/HTTP Handler, injecting processor and service
app := http.NewFiberApp(proc, svc, *dev)
// API Server configuration
apiAddr := fmt.Sprintf("%s:%d", *apiHost, *apiPort)
// Start API server in a goroutine
go func() {
log.Printf("Chess API Server starting...")
log.Printf("API Listening on: http://%s", apiAddr)
log.Printf("API Version: v1")
log.Printf("Authentication: Enabled (JWT)")
if *dev {
log.Printf("Rate Limit: 20 requests/second per IP (DEV MODE)")
} else {
log.Printf("Rate Limit: 10 requests/second per IP")
}
if *storagePath != "" {
log.Printf("Storage: Enabled (%s)", *storagePath)
} else {
log.Printf("Storage: Disabled (auth features unavailable)")
}
log.Printf("API Endpoints: http://%s/api/v1/games", apiAddr)
log.Printf("Auth Endpoints: http://%s/api/v1/auth/[register|login|me]", apiAddr)
log.Printf("Health: http://%s/health", apiAddr)
if err := app.Listen(apiAddr); err != nil {
log.Printf("API server listen error: %v", err)
}
}()
// 5. Start Web UI server (optional)
if *serve {
webAddr := fmt.Sprintf("%s:%d", *webHost, *webPort)
apiURL := fmt.Sprintf("http://%s", apiAddr)
go func() {
log.Printf("Web UI Server starting...")
log.Printf("Web UI Listening on: http://%s", webAddr)
log.Printf("Web UI API target: %s", apiURL)
if err := webserver.Start(*webHost, *webPort, apiURL); err != nil {
log.Printf("Web UI server error: %v", err)
}
}()
}
// Wait for an interrupt signal to gracefully shut down
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
log.Println("Shutting down servers...")
// Graceful shutdown of service (includes wait registry)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), gracefulShutdownTimeout)
defer shutdownCancel()
// Graceful shutdown of HTTP server with timeout
if err = app.ShutdownWithContext(shutdownCtx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
// Close processor after service shutdown
if err = proc.Close(); err != nil {
log.Printf("Processor close error: %v", err)
}
// Shutdown service first (includes wait registry cleanup)
if err = svc.Shutdown(gracefulShutdownTimeout); err != nil {
log.Printf("Service shutdown error: %v", err)
}
log.Println("Servers exited")
}

106
cmd/chess-server/pid.go Normal file
View File

@ -0,0 +1,106 @@
// FILE: lixenwraith/chess/cmd/chess-server/pid.go
package main
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"syscall"
)
// managePIDFile creates and manages a PID file with optional locking
// Returns a cleanup function that must be called on exit
func managePIDFile(path string, lock bool) (func(), error) {
// Open/create PID file with exclusive create first attempt
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
if !os.IsExist(err) {
return nil, fmt.Errorf("cannot create PID file: %w", err)
}
// File exists - check if stale
if lock {
if err := checkStalePID(path); err != nil {
return nil, err
}
}
// Reopen for writing (truncate existing content)
file, err = os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return nil, fmt.Errorf("cannot open PID file: %w", err)
}
}
// Acquire exclusive lock if requested
if lock {
if err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
file.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, fmt.Errorf("cannot acquire lock: another instance is running")
}
return nil, fmt.Errorf("lock failed: %w", err)
}
}
// Write current PID
pid := os.Getpid()
if _, err = fmt.Fprintf(file, "%d\n", pid); err != nil {
file.Close()
os.Remove(path)
return nil, fmt.Errorf("cannot write PID: %w", err)
}
// Sync to ensure PID is written
if err = file.Sync(); err != nil {
file.Close()
os.Remove(path)
return nil, fmt.Errorf("cannot sync PID file: %w", err)
}
// Return cleanup function
cleanup := func() {
if lock {
// Release lock explicitly, file close works too
syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
}
file.Close()
os.Remove(path)
}
return cleanup, nil
}
// checkStalePID reads an existing PID file and checks if the process is running
func checkStalePID(path string) error {
// Try to read existing PID
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("cannot read existing PID file: %w", err)
}
pidStr := string(data)
pid, err := strconv.Atoi(strings.TrimSpace(pidStr))
if err != nil {
// Corrupted PID file
return fmt.Errorf("corrupted PID file (contains: %q)", pidStr)
}
// Check if process exists using kill(0), never errors on Unix
proc, _ := os.FindProcess(pid)
// Send signal 0 to check if process exists
if err = proc.Signal(syscall.Signal(0)); err != nil {
// Process doesn't exist or we don't have permission
if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
return fmt.Errorf("stale PID file found for defunct process %d", pid)
}
// Process exists but we can't signal it (different user?)
return fmt.Errorf("process %d exists but cannot verify ownership: %v", pid, err)
}
// Process is running
return fmt.Errorf("stale PID file: process %d is running but not holding lock", pid)
}