diff --git a/ctl/cli.go b/cli/cli.go similarity index 71% rename from ctl/cli.go rename to cli/cli.go index 57ce66bce..c61e39c7c 100644 --- a/ctl/cli.go +++ b/cli/cli.go @@ -1,9 +1,7 @@ -package ctl +package cli import ( - "bytes" "context" - "encoding/json" "fmt" "io" "net/http" @@ -16,9 +14,7 @@ import ( "github.com/jedib0t/go-pretty/table" "github.com/jedib0t/go-pretty/text" featurebase "github.com/molecula/featurebase/v3" - "github.com/molecula/featurebase/v3/dax" - queryerhttp "github.com/molecula/featurebase/v3/dax/queryer/http" - "github.com/molecula/featurebase/v3/fbcloud" + "github.com/molecula/featurebase/v3/cli/fbcloud" "github.com/molecula/featurebase/v3/logger" "github.com/pkg/errors" ) @@ -32,6 +28,12 @@ const ( nullValue string = "NULL" ) +var ( + Stdin io.ReadCloser = os.Stdin + Stdout io.Writer = os.Stdout + Stderr io.Writer = os.Stderr +) + var ( splash string = fmt.Sprintf(`FeatureBase CLI (%s) Type "exit" to quit. @@ -55,35 +57,52 @@ type CLICommand struct { OrganizationID string `json:"org-id"` DatabaseID string `json:"db-id"` - queryer FBQueryer + Queryer Queryer `json:"-"` + + Stdin io.ReadCloser `json:"-"` + Stdout io.Writer `json:"-"` + Stderr io.Writer `json:"-"` } func NewCLICommand(logdest logger.Logger) *CLICommand { + return &CLICommand{ + Host: defaultHost, + HistoryPath: "", + + OrganizationID: "", + DatabaseID: "", + + Stdin: Stdin, + Stdout: Stdout, + Stderr: Stderr, + } +} + +func (cmd *CLICommand) setupHistory() { + // If HistoryPath has already been configured (i.e. with a command flag), + // don't bother setting up the default in the home directory. + if cmd.HistoryPath != "" { + return + } + historyPath := "" - home, err := os.UserHomeDir() - if err != nil { - fmt.Printf("Error getting home directory, command history persistence will be disabled: %v\n", err) + if home, err := os.UserHomeDir(); err != nil { + cmd.Printf("Error getting home directory, command history persistence will be disabled: %v\n", err) } else { historyDir := filepath.Join(home, ".featurebase") - err := os.MkdirAll(historyDir, 0750) + err := os.MkdirAll(historyDir, 0o750) if err != nil { - fmt.Printf("Creating directory for history: %v\n", err) + cmd.Printf("Creating directory for history: %v\n", err) } else { historyPath = filepath.Join(historyDir, "cli_history") } } - return &CLICommand{ - Host: defaultHost, - HistoryPath: historyPath, - - OrganizationID: "", - DatabaseID: "", - } + cmd.HistoryPath = historyPath } // printQualifiers displays the currently set OrganizationID and DatabaseID. func (cmd *CLICommand) printQualifiers() { - fmt.Printf(" Host: %s\n Org: %s\n DB: %s\n", + cmd.Printf(" Host: %s\n Org: %s\n DB: %s\n", hostPort(cmd.Host, cmd.Port), cmd.OrganizationID, cmd.DatabaseID, @@ -91,6 +110,12 @@ func (cmd *CLICommand) printQualifiers() { } func (cmd *CLICommand) setupClient() error { + // If the Queryer has already been set (in tests for example), don't bother + // trying to detect it. + if cmd.Queryer != nil { + return nil + } + if strings.TrimSpace(cmd.Host) == "" { return errors.Errorf("no host provided") } @@ -106,20 +131,20 @@ func (cmd *CLICommand) setupClient() error { switch typ { case featurebaseTypeStandard: - fmt.Println("Detected standard deployment") - cmd.queryer = &standardQueryer{ + cmd.Printf("Detected standard deployment\n") + cmd.Queryer = &standardQueryer{ Host: cmd.Host, Port: cmd.Port, } case featurebaseTypeDAX: - fmt.Println("Detected dax deployment") - cmd.queryer = &daxQueryer{ + cmd.Printf("Detected dax deployment\n") + cmd.Queryer = &daxQueryer{ Host: cmd.Host, Port: cmd.Port, } case featurebaseTypeCloud: - fmt.Println("Detected cloud deployment") - cmd.queryer = &fbcloud.Queryer{ + cmd.Printf("Detected cloud deployment\n") + cmd.Queryer = &fbcloud.Queryer{ Host: hostPort(cmd.Host, cmd.Port), ClientID: cmd.ClientID, @@ -222,9 +247,9 @@ func (cmd *CLICommand) detectFBType() (featurebaseType, error) { func (cmd *CLICommand) Run(ctx context.Context) error { // Print the splash message. - fmt.Print(splash) - err := cmd.setupClient() - if err != nil { + cmd.Printf(splash) + cmd.setupHistory() + if err := cmd.setupClient(); err != nil { return errors.Wrap(err, "setting up client") } cmd.printQualifiers() @@ -234,6 +259,10 @@ func (cmd *CLICommand) Run(ctx context.Context) error { HistoryFile: cmd.HistoryPath, HistoryLimit: 100000, DisableAutoSaveHistory: true, + + Stdin: cmd.Stdin, + Stdout: cmd.Stdout, + Stderr: cmd.Stderr, }) if err != nil { return errors.Wrap(err, "getting readline") @@ -254,7 +283,7 @@ func (cmd *CLICommand) Run(ctx context.Context) error { } else { rl.SetPrompt(promptBegin) // Add some white space before each new prompt. - fmt.Println() + cmd.Printf("\n") } // Read user provided input. @@ -263,6 +292,27 @@ func (cmd *CLICommand) Run(ctx context.Context) error { return errors.Wrap(err, "reading line") } + // We append a line feed at the end of each line because at this point + // we have effectively stripped any intentional line feeds (since we are + // reading a line at a time), and we don't want to do that. An example + // of an intentional line feed is in a BULK INSERT CSV STREAM like this + // example: + // + // bulk replace + // into foo (_id, age) + // map (0 id, 1 int) + // from + // x'3,33 + // 4,44 + // 5,55' + // with + // format 'CSV' + // input 'STREAM'; + // + // We want to preserve the line feeds that are contained in the x'' + // block; those are intentional as they demarc records within the csv. + line += "\n" + if !inMidCommand { // Handle the exit command. if line == exitCommand || line == exitCommand+terminationChar { @@ -284,7 +334,7 @@ func (cmd *CLICommand) Run(ctx context.Context) error { for i, part := range parts { partIsFinal := i == len(parts)-1 - partIsBlank := part == "" + partIsBlank := strings.TrimSpace(part) == "" if partIsBlank && partIsFinal { continue @@ -315,7 +365,7 @@ func (cmd *CLICommand) Run(ctx context.Context) error { err = rl.SaveHistory(strings.Join(cmd.commands, "; ") + ";") if err != nil { - fmt.Printf("Couldn't save history: %v\n", err) + cmd.Printf("Couldn't save history: %v\n", err) } if err := cmd.executeCommands(ctx); err != nil { @@ -330,14 +380,10 @@ func appendCommand(orig string, part string) string { if orig == "" { return part } else { - return orig + " " + part + return orig + part } } -type FBQueryer interface { - Query(org, db, sql string) (*featurebase.WireQueryResponse, error) -} - func (cmd *CLICommand) executeCommands(ctx context.Context) error { // Clear out the buffered commands on any exit from this method. defer func() { @@ -352,12 +398,12 @@ func (cmd *CLICommand) executeCommands(ctx context.Context) error { continue } - sqlResponse, err := cmd.queryer.Query(cmd.OrganizationID, cmd.DatabaseID, sql) + sqlResponse, err := cmd.Queryer.Query(cmd.OrganizationID, cmd.DatabaseID, sql) if err != nil { - fmt.Printf("making query: %v\n", err) + cmd.Printf("making query: %v\n", err) continue } - err = writeOut(sqlResponse, os.Stdout) + err = writeOut(sqlResponse, cmd.Stdout, cmd.Stderr) if err != nil { return errors.Wrap(err, "writing out response") } @@ -366,6 +412,12 @@ func (cmd *CLICommand) executeCommands(ctx context.Context) error { return nil } +// Printf is a helper method which sends the given payload to stdout. +func (cmd *CLICommand) Printf(format string, a ...any) { + out := fmt.Sprintf(format, a...) + cmd.Stdout.Write([]byte(out)) +} + // handleIfNonSQLCommand will handle special case command like "SET ..." and // "USE ...". If the sql command matches one of these conditions and is handled, // the bool returned will be true; @@ -431,19 +483,19 @@ func writeWarnings(r *featurebase.WireQueryResponse, w io.Writer) error { return nil } -func writeOut(r *featurebase.WireQueryResponse, w io.Writer) error { +func writeOut(r *featurebase.WireQueryResponse, wOut io.Writer, wErr io.Writer) error { if r == nil { return errors.New("attempt to write out nil response") } if r.Error != "" { - if _, err := w.Write([]byte("Error: " + r.Error + "\n")); err != nil { + if _, err := wErr.Write([]byte("Error: " + r.Error + "\n")); err != nil { return errors.Wrapf(err, "writing error: %s", r.Error) } - return writeWarnings(r, w) + return writeWarnings(r, wOut) } t := table.NewWriter() - t.SetOutputMirror(w) + t.SetOutputMirror(wOut) // Don't uppercase the header values. t.Style().Format.Header = text.FormatDefault @@ -461,7 +513,7 @@ func writeOut(r *featurebase.WireQueryResponse, w io.Writer) error { } t.Render() - err := writeWarnings(r, w) + err := writeWarnings(r, wOut) if err != nil { return err } @@ -474,7 +526,7 @@ func writeOut(r *featurebase.WireQueryResponse, w io.Writer) error { lifeAffirmingMessage = " (Sorry! That took longer than expected 😭)" } - if _, err := w.Write([]byte(fmt.Sprintf("\nExecution time: %dμs%s\n", r.ExecutionTime, lifeAffirmingMessage))); err != nil { + if _, err := wOut.Write([]byte(fmt.Sprintf("\nExecution time: %dμs%s\n", r.ExecutionTime, lifeAffirmingMessage))); err != nil { return errors.Wrapf(err, "writing execution time: %s", r.Error) } @@ -488,81 +540,3 @@ func schemaToRow(schema featurebase.WireQuerySchema) []interface{} { } return ret } - -// Ensure type implements interface. -var _ FBQueryer = (*standardQueryer)(nil) - -// standardQueryer supports a standard featurebase deployment hitting the /sql -// endpoint with a payload containing only the sql statement. -type standardQueryer struct { - Host string - Port string -} - -func (qryr *standardQueryer) Query(org, db, sql string) (*featurebase.WireQueryResponse, error) { - buf := bytes.Buffer{} - url := fmt.Sprintf("%s/sql", hostPort(qryr.Host, qryr.Port)) - - buf.Write([]byte(sql)) - - resp, err := http.Post(url, "application/json", &buf) - if err != nil { - return nil, errors.Wrapf(err, "posting query") - } - - fullbod, err := io.ReadAll(resp.Body) - if err != nil { - return nil, errors.Wrap(err, "reading response") - } - sqlResponse := &featurebase.WireQueryResponse{} - // TODO(tlt): switch this back once all responses are typed - // if err := json.Unmarshal(fullbod, sqlResponse); err != nil { - if err := sqlResponse.UnmarshalJSONTyped(fullbod, true); err != nil { - return nil, errors.Wrapf(err, "unmarshaling query response, body:\n'%s'\n", fullbod) - } - - return sqlResponse, nil -} - -// Ensure type implements interface. -var _ FBQueryer = (*daxQueryer)(nil) - -// daxQueryer is similar to the standardQueryer except that it hits a different -// endpoint, and its payload is a json object which includes, in addition to the -// sql statement, things like org and db. -type daxQueryer struct { - Host string - Port string -} - -func (qryr *daxQueryer) Query(org, db, sql string) (*featurebase.WireQueryResponse, error) { - buf := bytes.Buffer{} - url := fmt.Sprintf("%s/queryer/sql", hostPort(qryr.Host, qryr.Port)) - - sqlReq := &queryerhttp.SQLRequest{ - OrganizationID: dax.OrganizationID(org), - DatabaseID: dax.DatabaseID(db), - SQL: sql, - } - if err := json.NewEncoder(&buf).Encode(sqlReq); err != nil { - return nil, errors.Wrapf(err, "encoding sql request: %s", sql) - } - - resp, err := http.Post(url, "application/json", &buf) - if err != nil { - return nil, errors.Wrapf(err, "posting query") - } - - fullbod, err := io.ReadAll(resp.Body) - if err != nil { - return nil, errors.Wrap(err, "reading response") - } - sqlResponse := &featurebase.WireQueryResponse{} - // TODO(tlt): switch this back once all responses are typed - // if err := json.Unmarshal(fullbod, sqlResponse); err != nil { - if err := sqlResponse.UnmarshalJSONTyped(fullbod, true); err != nil { - return nil, errors.Wrapf(err, "unmarshaling query response, body:\n'%s'\n", fullbod) - } - - return sqlResponse, nil -} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 000000000..69bcb690f --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,199 @@ +package cli_test + +import ( + "context" + "io" + "strings" + "sync" + "testing" + "time" + + featurebase "github.com/molecula/featurebase/v3" + "github.com/molecula/featurebase/v3/cli" + "github.com/molecula/featurebase/v3/logger" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +func TestCLI(t *testing.T) { + t.Run("Input", func(t *testing.T) { + ctx := context.Background() + + capture := newCapture(t) + + cli := cli.NewCLICommand(logger.StderrLogger) + cli.Stdin = capture + cli.Stdout = capture + cli.Queryer = capture + + go func() { + assert.NoError(t, cli.Run(ctx)) + }() + + none := []string{} + + // One statement, one line. + capture.Assert("one;", []string{`one`}) + + // One statement, multiple lines. + capture.Assert("one", none) + capture.Assert(" two ", none) + capture.Assert("three;", []string{`one + two +three`}) + + // Multiple statements, one line. + capture.Assert("foo; bar;", []string{`foo`, `bar`}) + + // Multiple statements, multiple lines. + capture.Assert("a1", none) + capture.Assert("a2; b1", []string{`a1 +a2`}) + capture.Assert("b2;", []string{`b1 +b2`}) + + // Blank lines. + capture.Assert("one", none) + capture.Assert("", none) + capture.Assert("three;", []string{`one + +three`}) + + // Just a semi-colon. + capture.Assert(";", none) + + // Multi-line with just a semi-colon. + capture.Assert("one", none) + capture.Assert(";", []string{`one`}) + + // Ensure a clean exit with no errors. + assert.NoError(t, capture.Exit()) + }) +} + +//////////////////////////////////////////////////////// + +// Ensure type implementes interface. +var _ io.ReadCloser = (*capture)(nil) +var _ io.Writer = (*capture)(nil) +var _ cli.Queryer = (*capture)(nil) + +// capture implements the various CLI interfaces in order to capture test input +// and submit it as though that input were being read from the command line. It +// also captures calls made to the Queryer.Query method and ensures the sql the +// contain is expected. +type capture struct { + t *testing.T + + // ch is a channel of strings (one line at a time) of CLI input. + ch chan string + + mu sync.RWMutex + sqls []string + + // queryDone will receive an event any time the Query method is called and + // has completed. This is to tell the Assert method that it's safe to + // compare the sqls slice. + queryDone chan struct{} + + asserting chan struct{} + + err error +} + +func newCapture(t *testing.T) *capture { + return &capture{ + t: t, + ch: make(chan string), + sqls: make([]string, 0), + queryDone: make(chan struct{}), + } +} + +func (c *capture) Exit() error { + c.sendLine("exit") + c.mu.RLock() + defer c.mu.RUnlock() + return c.err +} + +func (c *capture) Assert(in string, out []string) { + c.asserting = make(chan struct{}) + + c.sendLine(in) + + // Wait for the CLI command to complete processing the input and send the + // sql to Query() by blocking on the queryDone channel. Because Query gets + // called for every sql statement in the input, an input resulting in + // multiple sql statements needs to wait for all expected queries to + // complete. A timeout is included to this so it doesn't deadlock in the + // case where Query is expected to be called, but isn't; after the timeout, + // the test should fail completely. In summary: we wait on queryDone the + // number of sql statements we expect. If we receive fewer than expected, + // the timeout will occur. If we receive more than expected, the Query() + // method will effectively deadlock, reach its own timout, then write to + // capture.err, which will be reported upon Exit(). + for range out { + select { + case <-c.queryDone: + case <-time.After(2 * time.Second): + c.t.Fatalf("expected Query() to be called") + } + } + + close(c.asserting) + + c.mu.Lock() + defer c.mu.Unlock() + + assert.Equal(c.t, out, c.sqls) + + // Reset the slice. + c.sqls = c.sqls[:0] + +} + +// sendLine sends the given string as a line input to the CLI command. It +// appends a line feed to the end of string in order to mimic the user hitting +// the return key. +func (c *capture) sendLine(s string) { + // Add a line feed before putting s on the channel in order to mimic the + // user hitting the return key. + c.ch <- s + "\n" +} + +// Read is read by the CLI in place of user input. It effectively sends lines of +// input to the CLI, getting each line to be sent off the channel. +func (c *capture) Read(b []byte) (n int, err error) { + s := <-c.ch + return strings.NewReader(s).Read(b) +} + +func (c *capture) Close() error { + close(c.ch) + return nil +} + +// Write is called with anything written to output. This would included results +// from calling Query() under normal, non-testing conditions, as well as other +// informational text sent to output, such as the splash message. +func (c *capture) Write(b []byte) (n int, err error) { + return 0, nil +} + +// Query is called by the CLI command once a full SQL statement is received +// (signified by the terminator: `;`). +func (c *capture) Query(org, db, sql string) (*featurebase.WireQueryResponse, error) { + c.mu.Lock() + c.sqls = append(c.sqls, sql) + c.mu.Unlock() + + select { + case c.queryDone <- struct{}{}: + case <-c.asserting: + c.mu.Lock() + c.err = errors.Errorf("unexpected query: %s", sql) + c.mu.Unlock() + } + return &featurebase.WireQueryResponse{}, nil +} diff --git a/fbcloud/auth.go b/cli/fbcloud/auth.go similarity index 100% rename from fbcloud/auth.go rename to cli/fbcloud/auth.go diff --git a/fbcloud/client.go b/cli/fbcloud/client.go similarity index 100% rename from fbcloud/client.go rename to cli/fbcloud/client.go diff --git a/cli/queryer.go b/cli/queryer.go new file mode 100644 index 000000000..cd54c57e4 --- /dev/null +++ b/cli/queryer.go @@ -0,0 +1,96 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + featurebase "github.com/molecula/featurebase/v3" + "github.com/molecula/featurebase/v3/dax" + queryerhttp "github.com/molecula/featurebase/v3/dax/queryer/http" + "github.com/pkg/errors" +) + +type Queryer interface { + Query(org, db, sql string) (*featurebase.WireQueryResponse, error) +} + +// Ensure type implements interface. +var _ Queryer = (*standardQueryer)(nil) + +// standardQueryer supports a standard featurebase deployment hitting the /sql +// endpoint with a payload containing only the sql statement. +type standardQueryer struct { + Host string + Port string +} + +func (qryr *standardQueryer) Query(org, db, sql string) (*featurebase.WireQueryResponse, error) { + buf := bytes.Buffer{} + url := fmt.Sprintf("%s/sql", hostPort(qryr.Host, qryr.Port)) + + buf.Write([]byte(sql)) + + resp, err := http.Post(url, "application/json", &buf) + if err != nil { + return nil, errors.Wrapf(err, "posting query") + } + + fullbod, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "reading response") + } + sqlResponse := &featurebase.WireQueryResponse{} + // TODO(tlt): switch this back once all responses are typed + // if err := json.Unmarshal(fullbod, sqlResponse); err != nil { + if err := sqlResponse.UnmarshalJSONTyped(fullbod, true); err != nil { + return nil, errors.Wrapf(err, "unmarshaling query response, body:\n'%s'\n", fullbod) + } + + return sqlResponse, nil +} + +// Ensure type implements interface. +var _ Queryer = (*daxQueryer)(nil) + +// daxQueryer is similar to the standardQueryer except that it hits a different +// endpoint, and its payload is a json object which includes, in addition to the +// sql statement, things like org and db. +type daxQueryer struct { + Host string + Port string +} + +func (qryr *daxQueryer) Query(org, db, sql string) (*featurebase.WireQueryResponse, error) { + buf := bytes.Buffer{} + url := fmt.Sprintf("%s/queryer/sql", hostPort(qryr.Host, qryr.Port)) + + sqlReq := &queryerhttp.SQLRequest{ + OrganizationID: dax.OrganizationID(org), + DatabaseID: dax.DatabaseID(db), + SQL: sql, + } + if err := json.NewEncoder(&buf).Encode(sqlReq); err != nil { + return nil, errors.Wrapf(err, "encoding sql request: %s", sql) + } + + resp, err := http.Post(url, "application/json", &buf) + if err != nil { + return nil, errors.Wrapf(err, "posting query") + } + + fullbod, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "reading response") + } + sqlResponse := &featurebase.WireQueryResponse{} + // TODO(tlt): switch this back once all responses are typed + // if err := json.Unmarshal(fullbod, sqlResponse); err != nil { + if err := sqlResponse.UnmarshalJSONTyped(fullbod, true); err != nil { + return nil, errors.Wrapf(err, "unmarshaling query response, body:\n'%s'\n", fullbod) + } + + return sqlResponse, nil +} diff --git a/cmd/cli.go b/cmd/cli.go index fd0e8d3e1..0def445ae 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -2,34 +2,34 @@ package cmd import ( - "github.com/molecula/featurebase/v3/ctl" + "github.com/molecula/featurebase/v3/cli" "github.com/molecula/featurebase/v3/logger" "github.com/spf13/cobra" ) -var cli *ctl.CLICommand +var cliCmd *cli.CLICommand // newCLICommand runs the FeatureBase CLI subcommand for ingesting bulk data. func newCLICommand(logdest logger.Logger) *cobra.Command { - cli = ctl.NewCLICommand(logdest) - cliCmd := &cobra.Command{ + cliCmd = cli.NewCLICommand(logdest) + cobraCmd := &cobra.Command{ Use: "cli", Short: "Query FB with SQL3 from the command line", Long: ``, - RunE: usageErrorWrapper(cli), + RunE: usageErrorWrapper(cliCmd), } - flags := cliCmd.Flags() - flags.StringVarP(&cli.Host, "host", "", cli.Host, "hostname of FeatureBase.") - flags.StringVarP(&cli.Port, "port", "", cli.Port, "port of FeatureBase.") - flags.StringVar(&cli.HistoryPath, "history-path", cli.HistoryPath, "path for history files.") - flags.StringVar(&cli.OrganizationID, "org-id", cli.OrganizationID, "OrganizationID.") - flags.StringVar(&cli.DatabaseID, "db-id", cli.DatabaseID, "DatabaseID.") + flags := cobraCmd.Flags() + flags.StringVarP(&cliCmd.Host, "host", "", cliCmd.Host, "hostname of FeatureBase.") + flags.StringVarP(&cliCmd.Port, "port", "", cliCmd.Port, "port of FeatureBase.") + flags.StringVar(&cliCmd.HistoryPath, "history-path", cliCmd.HistoryPath, "path for history files.") + flags.StringVar(&cliCmd.OrganizationID, "org-id", cliCmd.OrganizationID, "OrganizationID.") + flags.StringVar(&cliCmd.DatabaseID, "db-id", cliCmd.DatabaseID, "DatabaseID.") - flags.StringVar(&cli.ClientID, "client-id", cli.ClientID, "Cognito Client ID for FeatureBase Cloud access.") - flags.StringVar(&cli.Region, "region", cli.Region, "Cloud region for FeatureBase Cloud access (e.g. us-east-2).") - flags.StringVar(&cli.Email, "email", cli.Email, "Email address for FeatureBase Cloud access.") - flags.StringVar(&cli.Password, "password", cli.Password, "Password for FeatureBase Cloud access.") + flags.StringVar(&cliCmd.ClientID, "client-id", cliCmd.ClientID, "Cognito Client ID for FeatureBase Cloud access.") + flags.StringVar(&cliCmd.Region, "region", cliCmd.Region, "Cloud region for FeatureBase Cloud access (e.g. us-east-2).") + flags.StringVar(&cliCmd.Email, "email", cliCmd.Email, "Email address for FeatureBase Cloud access.") + flags.StringVar(&cliCmd.Password, "password", cliCmd.Password, "Password for FeatureBase Cloud access.") - return cliCmd + return cobraCmd }