Refactor CLI (#2417)

This commit moves the cli out of the `ctl` package and into its own
`cli` package. It also adds some basic tests for expected input.

Finally, it fixes a bug which was causing intentional line feeds to be
ignored, which was a problem with the BULK INSERT command.
This commit is contained in:
Travis Turner 2023-01-18 14:27:45 -06:00 committed by GitHub
parent b6e642338a
commit cf72bfa16f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 409 additions and 140 deletions

View file

@ -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
}

199
cli/cli_test.go Normal file
View file

@ -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
}

96
cli/queryer.go Normal file
View file

@ -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
}

View file

@ -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
}