cmd/headscale/cli: add confirmAction helper for force/prompt patterns

Centralise the repeated force-flag-check + YesNo-prompt logic into a
single confirmAction(cmd, prompt) helper.  Callers still decide what
to return on decline (error, message, or nil).
This commit is contained in:
Kristoffer Dalby 2026-02-18 14:51:42 +00:00
parent 7b7b270126
commit 6c08b49d63
4 changed files with 36 additions and 130 deletions

View File

@ -262,32 +262,23 @@ var deleteNodeCmd = &cobra.Command{
NodeId: identifier, NodeId: identifier,
} }
confirm := false if !confirmAction(cmd, fmt.Sprintf(
"Do you want to remove the node %s?",
force, _ := cmd.Flags().GetBool("force") getResponse.GetNode().GetName(),
if !force { )) {
confirm = util.YesNo(fmt.Sprintf( return printOutput(cmd, map[string]string{"Result": "Node not deleted"}, "Node not deleted")
"Do you want to remove the node %s?",
getResponse.GetNode().GetName(),
))
} }
if confirm || force { _, err = client.DeleteNode(ctx, deleteRequest)
response, err := client.DeleteNode(ctx, deleteRequest) if err != nil {
if err != nil { return fmt.Errorf("deleting node: %w", err)
return fmt.Errorf("deleting node: %w", err)
}
_ = response // consumed for structured output if needed
return printOutput(
cmd,
map[string]string{"Result": "Node deleted"},
"Node deleted",
)
} }
return printOutput(cmd, map[string]string{"Result": "Node not deleted"}, "Node not deleted") return printOutput(
cmd,
map[string]string{"Result": "Node deleted"},
"Node deleted",
)
}), }),
} }
@ -307,14 +298,7 @@ If you remove IPv4 or IPv6 prefixes from the config,
it can be run to remove the IPs that should no longer it can be run to remove the IPs that should no longer
be assigned to nodes.`, be assigned to nodes.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
confirm := false if !confirmAction(cmd, "Are you sure that you want to assign/remove IPs to/from nodes?") {
force, _ := cmd.Flags().GetBool("force")
if !force {
confirm = util.YesNo("Are you sure that you want to assign/remove IPs to/from nodes?")
}
if !confirm && !force {
return nil return nil
} }

View File

@ -10,7 +10,6 @@ import (
"github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/policy" "github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"tailscale.com/types/views" "tailscale.com/types/views"
) )
@ -49,87 +48,7 @@ var getPolicy = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
var policyData string var policyData string
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass { if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
confirm := false if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
force, _ := cmd.Flags().GetBool("force")
if !force {
confirm = util.YesNo("DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?")
}
if !confirm && !force {
return errAborted
}
cfg, err := types.LoadServerConfig()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
d, err := db.NewHeadscaleDatabase(cfg, nil)
if err != nil {
return fmt.Errorf("opening database: %w", err)
}
pol, err := d.GetPolicy()
if err != nil {
return fmt.Errorf("loading policy from database: %w", err)
}
policyData = pol.Data
} else {
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
defer conn.Close()
response, err := client.GetPolicy(ctx, &v1.GetPolicyRequest{})
if err != nil {
return fmt.Errorf("loading ACL policy: %w", err)
}
policyData = response.GetPolicy()
}
// This does not pass output format as we don't support yaml, json or
// json-line output for this command. It is HuJSON already.
fmt.Println(policyData)
return nil
},
}
var setPolicy = &cobra.Command{
Use: "set",
Short: "Updates the ACL Policy",
Long: `
Updates the existing ACL Policy with the provided policy. The policy must be a valid HuJSON object.
This command only works when the acl.policy_mode is set to "db", and the policy will be stored in the database.`,
Aliases: []string{"put", "update"},
RunE: func(cmd *cobra.Command, args []string) error {
policyPath, _ := cmd.Flags().GetString("file")
f, err := os.Open(policyPath)
if err != nil {
return fmt.Errorf("opening policy file: %w", err)
}
defer f.Close()
policyBytes, err := io.ReadAll(f)
if err != nil {
return fmt.Errorf("reading policy file: %w", err)
}
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
confirm := false
force, _ := cmd.Flags().GetBool("force")
if !force {
confirm = util.YesNo("DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?")
}
if !confirm && !force {
return errAborted return errAborted
} }

View File

@ -8,7 +8,6 @@ import (
"strconv" "strconv"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1" v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf" "github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/pterm/pterm" "github.com/pterm/pterm"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -140,28 +139,21 @@ var destroyUserCmd = &cobra.Command{
user := users.GetUsers()[0] user := users.GetUsers()[0]
confirm := false if !confirmAction(cmd, fmt.Sprintf(
"Do you want to remove the user %q (%d) and any associated preauthkeys?",
force, _ := cmd.Flags().GetBool("force") user.GetName(), user.GetId(),
if !force { )) {
confirm = util.YesNo(fmt.Sprintf( return printOutput(cmd, map[string]string{"Result": "User not destroyed"}, "User not destroyed")
"Do you want to remove the user %q (%d) and any associated preauthkeys?",
user.GetName(), user.GetId(),
))
} }
if confirm || force { deleteRequest := &v1.DeleteUserRequest{Id: user.GetId()}
request := &v1.DeleteUserRequest{Id: user.GetId()}
response, err := client.DeleteUser(ctx, request) response, err := client.DeleteUser(ctx, deleteRequest)
if err != nil { if err != nil {
return fmt.Errorf("destroying user: %w", err) return fmt.Errorf("destroying user: %w", err)
}
return printOutput(cmd, response, "User destroyed")
} }
return printOutput(cmd, map[string]string{"Result": "User not destroyed"}, "User not destroyed") return printOutput(cmd, response, "User destroyed")
}), }),
} }

View File

@ -221,6 +221,17 @@ func printOutput(cmd *cobra.Command, result any, override string) error {
return nil return nil
} }
// confirmAction returns true when the user confirms a prompt, or when
// --force is set. Callers decide what to do when it returns false.
func confirmAction(cmd *cobra.Command, prompt string) bool {
force, _ := cmd.Flags().GetBool("force")
if force {
return true
}
return util.YesNo(prompt)
}
// printListOutput checks the --output flag: when a machine-readable format is // printListOutput checks the --output flag: when a machine-readable format is
// requested it serialises data as JSON/YAML; otherwise it calls renderTable // requested it serialises data as JSON/YAML; otherwise it calls renderTable
// to produce the human-readable pterm table. // to produce the human-readable pterm table.