800 lines
20 KiB
Go
800 lines
20 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"syscall"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/lixenwraith/chess/internal/server/replay"
|
|
"github.com/lixenwraith/chess/internal/server/service"
|
|
"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, game, 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 "game":
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("game subcommand required: delete, import")
|
|
}
|
|
return runGame(args[1], args[2:])
|
|
case "user":
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, reset-link, 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 runGame(subcommand string, args []string) error {
|
|
switch subcommand {
|
|
case "delete":
|
|
return runGameDelete(args)
|
|
case "import":
|
|
return runGameImport(args)
|
|
default:
|
|
return fmt.Errorf("unknown game subcommand: %s", subcommand)
|
|
}
|
|
}
|
|
|
|
// runGameDelete removes a stored game and its moves outright, whoever holds
|
|
// it. A live copy in a running server keeps playing in memory until it is
|
|
// unloaded; its later writes find no row and are dropped.
|
|
func runGameDelete(args []string) error {
|
|
fs, dsn := newFlagSet("game delete")
|
|
gameID := fs.String("gameId", "", "Game ID or unique prefix of at least 8 hex digits (required)")
|
|
confirm := fs.Bool("confirm", false, "Confirm deleting the game and its moves")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *gameID == "" {
|
|
return errors.New("-gameId is required")
|
|
}
|
|
if !*confirm {
|
|
return errors.New("refusing to delete a game without -confirm")
|
|
}
|
|
|
|
store, err := openStore(*dsn, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
id, err := resolveGameID(store, *gameID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := store.DeleteGames([]string{id}); err != nil {
|
|
return fmt.Errorf("delete game: %w", err)
|
|
}
|
|
if err := store.Flush(context.Background()); err != nil {
|
|
return fmt.Errorf("delete game: %w", err)
|
|
}
|
|
fmt.Printf("Deleted game %s\n", id)
|
|
return nil
|
|
}
|
|
|
|
// runGameImport stores a PGN file as a game of the named account, as the
|
|
// API's import does: every move is checked by the rules core.
|
|
func runGameImport(args []string) error {
|
|
fs, dsn := newFlagSet("game import")
|
|
file := fs.String("file", "", "PGN file holding one game (required)")
|
|
username := fs.String("username", "", "Account that will own the game (required)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *file == "" || *username == "" {
|
|
return errors.New("-file and -username are required")
|
|
}
|
|
text, err := os.ReadFile(*file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
store, err := openStore(*dsn, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
ownerID, err := lookupUser(store, *username, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
svc, err := service.New(store, []byte(strings.Repeat("x", 32))) // no tokens are minted here
|
|
if err != nil {
|
|
return err
|
|
}
|
|
imported, err := svc.ImportGame(ownerID, string(text))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("Imported game %s (%d plies) for %s\n", imported.GameID, imported.Plies, *username)
|
|
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 "reset-link":
|
|
return runUserResetLink(args)
|
|
case "list":
|
|
return runUserList(args)
|
|
default:
|
|
return fmt.Errorf("unknown user subcommand: %s", subcommand)
|
|
}
|
|
}
|
|
|
|
// runUserResetLink prints a one-time password-reset link for an account, the
|
|
// same link the recovery email carries, for an administrator to pass on when
|
|
// the server sends no mail or the account has no email. It works for an hour
|
|
// and replaces any pending link.
|
|
func runUserResetLink(args []string) error {
|
|
fs, dsn := newFlagSet("user reset-link")
|
|
username := fs.String("username", "", "Username")
|
|
userID := fs.String("id", "", "User ID")
|
|
publicURL := fs.String("url", "", "Web client address the link opens, e.g. https://example.com/chess/web-client/ (required)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
base, err := url.Parse(*publicURL)
|
|
if *publicURL == "" || err != nil || base.Scheme == "" || base.Host == "" {
|
|
return fmt.Errorf("-url must be the web client's absolute address")
|
|
}
|
|
|
|
store, err := openStore(*dsn, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
targetID, err := lookupUser(store, *username, *userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token, hash, err := service.NewResetToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := store.CreatePasswordReset(storage.PasswordResetRecord{
|
|
UserID: targetID, TokenHash: hash, CreatedAt: now, ExpiresAt: now.Add(service.PasswordResetTTL),
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to create reset: %w", err)
|
|
}
|
|
fmt.Printf("Reset link for %s, valid until %s (one use):\n%s\n",
|
|
abbreviateID(targetID), now.Add(service.PasswordResetTTL).Format(time.RFC3339), service.ResetLink(base, token))
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|