feat(cli): enhance user and hook management with new commands and flags
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

This commit is contained in:
2026-02-20 21:17:09 +02:00
parent 500db67c72
commit b81febafc9
5 changed files with 529 additions and 171 deletions

View File

@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -22,6 +23,23 @@ import (
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)",
@@ -50,11 +68,15 @@ var usersAddCmd = &cobra.Command{
}
var usersSetPasswordCmd = &cobra.Command{
Use: "set-password <username>",
Use: "set-password <username> [password]",
Short: "Change a user's password",
Args: cobra.ExactArgs(1),
Args: cobra.RangeArgs(1, 2),
Run: func(cmd *cobra.Command, args []string) {
setPassword(args[0])
if len(args) == 2 {
setPasswordDirect(args[0], args[1])
} else {
setPassword(args[0])
}
},
}
@@ -105,6 +127,18 @@ var usersRemoveCmd = &cobra.Command{
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)
@@ -132,6 +166,41 @@ func resolveServerConfigPath() string {
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 == "" {
@@ -183,39 +252,29 @@ func listUsers() {
}
func addUser() {
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Username: ")
scanner.Scan()
username := strings.TrimSpace(scanner.Text())
username := addUsername
if username == "" {
fmt.Fprintln(os.Stderr, "Error: username is required")
os.Exit(1)
username = promptRequired("Username")
}
fmt.Print("Email: ")
scanner.Scan()
email := strings.TrimSpace(scanner.Text())
email := addEmail
if email == "" {
fmt.Fprintln(os.Stderr, "Error: email is required")
os.Exit(1)
email = promptRequired("Email")
}
fmt.Print("Full Name (optional): ")
scanner.Scan()
fullName := strings.TrimSpace(scanner.Text())
fullName := addFullName
if fullName == "" {
fullName = promptLine("Full Name (optional)", "")
}
fmt.Print("Role (admin/user) [user]: ")
scanner.Scan()
role := strings.TrimSpace(scanner.Text())
role := addRole
if role == "" {
role = "user"
role = promptLine("Role (admin/user)", "user")
}
password := readPassword("Password: ")
password := addPassword
if password == "" {
fmt.Fprintln(os.Stderr, "Error: password is required")
os.Exit(1)
password = readPassword("Password: ")
}
hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@@ -240,8 +299,21 @@ func addUser() {
fmt.Printf("User '%s' created\n", username)
}
func setPassword(username string) {
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: ")
@@ -249,15 +321,15 @@ func setPassword(username string) {
fmt.Fprintln(os.Stderr, "Error: passwords do not match")
os.Exit(1)
}
if password == "" {
fmt.Fprintln(os.Stderr, "Error: password cannot be empty")
os.Exit(1)
}
hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
checkError(err)
_, err = storage.DB.NewUpdate().Model(user).
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()).
@@ -286,36 +358,40 @@ func setUserActive(username string, active bool) {
func updateUser(username string) {
user := findUserByUsername(username)
scanner := bufio.NewScanner(os.Stdin)
fmt.Printf("Updating user '%s' (press Enter to keep current value)\n", username)
// If any flag was provided, apply flags only (non-interactive)
flagsProvided := updateUsername != "" || updateEmail != "" || updateFullName != "" || updateRole != ""
fmt.Printf("Username [%s]: ", user.Username.String())
scanner.Scan()
if v := strings.TrimSpace(scanner.Text()); v != "" {
user.Username = resolvespec_common.NewSqlString(v)
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)
}
}
fmt.Printf("Email [%s]: ", user.Email.String())
scanner.Scan()
if v := strings.TrimSpace(scanner.Text()); v != "" {
user.Email = resolvespec_common.NewSqlString(v)
}
fmt.Printf("Full Name [%s]: ", user.FullName.String())
scanner.Scan()
if v := strings.TrimSpace(scanner.Text()); v != "" {
user.FullName = resolvespec_common.NewSqlString(v)
}
fmt.Printf("Role (admin/user) [%s]: ", user.Role.String())
scanner.Scan()
if v := strings.TrimSpace(scanner.Text()); v != "" {
user.Role = resolvespec_common.NewSqlString(v)
}
user.UpdatedAt = resolvespec_common.NewSqlTimeStamp(time.Now())
_, err := storage.DB.NewUpdate().Model(user).
Set("username = ?", user.Username.String()).
Set("email = ?", user.Email.String()).
@@ -333,9 +409,9 @@ func deleteUser(username string) {
user := findUserByUsername(username)
fmt.Printf("Delete user '%s'? This cannot be undone. [y/N]: ", username)
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
if strings.ToLower(strings.TrimSpace(scanner.Text())) != "y" {
reader := bufio.NewReader(os.Stdin)
line, _ := reader.ReadString('\n')
if strings.ToLower(strings.TrimSpace(line)) != "y" {
fmt.Println("Cancelled")
return
}
@@ -356,14 +432,63 @@ func findUserByUsername(username string) *models.ModelPublicUsers {
return &user
}
func readPassword(prompt string) string {
fmt.Print(prompt)
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
return strings.TrimSpace(scanner.Text())
// 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.")
}
return string(pw)
}