From 2d4b947a49ea15e696011295d7ef49f7dc7721aa Mon Sep 17 00:00:00 2001 From: loicalleyne Date: Mon, 22 Apr 2024 20:02:15 -0400 Subject: [PATCH 1/5] Non-interactive mode https://github.com/danielgtaylor/restish/issues/238 Pass in []string which replaces the os.Args[], io.Writers to optionally redirect Stdout and Stderr, and optionally pass in an authentication token override. --- cli/auth.go | 31 ++++++++++ cli/cli.go | 145 +++++++++++++++++++++++++++++++++++++++++++++++ embedded/prep.go | 50 ++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 embedded/prep.go diff --git a/cli/auth.go b/cli/auth.go index cee743f..d554c59 100644 --- a/cli/auth.go +++ b/cli/auth.go @@ -11,6 +11,7 @@ import ( "strings" "syscall" + "github.com/spf13/viper" "golang.org/x/term" ) @@ -164,3 +165,33 @@ func (a *ExternalToolAuth) OnRequest(req *http.Request, key string, params map[s } return nil } + +// ExternalOverrideAuth implements External Override Auth where +// an HTTP Authorization token is passed in as an argument in non-interactive +// mode. +type ExternalOverrideAuth struct{} + +// Parameters define the External Override Auth parameter names. +func (a *ExternalOverrideAuth) Parameters() []AuthParam { + return []AuthParam{ + {Name: "prefix", Required: true}, + {Name: "token", Required: true}, + } +} + +// OnRequest gets run before the request goes out on the wire. +func (a *ExternalOverrideAuth) OnRequest(req *http.Request, key string, params map[string]string) error { + prefix := viper.GetString("ni-override-auth-prefix") + token := viper.GetString("ni-override-auth-token") + + if token == "" { + return fmt.Errorf("no token provided") + } + switch len(prefix) > 0 { + case true: + req.Header.Add("Authorization", fmt.Sprintf("%s %s", prefix, token)) + default: + req.Header.Add("Authorization", token) + } + return nil +} diff --git a/cli/cli.go b/cli/cli.go index 38402a0..759db98 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -862,6 +862,151 @@ func Run() (returnErr error) { return returnErr } +// Run the CLI! Parse arguments, make requests, print responses. +func RunEmbedded(args []string, overrideAuthPrefix, overrideAuthToken string, newOut, newErr io.Writer) (returnErr error) { + + Root.SetArgs(args) + Root.Use = filepath.Base(args[0]) + if os.Getenv("COLOR") != "" { + viper.Set("color", true) + } + if os.Getenv("NOCOLOR") != "" { + viper.Set("nocolor", true) + } + viper.Set("ni-override-auth-prefix", overrideAuthPrefix) + viper.Set("ni-override-auth-token", overrideAuthToken) + // Because we may be doing HTTP calls before cobra has parsed the flags + // we parse the GlobalFlags here and already set some config values + // to ensure they are available + if err := GlobalFlags.Parse(args[1:]); err != nil { + if err != pflag.ErrHelp { + panic(err) + } + } + if newOut != nil { + Root.SetOut(newOut) + Stdout = newOut + } + if newErr != nil { + Root.SetErr(newErr) + Stderr = newErr + } + if noCache, _ := GlobalFlags.GetBool("rsh-no-cache"); noCache { + viper.Set("rsh-no-cache", true) + } + if verbose, _ := GlobalFlags.GetBool("rsh-verbose"); verbose { + viper.Set("rsh-verbose", true) + } + if insecure, _ := GlobalFlags.GetBool("rsh-insecure"); insecure { + viper.Set("rsh-insecure", true) + } + if cert, _ := GlobalFlags.GetString("rsh-client-cert"); cert != "" { + viper.Set("rsh-client-cert", cert) + } + if key, _ := GlobalFlags.GetString("rsh-client-key"); key != "" { + viper.Set("rsh-client-key", key) + } + if caCert, _ := GlobalFlags.GetString("rsh-ca-cert"); caCert != "" { + viper.Set("rsh-ca-cert", caCert) + } + if query, _ := GlobalFlags.GetStringArray("rsh-query"); len(query) > 0 { + viper.Set("rsh-query", query) + } + if headers, _ := GlobalFlags.GetStringArray("rsh-header"); len(headers) > 0 { + viper.Set("rsh-header", headers) + } + profile, _ := GlobalFlags.GetString("rsh-profile") + viper.Set("rsh-profile", profile) + if retries, _ := GlobalFlags.GetInt("rsh-retry"); retries > 0 { + viper.Set("rsh-retry", retries) + } + if timeout, _ := GlobalFlags.GetDuration("rsh-timeout"); timeout > 0 { + viper.Set("rsh-timeout", timeout) + } + + // Now that global flags are parsed we can enable verbose mode if requested. + if viper.GetBool("rsh-verbose") { + enableVerbose = true + } + + // Load the API commands if we can. + if len(args) > 1 { + apiName := args[1] + + if apiName == "help" && len(args) > 2 { + // The explicit `help` command is followed by the actual commands + // you want help with. The first one is the API name. + apiName = args[2] + } + + loaded := false + if apiName != "help" && apiName != "head" && apiName != "options" && apiName != "get" && apiName != "post" && apiName != "put" && apiName != "patch" && apiName != "delete" && apiName != "api" && apiName != "links" && apiName != "edit" && apiName != "auth-header" { + // Try to find the registered config for this API. If not found, + // there is no need to do anything since the normal flow will catch + // the command being missing and print help. + if cfg, ok := configs[apiName]; ok { + + // This is used to give context to findApi + // Smallest fix for https://github.com/danielgtaylor/restish/issues/128 + viper.Set("api-name", apiName) + + currentConfig = cfg + for _, cmd := range Root.Commands() { + if cmd.Use == apiName { + currentBase := cfg.Base + currentProfile := cfg.Profiles[profile] + if currentProfile == nil { + if profile != "default" { + return fmt.Errorf("invalid profile " + profile) + } + } + if currentProfile != nil && currentProfile.Base != "" { + currentBase = currentProfile.Base + } + if _, err := Load(currentBase, cmd); err != nil { + return err + } + loaded = true + break + } + } + } + } + + if !loaded { + // This could be a URL or short-name as part of a URL for generic + // commands. We should load the config for shell completion. + if (apiName == "head" || apiName == "options" || apiName == "get" || apiName == "post" || apiName == "put" || apiName == "patch" || apiName == "delete") && len(args) > 2 { + apiName = args[2] + } + apiName = fixAddress(apiName) + if name, _ := findAPI(apiName); name != "" { + currentConfig = configs[name] + } + } + } + + // Phew, we made it. Execute the command now that everything is loaded + // and all the relevant sub-commands are registered. + defer func() { + if err := recover(); err != nil { + LogError("Caught error: %v", err) + LogDebug("%s", string(debug.Stack())) + if e, ok := err.(error); ok { + returnErr = e + } else { + returnErr = fmt.Errorf("%v", err) + } + } + }() + if err := Root.Execute(); err != nil { + LogError("Error: %v", err) + returnErr = err + } + + return returnErr +} + // GetExitCode returns the exit code to use based on the last HTTP status code. func GetExitCode() int { if s := GetLastStatus() / 100; s > 2 && !viper.GetBool("rsh-ignore-status-code") { diff --git a/embedded/prep.go b/embedded/prep.go new file mode 100644 index 0000000..e374184 --- /dev/null +++ b/embedded/prep.go @@ -0,0 +1,50 @@ +package embedded + +import ( + "fmt" + "io" + "strings" + + "github.com/danielgtaylor/restish/bulk" + "github.com/danielgtaylor/restish/cli" + "github.com/danielgtaylor/restish/oauth" + "github.com/danielgtaylor/restish/openapi" +) + +var version string = "embedded" + +func Restish(args []string, overrideAuthPrefix, overrideAuthToken string, newOut, newErr io.Writer) error { + + cli.Init("restish", version) + + // Register default encodings, content type handlers, and link parsers. + cli.Defaults() + + bulk.Init(cli.Root) + + // Register format loaders to auto-discover API descriptions + cli.AddLoader(openapi.New()) + + // Register auth schemes + cli.AddAuth("oauth-client-credentials", &oauth.ClientCredentialsHandler{}) + cli.AddAuth("oauth-authorization-code", &oauth.AuthorizationCodeHandler{}) + if overrideAuthToken != "" { + cli.AddAuth("override", &cli.ExternalOverrideAuth{}) + } + // We need to register new commands at runtime based on the selected API + // so that we don't have to potentially refresh and parse every single + // registered API just to run. So this is a little hacky, but we hijack + // the input args to find non-option arguments, get the first arg, and + // if it isn't from a well-known set try to load that API. + runArgs := []string{} + for _, arg := range args { + if !strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "__") { + runArgs = append(runArgs, arg) + } + } + // Run the CLI, parsing arguments, making requests, and printing responses. + if err := cli.RunEmbedded(runArgs, overrideAuthPrefix, overrideAuthToken, newOut, newErr); err != nil { + return fmt.Errorf("%w %v", err, cli.GetExitCode()) + } + return nil +} From 9259c03681370447aa05549f14dfda90cede8c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Alleyne?= Date: Wed, 24 Apr 2024 14:01:30 -0400 Subject: [PATCH 2/5] args parsing fix --- cli/auth.go | 394 ++++----- cli/cli.go | 2046 +++++++++++++++++++++++----------------------- embedded/prep.go | 15 +- 3 files changed, 1228 insertions(+), 1227 deletions(-) diff --git a/cli/auth.go b/cli/auth.go index d554c59..ffe73e5 100644 --- a/cli/auth.go +++ b/cli/auth.go @@ -1,197 +1,197 @@ -package cli - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "os/exec" - "strings" - "syscall" - - "github.com/spf13/viper" - "golang.org/x/term" -) - -// AuthParam describes an auth input parameter for an AuthHandler. -type AuthParam struct { - Name string - Help string - Required bool -} - -// AuthHandler is used to register new authentication handlers that will apply -// auth to an outgoing request as needed. -type AuthHandler interface { - // Parameters returns an ordered list of required and optional input - // parameters for this auth handler. Used when configuring an API. - Parameters() []AuthParam - - // OnRequest applies auth to an outgoing request before it hits the wire. - OnRequest(req *http.Request, key string, params map[string]string) error -} - -var authHandlers map[string]AuthHandler = map[string]AuthHandler{} - -// AddAuth registers a new named auth handler. -func AddAuth(name string, h AuthHandler) { - authHandlers[name] = h -} - -// BasicAuth implements HTTP Basic authentication. -type BasicAuth struct{} - -// Parameters define the HTTP Basic Auth parameter names. -func (a *BasicAuth) Parameters() []AuthParam { - return []AuthParam{ - {Name: "username", Required: true}, - {Name: "password", Required: true}, - } -} - -// OnRequest gets run before the request goes out on the wire. -func (a *BasicAuth) OnRequest(req *http.Request, key string, params map[string]string) error { - _, usernamePresent := params["username"] - _, passwordPresent := params["password"] - - if usernamePresent && !passwordPresent { - fmt.Print("password: ") - inputPassword, err := term.ReadPassword(int(syscall.Stdin)) - if err == nil { - params["password"] = string(inputPassword) - } - fmt.Println() - } - - req.SetBasicAuth(params["username"], params["password"]) - return nil -} - -// ExternalToolAuth defers authentication to a third party tool. -// This avoids baking all possible authentication implementations -// inside restish itself. -type ExternalToolAuth struct{} - -// Request is used to exchange requests with the external tool. -type Request struct { - Method string `json:"method"` - URI string `json:"uri"` - Header http.Header `json:"headers"` - Body string `json:"body"` -} - -// Parameters defines the ExternalToolAuth parameter names. -// A single parameter is supported and required: `commandline` which -// points to the tool to call to authenticate a request. -func (a *ExternalToolAuth) Parameters() []AuthParam { - return []AuthParam{ - {Name: "commandline", Required: true}, - {Name: "omitbody", Required: false}, - } -} - -// OnRequest gets run before the request goes out on the wire. -// The supplied commandline argument is ran with a JSON input -// and expects a JSON output on stdout -func (a *ExternalToolAuth) OnRequest(req *http.Request, key string, params map[string]string) error { - commandLine := params["commandline"] - omitBodyStr, omitBodyPresent := params["omitbody"] - omitBody := false - if omitBodyPresent && strings.EqualFold(omitBodyStr, "true") { - omitBody = true - } - shell, shellPresent := os.LookupEnv("SHELL") - if !shellPresent { - shell = "/bin/sh" - } - cmd := exec.Command(shell, "-c", commandLine) - stdin, err := cmd.StdinPipe() - if err != nil { - return err - } - - bodyStr := "" - if req.Body != nil && !omitBody { - bodyBytes, err := io.ReadAll(req.Body) - if err != nil { - return err - } - bodyStr = string(bodyBytes) - req.Body = io.NopCloser(strings.NewReader(bodyStr)) - } - - textRequest := Request{ - Method: req.Method, - URI: req.URL.String(), - Header: req.Header, - Body: bodyStr, - } - requestBytes, err := json.Marshal(textRequest) - if err != nil { - return err - } - _, err = stdin.Write(requestBytes) - if err != nil { - return err - } - stdin.Close() - outBytes, err := cmd.Output() - if err != nil { - return err - } - if len(outBytes) <= 0 { - return nil - } - var requestUpdates Request - err = json.Unmarshal(outBytes, &requestUpdates) - if err != nil { - return err - } - - if len(requestUpdates.URI) > 0 { - req.URL, err = url.Parse(requestUpdates.URI) - if err != nil { - return err - } - } - - for k, vs := range requestUpdates.Header { - for _, v := range vs { - // A single value is supported for each header - req.Header.Set(k, v) - } - } - return nil -} - -// ExternalOverrideAuth implements External Override Auth where -// an HTTP Authorization token is passed in as an argument in non-interactive -// mode. -type ExternalOverrideAuth struct{} - -// Parameters define the External Override Auth parameter names. -func (a *ExternalOverrideAuth) Parameters() []AuthParam { - return []AuthParam{ - {Name: "prefix", Required: true}, - {Name: "token", Required: true}, - } -} - -// OnRequest gets run before the request goes out on the wire. -func (a *ExternalOverrideAuth) OnRequest(req *http.Request, key string, params map[string]string) error { - prefix := viper.GetString("ni-override-auth-prefix") - token := viper.GetString("ni-override-auth-token") - - if token == "" { - return fmt.Errorf("no token provided") - } - switch len(prefix) > 0 { - case true: - req.Header.Add("Authorization", fmt.Sprintf("%s %s", prefix, token)) - default: - req.Header.Add("Authorization", token) - } - return nil -} +package cli + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "syscall" + + "golang.org/x/term" + "github.com/spf13/viper" +) + +// AuthParam describes an auth input parameter for an AuthHandler. +type AuthParam struct { + Name string + Help string + Required bool +} + +// AuthHandler is used to register new authentication handlers that will apply +// auth to an outgoing request as needed. +type AuthHandler interface { + // Parameters returns an ordered list of required and optional input + // parameters for this auth handler. Used when configuring an API. + Parameters() []AuthParam + + // OnRequest applies auth to an outgoing request before it hits the wire. + OnRequest(req *http.Request, key string, params map[string]string) error +} + +var authHandlers map[string]AuthHandler = map[string]AuthHandler{} + +// AddAuth registers a new named auth handler. +func AddAuth(name string, h AuthHandler) { + authHandlers[name] = h +} + +// BasicAuth implements HTTP Basic authentication. +type BasicAuth struct{} + +// Parameters define the HTTP Basic Auth parameter names. +func (a *BasicAuth) Parameters() []AuthParam { + return []AuthParam{ + {Name: "username", Required: true}, + {Name: "password", Required: true}, + } +} + +// OnRequest gets run before the request goes out on the wire. +func (a *BasicAuth) OnRequest(req *http.Request, key string, params map[string]string) error { + _, usernamePresent := params["username"] + _, passwordPresent := params["password"] + + if usernamePresent && !passwordPresent { + fmt.Print("password: ") + inputPassword, err := term.ReadPassword(int(syscall.Stdin)) + if err == nil { + params["password"] = string(inputPassword) + } + fmt.Println() + } + + req.SetBasicAuth(params["username"], params["password"]) + return nil +} + +// ExternalToolAuth defers authentication to a third party tool. +// This avoids baking all possible authentication implementations +// inside restish itself. +type ExternalToolAuth struct{} + +// Request is used to exchange requests with the external tool. +type Request struct { + Method string `json:"method"` + URI string `json:"uri"` + Header http.Header `json:"headers"` + Body string `json:"body"` +} + +// Parameters defines the ExternalToolAuth parameter names. +// A single parameter is supported and required: `commandline` which +// points to the tool to call to authenticate a request. +func (a *ExternalToolAuth) Parameters() []AuthParam { + return []AuthParam{ + {Name: "commandline", Required: true}, + {Name: "omitbody", Required: false}, + } +} + +// OnRequest gets run before the request goes out on the wire. +// The supplied commandline argument is ran with a JSON input +// and expects a JSON output on stdout +func (a *ExternalToolAuth) OnRequest(req *http.Request, key string, params map[string]string) error { + commandLine := params["commandline"] + omitBodyStr, omitBodyPresent := params["omitbody"] + omitBody := false + if omitBodyPresent && strings.EqualFold(omitBodyStr, "true") { + omitBody = true + } + shell, shellPresent := os.LookupEnv("SHELL") + if !shellPresent { + shell = "/bin/sh" + } + cmd := exec.Command(shell, "-c", commandLine) + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + bodyStr := "" + if req.Body != nil && !omitBody { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + return err + } + bodyStr = string(bodyBytes) + req.Body = io.NopCloser(strings.NewReader(bodyStr)) + } + + textRequest := Request{ + Method: req.Method, + URI: req.URL.String(), + Header: req.Header, + Body: bodyStr, + } + requestBytes, err := json.Marshal(textRequest) + if err != nil { + return err + } + _, err = stdin.Write(requestBytes) + if err != nil { + return err + } + stdin.Close() + outBytes, err := cmd.Output() + if err != nil { + return err + } + if len(outBytes) <= 0 { + return nil + } + var requestUpdates Request + err = json.Unmarshal(outBytes, &requestUpdates) + if err != nil { + return err + } + + if len(requestUpdates.URI) > 0 { + req.URL, err = url.Parse(requestUpdates.URI) + if err != nil { + return err + } + } + + for k, vs := range requestUpdates.Header { + for _, v := range vs { + // A single value is supported for each header + req.Header.Set(k, v) + } + } + return nil +} + +// ExternalOverrideAuth implements External Override Auth where +// an HTTP Authorization token is passed in as an argument in non-interactive +// mode. +type ExternalOverrideAuth struct{} + +// Parameters define the External Override Auth parameter names. +func (a *ExternalOverrideAuth) Parameters() []AuthParam { + return []AuthParam{ + {Name: "prefix", Required: true}, + {Name: "token", Required: true}, + } +} + +// OnRequest gets run before the request goes out on the wire. +func (a *ExternalOverrideAuth) OnRequest(req *http.Request, key string, params map[string]string) error { + prefix := viper.GetString("ni-override-auth-prefix") + token := viper.GetString("ni-override-auth-token") + + if token == "" { + return fmt.Errorf("no token provided") + } + switch len(prefix) > 0 { + case true: + req.Header.Add("Authorization", fmt.Sprintf("%s %s", prefix, token)) + default: + req.Header.Add("Authorization", token) + } + return nil +} \ No newline at end of file diff --git a/cli/cli.go b/cli/cli.go index 759db98..a39ba30 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -1,1017 +1,1029 @@ -package cli - -import ( - "crypto/tls" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "runtime/debug" - "strings" - "time" - - "github.com/charmbracelet/glamour" - "github.com/logrusorgru/aurora" - "github.com/mattn/go-colorable" - "github.com/mattn/go-isatty" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" - "golang.org/x/term" - "gopkg.in/yaml.v2" -) - -// Root command (entrypoint) of the CLI. -var Root *cobra.Command - -// GlobalFlags contains all the fixed up front flags -// This allows us to parse them before we hand over control -// to cobra -var GlobalFlags *pflag.FlagSet - -// Cache is used to store temporary data between runs. -var Cache *viper.Viper - -// Formatter is the currently configured response output formatter. -var Formatter ResponseFormatter - -// Stdout is a cross-platform, color-safe writer if colors are enabled, -// otherwise it defaults to `os.Stdout`. -var Stdout io.Writer = os.Stdout - -// Stderr is a cross-platform, color-safe writer if colors are enabled, -// otherwise it defaults to `os.Stderr`. -var Stderr io.Writer = os.Stderr - -var useColor bool -var au aurora.Aurora - -// Keeps track of currently selected API for shell completions -var currentConfig *APIConfig - -func generic(method string, addr string, args []string) { - var body io.Reader - - d, err := GetBody("application/json", args) - if err != nil { - panic(err) - } - if len(d) > 0 { - body = strings.NewReader(d) - } - - req, _ := http.NewRequest(method, fixAddress(addr), body) - MakeRequestAndFormat(req) -} - -// templateVarRegex used to find/replace variables `/{foo}/bar/{baz}` in a -// template string. -var templateVarRegex = regexp.MustCompile(`\{.*?\}`) - -// matchTemplate will see if a given URL matches a URL template, and if so, -// returns the template with the variable parts replaced by the matched part. -// If no match, returns the original template. Example: -// Input URL: https://example.com/items/foo -// Input tpl: https://example.com/items/{item-id}/tags/{tag-id} -// Output : https://example.com/items/foo/tags/{tag-id} -func matchTemplate(url, template string) string { - urlParts := strings.Split(url, "/") - tplParts := strings.Split(template, "/") - for i, urlPart := range urlParts { - if len(tplParts) < i+1 { - break - } - - tplPart := tplParts[i] - - if strings.Contains(tplPart, "{") { - matcher := regexp.MustCompile(templateVarRegex.ReplaceAllString(tplPart, ".*")) - if matcher.MatchString(urlPart) && urlPart != "" { - tplParts[i] = urlPart - continue - } - } else if urlPart == tplPart { - // This is an exact path match. - continue - } - - // Give up, not a match! - break - } - - return strings.Join(tplParts, "/") -} - -// completeCurrentConfig generates possible completions based on the currently -// selected API configuration's known operation URL templates. Takes into -// account short-names as well as the full URL. -func completeCurrentConfig(cmd *cobra.Command, args []string, toComplete string, method string) ([]string, cobra.ShellCompDirective) { - possible := []string{} - if currentConfig != nil { - for _, cmd := range Root.Commands() { - if cmd.Use == currentConfig.name { - // This is the matching command. Load the URL and check each operation. - currentBase := currentConfig.Base - currentProfile := currentConfig.Profiles[viper.GetString("rsh-profile")] - if currentProfile == nil { - if viper.GetString("rsh-profile") != "default" { - panic("invalid profile " + viper.GetString("rsh-profile")) - } - } - if currentProfile != nil && currentProfile.Base != "" { - currentBase = currentProfile.Base - } - api, _ := Load(currentBase, cmd) - for _, op := range api.Operations { - if op.Method != method { - // We only care about operations which match the currently selected - // HTTP method, otherwise it makes no sense to show it as an - // option since it couldn't possibly work. - continue - } - - // Handle short-name, missing https:// prefix. - fixed := fixAddress(toComplete) - - // Modify the template to fill in matched variables. - template := matchTemplate(fixed, op.URITemplate) - if strings.HasPrefix(toComplete, currentConfig.name) { - // We were using a short-name, convert back to it! This is - // friendlier than forcing the full URL on the user. - template = strings.Replace(template, currentConfig.Base, currentConfig.name, 1) - } else if !strings.HasPrefix(toComplete, "https://") { - // Handle missing prefix. - template = strings.TrimPrefix(template, "https://") - } - if strings.HasPrefix(template, toComplete) || strings.HasPrefix(template, fixed) { - if op.Short != "" { - // Cobra supports descriptions for each completion, so if - // available we add it here.s - template += "\t" + op.Short - } - possible = append(possible, template) - } - } - } - } - return possible, cobra.ShellCompDirectiveNoFileComp - } - return []string{}, cobra.ShellCompDirectiveDefault -} - -// completeGenericCmd shows possible completions for generic commands, for -// example get/post/put/patch/delete/etc. -func completeGenericCmd(method string, showAPIs bool) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - possible, directive := completeCurrentConfig(cmd, args, toComplete, method) - if directive != cobra.ShellCompDirectiveDefault { - return possible, directive - } - - if showAPIs && len(args) == 0 { - for name := range configs { - possible = append(possible, name) - } - } - - return possible, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace - } -} - -// Init will set up the CLI. -func Init(name string, version string) { - initConfig(name, "") - initCache(name) - - // Reset registries. - authHandlers = map[string]AuthHandler{} - contentTypes = map[string]contentTypeEntry{} - encodings = map[string]ContentEncoding{} - linkParsers = []LinkParser{} - loaders = []Loader{} - - // Determine if we are using a TTY or colored output is forced-on. - tty := false - if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) || viper.GetBool("tty") { - tty = true - viper.Set("tty", true) - } - - useColor = false - if viper.GetBool("color") || (tty && !viper.GetBool("nocolor")) { - useColor = true - } - - if useColor { - // Support colored output across operating systems. - Stdout = colorable.NewColorableStdout() - Stderr = colorable.NewColorableStderr() - - viper.Set("color", useColor) - } - - au = aurora.NewAurora(useColor) - - Formatter = NewDefaultFormatter(tty, useColor) - - cobra.AddTemplateFunc("highlight", func(s string) string { - // Highlighting is expensive, so only do this when the user actually asks - // for help via this template func and a custom help template. - if tty { - w, _, err := term.GetSize(0) - if err != nil { - // Default to standard terminal size - w = 80 - } - r, _ := glamour.NewTermRenderer( - glamour.WithStyles(MarkdownStyle), - glamour.WithWordWrap(w), - ) - if out, err := r.Render(s); err == nil { - return out - } - } - return s - }) - - Root = &cobra.Command{ - Use: filepath.Base(os.Args[0]), - Long: "A generic client for REST-ish APIs ", - Version: version, - Example: fmt.Sprintf(` # Get a URI - $ %s google.com - - # Specify verb, header, and body shorthand - $ %s post :8888/users -H authorization:abc123 name: Kari, role: admin`, name, name), - Args: cobra.MinimumNArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodGet, false), - PersistentPreRun: func(cmd *cobra.Command, args []string) { - settings := viper.AllSettings() - LogDebug("Configuration: %v", settings) - }, - Run: func(cmd *cobra.Command, args []string) { - generic(http.MethodGet, args[0], args[1:]) - }, - } - Root.AddGroup( - &cobra.Group{ID: "api", Title: "Available API Commands:"}, - &cobra.Group{ID: "generic", Title: "Generic Commands:"}, - ) - Root.SetHelpTemplate(`{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces | highlight}} - -{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}`) - - head := &cobra.Command{ - GroupID: "generic", - Use: "head uri", - Aliases: []string{"HEAD"}, - Short: "Head a URI", - Long: "Perform an HTTP HEAD on the given URI", - Args: cobra.MinimumNArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodHead, true), - Run: func(cmd *cobra.Command, args []string) { - generic(http.MethodHead, args[0], args[1:]) - }, - } - Root.AddCommand(head) - - options := &cobra.Command{ - GroupID: "generic", - Use: "options uri", - Aliases: []string{"OPTIONS"}, - Short: "Options a URI", - Long: "Perform an HTTP OPTIONS on the given URI", - Args: cobra.MinimumNArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodOptions, true), - Run: func(cmd *cobra.Command, args []string) { - generic(http.MethodOptions, args[0], args[1:]) - }, - } - Root.AddCommand(options) - - get := &cobra.Command{ - GroupID: "generic", - Use: "get uri", - Aliases: []string{"GET"}, - Short: "Get a URI", - Long: "Perform an HTTP GET on the given URI", - Args: cobra.MinimumNArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodGet, true), - Run: func(cmd *cobra.Command, args []string) { - generic(http.MethodGet, args[0], args[1:]) - }, - } - Root.AddCommand(get) - - post := &cobra.Command{ - GroupID: "generic", - Use: "post uri [body...]", - Aliases: []string{"POST"}, - Short: "Post a URI", - Long: "Perform an HTTP POST on the given URI", - Args: cobra.MinimumNArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodPost, true), - Run: func(cmd *cobra.Command, args []string) { - generic(http.MethodPost, args[0], args[1:]) - }, - } - Root.AddCommand(post) - - put := &cobra.Command{ - GroupID: "generic", - Use: "put uri [body...]", - Aliases: []string{"PUT"}, - Short: "Put a URI", - Long: "Perform an HTTP PUT on the given URI", - Args: cobra.MinimumNArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodPut, true), - Run: func(cmd *cobra.Command, args []string) { - generic(http.MethodPut, args[0], args[1:]) - }, - } - Root.AddCommand(put) - - patch := &cobra.Command{ - GroupID: "generic", - Use: "patch uri [body...]", - Aliases: []string{"PATCH"}, - Short: "Patch a URI", - Long: "Perform an HTTP PATCH on the given URI", - Args: cobra.MinimumNArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodPatch, true), - Run: func(cmd *cobra.Command, args []string) { - generic(http.MethodPatch, args[0], args[1:]) - }, - } - Root.AddCommand(patch) - - delete := &cobra.Command{ - GroupID: "generic", - Use: "delete uri [body...]", - Aliases: []string{"DELETE"}, - Short: "Delete a URI", - Long: "Perform an HTTP DELETE on the given URI", - Args: cobra.MinimumNArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodDelete, true), - Run: func(cmd *cobra.Command, args []string) { - generic(http.MethodDelete, args[0], args[1:]) - }, - } - Root.AddCommand(delete) - - var interactive *bool - var noPrompt *bool - var editFormat *string - edit := &cobra.Command{ - GroupID: "generic", - Use: "edit uri [-i] [body...]", - Short: "Edit a resource by URI", - Long: "Convenience function which combines a GET, edit, and PUT operation into one command", - Args: cobra.MinimumNArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodGet, true), - Run: func(cmd *cobra.Command, args []string) { - switch *editFormat { - case "json": - edit(args[0], args[1:], *interactive, *noPrompt, os.Exit, func(v interface{}) ([]byte, error) { - return json.MarshalIndent(v, "", " ") - }, json.Unmarshal, ".json") - case "yaml": - edit(args[0], args[1:], *interactive, *noPrompt, os.Exit, yaml.Marshal, yaml.Unmarshal, ".yaml") - } - }, - } - interactive = edit.Flags().BoolP("rsh-interactive", "i", false, "Open an interactive editor") - noPrompt = edit.Flags().BoolP("rsh-yes", "y", false, "Disable prompt (answer yes automatically)") - editFormat = edit.Flags().StringP("rsh-edit-format", "e", "json", "Format to edit (default: json) [json, yaml]") - Root.AddCommand(edit) - - authHeader := &cobra.Command{ - GroupID: "generic", - Use: "auth-header uri", - Short: "Get an auth header for a given API", - Long: "Get an OAuth2 bearer token in an Authorization header capable of being passed to other commands. Uses a cached token when possible, renewing as needed if it has expired.", - Example: fmt.Sprintf(` # Using API short name - $ %s auth-header my-api - - # Using a full URI - $ %s auth-header https://my-api.example.com/ - - # Example usage with curl - $ curl https://my-apiexample.com/ -H "Authorization: $(%s auth-header my-api)"`, name, name, name), - Args: cobra.ExactArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodGet, true), - RunE: func(cmd *cobra.Command, args []string) error { - addr := fixAddress(args[0]) - name, config := findAPI(addr) - - if config == nil { - return fmt.Errorf("no matched API for URL %s", args[0]) - } - - profile := config.Profiles[viper.GetString("rsh-profile")] - if profile == nil { - return fmt.Errorf("invalid profile %s", viper.GetString("rsh-profile")) - } - - if profile.Auth == nil || profile.Auth.Name == "" { - return fmt.Errorf("no auth set up for API") - } - - if auth, ok := authHandlers[profile.Auth.Name]; ok { - req, _ := http.NewRequest(http.MethodGet, addr, nil) - err := auth.OnRequest(req, name+":"+viper.GetString("rsh-profile"), profile.Auth.Params) - if err != nil { - panic(err) - } - fmt.Fprintln(Stdout, req.Header.Get("Authorization")) - } - return nil - }, - } - Root.AddCommand(authHeader) - - cert := &cobra.Command{ - GroupID: "generic", - Use: "cert uri", - Short: "Get cert info", - Long: "Get TLS certificate information including expiration date", - Args: cobra.ExactArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodGet, true), - Run: func(cmd *cobra.Command, args []string) { - u, err := url.Parse(fixAddress(args[0])) - if err != nil { - panic(err) - } - addr := u.Host - - if !strings.Contains(addr, ":") { - addr += ":443" - } - - conn, err := tls.Dial("tcp", addr, nil) - if err != nil { - panic(err) - } - - chains := conn.ConnectionState().VerifiedChains - if len(chains) > 0 && len(chains[0]) > 0 { - // The first cert in the first chain should represent the domain. - c := chains[0][0] - - expiresRelative := "" - days := time.Until(c.NotAfter).Hours() / 24 - if days > 0 { - expiresRelative = fmt.Sprintf("in %.1f days", days) - } else { - expiresRelative = fmt.Sprintf("%.1f days ago", -days) - } - - info := fmt.Sprintf(`Issuer: %s -Subject: %s -Signature Algorithm: %s -Not before: %s -Not after (expires): %s (%s) -`, c.Issuer.String(), c.Subject.String(), c.SignatureAlgorithm.String(), c.NotBefore.String(), c.NotAfter.String(), expiresRelative) - - if len(c.DNSNames) > 0 { - info += "DNS names:\n " + strings.Join(c.DNSNames, "\n ") + "\n" - } - - fmt.Print(info) - } - }, - } - Root.AddCommand(cert) - - linkCmd := &cobra.Command{ - GroupID: "generic", - Use: "links uri [rel1 rel2...]", - Short: "Get link relations from the given URI, with optional filtering", - Long: "Returns a list of resolved references to the link relations after making an HTTP GET request to the given URI. Additional arguments filter down the set of returned relationship names.", - Args: cobra.MinimumNArgs(1), - ValidArgsFunction: completeGenericCmd(http.MethodGet, true), - Run: func(cmd *cobra.Command, args []string) { - req, _ := http.NewRequest(http.MethodGet, fixAddress(args[0]), nil) - resp, err := GetParsedResponse(req) - if err != nil { - panic(err) - } - - var output interface{} = resp.Links - - if len(args) > 1 { - tmp := []*Link{} - for _, rel := range args[1:] { - tmp = append(tmp, resp.Links[rel]...) - } - output = tmp - } - - encoded, err := json.MarshalIndent(output, "", " ") - if err != nil { - panic(err) - } - - if useColor { - encoded, err = Highlight("json", encoded) - if err != nil { - panic(err) - } - } - - fmt.Fprintln(Stdout, string(encoded)) - }, - } - Root.AddCommand(linkCmd) - - GlobalFlags = pflag.NewFlagSet("eager-flags", pflag.ContinueOnError) - GlobalFlags.ParseErrorsWhitelist.UnknownFlags = true - // GlobalFlags are 'hidden', don't print anything on error - GlobalFlags.Usage = func() {} - // Ensure parsing doesn't stop if the help flag is set - // (help seems to be special cased from ParseErrorsWhitelist.UnknownFlags) - GlobalFlags.BoolP("help", "h", false, "") - - AddGlobalFlag("rsh-verbose", "v", "Enable verbose log output", false, false) - AddGlobalFlag("rsh-output-format", "o", "Output format [auto, json, table, ...]", "auto", false) - AddGlobalFlag("rsh-filter", "f", "Filter / project results using shorthand query", "", false) - AddGlobalFlag("rsh-raw", "r", "Output result of query as raw rather than an escaped JSON string or list", false, false) - AddGlobalFlag("rsh-server", "s", "Override scheme://server:port for an API", "", false) - AddGlobalFlag("rsh-header", "H", "Add custom header", []string{}, true) - AddGlobalFlag("rsh-query", "q", "Add custom query param", []string{}, true) - AddGlobalFlag("rsh-no-paginate", "", "Disable auto-pagination", false, false) - AddGlobalFlag("rsh-profile", "p", "API auth profile", "default", false) - AddGlobalFlag("rsh-no-cache", "", "Disable HTTP cache", false, false) - AddGlobalFlag("rsh-insecure", "", "Disable SSL verification", false, false) - AddGlobalFlag("rsh-client-cert", "", "Path to a PEM encoded client certificate", "", false) - AddGlobalFlag("rsh-client-key", "", "Path to a PEM encoded private key", "", false) - AddGlobalFlag("rsh-ca-cert", "", "Path to a PEM encoded CA cert", "", false) - AddGlobalFlag("rsh-ignore-status-code", "", "Do not set exit code from HTTP status code", false, false) - AddGlobalFlag("rsh-retry", "", "Number of times to retry on certain failures", 2, false) - AddGlobalFlag("rsh-timeout", "t", "Timeout for HTTP requests", time.Duration(0), false) - - Root.RegisterFlagCompletionFunc("rsh-output-format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"auto", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp - }) - - Root.RegisterFlagCompletionFunc("rsh-profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - profiles := []string{} - if currentConfig != nil { - for profile := range currentConfig.Profiles { - profiles = append(profiles, profile) - } - } - return profiles, cobra.ShellCompDirectiveNoFileComp - }) - - initAPIConfig() -} - -func userHomeDir() string { - home, err := os.UserHomeDir() - if err != nil { - panic("HOME directoy is not defined") - } - return home -} - -func getConfigDir(appName string) string { - configDirEnv := strings.ToUpper(appName) + "_CONFIG_DIR" - configDir := os.Getenv(configDirEnv) - - if configDir == "" { - // Create new config directory - configBase, _ := os.UserConfigDir() - configDir = filepath.Join(configBase, appName) - - // Check for legacy config dir - legacyConfigDir := filepath.Join(viper.GetString("home-directory"), "."+appName) - if _, err := os.Stat(legacyConfigDir); err == nil { - // Only migrate if the new config dir doesn't exist, so that this - // is a one-time operation. There are edge cases where configs could - // get lost if we migrate every time (e.g. running an old version - // that creates an empty ~/.restish/apis.json). - if _, err := os.Stat(configDir); err != nil { - // Define files to migrate - for _, filename := range []string{ - "config.json", - "apis.json", - "cache.json", - } { - oldPath := filepath.Join(legacyConfigDir, filename) - newDir := configDir - if filename == "cache.json" { - newDir = getCacheDir() - } - if _, err := os.Stat(oldPath); err == nil { - os.MkdirAll(newDir, 0700) - os.Rename(oldPath, filepath.Join(newDir, filename)) - } - } - // Everything else is a cache that can be regenerated - os.RemoveAll(legacyConfigDir) - } - } - } - return configDir -} - -func getCacheDir() string { - appName := viper.GetString("app-name") - cacheDirEnv := strings.ToUpper(appName) + "_CACHE_DIR" - - cacheDir := os.Getenv(cacheDirEnv) - - if cacheDir == "" { - cache, _ := os.UserCacheDir() - cacheDir = filepath.Join(cache, appName) - } - return cacheDir -} - -func initConfig(appName, envPrefix string) { - viper.Set("app-name", appName) - - // One-time setup to ensure the path exists so we can write files into it - // later as needed. - home := userHomeDir() - viper.Set("home-directory", home) - - configDir := getConfigDir(appName) - if err := os.MkdirAll(configDir, 0700); err != nil { - panic(err) - } - - // Load configuration from file(s) if provided. - viper.SetConfigName("config") - viper.AddConfigPath(filepath.Join("/etc/", appName)) - viper.AddConfigPath(filepath.Join(viper.GetString("home-directory"), "."+appName)) - viper.AddConfigPath(configDir) - viper.ReadInConfig() - - // Load configuration from the environment if provided. Flags below get - // transformed automatically, e.g. `client-id` -> `PREFIX_CLIENT_ID`. - viper.SetEnvPrefix(envPrefix) - viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - viper.AutomaticEnv() - - // Save a few things that will be useful elsewhere. - viper.Set("config-directory", configDir) - viper.SetDefault("server-index", 0) -} - -func initCache(appName string) { - Cache = viper.New() - Cache.SetConfigName("cache") - - cacheDir := getCacheDir() - if err := os.MkdirAll(cacheDir, 0700); err != nil { - panic(err) - } - - Cache.AddConfigPath(cacheDir) - - // Write a blank cache if no file is already there. Later you can use - // cli.Cache.SaveConfig() to write new values. - filename := filepath.Join(cacheDir, "cache.json") - if _, err := os.Stat(filename); os.IsNotExist(err) { - if err := os.WriteFile(filename, []byte("{}"), 0600); err != nil { - panic(err) - } - } - viper.Set("cache-dir", cacheDir) - Cache.ReadInConfig() -} - -// Defaults adds the default encodings, content types, and link parsers to -// the CLI. -func Defaults() { - // Register content encodings - AddEncoding("deflate", &DeflateEncoding{}) - AddEncoding("gzip", &GzipEncoding{}) - AddEncoding("br", &BrotliEncoding{}) - - // Register content type marshallers - AddContentType("cbor", "application/cbor", 0.9, &CBOR{}) - AddContentType("msgpack", "application/msgpack", 0.8, &MsgPack{}) - AddContentType("ion", "application/ion", 0.6, &Ion{}) - AddContentType("json", "application/json", 0.5, &JSON{}) - AddContentType("yaml", "application/yaml", 0.5, &YAML{}) - AddContentType("text", "text/*", 0.2, &Text{}) - AddContentType("table", "", -1, &Table{}) - AddContentType("readable", "", -1, &Readable{}) - AddContentType("gron", "", -1, &Gron{}) - - // Add link relation parsers - AddLinkParser(&LinkHeaderParser{}) - AddLinkParser(&HALParser{}) - AddLinkParser(&TerrificallySimpleJSONParser{}) - AddLinkParser(&JSONAPIParser{}) - - // Register auth schemes - AddAuth("http-basic", &BasicAuth{}) - AddAuth("external-tool", &ExternalToolAuth{}) -} - -// Run the CLI! Parse arguments, make requests, print responses. -func Run() (returnErr error) { - // We need to register new commands at runtime based on the selected API - // so that we don't have to potentially refresh and parse every single - // registered API just to run. So this is a little hacky, but we hijack - // the input args to find non-option arguments, get the first arg, and - // if it isn't from a well-known set try to load that API. - args := []string{} - for _, arg := range os.Args { - if !strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "__") { - args = append(args, arg) - } - } - - if os.Getenv("COLOR") != "" { - viper.Set("color", true) - } - if os.Getenv("NOCOLOR") != "" { - viper.Set("nocolor", true) - } - - // Because we may be doing HTTP calls before cobra has parsed the flags - // we parse the GlobalFlags here and already set some config values - // to ensure they are available - if err := GlobalFlags.Parse(os.Args[1:]); err != nil { - if err != pflag.ErrHelp { - panic(err) - } - } - if noCache, _ := GlobalFlags.GetBool("rsh-no-cache"); noCache { - viper.Set("rsh-no-cache", true) - } - if verbose, _ := GlobalFlags.GetBool("rsh-verbose"); verbose { - viper.Set("rsh-verbose", true) - } - if insecure, _ := GlobalFlags.GetBool("rsh-insecure"); insecure { - viper.Set("rsh-insecure", true) - } - if cert, _ := GlobalFlags.GetString("rsh-client-cert"); cert != "" { - viper.Set("rsh-client-cert", cert) - } - if key, _ := GlobalFlags.GetString("rsh-client-key"); key != "" { - viper.Set("rsh-client-key", key) - } - if caCert, _ := GlobalFlags.GetString("rsh-ca-cert"); caCert != "" { - viper.Set("rsh-ca-cert", caCert) - } - if query, _ := GlobalFlags.GetStringArray("rsh-query"); len(query) > 0 { - viper.Set("rsh-query", query) - } - if headers, _ := GlobalFlags.GetStringArray("rsh-header"); len(headers) > 0 { - viper.Set("rsh-header", headers) - } - profile, _ := GlobalFlags.GetString("rsh-profile") - viper.Set("rsh-profile", profile) - if retries, _ := GlobalFlags.GetInt("rsh-retry"); retries > 0 { - viper.Set("rsh-retry", retries) - } - if timeout, _ := GlobalFlags.GetDuration("rsh-timeout"); timeout > 0 { - viper.Set("rsh-timeout", timeout) - } - - // Now that global flags are parsed we can enable verbose mode if requested. - if viper.GetBool("rsh-verbose") { - enableVerbose = true - } - - // Load the API commands if we can. - if len(args) > 1 { - apiName := args[1] - - if apiName == "help" && len(args) > 2 { - // The explicit `help` command is followed by the actual commands - // you want help with. The first one is the API name. - apiName = args[2] - } - - loaded := false - if apiName != "help" && apiName != "head" && apiName != "options" && apiName != "get" && apiName != "post" && apiName != "put" && apiName != "patch" && apiName != "delete" && apiName != "api" && apiName != "links" && apiName != "edit" && apiName != "auth-header" { - // Try to find the registered config for this API. If not found, - // there is no need to do anything since the normal flow will catch - // the command being missing and print help. - if cfg, ok := configs[apiName]; ok { - - // This is used to give context to findApi - // Smallest fix for https://github.com/danielgtaylor/restish/issues/128 - viper.Set("api-name", apiName) - - currentConfig = cfg - for _, cmd := range Root.Commands() { - if cmd.Use == apiName { - currentBase := cfg.Base - currentProfile := cfg.Profiles[profile] - if currentProfile == nil { - if profile != "default" { - panic("invalid profile " + profile) - } - } - if currentProfile != nil && currentProfile.Base != "" { - currentBase = currentProfile.Base - } - if _, err := Load(currentBase, cmd); err != nil { - panic(err) - } - loaded = true - break - } - } - } - } - - if !loaded { - // This could be a URL or short-name as part of a URL for generic - // commands. We should load the config for shell completion. - if (apiName == "head" || apiName == "options" || apiName == "get" || apiName == "post" || apiName == "put" || apiName == "patch" || apiName == "delete") && len(args) > 2 { - apiName = args[2] - } - apiName = fixAddress(apiName) - if name, _ := findAPI(apiName); name != "" { - currentConfig = configs[name] - } - } - } - - // Phew, we made it. Execute the command now that everything is loaded - // and all the relevant sub-commands are registered. - defer func() { - if err := recover(); err != nil { - LogError("Caught error: %v", err) - LogDebug("%s", string(debug.Stack())) - if e, ok := err.(error); ok { - returnErr = e - } else { - returnErr = fmt.Errorf("%v", err) - } - } - }() - if err := Root.Execute(); err != nil { - LogError("Error: %v", err) - returnErr = err - } - - return returnErr -} - -// Run the CLI! Parse arguments, make requests, print responses. -func RunEmbedded(args []string, overrideAuthPrefix, overrideAuthToken string, newOut, newErr io.Writer) (returnErr error) { - - Root.SetArgs(args) - Root.Use = filepath.Base(args[0]) - if os.Getenv("COLOR") != "" { - viper.Set("color", true) - } - if os.Getenv("NOCOLOR") != "" { - viper.Set("nocolor", true) - } - viper.Set("ni-override-auth-prefix", overrideAuthPrefix) - viper.Set("ni-override-auth-token", overrideAuthToken) - // Because we may be doing HTTP calls before cobra has parsed the flags - // we parse the GlobalFlags here and already set some config values - // to ensure they are available - if err := GlobalFlags.Parse(args[1:]); err != nil { - if err != pflag.ErrHelp { - panic(err) - } - } - if newOut != nil { - Root.SetOut(newOut) - Stdout = newOut - } - if newErr != nil { - Root.SetErr(newErr) - Stderr = newErr - } - if noCache, _ := GlobalFlags.GetBool("rsh-no-cache"); noCache { - viper.Set("rsh-no-cache", true) - } - if verbose, _ := GlobalFlags.GetBool("rsh-verbose"); verbose { - viper.Set("rsh-verbose", true) - } - if insecure, _ := GlobalFlags.GetBool("rsh-insecure"); insecure { - viper.Set("rsh-insecure", true) - } - if cert, _ := GlobalFlags.GetString("rsh-client-cert"); cert != "" { - viper.Set("rsh-client-cert", cert) - } - if key, _ := GlobalFlags.GetString("rsh-client-key"); key != "" { - viper.Set("rsh-client-key", key) - } - if caCert, _ := GlobalFlags.GetString("rsh-ca-cert"); caCert != "" { - viper.Set("rsh-ca-cert", caCert) - } - if query, _ := GlobalFlags.GetStringArray("rsh-query"); len(query) > 0 { - viper.Set("rsh-query", query) - } - if headers, _ := GlobalFlags.GetStringArray("rsh-header"); len(headers) > 0 { - viper.Set("rsh-header", headers) - } - profile, _ := GlobalFlags.GetString("rsh-profile") - viper.Set("rsh-profile", profile) - if retries, _ := GlobalFlags.GetInt("rsh-retry"); retries > 0 { - viper.Set("rsh-retry", retries) - } - if timeout, _ := GlobalFlags.GetDuration("rsh-timeout"); timeout > 0 { - viper.Set("rsh-timeout", timeout) - } - - // Now that global flags are parsed we can enable verbose mode if requested. - if viper.GetBool("rsh-verbose") { - enableVerbose = true - } - - // Load the API commands if we can. - if len(args) > 1 { - apiName := args[1] - - if apiName == "help" && len(args) > 2 { - // The explicit `help` command is followed by the actual commands - // you want help with. The first one is the API name. - apiName = args[2] - } - - loaded := false - if apiName != "help" && apiName != "head" && apiName != "options" && apiName != "get" && apiName != "post" && apiName != "put" && apiName != "patch" && apiName != "delete" && apiName != "api" && apiName != "links" && apiName != "edit" && apiName != "auth-header" { - // Try to find the registered config for this API. If not found, - // there is no need to do anything since the normal flow will catch - // the command being missing and print help. - if cfg, ok := configs[apiName]; ok { - - // This is used to give context to findApi - // Smallest fix for https://github.com/danielgtaylor/restish/issues/128 - viper.Set("api-name", apiName) - - currentConfig = cfg - for _, cmd := range Root.Commands() { - if cmd.Use == apiName { - currentBase := cfg.Base - currentProfile := cfg.Profiles[profile] - if currentProfile == nil { - if profile != "default" { - return fmt.Errorf("invalid profile " + profile) - } - } - if currentProfile != nil && currentProfile.Base != "" { - currentBase = currentProfile.Base - } - if _, err := Load(currentBase, cmd); err != nil { - return err - } - loaded = true - break - } - } - } - } - - if !loaded { - // This could be a URL or short-name as part of a URL for generic - // commands. We should load the config for shell completion. - if (apiName == "head" || apiName == "options" || apiName == "get" || apiName == "post" || apiName == "put" || apiName == "patch" || apiName == "delete") && len(args) > 2 { - apiName = args[2] - } - apiName = fixAddress(apiName) - if name, _ := findAPI(apiName); name != "" { - currentConfig = configs[name] - } - } - } - - // Phew, we made it. Execute the command now that everything is loaded - // and all the relevant sub-commands are registered. - defer func() { - if err := recover(); err != nil { - LogError("Caught error: %v", err) - LogDebug("%s", string(debug.Stack())) - if e, ok := err.(error); ok { - returnErr = e - } else { - returnErr = fmt.Errorf("%v", err) - } - } - }() - if err := Root.Execute(); err != nil { - LogError("Error: %v", err) - returnErr = err - } - - return returnErr -} - -// GetExitCode returns the exit code to use based on the last HTTP status code. -func GetExitCode() int { - if s := GetLastStatus() / 100; s > 2 && !viper.GetBool("rsh-ignore-status-code") { - return s - } - - return 0 -} +package cli + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "runtime/debug" + "strings" + "time" + + "github.com/charmbracelet/glamour" + "github.com/logrusorgru/aurora" + "github.com/mattn/go-colorable" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "golang.org/x/term" + "gopkg.in/yaml.v2" +) + +// Root command (entrypoint) of the CLI. +var Root *cobra.Command + +// GlobalFlags contains all the fixed up front flags +// This allows us to parse them before we hand over control +// to cobra +var GlobalFlags *pflag.FlagSet + +// Cache is used to store temporary data between runs. +var Cache *viper.Viper + +// Formatter is the currently configured response output formatter. +var Formatter ResponseFormatter + +// Stdout is a cross-platform, color-safe writer if colors are enabled, +// otherwise it defaults to `os.Stdout`. +var Stdout io.Writer = os.Stdout + +// Stderr is a cross-platform, color-safe writer if colors are enabled, +// otherwise it defaults to `os.Stderr`. +var Stderr io.Writer = os.Stderr + +var useColor bool +var au aurora.Aurora + +// Keeps track of currently selected API for shell completions +var currentConfig *APIConfig + +func generic(method string, addr string, args []string) { + var body io.Reader + + d, err := GetBody("application/json", args) + if err != nil { + panic(err) + } + if len(d) > 0 { + body = strings.NewReader(d) + } + + req, _ := http.NewRequest(method, fixAddress(addr), body) + MakeRequestAndFormat(req) +} + +// templateVarRegex used to find/replace variables `/{foo}/bar/{baz}` in a +// template string. +var templateVarRegex = regexp.MustCompile(`\{.*?\}`) + +// matchTemplate will see if a given URL matches a URL template, and if so, +// returns the template with the variable parts replaced by the matched part. +// If no match, returns the original template. Example: +// Input URL: https://example.com/items/foo +// Input tpl: https://example.com/items/{item-id}/tags/{tag-id} +// Output : https://example.com/items/foo/tags/{tag-id} +func matchTemplate(url, template string) string { + urlParts := strings.Split(url, "/") + tplParts := strings.Split(template, "/") + for i, urlPart := range urlParts { + if len(tplParts) < i+1 { + break + } + + tplPart := tplParts[i] + + if strings.Contains(tplPart, "{") { + matcher := regexp.MustCompile(templateVarRegex.ReplaceAllString(tplPart, ".*")) + if matcher.MatchString(urlPart) && urlPart != "" { + tplParts[i] = urlPart + continue + } + } else if urlPart == tplPart { + // This is an exact path match. + continue + } + + // Give up, not a match! + break + } + + return strings.Join(tplParts, "/") +} + +// completeCurrentConfig generates possible completions based on the currently +// selected API configuration's known operation URL templates. Takes into +// account short-names as well as the full URL. +func completeCurrentConfig(cmd *cobra.Command, args []string, toComplete string, method string) ([]string, cobra.ShellCompDirective) { + possible := []string{} + if currentConfig != nil { + for _, cmd := range Root.Commands() { + if cmd.Use == currentConfig.name { + // This is the matching command. Load the URL and check each operation. + currentBase := currentConfig.Base + currentProfile := currentConfig.Profiles[viper.GetString("rsh-profile")] + if currentProfile == nil { + if viper.GetString("rsh-profile") != "default" { + panic("invalid profile " + viper.GetString("rsh-profile")) + } + } + if currentProfile != nil && currentProfile.Base != "" { + currentBase = currentProfile.Base + } + api, _ := Load(currentBase, cmd) + for _, op := range api.Operations { + if op.Method != method { + // We only care about operations which match the currently selected + // HTTP method, otherwise it makes no sense to show it as an + // option since it couldn't possibly work. + continue + } + + // Handle short-name, missing https:// prefix. + fixed := fixAddress(toComplete) + + // Modify the template to fill in matched variables. + template := matchTemplate(fixed, op.URITemplate) + if strings.HasPrefix(toComplete, currentConfig.name) { + // We were using a short-name, convert back to it! This is + // friendlier than forcing the full URL on the user. + template = strings.Replace(template, currentConfig.Base, currentConfig.name, 1) + } else if !strings.HasPrefix(toComplete, "https://") { + // Handle missing prefix. + template = strings.TrimPrefix(template, "https://") + } + if strings.HasPrefix(template, toComplete) || strings.HasPrefix(template, fixed) { + if op.Short != "" { + // Cobra supports descriptions for each completion, so if + // available we add it here.s + template += "\t" + op.Short + } + possible = append(possible, template) + } + } + } + } + return possible, cobra.ShellCompDirectiveNoFileComp + } + return []string{}, cobra.ShellCompDirectiveDefault +} + +// completeGenericCmd shows possible completions for generic commands, for +// example get/post/put/patch/delete/etc. +func completeGenericCmd(method string, showAPIs bool) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + possible, directive := completeCurrentConfig(cmd, args, toComplete, method) + if directive != cobra.ShellCompDirectiveDefault { + return possible, directive + } + + if showAPIs && len(args) == 0 { + for name := range configs { + possible = append(possible, name) + } + } + + return possible, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } +} + +// Init will set up the CLI. +func Init(name string, version string) { + initConfig(name, "") + initCache(name) + + // Reset registries. + authHandlers = map[string]AuthHandler{} + contentTypes = map[string]contentTypeEntry{} + encodings = map[string]ContentEncoding{} + linkParsers = []LinkParser{} + loaders = []Loader{} + + // Determine if we are using a TTY or colored output is forced-on. + tty := false + if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) || viper.GetBool("tty") { + tty = true + viper.Set("tty", true) + } + + useColor = false + if viper.GetBool("color") || (tty && !viper.GetBool("nocolor")) { + useColor = true + } + + if useColor { + // Support colored output across operating systems. + Stdout = colorable.NewColorableStdout() + Stderr = colorable.NewColorableStderr() + + viper.Set("color", useColor) + } + + au = aurora.NewAurora(useColor) + + Formatter = NewDefaultFormatter(tty, useColor) + + cobra.AddTemplateFunc("highlight", func(s string) string { + // Highlighting is expensive, so only do this when the user actually asks + // for help via this template func and a custom help template. + if tty { + w, _, err := term.GetSize(0) + if err != nil { + // Default to standard terminal size + w = 80 + } + r, _ := glamour.NewTermRenderer( + glamour.WithStyles(MarkdownStyle), + glamour.WithWordWrap(w), + ) + if out, err := r.Render(s); err == nil { + return out + } + } + return s + }) + + Root = &cobra.Command{ + Use: filepath.Base(os.Args[0]), + Long: "A generic client for REST-ish APIs ", + Version: version, + Example: fmt.Sprintf(` # Get a URI + $ %s google.com + + # Specify verb, header, and body shorthand + $ %s post :8888/users -H authorization:abc123 name: Kari, role: admin`, name, name), + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodGet, false), + PersistentPreRun: func(cmd *cobra.Command, args []string) { + settings := viper.AllSettings() + LogDebug("Configuration: %v", settings) + }, + Run: func(cmd *cobra.Command, args []string) { + generic(http.MethodGet, args[0], args[1:]) + }, + } + Root.AddGroup( + &cobra.Group{ID: "api", Title: "Available API Commands:"}, + &cobra.Group{ID: "generic", Title: "Generic Commands:"}, + ) + Root.SetHelpTemplate(`{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces | highlight}} + +{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}`) + + head := &cobra.Command{ + GroupID: "generic", + Use: "head uri", + Aliases: []string{"HEAD"}, + Short: "Head a URI", + Long: "Perform an HTTP HEAD on the given URI", + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodHead, true), + Run: func(cmd *cobra.Command, args []string) { + generic(http.MethodHead, args[0], args[1:]) + }, + } + Root.AddCommand(head) + + options := &cobra.Command{ + GroupID: "generic", + Use: "options uri", + Aliases: []string{"OPTIONS"}, + Short: "Options a URI", + Long: "Perform an HTTP OPTIONS on the given URI", + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodOptions, true), + Run: func(cmd *cobra.Command, args []string) { + generic(http.MethodOptions, args[0], args[1:]) + }, + } + Root.AddCommand(options) + + get := &cobra.Command{ + GroupID: "generic", + Use: "get uri", + Aliases: []string{"GET"}, + Short: "Get a URI", + Long: "Perform an HTTP GET on the given URI", + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodGet, true), + Run: func(cmd *cobra.Command, args []string) { + generic(http.MethodGet, args[0], args[1:]) + }, + } + Root.AddCommand(get) + + post := &cobra.Command{ + GroupID: "generic", + Use: "post uri [body...]", + Aliases: []string{"POST"}, + Short: "Post a URI", + Long: "Perform an HTTP POST on the given URI", + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodPost, true), + Run: func(cmd *cobra.Command, args []string) { + generic(http.MethodPost, args[0], args[1:]) + }, + } + Root.AddCommand(post) + + put := &cobra.Command{ + GroupID: "generic", + Use: "put uri [body...]", + Aliases: []string{"PUT"}, + Short: "Put a URI", + Long: "Perform an HTTP PUT on the given URI", + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodPut, true), + Run: func(cmd *cobra.Command, args []string) { + generic(http.MethodPut, args[0], args[1:]) + }, + } + Root.AddCommand(put) + + patch := &cobra.Command{ + GroupID: "generic", + Use: "patch uri [body...]", + Aliases: []string{"PATCH"}, + Short: "Patch a URI", + Long: "Perform an HTTP PATCH on the given URI", + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodPatch, true), + Run: func(cmd *cobra.Command, args []string) { + generic(http.MethodPatch, args[0], args[1:]) + }, + } + Root.AddCommand(patch) + + delete := &cobra.Command{ + GroupID: "generic", + Use: "delete uri [body...]", + Aliases: []string{"DELETE"}, + Short: "Delete a URI", + Long: "Perform an HTTP DELETE on the given URI", + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodDelete, true), + Run: func(cmd *cobra.Command, args []string) { + generic(http.MethodDelete, args[0], args[1:]) + }, + } + Root.AddCommand(delete) + + var interactive *bool + var noPrompt *bool + var editFormat *string + edit := &cobra.Command{ + GroupID: "generic", + Use: "edit uri [-i] [body...]", + Short: "Edit a resource by URI", + Long: "Convenience function which combines a GET, edit, and PUT operation into one command", + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodGet, true), + Run: func(cmd *cobra.Command, args []string) { + switch *editFormat { + case "json": + edit(args[0], args[1:], *interactive, *noPrompt, os.Exit, func(v interface{}) ([]byte, error) { + return json.MarshalIndent(v, "", " ") + }, json.Unmarshal, ".json") + case "yaml": + edit(args[0], args[1:], *interactive, *noPrompt, os.Exit, yaml.Marshal, yaml.Unmarshal, ".yaml") + } + }, + } + interactive = edit.Flags().BoolP("rsh-interactive", "i", false, "Open an interactive editor") + noPrompt = edit.Flags().BoolP("rsh-yes", "y", false, "Disable prompt (answer yes automatically)") + editFormat = edit.Flags().StringP("rsh-edit-format", "e", "json", "Format to edit (default: json) [json, yaml]") + Root.AddCommand(edit) + + authHeader := &cobra.Command{ + GroupID: "generic", + Use: "auth-header uri", + Short: "Get an auth header for a given API", + Long: "Get an OAuth2 bearer token in an Authorization header capable of being passed to other commands. Uses a cached token when possible, renewing as needed if it has expired.", + Example: fmt.Sprintf(` # Using API short name + $ %s auth-header my-api + + # Using a full URI + $ %s auth-header https://my-api.example.com/ + + # Example usage with curl + $ curl https://my-apiexample.com/ -H "Authorization: $(%s auth-header my-api)"`, name, name, name), + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodGet, true), + RunE: func(cmd *cobra.Command, args []string) error { + addr := fixAddress(args[0]) + name, config := findAPI(addr) + + if config == nil { + return fmt.Errorf("no matched API for URL %s", args[0]) + } + + profile := config.Profiles[viper.GetString("rsh-profile")] + if profile == nil { + return fmt.Errorf("invalid profile %s", viper.GetString("rsh-profile")) + } + + if profile.Auth == nil || profile.Auth.Name == "" { + return fmt.Errorf("no auth set up for API") + } + + if auth, ok := authHandlers[profile.Auth.Name]; ok { + req, _ := http.NewRequest(http.MethodGet, addr, nil) + err := auth.OnRequest(req, name+":"+viper.GetString("rsh-profile"), profile.Auth.Params) + if err != nil { + panic(err) + } + fmt.Fprintln(Stdout, req.Header.Get("Authorization")) + } + return nil + }, + } + Root.AddCommand(authHeader) + + cert := &cobra.Command{ + GroupID: "generic", + Use: "cert uri", + Short: "Get cert info", + Long: "Get TLS certificate information including expiration date", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodGet, true), + Run: func(cmd *cobra.Command, args []string) { + u, err := url.Parse(fixAddress(args[0])) + if err != nil { + panic(err) + } + addr := u.Host + + if !strings.Contains(addr, ":") { + addr += ":443" + } + + conn, err := tls.Dial("tcp", addr, nil) + if err != nil { + panic(err) + } + + chains := conn.ConnectionState().VerifiedChains + if len(chains) > 0 && len(chains[0]) > 0 { + // The first cert in the first chain should represent the domain. + c := chains[0][0] + + expiresRelative := "" + days := time.Until(c.NotAfter).Hours() / 24 + if days > 0 { + expiresRelative = fmt.Sprintf("in %.1f days", days) + } else { + expiresRelative = fmt.Sprintf("%.1f days ago", -days) + } + + info := fmt.Sprintf(`Issuer: %s +Subject: %s +Signature Algorithm: %s +Not before: %s +Not after (expires): %s (%s) +`, c.Issuer.String(), c.Subject.String(), c.SignatureAlgorithm.String(), c.NotBefore.String(), c.NotAfter.String(), expiresRelative) + + if len(c.DNSNames) > 0 { + info += "DNS names:\n " + strings.Join(c.DNSNames, "\n ") + "\n" + } + + fmt.Print(info) + } + }, + } + Root.AddCommand(cert) + + linkCmd := &cobra.Command{ + GroupID: "generic", + Use: "links uri [rel1 rel2...]", + Short: "Get link relations from the given URI, with optional filtering", + Long: "Returns a list of resolved references to the link relations after making an HTTP GET request to the given URI. Additional arguments filter down the set of returned relationship names.", + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeGenericCmd(http.MethodGet, true), + Run: func(cmd *cobra.Command, args []string) { + req, _ := http.NewRequest(http.MethodGet, fixAddress(args[0]), nil) + resp, err := GetParsedResponse(req) + if err != nil { + panic(err) + } + + var output interface{} = resp.Links + + if len(args) > 1 { + tmp := []*Link{} + for _, rel := range args[1:] { + tmp = append(tmp, resp.Links[rel]...) + } + output = tmp + } + + encoded, err := json.MarshalIndent(output, "", " ") + if err != nil { + panic(err) + } + + if useColor { + encoded, err = Highlight("json", encoded) + if err != nil { + panic(err) + } + } + + fmt.Fprintln(Stdout, string(encoded)) + }, + } + Root.AddCommand(linkCmd) + + GlobalFlags = pflag.NewFlagSet("eager-flags", pflag.ContinueOnError) + GlobalFlags.ParseErrorsWhitelist.UnknownFlags = true + // GlobalFlags are 'hidden', don't print anything on error + GlobalFlags.Usage = func() {} + // Ensure parsing doesn't stop if the help flag is set + // (help seems to be special cased from ParseErrorsWhitelist.UnknownFlags) + GlobalFlags.BoolP("help", "h", false, "") + + AddGlobalFlag("rsh-verbose", "v", "Enable verbose log output", false, false) + AddGlobalFlag("rsh-output-format", "o", "Output format [auto, json, table, ...]", "auto", false) + AddGlobalFlag("rsh-filter", "f", "Filter / project results using shorthand query", "", false) + AddGlobalFlag("rsh-raw", "r", "Output result of query as raw rather than an escaped JSON string or list", false, false) + AddGlobalFlag("rsh-server", "s", "Override scheme://server:port for an API", "", false) + AddGlobalFlag("rsh-header", "H", "Add custom header", []string{}, true) + AddGlobalFlag("rsh-query", "q", "Add custom query param", []string{}, true) + AddGlobalFlag("rsh-no-paginate", "", "Disable auto-pagination", false, false) + AddGlobalFlag("rsh-profile", "p", "API auth profile", "default", false) + AddGlobalFlag("rsh-no-cache", "", "Disable HTTP cache", false, false) + AddGlobalFlag("rsh-insecure", "", "Disable SSL verification", false, false) + AddGlobalFlag("rsh-client-cert", "", "Path to a PEM encoded client certificate", "", false) + AddGlobalFlag("rsh-client-key", "", "Path to a PEM encoded private key", "", false) + AddGlobalFlag("rsh-ca-cert", "", "Path to a PEM encoded CA cert", "", false) + AddGlobalFlag("rsh-ignore-status-code", "", "Do not set exit code from HTTP status code", false, false) + AddGlobalFlag("rsh-retry", "", "Number of times to retry on certain failures", 2, false) + AddGlobalFlag("rsh-timeout", "t", "Timeout for HTTP requests", time.Duration(0), false) + + Root.RegisterFlagCompletionFunc("rsh-output-format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"auto", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp + }) + + Root.RegisterFlagCompletionFunc("rsh-profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + profiles := []string{} + if currentConfig != nil { + for profile := range currentConfig.Profiles { + profiles = append(profiles, profile) + } + } + return profiles, cobra.ShellCompDirectiveNoFileComp + }) + + initAPIConfig() +} + +func userHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + panic("HOME directoy is not defined") + } + return home +} + +func getConfigDir(appName string) string { + configDirEnv := strings.ToUpper(appName) + "_CONFIG_DIR" + configDir := os.Getenv(configDirEnv) + + if configDir == "" { + // Create new config directory + configBase, _ := os.UserConfigDir() + configDir = filepath.Join(configBase, appName) + + // Check for legacy config dir + legacyConfigDir := filepath.Join(viper.GetString("home-directory"), "."+appName) + if _, err := os.Stat(legacyConfigDir); err == nil { + // Only migrate if the new config dir doesn't exist, so that this + // is a one-time operation. There are edge cases where configs could + // get lost if we migrate every time (e.g. running an old version + // that creates an empty ~/.restish/apis.json). + if _, err := os.Stat(configDir); err != nil { + // Define files to migrate + for _, filename := range []string{ + "config.json", + "apis.json", + "cache.json", + } { + oldPath := filepath.Join(legacyConfigDir, filename) + newDir := configDir + if filename == "cache.json" { + newDir = getCacheDir() + } + if _, err := os.Stat(oldPath); err == nil { + os.MkdirAll(newDir, 0700) + os.Rename(oldPath, filepath.Join(newDir, filename)) + } + } + // Everything else is a cache that can be regenerated + os.RemoveAll(legacyConfigDir) + } + } + } + return configDir +} + +func getCacheDir() string { + appName := viper.GetString("app-name") + cacheDirEnv := strings.ToUpper(appName) + "_CACHE_DIR" + + cacheDir := os.Getenv(cacheDirEnv) + + if cacheDir == "" { + cache, _ := os.UserCacheDir() + cacheDir = filepath.Join(cache, appName) + } + return cacheDir +} + +func initConfig(appName, envPrefix string) { + viper.Set("app-name", appName) + + // One-time setup to ensure the path exists so we can write files into it + // later as needed. + home := userHomeDir() + viper.Set("home-directory", home) + + configDir := getConfigDir(appName) + if err := os.MkdirAll(configDir, 0700); err != nil { + panic(err) + } + + // Load configuration from file(s) if provided. + viper.SetConfigName("config") + viper.AddConfigPath(filepath.Join("/etc/", appName)) + viper.AddConfigPath(filepath.Join(viper.GetString("home-directory"), "."+appName)) + viper.AddConfigPath(configDir) + viper.ReadInConfig() + + // Load configuration from the environment if provided. Flags below get + // transformed automatically, e.g. `client-id` -> `PREFIX_CLIENT_ID`. + viper.SetEnvPrefix(envPrefix) + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() + + // Save a few things that will be useful elsewhere. + viper.Set("config-directory", configDir) + viper.SetDefault("server-index", 0) +} + +func initCache(appName string) { + Cache = viper.New() + Cache.SetConfigName("cache") + + cacheDir := getCacheDir() + if err := os.MkdirAll(cacheDir, 0700); err != nil { + panic(err) + } + + Cache.AddConfigPath(cacheDir) + + // Write a blank cache if no file is already there. Later you can use + // cli.Cache.SaveConfig() to write new values. + filename := filepath.Join(cacheDir, "cache.json") + if _, err := os.Stat(filename); os.IsNotExist(err) { + if err := os.WriteFile(filename, []byte("{}"), 0600); err != nil { + panic(err) + } + } + viper.Set("cache-dir", cacheDir) + Cache.ReadInConfig() +} + +// Defaults adds the default encodings, content types, and link parsers to +// the CLI. +func Defaults() { + // Register content encodings + AddEncoding("deflate", &DeflateEncoding{}) + AddEncoding("gzip", &GzipEncoding{}) + AddEncoding("br", &BrotliEncoding{}) + + // Register content type marshallers + AddContentType("cbor", "application/cbor", 0.9, &CBOR{}) + AddContentType("msgpack", "application/msgpack", 0.8, &MsgPack{}) + AddContentType("ion", "application/ion", 0.6, &Ion{}) + AddContentType("json", "application/json", 0.5, &JSON{}) + AddContentType("yaml", "application/yaml", 0.5, &YAML{}) + AddContentType("text", "text/*", 0.2, &Text{}) + AddContentType("table", "", -1, &Table{}) + AddContentType("readable", "", -1, &Readable{}) + AddContentType("gron", "", -1, &Gron{}) + + // Add link relation parsers + AddLinkParser(&LinkHeaderParser{}) + AddLinkParser(&HALParser{}) + AddLinkParser(&TerrificallySimpleJSONParser{}) + AddLinkParser(&JSONAPIParser{}) + + // Register auth schemes + AddAuth("http-basic", &BasicAuth{}) + AddAuth("external-tool", &ExternalToolAuth{}) +} + +// Run the CLI! Parse arguments, make requests, print responses. +func Run() (returnErr error) { + // We need to register new commands at runtime based on the selected API + // so that we don't have to potentially refresh and parse every single + // registered API just to run. So this is a little hacky, but we hijack + // the input args to find non-option arguments, get the first arg, and + // if it isn't from a well-known set try to load that API. + args := []string{} + for _, arg := range os.Args { + if !strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "__") { + args = append(args, arg) + } + } + + if os.Getenv("COLOR") != "" { + viper.Set("color", true) + } + if os.Getenv("NOCOLOR") != "" { + viper.Set("nocolor", true) + } + + // Because we may be doing HTTP calls before cobra has parsed the flags + // we parse the GlobalFlags here and already set some config values + // to ensure they are available + if err := GlobalFlags.Parse(os.Args[1:]); err != nil { + if err != pflag.ErrHelp { + panic(err) + } + } + if noCache, _ := GlobalFlags.GetBool("rsh-no-cache"); noCache { + viper.Set("rsh-no-cache", true) + } + if verbose, _ := GlobalFlags.GetBool("rsh-verbose"); verbose { + viper.Set("rsh-verbose", true) + } + if insecure, _ := GlobalFlags.GetBool("rsh-insecure"); insecure { + viper.Set("rsh-insecure", true) + } + if cert, _ := GlobalFlags.GetString("rsh-client-cert"); cert != "" { + viper.Set("rsh-client-cert", cert) + } + if key, _ := GlobalFlags.GetString("rsh-client-key"); key != "" { + viper.Set("rsh-client-key", key) + } + if caCert, _ := GlobalFlags.GetString("rsh-ca-cert"); caCert != "" { + viper.Set("rsh-ca-cert", caCert) + } + if query, _ := GlobalFlags.GetStringArray("rsh-query"); len(query) > 0 { + viper.Set("rsh-query", query) + } + if headers, _ := GlobalFlags.GetStringArray("rsh-header"); len(headers) > 0 { + viper.Set("rsh-header", headers) + } + profile, _ := GlobalFlags.GetString("rsh-profile") + viper.Set("rsh-profile", profile) + if retries, _ := GlobalFlags.GetInt("rsh-retry"); retries > 0 { + viper.Set("rsh-retry", retries) + } + if timeout, _ := GlobalFlags.GetDuration("rsh-timeout"); timeout > 0 { + viper.Set("rsh-timeout", timeout) + } + + // Now that global flags are parsed we can enable verbose mode if requested. + if viper.GetBool("rsh-verbose") { + enableVerbose = true + } + + // Load the API commands if we can. + if len(args) > 1 { + apiName := args[1] + + if apiName == "help" && len(args) > 2 { + // The explicit `help` command is followed by the actual commands + // you want help with. The first one is the API name. + apiName = args[2] + } + + loaded := false + if apiName != "help" && apiName != "head" && apiName != "options" && apiName != "get" && apiName != "post" && apiName != "put" && apiName != "patch" && apiName != "delete" && apiName != "api" && apiName != "links" && apiName != "edit" && apiName != "auth-header" { + // Try to find the registered config for this API. If not found, + // there is no need to do anything since the normal flow will catch + // the command being missing and print help. + if cfg, ok := configs[apiName]; ok { + + // This is used to give context to findApi + // Smallest fix for https://github.com/danielgtaylor/restish/issues/128 + viper.Set("api-name", apiName) + + currentConfig = cfg + for _, cmd := range Root.Commands() { + if cmd.Use == apiName { + currentBase := cfg.Base + currentProfile := cfg.Profiles[profile] + if currentProfile == nil { + if profile != "default" { + panic("invalid profile " + profile) + } + } + if currentProfile != nil && currentProfile.Base != "" { + currentBase = currentProfile.Base + } + if _, err := Load(currentBase, cmd); err != nil { + panic(err) + } + loaded = true + break + } + } + } + } + + if !loaded { + // This could be a URL or short-name as part of a URL for generic + // commands. We should load the config for shell completion. + if (apiName == "head" || apiName == "options" || apiName == "get" || apiName == "post" || apiName == "put" || apiName == "patch" || apiName == "delete") && len(args) > 2 { + apiName = args[2] + } + apiName = fixAddress(apiName) + if name, _ := findAPI(apiName); name != "" { + currentConfig = configs[name] + } + } + } + + // Phew, we made it. Execute the command now that everything is loaded + // and all the relevant sub-commands are registered. + defer func() { + if err := recover(); err != nil { + LogError("Caught error: %v", err) + LogDebug("%s", string(debug.Stack())) + if e, ok := err.(error); ok { + returnErr = e + } else { + returnErr = fmt.Errorf("%v", err) + } + } + }() + if err := Root.Execute(); err != nil { + LogError("Error: %v", err) + returnErr = err + } + + return returnErr +} + +// Run the CLI! Parse arguments, make requests, print responses. +func RunEmbedded(runArgs []string, overrideAuthPrefix, overrideAuthToken string, newOut, newErr io.Writer) (returnErr error) { + // We need to register new commands at runtime based on the selected API + // so that we don't have to potentially refresh and parse every single + // registered API just to run. So this is a little hacky, but we hijack + // the input args to find non-option arguments, get the first arg, and + // if it isn't from a well-known set try to load that API. + args := []string{} + for _, arg := range runArgs { + if !strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "__") { + args = append(args, arg) + } + } + Root.Use = filepath.Base(args[0]) + + if os.Getenv("COLOR") != "" { + viper.Set("color", true) + } + if os.Getenv("NOCOLOR") != "" { + viper.Set("nocolor", true) + } + viper.Set("ni-override-auth-prefix", overrideAuthPrefix) + viper.Set("ni-override-auth-token", overrideAuthToken) + + // Because we may be doing HTTP calls before cobra has parsed the flags + // we parse the GlobalFlags here and already set some config values + // to ensure they are available + if err := GlobalFlags.Parse(args[1:]); err != nil { + if err != pflag.ErrHelp { + panic(err) + } + } + Root.SetArgs(args) + if newOut != nil { + Root.SetOut(newOut) + Stdout = newOut + } + if newErr != nil { + Root.SetErr(newErr) + Stderr = newErr + } + if noCache, _ := GlobalFlags.GetBool("rsh-no-cache"); noCache { + viper.Set("rsh-no-cache", true) + } + if verbose, _ := GlobalFlags.GetBool("rsh-verbose"); verbose { + viper.Set("rsh-verbose", true) + } + if insecure, _ := GlobalFlags.GetBool("rsh-insecure"); insecure { + viper.Set("rsh-insecure", true) + } + if cert, _ := GlobalFlags.GetString("rsh-client-cert"); cert != "" { + viper.Set("rsh-client-cert", cert) + } + if key, _ := GlobalFlags.GetString("rsh-client-key"); key != "" { + viper.Set("rsh-client-key", key) + } + if caCert, _ := GlobalFlags.GetString("rsh-ca-cert"); caCert != "" { + viper.Set("rsh-ca-cert", caCert) + } + if query, _ := GlobalFlags.GetStringArray("rsh-query"); len(query) > 0 { + viper.Set("rsh-query", query) + } + if headers, _ := GlobalFlags.GetStringArray("rsh-header"); len(headers) > 0 { + viper.Set("rsh-header", headers) + } + profile, _ := GlobalFlags.GetString("rsh-profile") + viper.Set("rsh-profile", profile) + if retries, _ := GlobalFlags.GetInt("rsh-retry"); retries > 0 { + viper.Set("rsh-retry", retries) + } + if timeout, _ := GlobalFlags.GetDuration("rsh-timeout"); timeout > 0 { + viper.Set("rsh-timeout", timeout) + } + + // Now that global flags are parsed we can enable verbose mode if requested. + if viper.GetBool("rsh-verbose") { + enableVerbose = true + } + + // Load the API commands if we can. + if len(args) > 1 { + apiName := args[0] + + if apiName == "help" && len(args) > 2 { + // The explicit `help` command is followed by the actual commands + // you want help with. The first one is the API name. + apiName = args[1] + } + + loaded := false + if apiName != "help" && apiName != "head" && apiName != "options" && apiName != "get" && apiName != "post" && apiName != "put" && apiName != "patch" && apiName != "delete" && apiName != "api" && apiName != "links" && apiName != "edit" && apiName != "auth-header" { + // Try to find the registered config for this API. If not found, + // there is no need to do anything since the normal flow will catch + // the command being missing and print help. + if cfg, ok := configs[apiName]; ok { + + // This is used to give context to findApi + // Smallest fix for https://github.com/danielgtaylor/restish/issues/128 + viper.Set("api-name", apiName) + + currentConfig = cfg + for _, cmd := range Root.Commands() { + if cmd.Use == apiName { + currentBase := cfg.Base + currentProfile := cfg.Profiles[profile] + if currentProfile == nil { + if profile != "default" { + return fmt.Errorf("invalid profile " + profile) + } + } + if currentProfile != nil && currentProfile.Base != "" { + currentBase = currentProfile.Base + } + if _, err := Load(currentBase, cmd); err != nil { + return err + } + loaded = true + break + } + } + } + } + + if !loaded { + // This could be a URL or short-name as part of a URL for generic + // commands. We should load the config for shell completion. + if (apiName == "head" || apiName == "options" || apiName == "get" || apiName == "post" || apiName == "put" || apiName == "patch" || apiName == "delete") && len(args) > 2 { + apiName = args[2] + } + apiName = fixAddress(apiName) + if name, _ := findAPI(apiName); name != "" { + currentConfig = configs[name] + } + } + } + + // Phew, we made it. Execute the command now that everything is loaded + // and all the relevant sub-commands are registered. + defer func() { + if err := recover(); err != nil { + LogError("Caught error: %v", err) + LogDebug("%s", string(debug.Stack())) + if e, ok := err.(error); ok { + returnErr = e + } else { + returnErr = fmt.Errorf("%v", err) + } + } + }() + if err := Root.Execute(); err != nil { + LogError("Error: %v", err) + returnErr = err + } + + return returnErr +} + +// GetExitCode returns the exit code to use based on the last HTTP status code. +func GetExitCode() int { + if s := GetLastStatus() / 100; s > 2 && !viper.GetBool("rsh-ignore-status-code") { + return s + } + + return 0 +} diff --git a/embedded/prep.go b/embedded/prep.go index e374184..fb68373 100644 --- a/embedded/prep.go +++ b/embedded/prep.go @@ -3,7 +3,6 @@ package embedded import ( "fmt" "io" - "strings" "github.com/danielgtaylor/restish/bulk" "github.com/danielgtaylor/restish/cli" @@ -31,19 +30,9 @@ func Restish(args []string, overrideAuthPrefix, overrideAuthToken string, newOut if overrideAuthToken != "" { cli.AddAuth("override", &cli.ExternalOverrideAuth{}) } - // We need to register new commands at runtime based on the selected API - // so that we don't have to potentially refresh and parse every single - // registered API just to run. So this is a little hacky, but we hijack - // the input args to find non-option arguments, get the first arg, and - // if it isn't from a well-known set try to load that API. - runArgs := []string{} - for _, arg := range args { - if !strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "__") { - runArgs = append(runArgs, arg) - } - } + // Run the CLI, parsing arguments, making requests, and printing responses. - if err := cli.RunEmbedded(runArgs, overrideAuthPrefix, overrideAuthToken, newOut, newErr); err != nil { + if err := cli.RunEmbedded(args, overrideAuthPrefix, overrideAuthToken, newOut, newErr); err != nil { return fmt.Errorf("%w %v", err, cli.GetExitCode()) } return nil From 4344d61aa0e1c8de06e0a112df2252cb8aee054c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Alleyne?= Date: Wed, 24 Apr 2024 14:31:12 -0400 Subject: [PATCH 3/5] add configurable appname to allow separate config for embedded and native restish on same system --- embedded/prep.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/embedded/prep.go b/embedded/prep.go index fb68373..10cf27b 100644 --- a/embedded/prep.go +++ b/embedded/prep.go @@ -12,9 +12,13 @@ import ( var version string = "embedded" -func Restish(args []string, overrideAuthPrefix, overrideAuthToken string, newOut, newErr io.Writer) error { - - cli.Init("restish", version) +func Restish(appName string, args []string, overrideAuthPrefix, overrideAuthToken string, newOut, newErr io.Writer) error { + switch appName { + case "": + return fmt.Errorf("no app name provided") + default: + cli.Init(appName, version) + } // Register default encodings, content type handlers, and link parsers. cli.Defaults() From 8299eca31e2115f3e6a0104c7704c5cc3066a22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Alleyne?= Date: Wed, 24 Apr 2024 15:49:06 -0400 Subject: [PATCH 4/5] fix args+flags --- cli/cli.go | 13 ++++--- embedded/prep.go | 90 +++++++++++++++++++++++++----------------------- 2 files changed, 53 insertions(+), 50 deletions(-) diff --git a/cli/cli.go b/cli/cli.go index a39ba30..ca61ce9 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -862,7 +862,7 @@ func Run() (returnErr error) { return returnErr } -// Run the CLI! Parse arguments, make requests, print responses. +// Run the CLI in embedded mode! Parse arguments, make requests, print responses. func RunEmbedded(runArgs []string, overrideAuthPrefix, overrideAuthToken string, newOut, newErr io.Writer) (returnErr error) { // We need to register new commands at runtime based on the selected API // so that we don't have to potentially refresh and parse every single @@ -870,12 +870,11 @@ func RunEmbedded(runArgs []string, overrideAuthPrefix, overrideAuthToken string, // the input args to find non-option arguments, get the first arg, and // if it isn't from a well-known set try to load that API. args := []string{} - for _, arg := range runArgs { + for _, arg := range os.Args { if !strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "__") { args = append(args, arg) } } - Root.Use = filepath.Base(args[0]) if os.Getenv("COLOR") != "" { viper.Set("color", true) @@ -889,12 +888,12 @@ func RunEmbedded(runArgs []string, overrideAuthPrefix, overrideAuthToken string, // Because we may be doing HTTP calls before cobra has parsed the flags // we parse the GlobalFlags here and already set some config values // to ensure they are available - if err := GlobalFlags.Parse(args[1:]); err != nil { + if err := GlobalFlags.Parse(os.Args[1:]); err != nil { if err != pflag.ErrHelp { panic(err) } } - Root.SetArgs(args) + if newOut != nil { Root.SetOut(newOut) Stdout = newOut @@ -943,12 +942,12 @@ func RunEmbedded(runArgs []string, overrideAuthPrefix, overrideAuthToken string, // Load the API commands if we can. if len(args) > 1 { - apiName := args[0] + apiName := args[1] if apiName == "help" && len(args) > 2 { // The explicit `help` command is followed by the actual commands // you want help with. The first one is the API name. - apiName = args[1] + apiName = args[2] } loaded := false diff --git a/embedded/prep.go b/embedded/prep.go index 10cf27b..a2332ae 100644 --- a/embedded/prep.go +++ b/embedded/prep.go @@ -1,43 +1,47 @@ -package embedded - -import ( - "fmt" - "io" - - "github.com/danielgtaylor/restish/bulk" - "github.com/danielgtaylor/restish/cli" - "github.com/danielgtaylor/restish/oauth" - "github.com/danielgtaylor/restish/openapi" -) - -var version string = "embedded" - -func Restish(appName string, args []string, overrideAuthPrefix, overrideAuthToken string, newOut, newErr io.Writer) error { - switch appName { - case "": - return fmt.Errorf("no app name provided") - default: - cli.Init(appName, version) - } - - // Register default encodings, content type handlers, and link parsers. - cli.Defaults() - - bulk.Init(cli.Root) - - // Register format loaders to auto-discover API descriptions - cli.AddLoader(openapi.New()) - - // Register auth schemes - cli.AddAuth("oauth-client-credentials", &oauth.ClientCredentialsHandler{}) - cli.AddAuth("oauth-authorization-code", &oauth.AuthorizationCodeHandler{}) - if overrideAuthToken != "" { - cli.AddAuth("override", &cli.ExternalOverrideAuth{}) - } - - // Run the CLI, parsing arguments, making requests, and printing responses. - if err := cli.RunEmbedded(args, overrideAuthPrefix, overrideAuthToken, newOut, newErr); err != nil { - return fmt.Errorf("%w %v", err, cli.GetExitCode()) - } - return nil -} +package embedded + +import ( + "fmt" + "io" + "os" + + "github.com/loicalleyne/restish/bulk" + "github.com/loicalleyne/restish/cli" + "github.com/loicalleyne/restish/oauth" + "github.com/loicalleyne/restish/openapi" +) + +var version string = "embedded" + +func Restish(appName string, args []string, overrideAuthPrefix, overrideAuthToken string, newOut, newErr io.Writer) error { + switch appName { + case "": + return fmt.Errorf("no app name provided") + default: + cli.Init(appName, version) + } + osArgsBackup := os.Args + defer func() { os.Args = osArgsBackup }() + os.Args = []string{"embedded"} + os.Args = append(os.Args, args...) + // Register default encodings, content type handlers, and link parsers. + cli.Defaults() + + bulk.Init(cli.Root) + + // Register format loaders to auto-discover API descriptions + cli.AddLoader(openapi.New()) + + // Register auth schemes + cli.AddAuth("oauth-client-credentials", &oauth.ClientCredentialsHandler{}) + cli.AddAuth("oauth-authorization-code", &oauth.AuthorizationCodeHandler{}) + if overrideAuthToken != "" { + cli.AddAuth("override", &cli.ExternalOverrideAuth{}) + } + + // Run the CLI, parsing arguments, making requests, and printing responses. + if err := cli.RunEmbedded(args, overrideAuthPrefix, overrideAuthToken, newOut, newErr); err != nil { + return fmt.Errorf("%w %v", err, cli.GetExitCode()) + } + return nil +} From 7559ab58e1149ad8c797f3c2842efa75eb22df12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Alleyne?= Date: Wed, 24 Apr 2024 16:07:26 -0400 Subject: [PATCH 5/5] simplified arg+flag passing reverted cli.go to original, no need for separate Run() for embedded since cli package exports Root, Stdout and Stderr. --- cli/cli.go | 156 ----------------------------------------------- embedded/prep.go | 24 +++++--- 2 files changed, 17 insertions(+), 163 deletions(-) diff --git a/cli/cli.go b/cli/cli.go index ca61ce9..9445dbf 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -862,162 +862,6 @@ func Run() (returnErr error) { return returnErr } -// Run the CLI in embedded mode! Parse arguments, make requests, print responses. -func RunEmbedded(runArgs []string, overrideAuthPrefix, overrideAuthToken string, newOut, newErr io.Writer) (returnErr error) { - // We need to register new commands at runtime based on the selected API - // so that we don't have to potentially refresh and parse every single - // registered API just to run. So this is a little hacky, but we hijack - // the input args to find non-option arguments, get the first arg, and - // if it isn't from a well-known set try to load that API. - args := []string{} - for _, arg := range os.Args { - if !strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "__") { - args = append(args, arg) - } - } - - if os.Getenv("COLOR") != "" { - viper.Set("color", true) - } - if os.Getenv("NOCOLOR") != "" { - viper.Set("nocolor", true) - } - viper.Set("ni-override-auth-prefix", overrideAuthPrefix) - viper.Set("ni-override-auth-token", overrideAuthToken) - - // Because we may be doing HTTP calls before cobra has parsed the flags - // we parse the GlobalFlags here and already set some config values - // to ensure they are available - if err := GlobalFlags.Parse(os.Args[1:]); err != nil { - if err != pflag.ErrHelp { - panic(err) - } - } - - if newOut != nil { - Root.SetOut(newOut) - Stdout = newOut - } - if newErr != nil { - Root.SetErr(newErr) - Stderr = newErr - } - if noCache, _ := GlobalFlags.GetBool("rsh-no-cache"); noCache { - viper.Set("rsh-no-cache", true) - } - if verbose, _ := GlobalFlags.GetBool("rsh-verbose"); verbose { - viper.Set("rsh-verbose", true) - } - if insecure, _ := GlobalFlags.GetBool("rsh-insecure"); insecure { - viper.Set("rsh-insecure", true) - } - if cert, _ := GlobalFlags.GetString("rsh-client-cert"); cert != "" { - viper.Set("rsh-client-cert", cert) - } - if key, _ := GlobalFlags.GetString("rsh-client-key"); key != "" { - viper.Set("rsh-client-key", key) - } - if caCert, _ := GlobalFlags.GetString("rsh-ca-cert"); caCert != "" { - viper.Set("rsh-ca-cert", caCert) - } - if query, _ := GlobalFlags.GetStringArray("rsh-query"); len(query) > 0 { - viper.Set("rsh-query", query) - } - if headers, _ := GlobalFlags.GetStringArray("rsh-header"); len(headers) > 0 { - viper.Set("rsh-header", headers) - } - profile, _ := GlobalFlags.GetString("rsh-profile") - viper.Set("rsh-profile", profile) - if retries, _ := GlobalFlags.GetInt("rsh-retry"); retries > 0 { - viper.Set("rsh-retry", retries) - } - if timeout, _ := GlobalFlags.GetDuration("rsh-timeout"); timeout > 0 { - viper.Set("rsh-timeout", timeout) - } - - // Now that global flags are parsed we can enable verbose mode if requested. - if viper.GetBool("rsh-verbose") { - enableVerbose = true - } - - // Load the API commands if we can. - if len(args) > 1 { - apiName := args[1] - - if apiName == "help" && len(args) > 2 { - // The explicit `help` command is followed by the actual commands - // you want help with. The first one is the API name. - apiName = args[2] - } - - loaded := false - if apiName != "help" && apiName != "head" && apiName != "options" && apiName != "get" && apiName != "post" && apiName != "put" && apiName != "patch" && apiName != "delete" && apiName != "api" && apiName != "links" && apiName != "edit" && apiName != "auth-header" { - // Try to find the registered config for this API. If not found, - // there is no need to do anything since the normal flow will catch - // the command being missing and print help. - if cfg, ok := configs[apiName]; ok { - - // This is used to give context to findApi - // Smallest fix for https://github.com/danielgtaylor/restish/issues/128 - viper.Set("api-name", apiName) - - currentConfig = cfg - for _, cmd := range Root.Commands() { - if cmd.Use == apiName { - currentBase := cfg.Base - currentProfile := cfg.Profiles[profile] - if currentProfile == nil { - if profile != "default" { - return fmt.Errorf("invalid profile " + profile) - } - } - if currentProfile != nil && currentProfile.Base != "" { - currentBase = currentProfile.Base - } - if _, err := Load(currentBase, cmd); err != nil { - return err - } - loaded = true - break - } - } - } - } - - if !loaded { - // This could be a URL or short-name as part of a URL for generic - // commands. We should load the config for shell completion. - if (apiName == "head" || apiName == "options" || apiName == "get" || apiName == "post" || apiName == "put" || apiName == "patch" || apiName == "delete") && len(args) > 2 { - apiName = args[2] - } - apiName = fixAddress(apiName) - if name, _ := findAPI(apiName); name != "" { - currentConfig = configs[name] - } - } - } - - // Phew, we made it. Execute the command now that everything is loaded - // and all the relevant sub-commands are registered. - defer func() { - if err := recover(); err != nil { - LogError("Caught error: %v", err) - LogDebug("%s", string(debug.Stack())) - if e, ok := err.(error); ok { - returnErr = e - } else { - returnErr = fmt.Errorf("%v", err) - } - } - }() - if err := Root.Execute(); err != nil { - LogError("Error: %v", err) - returnErr = err - } - - return returnErr -} - // GetExitCode returns the exit code to use based on the last HTTP status code. func GetExitCode() int { if s := GetLastStatus() / 100; s > 2 && !viper.GetBool("rsh-ignore-status-code") { diff --git a/embedded/prep.go b/embedded/prep.go index a2332ae..300bb3f 100644 --- a/embedded/prep.go +++ b/embedded/prep.go @@ -5,10 +5,11 @@ import ( "io" "os" - "github.com/loicalleyne/restish/bulk" - "github.com/loicalleyne/restish/cli" - "github.com/loicalleyne/restish/oauth" - "github.com/loicalleyne/restish/openapi" + "github.com/danielgtaylor/restish/bulk" + "github.com/danielgtaylor/restish/cli" + "github.com/danielgtaylor/restish/oauth" + "github.com/danielgtaylor/restish/openapi" + "github.com/spf13/viper" ) var version string = "embedded" @@ -22,7 +23,7 @@ func Restish(appName string, args []string, overrideAuthPrefix, overrideAuthToke } osArgsBackup := os.Args defer func() { os.Args = osArgsBackup }() - os.Args = []string{"embedded"} + os.Args = []string{"restish-embedded"} os.Args = append(os.Args, args...) // Register default encodings, content type handlers, and link parsers. cli.Defaults() @@ -37,10 +38,19 @@ func Restish(appName string, args []string, overrideAuthPrefix, overrideAuthToke cli.AddAuth("oauth-authorization-code", &oauth.AuthorizationCodeHandler{}) if overrideAuthToken != "" { cli.AddAuth("override", &cli.ExternalOverrideAuth{}) + viper.Set("ni-override-auth-prefix", overrideAuthPrefix) + viper.Set("ni-override-auth-token", overrideAuthToken) + } + if newOut != nil { + cli.Root.SetOut(newOut) + cli.Stdout = newOut + } + if newErr != nil { + cli.Root.SetErr(newErr) + cli.Stderr = newErr } - // Run the CLI, parsing arguments, making requests, and printing responses. - if err := cli.RunEmbedded(args, overrideAuthPrefix, overrideAuthToken, newOut, newErr); err != nil { + if err := cli.Run(); err != nil { return fmt.Errorf("%w %v", err, cli.GetExitCode()) } return nil