// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package cmd import ( "bufio" "bytes" "fmt" "io" "os" "sort" "strconv" "strings" "text/tabwriter" "github.com/fatih/color" "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/cmd/base" "github.com/hashicorp/boundary/internal/types/resource" colorable "github.com/mattn/go-colorable" "github.com/mitchellh/cli" ) // setupEnv parses args and may replace them and sets some env vars to known // values based on format options func setupEnv(args []string) (retArgs []string, format string, outputCurlString bool) { // handle the workaround for autocomplete install/uninstall not being exported if len(args) == 3 && args[0] == "config" && args[1] == "autocomplete" { switch args[2] { case "install": return []string{"-autocomplete-install"}, "table", false case "uninstall": return []string{"-autocomplete-uninstall"}, "table", false } } var nextArgFormat bool for _, arg := range args { if nextArgFormat { nextArgFormat = false format = arg continue } if arg == "--" { break } if len(args) == 1 && (arg == "-version" || arg == "-v") { args = []string{"version"} break } if arg == "-output-curl-string" { outputCurlString = true continue } // Parse a given flag here, which overrides the env var if strings.HasPrefix(arg, "-format=") { format = strings.TrimPrefix(arg, "-format=") } // Handle the case where it is specified without an equal sign if arg == "-format" { nextArgFormat = true } } envBoundaryCLIFormat := os.Getenv(base.EnvBoundaryCLIFormat) // If we did not parse a value, fetch the env var if format == "" && envBoundaryCLIFormat != "" { format = envBoundaryCLIFormat } // Lowercase for consistency format = strings.ToLower(format) if format == "" { format = "table" } return args, format, outputCurlString } func handleHighLevelShortcuts(args []string, runOpts *RunOptions) []string { switch len(args) { case 0, 1: return args } switch strings.ToLower(args[0]) { case "read", "update", "delete": default: return args } // At this point we know it's something we want to handle in this function info := globals.ResourceInfoFromPrefix(args[1]) if info.Type == resource.Unknown { return args } newArgs := make([]string, 0, len(args)+5) newArgs = append(newArgs, // Start with the plural of the resource type ("credential-libraries") info.Type.PluralString(), // Add the action to take ("update") args[0], ) // If it's update, add the subtype if info.Subtype != globals.UnknownSubtype && strings.ToLower(args[0]) == "update" { newArgs = append(newArgs, strings.ReplaceAll(info.Subtype.String(), "_", "-"), ) } // Make sure we override the ID in commands runOpts.ImplicitId = args[1] // Now add the rest of the args newArgs = append(newArgs, args[2:]..., ) return newArgs } type RunOptions struct { Stdout io.Writer Stderr io.Writer Address string ImplicitId string } func Run(args []string) int { return RunCustom(args, nil) } // RunCustom allows passing in a base command template to pass to other // commands. Currently, this is only used for setting a custom token helper. func RunCustom(args []string, runOpts *RunOptions) (exitCode int) { if runOpts == nil { runOpts = &RunOptions{} } var format string var outputCurlString bool args, format, outputCurlString = setupEnv(args) // Don't use color if disabled useColor := true if os.Getenv(base.EnvBoundaryCLINoColor) != "" || color.NoColor { useColor = false } if runOpts.Stdout == nil { runOpts.Stdout = os.Stdout } if runOpts.Stderr == nil { runOpts.Stderr = os.Stderr } // Only use colored UI if stdout is a tty, and not disabled if useColor && format == "table" { if f, ok := runOpts.Stdout.(*os.File); ok { runOpts.Stdout = colorable.NewColorable(f) } if f, ok := runOpts.Stderr.(*os.File); ok { runOpts.Stderr = colorable.NewColorable(f) } } else { runOpts.Stdout = colorable.NewNonColorable(runOpts.Stdout) runOpts.Stderr = colorable.NewNonColorable(runOpts.Stderr) } uiErrWriter := runOpts.Stderr if outputCurlString { uiErrWriter = io.Discard } ui := &base.BoundaryUI{ Ui: &cli.ColoredUi{ ErrorColor: cli.UiColorRed, WarnColor: cli.UiColorYellow, Ui: &cli.BasicUi{ Reader: bufio.NewReader(os.Stdin), Writer: runOpts.Stdout, ErrorWriter: uiErrWriter, }, }, Format: format, } serverCmdUi := &base.BoundaryUI{ Ui: &cli.ColoredUi{ ErrorColor: cli.UiColorRed, WarnColor: cli.UiColorYellow, Ui: &cli.BasicUi{ Reader: bufio.NewReader(os.Stdin), Writer: runOpts.Stdout, }, }, Format: format, } switch format { case "table", "json": default: ui.Error(fmt.Sprintf("Invalid output format: %s", format)) return 1 } // For autocompletion we need to manage the COMP_LINE var. That means // reading args out of it now and then setting updated args back. compLine := os.Getenv("COMP_LINE") if compLine != "" { point, err := strconv.Atoi(os.Getenv("COMP_POINT")) if err != nil { point = len(compLine) } if point != 0 && point < len(compLine) { compLine = compLine[:point] } args = strings.Split(compLine, " ") args = args[1:] // elide "boundary" since the function below expects it to not be there } args = handleHighLevelShortcuts(args, runOpts) // Set args back if compLine != "" { // We need to add back "boundary" as well os.Setenv("COMP_LINE", strings.Join(append([]string{"boundary"}, args...), " ")) } initCommands(ui, serverCmdUi, runOpts) hiddenCommands := []string{"version"} cli := &cli.CLI{ Name: "boundary", Args: args, Commands: Commands, HelpFunc: groupedHelpFunc( cli.BasicHelpFunc("boundary"), ), HelpWriter: runOpts.Stderr, HiddenCommands: hiddenCommands, Autocomplete: true, AutocompleteNoDefaultFlags: true, } var err error exitCode, err = cli.Run() if outputCurlString { if exitCode == 0 { fmt.Fprint(runOpts.Stderr, "Could not generate cURL command\n") return 1 } else { if api.LastOutputStringError == nil { if exitCode == 127 { // Usage, just pass it through return exitCode } fmt.Fprint(runOpts.Stderr, "cURL command not set by API operation; run without -output-curl-string to see the generated error\n") return exitCode } if !strings.Contains(api.LastOutputStringError.Error(), api.ErrOutputStringRequest) { runOpts.Stdout.Write([]byte(fmt.Sprintf("Error creating request string: %s\n", api.LastOutputStringError.Error()))) return 1 } runOpts.Stdout.Write([]byte(fmt.Sprintf("%s\n", api.LastOutputStringError.CurlString()))) return 0 } } else if err != nil { fmt.Fprintf(runOpts.Stderr, "Error executing CLI: %s\n", err.Error()) return 1 } return exitCode } func groupedHelpFunc(f cli.HelpFunc) cli.HelpFunc { return func(commands map[string]cli.CommandFactory) string { var b bytes.Buffer tw := tabwriter.NewWriter(&b, 0, 2, 6, ' ', 0) fmt.Fprintf(tw, "Usage: boundary [args]\n") genericCommands := make([]string, 0, 3) clientCommands := make([]string, 0, 6) typeSpecificCommands := make([]string, 0, len(commands)-cap(genericCommands)-cap(clientCommands)) for k := range commands { switch k { case "authenticate", "config", "connect", "dev", "logout", "server": clientCommands = append(clientCommands, k) case "read", "update", "delete": genericCommands = append(genericCommands, k) default: typeSpecificCommands = append(typeSpecificCommands, k) } } sort.Strings(clientCommands) fmt.Fprintf(tw, "\n") fmt.Fprintf(tw, "Local/Client Commands:\n") for _, v := range clientCommands { printCommand(tw, v, commands[v]) } sort.Strings(genericCommands) fmt.Fprintf(tw, "\n") fmt.Fprintf(tw, "Generic Commands:\n") for _, v := range genericCommands { printCommand(tw, v, commands[v]) } sort.Strings(typeSpecificCommands) fmt.Fprintf(tw, "\n") fmt.Fprintf(tw, "Type-Specific Commands:\n") for _, v := range typeSpecificCommands { printCommand(tw, v, commands[v]) } tw.Flush() return strings.TrimSpace(b.String()) } } func printCommand(w io.Writer, name string, cmdFn cli.CommandFactory) { cmd, err := cmdFn() if err != nil { panic(fmt.Sprintf("failed to load %q command: %s", name, err)) } fmt.Fprintf(w, " %s\t%s\n", name, cmd.Synopsis()) }