diff --git a/newreleases/cmd/auth.go b/newreleases/cmd/auth.go index 55452f0..dba4336 100644 --- a/newreleases/cmd/auth.go +++ b/newreleases/cmd/auth.go @@ -15,7 +15,7 @@ import ( "newreleases.io/newreleases" ) -func init() { +func (c *command) initAuthCmd() (err error) { authCmd := &cobra.Command{ Use: "auth", Short: "Information about API authentication", @@ -25,10 +25,10 @@ func init() { Use: "list", Short: "Get all API authentication keys", RunE: func(cmd *cobra.Command, args []string) (err error) { - ctx, cancel := newClientContext() + ctx, cancel := newClientContext(c.config) defer cancel() - keys, err := cmdAuthService.List(ctx) + keys, err := c.authService.List(ctx) if err != nil { return err } @@ -42,27 +42,28 @@ func init() { return nil }, - PreRunE: setCmdAuthService, + PreRunE: c.setAuthService, } - addClientFlags(listCmd) + if err := addClientFlags(listCmd, c.config); err != nil { + return err + } authCmd.AddCommand(listCmd) - rootCmd.AddCommand(authCmd) + c.root.AddCommand(authCmd) + return nil } -var cmdAuthService authService - -func setCmdAuthService(cmd *cobra.Command, args []string) (err error) { - if cmdAuthService != nil { +func (c *command) setAuthService(cmd *cobra.Command, args []string) (err error) { + if c.authService != nil { return nil } - client, err := newClient() + client, err := c.getClient(cmd) if err != nil { return err } - cmdAuthService = client.Auth + c.authService = client.Auth return nil } diff --git a/newreleases/cmd/auth_test.go b/newreleases/cmd/auth_test.go index 803031d..792df27 100644 --- a/newreleases/cmd/auth_test.go +++ b/newreleases/cmd/auth_test.go @@ -60,16 +60,19 @@ func TestAuthCmd(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { var outputBuf bytes.Buffer - ExecuteT(t, - WithArgs("auth", "list"), - WithOutput(&outputBuf), - WithAuthService(tc.authService), - WithError(tc.wantError), + + c := newCommand(t, + cmd.WithArgs("auth", "list"), + cmd.WithOutput(&outputBuf), + cmd.WithAuthService(tc.authService), ) + if err := c.Execute(); err != tc.wantError { + t.Fatalf("got error %v, want %v", err, tc.wantError) + } gotOutput := outputBuf.String() if gotOutput != tc.wantOutput { - t.Errorf("got error output %q, want %q", gotOutput, tc.wantOutput) + t.Errorf("got output %q, want %q", gotOutput, tc.wantOutput) } }) } diff --git a/newreleases/cmd/client.go b/newreleases/cmd/client.go new file mode 100644 index 0000000..5d590f1 --- /dev/null +++ b/newreleases/cmd/client.go @@ -0,0 +1,71 @@ +// Copyright (c) 2019, NewReleases CLI AUTHORS. +// All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmd + +import ( + "context" + "errors" + "net/url" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "newreleases.io/newreleases" +) + +func (c *command) getClient(cmd *cobra.Command) (client *newreleases.Client, err error) { + if c.client != nil { + return c.client, nil + } + + authKey := c.config.GetString(optionNameAuthKey) + if authKey == "" { + return nil, errors.New("auth key not configured") + } + o, err := newClientOptions(cmd) + if err != nil { + return nil, err + } + c.client = newreleases.NewClient(authKey, o) + return c.client, nil +} + +func newClientOptions(cmd *cobra.Command) (o *newreleases.ClientOptions, err error) { + v, err := cmd.Flags().GetString(optionNameAPIEndpoint) + if err != nil { + return nil, err + } + var baseURL *url.URL + if v != "" { + baseURL, err = url.Parse(v) + if err != nil { + return nil, err + } + } + return &newreleases.ClientOptions{BaseURL: baseURL}, nil +} + +func newClientContext(config *viper.Viper) (ctx context.Context, cancel context.CancelFunc) { + return context.WithTimeout(context.Background(), config.GetDuration(optionNameTimeout)) +} + +func addClientFlags(cmd *cobra.Command, config *viper.Viper) (err error) { + flags := cmd.Flags() + flags.String(optionNameAuthKey, "", "API auth key") + flags.Duration(optionNameTimeout, 30*time.Second, "API request timeout") + flags.String(optionNameAPIEndpoint, "", "API Endpoint") + if err := flags.MarkHidden(optionNameAPIEndpoint); err != nil { + return err + } + + if err := config.BindPFlag(optionNameAuthKey, flags.Lookup(optionNameAuthKey)); err != nil { + return err + } + if err := config.BindPFlag(optionNameTimeout, flags.Lookup(optionNameTimeout)); err != nil { + return err + } + return nil +} diff --git a/newreleases/cmd/cmd.go b/newreleases/cmd/cmd.go index f7d1e33..d1e3c85 100644 --- a/newreleases/cmd/cmd.go +++ b/newreleases/cmd/cmd.go @@ -6,17 +6,13 @@ package cmd import ( - "context" "errors" - "fmt" - "net/url" - "os" + "path/filepath" "strings" - "time" + "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" "github.com/spf13/viper" - "golang.org/x/crypto/ssh/terminal" "newreleases.io/newreleases" ) @@ -26,93 +22,127 @@ const ( optionNameAPIEndpoint = "api-endpoint" ) -var cmdPasswordReader passwordReader = new(stdInPasswordReader) - -type passwordReader interface { - ReadPassword() (password string, err error) +type command struct { + root *cobra.Command + config *viper.Viper + client *newreleases.Client + cfgFile string + homeDir string + passwordReader passwordReader + authService authService + providerService providerService + authKeysGetter authKeysGetter } -type stdInPasswordReader struct{} +type option func(*command) -func (stdInPasswordReader) ReadPassword() (password string, err error) { - v, err := terminal.ReadPassword(int(os.Stdin.Fd())) - if err != nil { - return "", err +func newCommand(opts ...option) (c *command, err error) { + c = &command{ + root: &cobra.Command{ + Use: "newreleases", + Short: "Release tracker for software engineers", + SilenceErrors: true, + SilenceUsage: true, + }, } - return string(v), err -} -func terminalPrompt(cmd *cobra.Command, reader interface{ ReadString(byte) (string, error) }, title string) (value string, err error) { - cmd.Print(title + ": ") - value, err = reader.ReadString('\n') - if err != nil { - return "", err + for _, o := range opts { + o(c) + } + if c.passwordReader == nil { + c.passwordReader = new(stdInPasswordReader) } - return strings.TrimSpace(value), nil -} -func terminalPromptPassword(cmd *cobra.Command, title string) (password string, err error) { - cmd.Print(title + ": ") - password, err = cmdPasswordReader.ReadPassword() - cmd.Println() - if err != nil { - return "", err + c.initGlobalFlags() + if err := c.initConfig(); err != nil { + return nil, err } - return password, nil -} -func writeConfig(cmd *cobra.Command, authKey string) (err error) { - viper.Set(optionNameAuthKey, strings.TrimSpace(authKey)) - err = viper.WriteConfig() - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - err = viper.SafeWriteConfigAs(cfgFile) + if err := c.initAuthCmd(); err != nil { + return nil, err } - return err + c.initConfigureCmd() + if err := c.initGetAuthKeyCmd(); err != nil { + return nil, err + } + if err := c.initProviderCmd(); err != nil { + return nil, err + } + c.initVersionCmd() + return c, nil } -func newClient() (client *newreleases.Client, err error) { - authKey := viper.GetString(optionNameAuthKey) - if authKey == "" { - return nil, errors.New("auth key not configured") +func (c *command) Execute() (err error) { + return c.root.Execute() +} + +// Execute parses command line arguments and runs appropriate functions. +func Execute() (err error) { + c, err := newCommand() + if err != nil { + return err } - return newreleases.NewClient(authKey, newClientOptions()), nil + return c.Execute() } -func addClientFlags(cmd *cobra.Command) { - flags := cmd.Flags() - flags.String(optionNameAuthKey, "", "API auth key") - flags.Duration(optionNameTimeout, 30*time.Second, "API request timeout") - flags.String(optionNameAPIEndpoint, "", "API Endpoint") - must(flags.MarkHidden(optionNameAPIEndpoint)) - - cobra.OnInitialize(func() { - must(viper.BindPFlag(optionNameAuthKey, flags.Lookup(optionNameAuthKey))) - must(viper.BindPFlag(optionNameTimeout, flags.Lookup(optionNameTimeout))) - }) +func (c *command) initGlobalFlags() { + globalFlags := c.root.PersistentFlags() + globalFlags.StringVar(&c.cfgFile, "config", "", "config file (default is $HOME/.newreleases.yaml)") } -func newClientOptions() (o *newreleases.ClientOptions) { - return &newreleases.ClientOptions{ - BaseURL: mustURLParse(viper.GetString(optionNameAPIEndpoint)), +func (c *command) initConfig() (err error) { + config := viper.New() + configName := ".newreleases" + if c.cfgFile != "" { + // Use config file from the flag. + config.SetConfigFile(c.cfgFile) + } else { + // Find home directory. + if err := c.setHomeDir(); err != nil { + return err + } + // Search config in home directory with name ".newreleases" (without extension). + config.AddConfigPath(c.homeDir) + config.SetConfigName(configName) } -} -func newClientContext() (ctx context.Context, cancel context.CancelFunc) { - return context.WithTimeout(context.Background(), viper.GetDuration(optionNameTimeout)) + // Environment + config.SetEnvPrefix("newreleases") + config.AutomaticEnv() // read in environment variables that match + config.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + + if c.homeDir != "" && c.cfgFile == "" { + c.cfgFile = filepath.Join(c.homeDir, configName+".yaml") + } + + // If a config file is found, read it in. + if err := config.ReadInConfig(); err != nil { + var e viper.ConfigFileNotFoundError + if !errors.As(err, &e) { + return err + } + } + c.config = config + return nil } -func must(err error) { +func (c *command) setHomeDir() (err error) { + if c.homeDir != "" { + return + } + dir, err := homedir.Dir() if err != nil { - fmt.Fprintln(os.Stderr, "Error:", err) - os.Exit(1) + return err } + c.homeDir = dir + return nil } -func mustURLParse(s string) (u *url.URL) { - if s == "" { - return nil +func (c *command) writeConfig(cmd *cobra.Command, authKey string) (err error) { + c.config.Set(optionNameAuthKey, strings.TrimSpace(authKey)) + err = c.config.WriteConfig() + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + err = c.config.SafeWriteConfigAs(c.cfgFile) } - u, err := url.Parse(s) - must(err) - return u + return err } diff --git a/newreleases/cmd/cmd_test.go b/newreleases/cmd/cmd_test.go index 6f65b48..4de143e 100644 --- a/newreleases/cmd/cmd_test.go +++ b/newreleases/cmd/cmd_test.go @@ -6,18 +6,16 @@ package cmd_test import ( - "bytes" "fmt" - "io" "io/ioutil" "os" "testing" "newreleases.io/cmd/newreleases/cmd" - - "github.com/spf13/cobra" ) +var homeDir string + func TestMain(m *testing.M) { dir, err := ioutil.TempDir("", "newreleases-cmd-") if err != nil { @@ -26,111 +24,18 @@ func TestMain(m *testing.M) { } defer os.RemoveAll(dir) - cmd.SetTestHomeDir(dir) + homeDir = dir os.Exit(m.Run()) } -// ExecuteT is a test function that executes command with options. -func ExecuteT(t *testing.T, opts ...Option) { +func newCommand(t *testing.T, opts ...cmd.Option) (c *cmd.Command) { t.Helper() - o := &options{ - cmd: cmd.RootCmd, - errorRecorder: new(bytes.Buffer), - } - cmd.RootCmd.SetErr(o.errorRecorder) - for _, opt := range opts { - callback := opt.apply(o) - if callback != nil { - defer callback() - } - } - defer cmd.NewResetCfgFileFunc()() - - if err := cmd.Execute(); err != o.wantError { - t.Fatalf("got error %v, want %v", err, o.wantError) - } - if o.errorRecorder != nil { - if errorOutput := o.errorRecorder.String(); errorOutput != "" { - t.Fatalf("got unexpected error output:\n%q", errorOutput) - } + opts = append([]cmd.Option{cmd.WithHomeDir(homeDir)}, opts...) + c, err := cmd.NewCommand(opts...) + if err != nil { + t.Fatal(err) } + return c } - -type Option interface { - apply(*options) (callback func()) -} - -type options struct { - cmd *cobra.Command - errorRecorder *bytes.Buffer - wantError error -} - -func WithArgs(a ...string) Option { - return optionFunc(func(o *options) func() { - o.cmd.SetArgs(a) - return nil - }) -} - -func WithInput(r io.Reader) Option { - return optionFunc(func(o *options) func() { - o.cmd.SetIn(r) - return nil - }) -} - -func WithOutput(w io.Writer) Option { - return optionFunc(func(o *options) func() { - o.cmd.SetOut(w) - return nil - }) -} - -func WithErrorOutput(w io.Writer) Option { - return optionFunc(func(o *options) func() { - o.cmd.SetErr(w) - o.errorRecorder = nil - return nil - }) -} - -func WithError(err error) Option { - return optionFunc(func(o *options) func() { - o.wantError = err - return nil - }) -} - -func WithPasswordReader(r cmd.PasswordReader) Option { - return optionFunc(func(o *options) func() { - orig := cmd.SetCMDPasswordReader(r) - return func() { - cmd.SetCMDPasswordReader(orig) - } - }) -} - -func WithAuthKeysGetter(g cmd.AuthKeysGetter) Option { - return optionFunc(func(o *options) func() { - orig := cmd.SetCMDAuthKeysGetter(g) - return func() { - cmd.SetCMDAuthKeysGetter(orig) - } - }) -} - -func WithAuthService(s cmd.AuthService) Option { - return optionFunc(func(o *options) func() { - orig := cmd.SetCMDAuthService(s) - return func() { - cmd.SetCMDAuthService(orig) - } - }) -} - -type optionFunc func(o *options) func() - -func (f optionFunc) apply(o *options) func() { return f(o) } diff --git a/newreleases/cmd/configure.go b/newreleases/cmd/configure.go index ddd1f34..22be75a 100644 --- a/newreleases/cmd/configure.go +++ b/newreleases/cmd/configure.go @@ -11,8 +11,8 @@ import ( "github.com/spf13/cobra" ) -func init() { - rootCmd.AddCommand(&cobra.Command{ +func (c *command) initConfigureCmd() { + c.root.AddCommand(&cobra.Command{ Use: "configure", Short: "Provide configuration values to be stored in a file", RunE: func(cmd *cobra.Command, args []string) (err error) { @@ -28,11 +28,11 @@ func init() { return nil } - if err := writeConfig(cmd, authKey); err != nil { + if err := c.writeConfig(cmd, authKey); err != nil { return err } - cmd.Printf("Configuration saved to: %s.\n", cfgFile) + cmd.Printf("Configuration saved to: %s.\n", c.cfgFile) return nil }, }) diff --git a/newreleases/cmd/configure_test.go b/newreleases/cmd/configure_test.go index 8a8f8ab..0a53fb7 100644 --- a/newreleases/cmd/configure_test.go +++ b/newreleases/cmd/configure_test.go @@ -72,19 +72,25 @@ func TestConfigureCmd(t *testing.T) { } args := []string{"configure"} + var setCfgFile string if tc.withConfigFlag { args = append(args, "--config", cfgFile) } else { - defer cmd.SetCfgFile(cfgFile)() + setCfgFile = cfgFile } var outputBuf, errorOutputBuf bytes.Buffer - ExecuteT(t, - WithArgs(args...), - WithOutput(&outputBuf), - WithErrorOutput(&errorOutputBuf), - WithInput(strings.NewReader(tc.authKey+"\n")), + c := newCommand(t, + cmd.WithArgs(args...), + cmd.WithOutput(&outputBuf), + cmd.WithErrorOutput(&errorOutputBuf), + cmd.WithInput(strings.NewReader(tc.authKey+"\n")), + cmd.WithCfgFile(setCfgFile), + cmd.WithHomeDir(dir), ) + if err := c.Execute(); err != nil { + t.Fatal(err) + } gotOutput := outputBuf.String() if wantOutput := tc.wantOutputFunc(cfgFile); wantOutput != "" { @@ -136,17 +142,21 @@ func TestConfigureCmd_overwrite(t *testing.T) { if err := f.Close(); err != nil { t.Fatal(err) } - defer cmd.SetCfgFile(cfgFile)() testConfigre := func(t *testing.T, authKey string) { t.Helper() var outputBuf bytes.Buffer - ExecuteT(t, - WithArgs("configure"), - WithOutput(&outputBuf), - WithInput(strings.NewReader(authKey+"\n")), + c := newCommand(t, + cmd.WithCfgFile(cfgFile), + cmd.WithHomeDir(dir), + cmd.WithArgs("configure"), + cmd.WithOutput(&outputBuf), + cmd.WithInput(strings.NewReader(authKey+"\n")), ) + if err := c.Execute(); err != nil { + t.Fatal(err) + } gotOutput := outputBuf.String() wantOutput := fmt.Sprintf("Auth Key: Configuration saved to: %s.\n", cfgFile) diff --git a/newreleases/cmd/export_test.go b/newreleases/cmd/export_test.go index 6f8abe7..d0e5d72 100644 --- a/newreleases/cmd/export_test.go +++ b/newreleases/cmd/export_test.go @@ -5,43 +5,77 @@ package cmd -var RootCmd = rootCmd +import "io" -func SetTestHomeDir(dir string) { - testHomeDir = dir +type ( + Command = command + Option = option + PasswordReader = passwordReader + AuthService = authService + AuthKeysGetter = authKeysGetter + ProviderService = providerService +) + +var ( + NewCommand = newCommand +) + +func WithCfgFile(f string) func(c *Command) { + return func(c *Command) { + c.cfgFile = f + } } -func SetCfgFile(filename string) (reset func()) { - reset = NewResetCfgFileFunc() - cfgFile = filename - return reset +func WithHomeDir(dir string) func(c *Command) { + return func(c *Command) { + c.homeDir = dir + } } -func NewResetCfgFileFunc() (reset func()) { - orig := cfgFile - return func() { cfgFile = orig } +func WithArgs(a ...string) func(c *Command) { + return func(c *Command) { + c.root.SetArgs(a) + } } -type PasswordReader = passwordReader +func WithInput(r io.Reader) func(c *Command) { + return func(c *Command) { + c.root.SetIn(r) + } +} -func SetCMDPasswordReader(new PasswordReader) (orig PasswordReader) { - orig = cmdPasswordReader - cmdPasswordReader = new - return orig +func WithOutput(w io.Writer) func(c *Command) { + return func(c *Command) { + c.root.SetOut(w) + } } -type AuthKeysGetter = authKeysGetter +func WithErrorOutput(w io.Writer) func(c *Command) { + return func(c *Command) { + c.root.SetErr(w) + } +} -func SetCMDAuthKeysGetter(new AuthKeysGetter) (orig AuthKeysGetter) { - orig = cmdAuthKeysGetter - cmdAuthKeysGetter = new - return orig +func WithPasswordReader(r PasswordReader) func(c *Command) { + return func(c *Command) { + c.passwordReader = r + } } -type AuthService = authService +func WithAuthKeysGetter(g AuthKeysGetter) func(c *Command) { + return func(c *Command) { + c.authKeysGetter = g + } +} + +func WithAuthService(s AuthService) func(c *Command) { + return func(c *Command) { + c.authService = s + } +} -func SetCMDAuthService(new AuthService) (orig AuthService) { - orig = cmdAuthService - cmdAuthService = new - return orig +func WithProviderService(s ProviderService) func(c *Command) { + return func(c *Command) { + c.providerService = s + } } diff --git a/newreleases/cmd/get_auth_key.go b/newreleases/cmd/get_auth_key.go index 1b7fb40..3fd526b 100644 --- a/newreleases/cmd/get_auth_key.go +++ b/newreleases/cmd/get_auth_key.go @@ -16,7 +16,7 @@ import ( "github.com/spf13/cobra" ) -func init() { +func (c *command) initGetAuthKeyCmd() (err error) { getAuthKeyCmd := &cobra.Command{ Use: "get-auth-key", Short: "Get API auth key and store it in the configuration", @@ -30,15 +30,19 @@ func init() { if err != nil { return err } - password, err := terminalPromptPassword(cmd, "Password") + password, err := c.terminalPromptPassword(cmd, "Password") if err != nil { return err } - ctx, cancel := newClientContext() + ctx, cancel := newClientContext(c.config) defer cancel() - keys, err := cmdAuthKeysGetter.GetAuthKeys(ctx, email, password, newClientOptions()) + o, err := newClientOptions(cmd) + if err != nil { + return err + } + keys, err := c.authKeysGetter.GetAuthKeys(ctx, email, password, o) if err != nil { return err } @@ -79,20 +83,32 @@ func init() { } key := keys[selection] - if err := writeConfig(cmd, key.Secret); err != nil { + if err := c.writeConfig(cmd, key.Secret); err != nil { return err } cmd.Printf("Using auth key: %s.\n", key.Name) - cmd.Printf("Configuration saved to: %s.\n", cfgFile) + cmd.Printf("Configuration saved to: %s.\n", c.cfgFile) return nil }, + PreRunE: c.setAuthKeysGetter, } - rootCmd.AddCommand(getAuthKeyCmd) + if err := addClientFlags(getAuthKeyCmd, c.config); err != nil { + return err + } + + c.root.AddCommand(getAuthKeyCmd) + return nil } -var cmdAuthKeysGetter authKeysGetter = authKeysGetterFunc(newreleases.GetAuthKeys) +func (c *command) setAuthKeysGetter(cmd *cobra.Command, args []string) (err error) { + if c.authKeysGetter != nil { + return nil + } + c.authKeysGetter = authKeysGetterFunc(newreleases.GetAuthKeys) + return nil +} type authKeysGetter interface { GetAuthKeys(ctx context.Context, email, password string, o *newreleases.ClientOptions) (keys []newreleases.AuthKey, err error) diff --git a/newreleases/cmd/get_auth_key_test.go b/newreleases/cmd/get_auth_key_test.go index 873e88f..3f7341d 100644 --- a/newreleases/cmd/get_auth_key_test.go +++ b/newreleases/cmd/get_auth_key_test.go @@ -147,22 +147,27 @@ func TestGetAuthKeyCmd(t *testing.T) { } args := []string{"get-auth-key"} + var setCfgFile string if tc.withConfigFlag { args = append(args, "--config", cfgFile) } else { - defer cmd.SetCfgFile(cfgFile)() + setCfgFile = cfgFile } var outputBuf, errorOutputBuf bytes.Buffer - ExecuteT(t, - WithArgs(args...), - WithOutput(&outputBuf), - WithErrorOutput(&errorOutputBuf), - WithInput(strings.NewReader(tc.input)), - WithError(tc.wantError), - WithPasswordReader(newMockPasswordReader("myPassword", nil)), - WithAuthKeysGetter(tc.authKeysGetter), + c := newCommand(t, + cmd.WithCfgFile(setCfgFile), + cmd.WithHomeDir(dir), + cmd.WithArgs(args...), + cmd.WithOutput(&outputBuf), + cmd.WithErrorOutput(&errorOutputBuf), + cmd.WithInput(strings.NewReader(tc.input)), + cmd.WithPasswordReader(newMockPasswordReader("myPassword", nil)), + cmd.WithAuthKeysGetter(tc.authKeysGetter), ) + if err := c.Execute(); err != tc.wantError { + t.Fatalf("got error %v, want %v", err, tc.wantError) + } gotOutput := outputBuf.String() if wantOutput := tc.wantOutputFunc(cfgFile); wantOutput != "" { diff --git a/newreleases/cmd/root_test.go b/newreleases/cmd/help_test.go similarity index 76% rename from newreleases/cmd/root_test.go rename to newreleases/cmd/help_test.go index 0463e46..c7b594a 100644 --- a/newreleases/cmd/root_test.go +++ b/newreleases/cmd/help_test.go @@ -9,6 +9,8 @@ import ( "bytes" "strings" "testing" + + "newreleases.io/cmd/newreleases/cmd" ) func TestRootCmdHelp(t *testing.T) { @@ -18,7 +20,13 @@ func TestRootCmdHelp(t *testing.T) { "--help", } { var outputBuf bytes.Buffer - ExecuteT(t, WithArgs(arg), WithOutput(&outputBuf)) + c := newCommand(t, + cmd.WithArgs(arg), + cmd.WithOutput(&outputBuf), + ) + if err := c.Execute(); err != nil { + t.Fatal(err) + } want := "Release tracker for software engineers" got := outputBuf.String() diff --git a/newreleases/cmd/provider.go b/newreleases/cmd/provider.go new file mode 100644 index 0000000..4fb3f88 --- /dev/null +++ b/newreleases/cmd/provider.go @@ -0,0 +1,95 @@ +// Copyright (c) 2019, NewReleases CLI AUTHORS. +// All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmd + +import ( + "context" + "strconv" + + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" +) + +func (c *command) initProviderCmd() (err error) { + providerCmd := &cobra.Command{ + Use: "provider", + Short: "Information about project providers", + } + + optionNameAdded := "added" + + listCmd := &cobra.Command{ + Use: "list", + Short: "Get project providers", + RunE: func(cmd *cobra.Command, args []string) (err error) { + ctx, cancel := newClientContext(c.config) + defer cancel() + + var providers []string + + added, err := cmd.Flags().GetBool(optionNameAdded) + if err != nil { + return err + } + + if added { + providers, err = c.providerService.ListAdded(ctx) + } else { + providers, err = c.providerService.List(ctx) + } + if err != nil { + return err + } + + if len(providers) == 0 { + cmd.Println("No providers found.") + return nil + } + + printProvidersTable(cmd, providers) + + return nil + }, + PreRunE: c.setProviderService, + } + + listCmd.Flags().Bool(optionNameAdded, false, "get only providers for projects that are added for tracking") + + if err := addClientFlags(listCmd, c.config); err != nil { + return err + } + providerCmd.AddCommand(listCmd) + + c.root.AddCommand(providerCmd) + return nil +} + +func (c *command) setProviderService(cmd *cobra.Command, args []string) (err error) { + if c.providerService != nil { + return nil + } + client, err := c.getClient(cmd) + if err != nil { + return err + } + c.providerService = client.Providers + return nil +} + +type providerService interface { + List(ctx context.Context) (providers []string, err error) + ListAdded(ctx context.Context) (providers []string, err error) +} + +func printProvidersTable(cmd *cobra.Command, providers []string) { + table := tablewriter.NewWriter(cmd.OutOrStdout()) + table.SetBorder(false) + table.SetHeader([]string{"", "Name"}) + for i, name := range providers { + table.Append([]string{strconv.Itoa(i + 1), name}) + } + table.Render() +} diff --git a/newreleases/cmd/provider_test.go b/newreleases/cmd/provider_test.go new file mode 100644 index 0000000..145ec52 --- /dev/null +++ b/newreleases/cmd/provider_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2019, NewReleases CLI AUTHORS. +// All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmd_test + +import ( + "bytes" + "context" + "errors" + "testing" + + "newreleases.io/cmd/newreleases/cmd" +) + +func TestProviderCmd(t *testing.T) { + errTest := errors.New("test error") + + for _, tc := range []struct { + name string + providerService cmd.ProviderService + added bool + wantOutput string + wantError error + }{ + { + name: "no providers", + providerService: newMockProviderService(nil, nil, nil), + wantOutput: "No providers found.\n", + }, + { + name: "no added providers", + added: true, + providerService: newMockProviderService([]string{"github", "pypi", "npm"}, nil, nil), + wantOutput: "No providers found.\n", + }, + { + name: "providers", + providerService: newMockProviderService([]string{"github", "pypi", "cargo", "dockerhub"}, []string{"github", "pypi"}, nil), + wantOutput: " | NAME \n----+------------\n 1 | github \n 2 | pypi \n 3 | cargo \n 4 | dockerhub \n", + }, + { + name: "added providers", + added: true, + providerService: newMockProviderService([]string{"github", "pypi", "yarn", "dockerhub"}, []string{"github", "pypi"}, nil), + wantOutput: " | NAME \n----+---------\n 1 | github \n 2 | pypi \n", + }, + { + name: "error", + providerService: newMockProviderService(nil, nil, errTest), + wantError: errTest, + }, + } { + t.Run(tc.name, func(t *testing.T) { + args := []string{"provider", "list"} + if tc.added { + args = append(args, "--added") + } + var outputBuf bytes.Buffer + c := newCommand(t, + cmd.WithArgs(args...), + cmd.WithOutput(&outputBuf), + cmd.WithProviderService(tc.providerService), + ) + if err := c.Execute(); err != tc.wantError { + t.Fatalf("got error %v, want %v", err, tc.wantError) + } + + gotOutput := outputBuf.String() + if gotOutput != tc.wantOutput { + t.Errorf("got output %q, want %q", gotOutput, tc.wantOutput) + } + }) + } +} + +type mockProviderService struct { + providers []string + addedProviders []string + err error +} + +func newMockProviderService(providers, addedProviders []string, err error) (s mockProviderService) { + return mockProviderService{providers: providers, addedProviders: addedProviders, err: err} +} + +func (s mockProviderService) List(ctx context.Context) (providers []string, err error) { + return s.providers, s.err +} + +func (s mockProviderService) ListAdded(ctx context.Context) (providers []string, err error) { + return s.addedProviders, s.err +} diff --git a/newreleases/cmd/root.go b/newreleases/cmd/root.go deleted file mode 100644 index 40190db..0000000 --- a/newreleases/cmd/root.go +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2019, NewReleases CLI AUTHORS. -// All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmd - -import ( - "errors" - "path/filepath" - "strings" - - "github.com/mitchellh/go-homedir" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -// Execute adds all child commands to the root command and sets flags appropriately. -func Execute() error { - return rootCmd.Execute() -} - -// rootCmd represents the base command when called without any subcommands. -var rootCmd = &cobra.Command{ - Use: "newreleases", - Short: "Release tracker for software engineers", - SilenceErrors: true, - SilenceUsage: true, -} - -var cfgFile string - -func init() { - cobra.OnInitialize(initConfig) - - // Persistent flags, which are global for application. - globalFlags := rootCmd.PersistentFlags() - globalFlags.StringVar(&cfgFile, "config", "", "config file (default is $HOME/.newreleases.yaml)") -} - -// initConfig reads in config file and ENV variables if set. -func initConfig() { - configName := ".newreleases" - var home string - if cfgFile != "" { - // Use config file from the flag. - viper.SetConfigFile(cfgFile) - } else { - // Find home directory. - home = findHomeDir() - // Search config in home directory with name ".newreleases" (without extension). - viper.AddConfigPath(home) - viper.SetConfigName(configName) - } - - // Environment - viper.SetEnvPrefix("newreleases") - viper.AutomaticEnv() // read in environment variables that match - viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - - // If a config file is found, read it in. - if err := viper.ReadInConfig(); err != nil { - var e viper.ConfigFileNotFoundError - if !errors.As(err, &e) { - // Function cobra.OnInitialize does not provide error propagation, so - // handle the error as it would be handled in the main package. - must(err) - } else if home != "" { - cfgFile = filepath.Join(home, configName+".yaml") - } - } -} - -func findHomeDir() (dir string) { - if testHomeDir != "" { - return testHomeDir - } - dir, err := homedir.Dir() - // Function cobra.OnInitialize does not provide error propagation, so - // handle the error as it would be handled in the main package. - must(err) - return dir -} - -// testHomeDir is set on test runs in order not to interfere with potential -// configuration in user dir of the user that runs tests. -var testHomeDir string diff --git a/newreleases/cmd/terminal.go b/newreleases/cmd/terminal.go new file mode 100644 index 0000000..3a64c1e --- /dev/null +++ b/newreleases/cmd/terminal.go @@ -0,0 +1,47 @@ +// Copyright (c) 2019, NewReleases CLI AUTHORS. +// All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmd + +import ( + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh/terminal" +) + +func terminalPrompt(cmd *cobra.Command, reader interface{ ReadString(byte) (string, error) }, title string) (value string, err error) { + cmd.Print(title + ": ") + value, err = reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(value), nil +} + +type passwordReader interface { + ReadPassword() (password string, err error) +} + +type stdInPasswordReader struct{} + +func (stdInPasswordReader) ReadPassword() (password string, err error) { + v, err := terminal.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", err + } + return string(v), err +} + +func (c *command) terminalPromptPassword(cmd *cobra.Command, title string) (password string, err error) { + cmd.Print(title + ": ") + password, err = c.passwordReader.ReadPassword() + cmd.Println() + if err != nil { + return "", err + } + return password, nil +} diff --git a/newreleases/cmd/version.go b/newreleases/cmd/version.go index c15a10a..7b0fb63 100644 --- a/newreleases/cmd/version.go +++ b/newreleases/cmd/version.go @@ -11,8 +11,8 @@ import ( nrcmd "newreleases.io/cmd" ) -func init() { - rootCmd.AddCommand(&cobra.Command{ +func (c *command) initVersionCmd() { + c.root.AddCommand(&cobra.Command{ Use: "version", Short: "Print version number", Run: func(cmd *cobra.Command, args []string) { diff --git a/newreleases/cmd/version_test.go b/newreleases/cmd/version_test.go index f93f3dc..adf1387 100644 --- a/newreleases/cmd/version_test.go +++ b/newreleases/cmd/version_test.go @@ -10,11 +10,18 @@ import ( "testing" nrcmd "newreleases.io/cmd" + "newreleases.io/cmd/newreleases/cmd" ) func TestVersionCmd(t *testing.T) { var outputBuf bytes.Buffer - ExecuteT(t, WithArgs("version"), WithOutput(&outputBuf)) + c := newCommand(t, + cmd.WithArgs("version"), + cmd.WithOutput(&outputBuf), + ) + if err := c.Execute(); err != nil { + t.Fatal(err) + } want := nrcmd.Version + "\n" got := outputBuf.String()