package cli import ( "database/sql" "errors" "flag" "fmt" "os" "strings" "syscall" "text/tabwriter" "time" "github.com/lixenwraith/chess/internal/server/replay" "github.com/lixenwraith/chess/internal/server/storage" "github.com/google/uuid" "github.com/lixenwraith/auth" "golang.org/x/term" ) const dsnEnv = "CHESS_DSN" // 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, pgn, verify, user") } switch args[0] { case "init": return runInit(args[1:]) case "delete": return runDelete(args[1:]) case "query": return runQuery(args[1:]) case "pgn": return runPGN(args[1:]) case "verify": return runVerify(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]) } } // newFlagSet returns a flag set with the shared -dsn flag. An empty -dsn falls // back to $CHESS_DSN (in openStore, so usage output never prints it), which // keeps a password-bearing DSN out of process arguments. func newFlagSet(name string) (*flag.FlagSet, *string) { fs := flag.NewFlagSet(name, flag.ContinueOnError) dsn := fs.String("dsn", "", "PostgreSQL connection string (default $"+dsnEnv+")") return fs, dsn } // openStore connects and, unless migrating, verifies the schema is current. func openStore(dsn string, requireSchema bool) (*storage.Store, error) { if dsn == "" { dsn = os.Getenv(dsnEnv) } if dsn == "" { return nil, fmt.Errorf("database connection required: use -dsn or %s", dsnEnv) } store, err := storage.NewStore(dsn) if err != nil { return nil, err } if requireSchema { if err := store.CheckSchema(); err != nil { store.Close() return nil, err } } return store, nil } func runInit(args []string) error { fs, dsn := newFlagSet("init") if err := fs.Parse(args); err != nil { return err } store, err := openStore(*dsn, false) if err != nil { return err } defer store.Close() if err := store.InitDB(); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } version, err := store.SchemaVersion() if err != nil { return err } fmt.Printf("Database schema ready (version %d)\n", version) return nil } func runDelete(args []string) error { fs, dsn := newFlagSet("delete") confirm := fs.Bool("confirm", false, "Confirm dropping all chess tables and data") if err := fs.Parse(args); err != nil { return err } if !*confirm { return fmt.Errorf("refusing to drop all chess tables without -confirm") } store, err := openStore(*dsn, false) if err != nil { return err } defer store.Close() // ☣ DESTRUCTIVE: drops every chess table in the connection's search_path if err := store.DropSchema(); err != nil { return err } fmt.Println("Chess tables dropped") return nil } func runQuery(args []string) error { fs, dsn := newFlagSet("query") gameID := fs.String("gameId", "", "Game ID to filter (optional, * for all)") playerID := fs.String("playerId", "", "Player or user ID to filter (optional, * for all)") if err := fs.Parse(args); err != nil { return err } store, err := openStore(*dsn, true) if err != nil { return 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\tResult\tStarted\tEnded") fmt.Fprintln(w, strings.Repeat("-", 130)) for _, g := range games { whiteInfo := formatStoredPlayer(g.WhitePlayerID, g.WhiteClaimedBy, g.WhiteType) blackInfo := formatStoredPlayer(g.BlackPlayerID, g.BlackClaimedBy, g.BlackType) result := g.Result if result == "" { result = "ongoing" } ended := "-" if g.EndTimeUTC != nil { ended = g.EndTimeUTC.Format("2006-01-02 15:04:05") } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", abbreviateID(g.GameID), whiteInfo, blackInfo, result, g.StartTimeUTC.Format("2006-01-02 15:04:05"), ended, ) } w.Flush() fmt.Printf("\nFound %d game(s)\n", len(games)) return nil } // runPGN prints a stored game as PGN. The game may be named by the 8-digit // prefix that `db query` prints. func runPGN(args []string) error { fs, dsn := newFlagSet("pgn") gameID := fs.String("gameId", "", "Game ID or unique prefix of at least 8 hex digits (required)") ply := fs.Int("ply", -1, "Export only the first N plies (default: all)") if err := fs.Parse(args); err != nil { return err } if *gameID == "" { return errors.New("-gameId is required") } store, err := openStore(*dsn, true) if err != nil { return err } defer store.Close() id, err := resolveGameID(store, *gameID) if err != nil { return err } record, moves, err := store.GetGameHistory(id) if err != nil { return fmt.Errorf("load game %s: %w", id, err) } pgn, err := replay.BuildPGN(record, moves, *ply) if err != nil { return err } fmt.Print(pgn.Text) return nil } // runVerify checks stored games against the rules (see replay.Verify) and // fails when any problem is found. func runVerify(args []string) error { fs, dsn := newFlagSet("verify") gameID := fs.String("gameId", "", "Verify one game (ID or unique prefix); default all") if err := fs.Parse(args); err != nil { return err } store, err := openStore(*dsn, true) if err != nil { return err } defer store.Close() var ids []string if *gameID != "" { id, err := resolveGameID(store, *gameID) if err != nil { return err } ids = []string{id} } else { games, err := store.QueryGames("", "") if err != nil { return fmt.Errorf("list games: %w", err) } for _, g := range games { ids = append(ids, g.GameID) } } plies, problems := 0, 0 for _, id := range ids { record, moves, err := store.GetGameHistory(id) if err != nil { return fmt.Errorf("load game %s: %w", id, err) } plies += len(moves) for _, problem := range replay.Verify(record, moves) { fmt.Printf("%s: %s\n", id, problem) problems++ } } fmt.Printf("Verified %d game(s), %d plies: %d problem(s)\n", len(ids), plies, problems) if problems > 0 { return fmt.Errorf("%d problem(s) found", problems) } return nil } func resolveGameID(store *storage.Store, prefix string) (string, error) { id, err := store.ResolveGameID(prefix) if errors.Is(err, sql.ErrNoRows) { return "", fmt.Errorf("no game matches %q", prefix) } return id, err } func formatStoredPlayer(playerID, claimedBy string, playerType int) string { value := fmt.Sprintf("%s (T%d)", abbreviateID(playerID), playerType) if claimedBy != "" && claimedBy != playerID { value += " claim:" + abbreviateID(claimedBy) } return value } func abbreviateID(value string) string { if len(value) <= 8 { return value } return value[:8] + "..." } 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) } } // readPassword prompts on the terminal without echo. func readPassword(prompt string) (string, error) { fmt.Print(prompt) pwBytes, err := term.ReadPassword(int(syscall.Stdin)) fmt.Println() if err != nil { return "", fmt.Errorf("failed to read password: %w", err) } return string(pwBytes), nil } func hashPassword(password string) (string, error) { if len(password) < 8 { return "", fmt.Errorf("password must be at least 8 characters") } hash, err := auth.HashPassword(password) if err != nil { return "", fmt.Errorf("failed to hash password: %w", err) } return hash, nil } func runUserAdd(args []string) error { fs, dsn := newFlagSet("user add") username := fs.String("username", "", "Username (required)") email := fs.String("email", "", "Email address (optional)") password := fs.String("password", "", "Password (optional, will prompt if not provided)") hash := fs.String("hash", "", "Pre-computed password hash (optional)") interactive := fs.Bool("interactive", false, "Interactive password prompt") if err := fs.Parse(args); err != nil { return err } if *username == "" { return fmt.Errorf("username required") } if *password != "" && *hash != "" { return fmt.Errorf("cannot specify both -password and -hash") } var passwordHash string switch { case *interactive: if *password != "" || *hash != "" { return fmt.Errorf("cannot use -interactive with -password or -hash") } entered, err := readPassword("Enter password: ") if err != nil { return err } if passwordHash, err = hashPassword(entered); err != nil { return err } case *hash != "": if err := auth.ValidatePHCHashFormat(*hash); err != nil { return fmt.Errorf("invalid hash format: %w", err) } passwordHash = *hash case *password != "": var err error if passwordHash, err = hashPassword(*password); err != nil { return err } default: return fmt.Errorf("password required: use -password, -hash, or -interactive") } store, err := openStore(*dsn, true) if err != nil { return err } defer store.Close() userID := uuid.NewString() 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", record.Username) if record.Email != "" { fmt.Printf(" Email: %s\n", record.Email) } return nil } // lookupUser resolves -username or -id to a user ID. func lookupUser(store *storage.Store, username, userID string) (string, error) { switch { case username == "" && userID == "": return "", fmt.Errorf("either -username or -id required") case username != "" && userID != "": return "", fmt.Errorf("specify either -username or -id, not both") case userID != "": if _, err := store.GetUserByID(userID); err != nil { return "", userLookupError(userID, err) } return userID, nil default: user, err := store.GetUserByUsername(username) if err != nil { return "", userLookupError(username, err) } return user.UserID, nil } } func userLookupError(identifier string, err error) error { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("user not found: %s", identifier) } return fmt.Errorf("failed to look up user %s: %w", identifier, err) } func runUserDelete(args []string) error { fs, dsn := newFlagSet("user delete") username := fs.String("username", "", "Username to delete") userID := fs.String("id", "", "User ID to delete") if err := fs.Parse(args); err != nil { return err } store, err := openStore(*dsn, true) if err != nil { return err } defer store.Close() targetID, err := lookupUser(store, *username, *userID) if err != nil { return err } 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, dsn := newFlagSet("user set-password") 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 *username == "" { return fmt.Errorf("username required") } var newPassword string switch { case *interactive: if *password != "" { return fmt.Errorf("cannot use -interactive with -password") } entered, err := readPassword("Enter new password: ") if err != nil { return err } newPassword = entered case *password != "": newPassword = *password default: return fmt.Errorf("password required: use -password or -interactive") } passwordHash, err := hashPassword(newPassword) if err != nil { return err } store, err := openStore(*dsn, true) if err != nil { return err } defer store.Close() targetID, err := lookupUser(store, *username, "") if err != nil { return err } if err := store.UpdateUserPassword(targetID, 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, dsn := newFlagSet("user set-hash") username := fs.String("username", "", "Username (required)") hash := fs.String("hash", "", "Password hash (required)") if err := fs.Parse(args); err != nil { return err } 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 := openStore(*dsn, true) if err != nil { return err } defer store.Close() targetID, err := lookupUser(store, *username, "") if err != nil { return err } if err := store.UpdateUserPassword(targetID, *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, dsn := newFlagSet("user set-email") username := fs.String("username", "", "Username (required)") email := fs.String("email", "", "New email address (required)") if err := fs.Parse(args); err != nil { return err } if *username == "" { return fmt.Errorf("username required") } if *email == "" { return fmt.Errorf("email required") } store, err := openStore(*dsn, true) if err != nil { return err } defer store.Close() targetID, err := lookupUser(store, *username, "") if err != nil { return err } if err := store.UpdateUserEmail(targetID, *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, dsn := newFlagSet("user set-username") current := fs.String("current", "", "Current username (required)") newName := fs.String("new", "", "New username (required)") if err := fs.Parse(args); err != nil { return err } if *current == "" { return fmt.Errorf("current username required") } if *newName == "" { return fmt.Errorf("new username required") } store, err := openStore(*dsn, true) if err != nil { return err } defer store.Close() targetID, err := lookupUser(store, *current, "") if err != nil { return err } if err := store.UpdateUserUsername(targetID, *newName); err != nil { return fmt.Errorf("failed to update username: %w", err) } fmt.Printf("Username updated: %s -> %s\n", *current, strings.ToLower(*newName)) return nil } func runUserList(args []string) error { fs, dsn := newFlagSet("user list") if err := fs.Parse(args); err != nil { return err } store, err := openStore(*dsn, true) if err != nil { return 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") } email := u.Email if email == "" { email = "(none)" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", abbreviateID(u.UserID), u.Username, email, u.CreatedAt.Format("2006-01-02 15:04"), lastLogin, ) } w.Flush() fmt.Printf("\nTotal users: %d\n", len(users)) return nil }