From 48df4c2a360326bc8e92ad70a97259e4bbb0709a Mon Sep 17 00:00:00 2001
From: Theo <tss24@inf.ufpr.br>
Date: Mon, 31 Mar 2025 22:41:39 -0300
Subject: [PATCH] Start group subcommand implementation

---
 cmd/bulk.go         |  2 +-
 cmd/group.go        |  2 +-
 cmd/group/create.go | 60 +++++++++++++++++++-----------
 cmd/group/delete.go | 51 ++++++++++++++++++++++++--
 cmd/user/block.go   | 28 ++++++++++----
 cmd/user/create.go  | 10 +++--
 cmd/user/group.go   | 62 +++++++++++++++++++++++++++++++
 cmd/user/mod.go     |  2 +-
 cmd/user/remove.go  | 15 ++++----
 cmd/user/show.go    |  4 +-
 cmd/user/temp.go    |  3 +-
 cmd/user/unblock.go | 20 ++++++----
 model/ldap.go       | 89 +++++++++++++++++++++++++++++++++++++++++++--
 model/user.go       | 24 ++++++++++++
 utils/utils.go      |  2 +-
 15 files changed, 310 insertions(+), 64 deletions(-)
 create mode 100644 cmd/user/group.go

diff --git a/cmd/bulk.go b/cmd/bulk.go
index 3d75a75..0dde21e 100644
--- a/cmd/bulk.go
+++ b/cmd/bulk.go
@@ -124,7 +124,7 @@ func bulkCreate(cmd *cobra.Command, args []string) error {
 			if !opts.Confirm {
 				fmt.Printf("Ready to create: %v\n", u.FullToString())
 			}
-			utils.ConfirmationPrompt(opts.Confirm, "creation")
+			utils.ConfirmationPrompt(opts.Confirm, "user creation")
 			err = u.Create(l)
 			if err != nil {
 				return err
diff --git a/cmd/group.go b/cmd/group.go
index 49a7be1..5e41212 100644
--- a/cmd/group.go
+++ b/cmd/group.go
@@ -7,7 +7,7 @@ import (
 
 var groupCmd = &cobra.Command{
 	Use:   "group",
-	Short: "User subcommand",
+	Short: "Group subcommand",
 }
 
 func init() {
diff --git a/cmd/group/create.go b/cmd/group/create.go
index 1f2a9a6..25e2fa5 100644
--- a/cmd/group/create.go
+++ b/cmd/group/create.go
@@ -1,48 +1,64 @@
 package group
 
 import (
-	"bufio"
 	"fmt"
-	"os"
-	"strings"
 
 	"github.com/spf13/cobra"
+	"gitlab.c3sl.ufpr.br/tss24/useradm/model"
+	"gitlab.c3sl.ufpr.br/tss24/useradm/utils"
 )
 
 var CreateCmd = &cobra.Command{
-	Use:   "create",
+	Use:   "create [groupname]",
 	Short: "Create new group",
-	RunE:  createGroupFunc,
+	Args:  cobra.ExactArgs(1),
+	RunE:  createGroupCmd,
 }
 
 func init() {
-	CreateCmd.Flags().StringP("groupname", "e", "", "Group name (use quotes for spaces)")
 	CreateCmd.Flags().BoolP("confirm", "y", false, "Skip confirmation prompt")
-
-	CreateCmd.MarkFlagRequired("groupname")
 }
 
-func createGroupFunc(cmd *cobra.Command, args []string) error {
-	groupName, err := cmd.Flags().GetString("groupname")
+func createGroupCmd(cmd *cobra.Command, args []string) error {
+	var opts model.Opts
+
+	err := opts.RetrieveOpts(cmd)
+	if err != nil {
+		return err
+	}
+
+	l, err := model.ConnLDAP()
+	if err != nil {
+		return err
+	}
+	defer l.Close()
+
+	groups, err := model.GetAllGroupsLDAP(l)
 	if err != nil {
 		return err
 	}
-	confirm, err := cmd.Flags().GetBool("confirm")
+
+	// there has to be a not found error
+	_, err = utils.GetGIDNumFromGID(groups, args[0])
+	if err == nil {
+		return fmt.Errorf("Groupname already exists")
+	}
+
+	GIDNumber, err := model.GetNewGIDNumber(l)
 	if err != nil {
 		return err
 	}
-	fmt.Printf("Groupname: %s\n", groupName)
-
-	if !confirm {
-		fmt.Print("Proceed with group creation? [y/N] ")
-		reader := bufio.NewReader(os.Stdin)
-		response, _ := reader.ReadString('\n')
-		if strings.TrimSpace(strings.ToLower(response)) != "y" {
-			fmt.Println("Aborted.")
-			os.Exit(1)
-		}
+
+	fmt.Printf("Groupname: %v\n", args[0])
+	fmt.Printf("gidNumber: %v\n", GIDNumber)
+	utils.ConfirmationPrompt(opts.Confirm, "group creation")
+
+	err = model.LDAPCreateGroup(l, args[0], GIDNumber)
+	if err != nil {
+		return err
 	}
 
-	fmt.Println("Done!")
+	fmt.Println("Group Created!")
+
 	return nil
 }
diff --git a/cmd/group/delete.go b/cmd/group/delete.go
index 7fdbd73..f021b47 100644
--- a/cmd/group/delete.go
+++ b/cmd/group/delete.go
@@ -1,13 +1,58 @@
 package group
 
-import "github.com/spf13/cobra"
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	"gitlab.c3sl.ufpr.br/tss24/useradm/model"
+	"gitlab.c3sl.ufpr.br/tss24/useradm/utils"
+)
 
 var DeleteCmd = &cobra.Command{
 	Use:   "delete",
 	Short: "Delete a group",
-	RunE:  deleteRun,
+	Args:  cobra.ExactArgs(1),
+	RunE:  deleteCmd,
+}
+
+func init() {
+	DeleteCmd.Flags().BoolP("confirm", "y", false, "Skip confirmation prompt")
 }
 
-func deleteRun(cmd *cobra.Command, args []string) error {
+func deleteCmd(cmd *cobra.Command, args []string) error {
+	var opts model.Opts
+
+	err := opts.RetrieveOpts(cmd)
+	if err != nil {
+		return err
+	}
+
+	l, err := model.ConnLDAP()
+	if err != nil {
+		return err
+	}
+	defer l.Close()
+
+	groups, err := model.GetAllGroupsLDAP(l)
+	if err != nil {
+		return err
+	}
+
+	gidNumber, err := utils.GetGIDNumFromGID(groups, args[0])
+	if err != nil {
+		return fmt.Errorf("Groupname not found")
+	}
+
+	fmt.Printf("Groupname: %v\n", args[0])
+	fmt.Printf("gidNumber: %v\n", gidNumber)
+	utils.ConfirmationPrompt(opts.Confirm, "group deletion")
+
+	err = model.LDAPDeleteGroup(l, args[0])
+	if err != nil {
+		return err
+	}
+
+	fmt.Println("Group deleted!")
+
 	return nil
 }
diff --git a/cmd/user/block.go b/cmd/user/block.go
index e148992..c251bbb 100644
--- a/cmd/user/block.go
+++ b/cmd/user/block.go
@@ -11,27 +11,41 @@ import (
 var BlockCmd = &cobra.Command{
 	Use:     "block [username]",
 	Short:   "Block a user",
-	Example: "  useradm block bpt22",
+	Example: "  useradm user block bpt22",
 	Args:    cobra.ExactArgs(1),
-	RunE:    BlockUserCmd,
+	RunE:    blockUserCmd,
 }
 
-func BlockUserCmd(cmd *cobra.Command, args []string) error {
+func init() {
+	BlockCmd.Flags().BoolP("confirm", "y", false, "Skip confirmation prompt")
+}
+
+func blockUserCmd(cmd *cobra.Command, args []string) error {
+	confirm, err := cmd.Flags().GetBool("confirm")
+	if err != nil {
+		return err
+	}
+
 	l, err := model.ConnLDAP()
 	if err != nil {
 		return err
 	}
 	defer l.Close()
 
-	login := args[0]
-	u, err := model.Locate(l, login)
+	// Locate returns a error if more than one user was found
+	u, err := model.Locate(l, args[0])
 	if err != nil {
 		return err
 	}
 
 	fmt.Printf("%v\n", u.FullToString())
+	utils.ConfirmationPrompt(confirm, "user blocking")
+
+	err = u.Block()
+	if err != nil {
+		return err
+	}
 
-	utils.ConfirmationPrompt(false, "blocking")
-	u.Block()
+	fmt.Println("User blocked!")
 	return nil
 }
diff --git a/cmd/user/create.go b/cmd/user/create.go
index 1c430db..73ac9f3 100644
--- a/cmd/user/create.go
+++ b/cmd/user/create.go
@@ -3,7 +3,6 @@ package user
 import (
 	"fmt"
 
-	"github.com/go-ldap/ldap/v3"
 	"github.com/spf13/cobra"
 	"gitlab.c3sl.ufpr.br/tss24/useradm/model"
 	"gitlab.c3sl.ufpr.br/tss24/useradm/utils"
@@ -48,7 +47,7 @@ func createUserCmd(cmd *cobra.Command, args []string) error {
 	defer l.Close()
 
 	// creates model from users input
-	usr, confirm, err := createNewUserModel(cmd, l)
+	usr, confirm, err := createNewUserModel(cmd)
 	if err != nil {
 		return err
 	}
@@ -61,12 +60,14 @@ func createUserCmd(cmd *cobra.Command, args []string) error {
 	fmt.Printf("%v\n", usr.FullToString()) // for debug
 	//fmt.Printf("%v\n	Passwd:  %v\n\n", usr.ToString(), usr.Password)
 
+	// print a warning if name was found,
+	// better because UID is forced to be unique
 	if model.NameExists(users, usr.Name) {
 		fmt.Printf("WARNING: Name was found in LDAP database!\n")
 		confirm = false
 	}
 
-	utils.ConfirmationPrompt(confirm, "creation")
+	utils.ConfirmationPrompt(confirm, "user creation")
 
 	err = usr.Create(l)
 	if err != nil {
@@ -82,7 +83,7 @@ func createUserCmd(cmd *cobra.Command, args []string) error {
 }
 
 // creates and validates user inputs into the User model
-func createNewUserModel(cmd *cobra.Command, l *ldap.Conn) (model.User, bool, error) {
+func createNewUserModel(cmd *cobra.Command) (model.User, bool, error) {
 	var u model.User
 	var opts model.Opts
 
@@ -119,6 +120,7 @@ func createNewUserModel(cmd *cobra.Command, l *ldap.Conn) (model.User, bool, err
 
 	u.Ltype.Parse(opts.Ltype)
 
+	// this will infer the fields the user didn't provide (blank)
 	err = u.GenMissingFields()
 	if err != nil {
 		return u, false, err
diff --git a/cmd/user/group.go b/cmd/user/group.go
new file mode 100644
index 0000000..6242aa8
--- /dev/null
+++ b/cmd/user/group.go
@@ -0,0 +1,62 @@
+package user
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	"gitlab.c3sl.ufpr.br/tss24/useradm/model"
+	"gitlab.c3sl.ufpr.br/tss24/useradm/utils"
+)
+
+var GroupCmd = &cobra.Command{
+	Use:     "group [username] [new-group]",
+	Short:   "Change a user's base group",
+	Example: "  useradm user group temp2 prof",
+	Args:    cobra.ExactArgs(2),
+	RunE:    groupUserCmd,
+}
+
+func init() {
+	GroupCmd.Flags().BoolP("confirm", "y", false, "Skip confirmation prompt")
+}
+
+func groupUserCmd(cmd *cobra.Command, args []string) error {
+	confirm, err := cmd.Flags().GetBool("confirm")
+	if err != nil {
+		return err
+	}
+
+	l, err := model.ConnLDAP()
+	if err != nil {
+		return err
+	}
+	defer l.Close()
+
+	// Locate returns a error if more than one user was found
+	u, err := model.Locate(l, args[0])
+	if err != nil {
+		return err
+	}
+
+	groups, err := model.GetAllGroupsLDAP(l)
+	if err != nil {
+		return err
+	}
+
+	_, err = utils.GetGIDNumFromGID(groups, args[1])
+	if err != nil {
+		return err
+	}
+
+	fmt.Printf("%v\n", u.FullToString())
+	utils.ConfirmationPrompt(confirm, "user group change")
+
+	err = u.ChangeBaseGroupLDAP(l, args[1])
+	if err != nil {
+		return err
+	}
+
+	fmt.Println("Base group updated!")
+
+	return nil
+}
diff --git a/cmd/user/mod.go b/cmd/user/mod.go
index 9b9b1d7..5a81ddf 100644
--- a/cmd/user/mod.go
+++ b/cmd/user/mod.go
@@ -108,7 +108,7 @@ func modUserCmd(cmd *cobra.Command, args []string) error {
 	}
 
 	fmt.Printf("%v\n\n", curr.ToString())
-	utils.ConfirmationPrompt(opts.Confirm, "update")
+	utils.ConfirmationPrompt(opts.Confirm, "user update")
 
 	if err := l.Modify(req); err != nil {
 		return fmt.Errorf("Failed to update user attributes: %v", err)
diff --git a/cmd/user/remove.go b/cmd/user/remove.go
index 5f2debf..58a40e4 100644
--- a/cmd/user/remove.go
+++ b/cmd/user/remove.go
@@ -25,19 +25,18 @@ func removeUserCmd(cmd *cobra.Command, args []string) error {
 	var opts model.Opts
 	success := false
 
-	l, err := model.ConnLDAP()
+	err := opts.RetrieveOpts(cmd)
 	if err != nil {
 		return err
 	}
-	defer l.Close()
 
-	err = opts.RetrieveOpts(cmd)
+	l, err := model.ConnLDAP()
 	if err != nil {
 		return err
 	}
+	defer l.Close()
 
-	login := args[0]
-	u, err := model.Locate(l, login)
+	u, err := model.Locate(l, args[0])
 	if err != nil {
 		return err
 	}
@@ -56,7 +55,7 @@ func removeUserCmd(cmd *cobra.Command, args []string) error {
 
 	fmt.Printf("Found %v\n\n", u.FullToString())
 
-	utils.ConfirmationPrompt(opts.Confirm, "removal")
+	utils.ConfirmationPrompt(opts.Confirm, "user removal")
 
 	err = u.DirsToTrash()
 	if err != nil {
@@ -64,7 +63,7 @@ func removeUserCmd(cmd *cobra.Command, args []string) error {
 		// directory in trash with the username this will fail
 		// and the defer will activate. That moves the trash dir
 		// to the users home which is bad, so if a dir move fails,
-		// its better not to try to fix it. Just warn the admin
+		// it's better not to try to fix it. Just warn the admin
 		// to do it by hand, either removing the destination dir
 		// or changing the name. Remeber that having no dir to move
 		// is not a fail, so you can run the command again no fear
@@ -77,7 +76,7 @@ func removeUserCmd(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	fmt.Printf("\nUser removed!\n")
+	fmt.Println("User removed!")
 
 	success = true
 	return nil
diff --git a/cmd/user/show.go b/cmd/user/show.go
index 6bb389e..35dd399 100644
--- a/cmd/user/show.go
+++ b/cmd/user/show.go
@@ -56,9 +56,9 @@ func searchUserCmd(cmd *cobra.Command, args []string) error {
 		return nil
 	}
 
-	for i := range found {
+	for _, u := range found {
 		//fmt.Printf("%v\n\n", found[i].ToString())
-		fmt.Printf("%v\n\n", found[i].FullToString()) // for debugging
+		fmt.Printf("%v\n\n", u.FullToString()) // for debugging
 	}
 
 	return nil
diff --git a/cmd/user/temp.go b/cmd/user/temp.go
index ff46b6d..0af802a 100644
--- a/cmd/user/temp.go
+++ b/cmd/user/temp.go
@@ -1,6 +1,5 @@
 package user
 
-// TODO: PQ Q N FUNCIONA??????????????
 import (
 	"fmt"
 	"os"
@@ -171,7 +170,7 @@ func createTempUser(base model.User, num int) error {
 	}
 
 	fmt.Printf("Pronto para criar:\n%v\n\n", base.FullToString())
-	utils.ConfirmationPrompt(false, "creation")
+	utils.ConfirmationPrompt(false, "user creation")
 
 	err = base.Create(l)
 	if err != nil {
diff --git a/cmd/user/unblock.go b/cmd/user/unblock.go
index 56ec9b6..701efd1 100644
--- a/cmd/user/unblock.go
+++ b/cmd/user/unblock.go
@@ -7,18 +7,22 @@ import (
 )
 
 var UnblockCmd = &cobra.Command{
-	Use:   "unblock [username]",
-	Short: "Unblock a user",
-	Args:  cobra.ExactArgs(1),
-	RunE:  UnblockUserCmd,
+	Use:     "unblock [username]",
+	Short:   "Unblock a user",
+	Example: "  useradm user unblock bpt22",
+	Args:    cobra.ExactArgs(1),
+	RunE:    unblockUserCmd,
 }
 
 func init() {
 	UnblockCmd.Flags().StringP("passwd", "p", "_", "User's new password")
+	UnblockCmd.Flags().BoolP("confirm", "y", false, "Skip confirmation prompt")
 }
 
-func UnblockUserCmd(cmd *cobra.Command, args []string) error {
-	pass, err := cmd.Flags().GetString("passwd")
+func unblockUserCmd(cmd *cobra.Command, args []string) error {
+	var opts model.Opts
+
+	err := opts.RetrieveOpts(cmd)
 	if err != nil {
 		return err
 	}
@@ -35,7 +39,7 @@ func UnblockUserCmd(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	utils.ConfirmationPrompt(false, "unblocking")
-	u.Unblock(pass)
+	utils.ConfirmationPrompt(opts.Confirm, "user unblocking")
+	u.Unblock(opts.Passwd)
 	return nil
 }
diff --git a/model/ldap.go b/model/ldap.go
index 10e6f82..4f77762 100644
--- a/model/ldap.go
+++ b/model/ldap.go
@@ -119,6 +119,38 @@ func DelFromLDAP(UID string) error {
 	return nil
 }
 
+func LDAPCreateGroup(conn *ldap.Conn, GID, GIDNumber string) error {
+	// contruct dn
+	dn := fmt.Sprintf("cn=%s,ou=grupos,dc=c3local", GID)
+
+	req := ldap.NewAddRequest(dn, []ldap.Control{})
+
+	// contruct request
+	req.Attribute("objectClass", []string{"posixGroup"})
+	req.Attribute("cn", []string{GID})
+	req.Attribute("gidNumber", []string{GIDNumber})
+
+	// create
+	if err := conn.Add(req); err != nil {
+		return fmt.Errorf("failed to add group: %w", err)
+	}
+
+	return nil
+}
+
+func LDAPDeleteGroup(conn *ldap.Conn, GID string) error {
+	// construct dn
+	dn := fmt.Sprintf("cn=%s,ou=grupos,dc=c3local", GID)
+
+	delReq := ldap.NewDelRequest(dn, []ldap.Control{})
+
+	if err := conn.Del(delReq); err != nil {
+		return fmt.Errorf("failed to delete group: %w", err)
+	}
+
+	return nil
+}
+
 func DelFromGroupLDAP(l *ldap.Conn, userUID, GID string) error {
 	groupDN := fmt.Sprintf("cn=%s,ou=grupos,dc=c3local", GID)
 	modReq := ldap.NewModifyRequest(groupDN, nil)
@@ -146,6 +178,17 @@ func AddToGroupLDAP(l *ldap.Conn, UID, GID string) error {
 	return nil
 }
 
+func (u *User) ChangeBaseGroupLDAP(l *ldap.Conn, newGID string) error {
+	req := ldap.NewModifyRequest(u.DN, []ldap.Control{})
+	req.Replace("gidNumber", []string{newGID})
+
+	if err := l.Modify(req); err != nil {
+		return fmt.Errorf("failed to modify user's base group: %w", err)
+	}
+
+	return nil
+}
+
 func GetAllUsersLDAP(l *ldap.Conn) ([]User, error) {
 	var users []User
 
@@ -215,7 +258,7 @@ func GetAllGroupsLDAP(l *ldap.Conn) (map[string]string, error) {
 	groupMap := make(map[string]string)
 
 	// build search
-	searchRequest := ldap.NewSearchRequest(
+	req := ldap.NewSearchRequest(
 		"dc=c3local",
 		ldap.ScopeWholeSubtree,
 		ldap.NeverDerefAliases,
@@ -226,7 +269,7 @@ func GetAllGroupsLDAP(l *ldap.Conn) (map[string]string, error) {
 	)
 
 	// search
-	sr, err := l.Search(searchRequest)
+	sr, err := l.Search(req)
 	if err != nil {
 		return nil, fmt.Errorf("LDAP search failed: %w", err)
 	}
@@ -247,7 +290,7 @@ func GetAllUIDsLDAP(l *ldap.Conn) ([]int, error) {
 	var uidNumbers []int
 
 	// build search
-	searchRequest := ldap.NewSearchRequest(
+	req := ldap.NewSearchRequest(
 		"dc=c3local",
 		ldap.ScopeWholeSubtree,
 		ldap.NeverDerefAliases,
@@ -258,7 +301,7 @@ func GetAllUIDsLDAP(l *ldap.Conn) ([]int, error) {
 	)
 
 	// search
-	sr, err := l.Search(searchRequest)
+	sr, err := l.Search(req)
 	if err != nil {
 		return nil, fmt.Errorf("LDAP search failed: %w", err)
 	}
@@ -280,3 +323,41 @@ func GetAllUIDsLDAP(l *ldap.Conn) ([]int, error) {
 
 	return uidNumbers, nil
 }
+
+func GetAllGIDsLDAP(l *ldap.Conn) ([]int, error) {
+	var gidNumbers []int
+
+	// build search
+	req := ldap.NewSearchRequest(
+		"dc=c3local",
+		ldap.ScopeWholeSubtree,
+		ldap.NeverDerefAliases,
+		0, 0, false,
+		"(&(objectClass=posixGroup))",
+		[]string{"gidNumber"},
+		nil,
+	)
+
+	// search
+	sr, err := l.Search(req)
+	if err != nil {
+		return nil, fmt.Errorf("LDAP search failed: %w", err)
+	}
+
+	// arrange result into a array of integers
+	for _, entry := range sr.Entries {
+		gidStr := entry.GetAttributeValue("gidNumber")
+		if gidStr != "" {
+			gid, err := strconv.Atoi(gidStr)
+			if err != nil {
+				return nil, fmt.Errorf("invalid GID number: %s", gidStr)
+			}
+			gidNumbers = append(gidNumbers, gid)
+		}
+	}
+
+	// sort for ease of use :)
+	sort.Ints(gidNumbers)
+
+	return gidNumbers, nil
+}
diff --git a/model/user.go b/model/user.go
index 0a04d47..a1f4510 100644
--- a/model/user.go
+++ b/model/user.go
@@ -16,6 +16,8 @@ import (
 const (
 	MIN_UID          = 1000   // up to 1000 are system users, 0 is root
 	MAX_UID          = 30000  // max UIDNumber permitted
+	MIN_GID          = 1000   // up to 1000 are system users, 0 is root
+	MAX_GID          = 5000   // max GIDNumber permitted
 	MAX_VARIANCE     = 60     // stops iteration at some point
 	DEF_PERMISSION   = "0700" // default user dir permission
 	BLK_PERMISSION   = "0000" // user dir permission if blocked
@@ -237,6 +239,28 @@ func (u *User) GetNewUIDNumber(l *ldap.Conn) error {
 	return nil
 }
 
+func GetNewGIDNumber(l *ldap.Conn) (string, error) {
+	uids, err := GetAllGIDsLDAP(l)
+	if err != nil {
+		return "", err
+	}
+
+	candidate := MIN_GID
+	for _, uid := range uids {
+		if uid == candidate { // check if taken
+			candidate++
+		} else if uid > candidate { // found a gap
+			break
+		}
+	}
+
+	if candidate > MAX_GID {
+		return "", fmt.Errorf("No more available GID numbers")
+	}
+
+	return strconv.Itoa(candidate), nil
+}
+
 // creates the login, if it already exists try again with variance
 func (u *User) GenUniqueUID(users []User) error {
 	used, variance := true, 0
diff --git a/utils/utils.go b/utils/utils.go
index 4630f35..a916532 100644
--- a/utils/utils.go
+++ b/utils/utils.go
@@ -143,7 +143,7 @@ func MoveAndChown(orig, dest, owner, group string) error {
 // prints a confirmation prompt, given the operation being performed
 func ConfirmationPrompt(confirm bool, operation string) {
 	if !confirm {
-		fmt.Printf("Proceed with user %v? [y/N] ", operation)
+		fmt.Printf("Proceed with %v? [y/N] ", operation)
 
 		reader := bufio.NewReader(os.Stdin)
 		response, _ := reader.ReadString('\n')
-- 
GitLab