Files
whatshooked/cmd/cli/commands_users.go
Hein b81febafc9
Some checks failed
CI / Test (1.22) (push) Failing after -22m38s
CI / Test (1.23) (push) Failing after -22m21s
CI / Lint (push) Failing after -22m42s
CI / Build (push) Failing after -23m0s
feat(cli): enhance user and hook management with new commands and flags
2026-02-20 21:17:09 +02:00

495 lines
13 KiB
Go

package main
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/models"
"git.warky.dev/wdevs/whatshooked/pkg/storage"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/google/uuid"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
"golang.org/x/term"
)
var serverConfigPath string
// flags for add
var (
addUsername string
addEmail string
addFullName string
addPassword string
addRole string
)
// flags for update
var (
updateUsername string
updateEmail string
updateFullName string
updateRole string
)
var usersCmd = &cobra.Command{
Use: "users",
Short: "Manage users (direct DB access)",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
initUserDB()
},
Run: func(cmd *cobra.Command, args []string) {
listUsers()
},
}
var usersListCmd = &cobra.Command{
Use: "list",
Short: "List all users",
Run: func(cmd *cobra.Command, args []string) {
listUsers()
},
}
var usersAddCmd = &cobra.Command{
Use: "add",
Short: "Add a new user",
Run: func(cmd *cobra.Command, args []string) {
addUser()
},
}
var usersSetPasswordCmd = &cobra.Command{
Use: "set-password <username> [password]",
Short: "Change a user's password",
Args: cobra.RangeArgs(1, 2),
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 2 {
setPasswordDirect(args[0], args[1])
} else {
setPassword(args[0])
}
},
}
var usersDisableCmd = &cobra.Command{
Use: "disable <username>",
Short: "Disable a user account",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
setUserActive(args[0], false)
},
}
var usersEnableCmd = &cobra.Command{
Use: "enable <username>",
Short: "Enable a user account",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
setUserActive(args[0], true)
},
}
var usersUpdateCmd = &cobra.Command{
Use: "update <username>",
Short: "Update a user's details",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
updateUser(args[0])
},
}
var usersDeleteCmd = &cobra.Command{
Use: "delete <username>",
Short: "Delete a user",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
deleteUser(args[0])
},
}
var usersRemoveCmd = &cobra.Command{
Use: "remove <username>",
Short: "Remove a user (alias for delete)",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
deleteUser(args[0])
},
}
func init() {
usersCmd.PersistentFlags().StringVar(&serverConfigPath, "server-config", "", "server config file (default: config.json or ~/.whatshooked/config.json)")
usersAddCmd.Flags().StringVarP(&addUsername, "username", "u", "", "Username")
usersAddCmd.Flags().StringVarP(&addEmail, "email", "e", "", "Email address")
usersAddCmd.Flags().StringVarP(&addFullName, "full-name", "n", "", "Full name")
usersAddCmd.Flags().StringVarP(&addPassword, "password", "p", "", "Password")
usersAddCmd.Flags().StringVarP(&addRole, "role", "r", "", "Role (admin/user)")
usersUpdateCmd.Flags().StringVarP(&updateUsername, "username", "u", "", "New username")
usersUpdateCmd.Flags().StringVarP(&updateEmail, "email", "e", "", "New email address")
usersUpdateCmd.Flags().StringVarP(&updateFullName, "full-name", "n", "", "New full name")
usersUpdateCmd.Flags().StringVarP(&updateRole, "role", "r", "", "New role (admin/user)")
usersCmd.AddCommand(usersListCmd)
usersCmd.AddCommand(usersAddCmd)
usersCmd.AddCommand(usersUpdateCmd)
usersCmd.AddCommand(usersSetPasswordCmd)
usersCmd.AddCommand(usersDisableCmd)
usersCmd.AddCommand(usersEnableCmd)
usersCmd.AddCommand(usersDeleteCmd)
usersCmd.AddCommand(usersRemoveCmd)
}
func resolveServerConfigPath() string {
if serverConfigPath != "" {
return serverConfigPath
}
if _, err := os.Stat("config.json"); err == nil {
return "config.json"
}
home, err := os.UserHomeDir()
if err == nil {
p := filepath.Join(home, ".whatshooked", "config.json")
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
// tryInitDB attempts to initialize the DB, returning false if it cannot.
func tryInitDB() bool {
if storage.DB != nil {
return true
}
cfgPath := resolveServerConfigPath()
if cfgPath == "" {
return false
}
cfg, err := config.Load(cfgPath)
if err != nil {
return false
}
return storage.Initialize(&cfg.Database) == nil
}
// dbOwnerUserID returns the ID of the first admin user (or first user) for use
// when creating records via DB that require a user_id.
func dbOwnerUserID(username string) string {
var user models.ModelPublicUsers
q := storage.DB.NewSelect().Model(&user)
if username != "" {
q = q.Where("username = ?", username)
} else {
q = q.Where("role = ?", "admin")
}
if err := q.Limit(1).Scan(context.Background()); err != nil {
// fall back to any user
if err2 := storage.DB.NewSelect().Model(&user).Limit(1).Scan(context.Background()); err2 != nil {
return ""
}
}
return user.ID.String()
}
func initUserDB() {
cfgPath := resolveServerConfigPath()
if cfgPath == "" {
fmt.Fprintln(os.Stderr, "Error: server config not found. Use --server-config to specify path.")
os.Exit(1)
}
cfg, err := config.Load(cfgPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading server config %s: %v\n", cfgPath, err)
os.Exit(1)
}
if err := storage.Initialize(&cfg.Database); err != nil {
fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
os.Exit(1)
}
}
func listUsers() {
var users []models.ModelPublicUsers
err := storage.DB.NewSelect().Model(&users).OrderExpr("created_at ASC").Scan(context.Background())
checkError(err)
if len(users) == 0 {
fmt.Println("No users found")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "USERNAME\tEMAIL\tFULL NAME\tROLE\tACTIVE\tCREATED")
for _, u := range users {
active := "yes"
if !u.Active {
active = "no"
}
created := fmt.Sprint(u.CreatedAt)
if len(created) > 10 {
created = created[:10]
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
u.Username.String(),
u.Email.String(),
u.FullName.String(),
u.Role.String(),
active,
created,
)
}
w.Flush()
}
func addUser() {
username := addUsername
if username == "" {
username = promptRequired("Username")
}
email := addEmail
if email == "" {
email = promptRequired("Email")
}
fullName := addFullName
if fullName == "" {
fullName = promptLine("Full Name (optional)", "")
}
role := addRole
if role == "" {
role = promptLine("Role (admin/user)", "user")
}
password := addPassword
if password == "" {
password = readPassword("Password: ")
}
hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
checkError(err)
now := time.Now()
user := &models.ModelPublicUsers{
ID: resolvespec_common.NewSqlString(uuid.New().String()),
Username: resolvespec_common.NewSqlString(username),
Email: resolvespec_common.NewSqlString(email),
FullName: resolvespec_common.NewSqlString(fullName),
Role: resolvespec_common.NewSqlString(role),
Password: resolvespec_common.NewSqlString(string(hashedPw)),
Active: true,
CreatedAt: resolvespec_common.NewSqlTimeStamp(now),
UpdatedAt: resolvespec_common.NewSqlTimeStamp(now),
}
_, err = storage.DB.NewInsert().Model(user).Exec(context.Background())
checkError(err)
fmt.Printf("User '%s' created\n", username)
}
func setPasswordDirect(username, password string) {
user := findUserByUsername(username)
hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
checkError(err)
_, err = storage.DB.NewUpdate().Model(user).
Set("password = ?", string(hashedPw)).
Set("updated_at = ?", time.Now()).
Where("id = ?", user.ID.String()).
Exec(context.Background())
checkError(err)
fmt.Printf("Password updated for '%s'\n", username)
}
func setPassword(username string) {
findUserByUsername(username) // validate exists
password := readPassword("New password: ")
confirm := readPassword("Confirm password: ")
if password != confirm {
fmt.Fprintln(os.Stderr, "Error: passwords do not match")
os.Exit(1)
}
hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
checkError(err)
var user models.ModelPublicUsers
err = storage.DB.NewSelect().Model(&user).Where("username = ?", username).Scan(context.Background())
checkError(err)
_, err = storage.DB.NewUpdate().Model(&user).
Set("password = ?", string(hashedPw)).
Set("updated_at = ?", time.Now()).
Where("id = ?", user.ID.String()).
Exec(context.Background())
checkError(err)
fmt.Printf("Password updated for '%s'\n", username)
}
func setUserActive(username string, active bool) {
user := findUserByUsername(username)
_, err := storage.DB.NewUpdate().Model(user).
Set("active = ?", active).
Set("updated_at = ?", time.Now()).
Where("id = ?", user.ID.String()).
Exec(context.Background())
checkError(err)
state := "enabled"
if !active {
state = "disabled"
}
fmt.Printf("User '%s' %s\n", username, state)
}
func updateUser(username string) {
user := findUserByUsername(username)
// If any flag was provided, apply flags only (non-interactive)
flagsProvided := updateUsername != "" || updateEmail != "" || updateFullName != "" || updateRole != ""
if flagsProvided {
if updateUsername != "" {
user.Username = resolvespec_common.NewSqlString(updateUsername)
}
if updateEmail != "" {
user.Email = resolvespec_common.NewSqlString(updateEmail)
}
if updateFullName != "" {
user.FullName = resolvespec_common.NewSqlString(updateFullName)
}
if updateRole != "" {
user.Role = resolvespec_common.NewSqlString(updateRole)
}
} else {
fmt.Printf("Updating user '%s' (press Enter to keep current value)\n", username)
if v := promptLine("Username", user.Username.String()); v != "" {
user.Username = resolvespec_common.NewSqlString(v)
}
if v := promptLine("Email", user.Email.String()); v != "" {
user.Email = resolvespec_common.NewSqlString(v)
}
if v := promptLine("Full Name", user.FullName.String()); v != "" {
user.FullName = resolvespec_common.NewSqlString(v)
}
if v := promptLine("Role (admin/user)", user.Role.String()); v != "" {
user.Role = resolvespec_common.NewSqlString(v)
}
}
_, err := storage.DB.NewUpdate().Model(user).
Set("username = ?", user.Username.String()).
Set("email = ?", user.Email.String()).
Set("full_name = ?", user.FullName.String()).
Set("role = ?", user.Role.String()).
Set("updated_at = ?", time.Now()).
Where("id = ?", user.ID.String()).
Exec(context.Background())
checkError(err)
fmt.Printf("User '%s' updated\n", user.Username.String())
}
func deleteUser(username string) {
user := findUserByUsername(username)
fmt.Printf("Delete user '%s'? This cannot be undone. [y/N]: ", username)
reader := bufio.NewReader(os.Stdin)
line, _ := reader.ReadString('\n')
if strings.ToLower(strings.TrimSpace(line)) != "y" {
fmt.Println("Cancelled")
return
}
_, err := storage.DB.NewDelete().Model(user).Where("id = ?", user.ID.String()).Exec(context.Background())
checkError(err)
fmt.Printf("User '%s' deleted\n", username)
}
func findUserByUsername(username string) *models.ModelPublicUsers {
var user models.ModelPublicUsers
err := storage.DB.NewSelect().Model(&user).Where("username = ?", username).Scan(context.Background())
if err != nil {
fmt.Fprintf(os.Stderr, "Error: user '%s' not found\n", username)
os.Exit(1)
}
return &user
}
// promptLine prints a prompt and reads one line. Returns defaultVal if Enter pressed with no input.
func promptLine(label, defaultVal string) string {
if defaultVal != "" {
fmt.Printf("%s [%s]: ", label, defaultVal)
} else {
fmt.Printf("%s: ", label)
}
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return defaultVal
}
v := strings.TrimRight(line, "\r\n")
if v == "" {
return defaultVal
}
return v
}
// promptRequired loops until a non-empty value is entered.
func promptRequired(label string) string {
for {
v := promptLine(label, "")
if v != "" {
return v
}
fmt.Printf("%s is required, please try again.\n", label)
}
}
// readPassword reads a password with hidden input when on a TTY, plain text otherwise.
// Loops until a non-empty value is entered.
func readPassword(prompt string) string {
fd := int(os.Stdin.Fd())
isTTY := term.IsTerminal(fd)
for {
fmt.Print(prompt)
var pw string
if isTTY {
p, err := term.ReadPassword(fd)
fmt.Println()
if err == nil {
pw = string(p)
}
} else {
reader := bufio.NewReader(os.Stdin)
p, err := reader.ReadString('\n')
fmt.Println()
if err != nil && err != io.EOF {
fmt.Fprintln(os.Stderr, "Error: cannot read password interactively. Use set-password <username> <password> instead.")
os.Exit(1)
}
pw = strings.TrimRight(p, "\r\n")
}
if pw != "" {
return pw
}
fmt.Println("Password cannot be empty, please try again.")
}
}