spf13 / cobra

A Commander for modern Go CLI interactions
https://cobra.dev
Apache License 2.0
38.41k stars 2.86k forks source link

Can I pass my application configuration to Cobra, and modify it once all of my flags have been parsed? #2184

Closed tomgeorge closed 3 months ago

tomgeorge commented 3 months ago

TLDR: How can I write up my application before starting cobra when my application's configuration depends on the value of a flag?

I watched Carolyn Van Slyck's excellent video at GopherCon 2019 about writing CLIs with Cobra, and read her example repository and some of the porter codebase, where the flow of execution is

  1. Construct your application via an Application struct, which has a Configuration that is unmarshalled from viper
  2. When constructing your Cobra commands, you make the Application a dependency, like so
func buildRootCmd(a *Application) *Application{
  return &cobra.Cmd{}...
}

This style of writing Cobra apps is very nice to work with because it becomes much more testable than other implementations that I have tried, where I'm wiring my application together in PreRun etc. You also don't need to query the viper store directly.

My question is: how I would go about doing this if I have application dependencies that require a flag value?

Consider a command foo --github-token abcdefg. You can't create your top-level Application in a fully complete state and pass that to your root command constructor because you need to wait for cobra to parse the flagsets for the various commands. You also cannot modify the *Application that is getting passed around because you're not actually passing it by reference, so something like this would fail:

func main() {
    config := cmd.NewConfig()
    cli := github.NewClient(nil).WithAuthToken(config.Data.Github.Token)
    a := cmd.App{
        Config:     config,
        RepoLister: &cmd.GhRepoLister{Cli: cli},
    }
    if err := cmd.NewRootCommand(a).Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

func NewRootCommand(a *App) *cobra.Command {
    rootCmd := &cobra.Command{
        Use:   "cobra-late-bind",
        PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
            a.Config.BindFlags(cmd)
                         a.BindServices() // only affects the top-level *Application
            return nil
        },
    }

    rootCmd.PersistentFlags().StringVar(&a.Config.Data.Github.Token, "github-token", "bar", "github token")
    rootCmd.AddCommand(NewFooCommand(a))
    return rootCmd
}

...

func (a *App) BindServices() {
    fmt.Println("Binding services")
    if a.RepoLister == nil {
        a.RepoLister = &GhRepoLister{Cli: github.NewClient(nil).WithAuthToken(a.Config.Data.Github.Token)}
        fmt.Println("Set repolister")
    }
}

I think there are two solutions to this:

Has anybody else run into this problem, and solved it in a way that did not feel hacky? I have included some example code that reproduces this issue here

tomgeorge commented 3 months ago

This can indeed be done in the way I described, I think I messed up my pointer arguments. If you construct a pointer to your application "wiring" and bind the flags in the root commands PreRun, and have all child command constructors take a pointer to your application wiring you can do this.

I pushed up my fix to the above repo, for posterity. I don't know if I have a great way to update the clients that my application uses right now. I guess I will set any services that need flag values to nil initially, and then update them in PreRunE:

    config := cmd.NewConfig()
    a := &cmd.App{
        Config:     config,
        RepoLister: nil,
    }

root command constructor:

func NewRootCommand(a *App) *cobra.Command {
    // rootCmd represents the base command when called without any subcommands
    rootCmd := &cobra.Command{
        Use:   "cobra-late-bind",
        Short: "A brief description of your application",
        Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
        PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
            fmt.Printf("prerunE github token %s\n", a.Config.Data.Github.Token)
            a.Config.BindFlags(cmd)
            a.BindServices()
            fmt.Printf("prerunE post bind github token %s\n", a.Config.Data.Github.Token)
            return nil
        },
.....

where BindServices sets the additional wiring:

func (a *App) BindServices() {
    a.RepoLister = &GhRepoLister{Cli: github.NewClient(nil).WithAuthToken(a.Config.Data.Github.Token)}
}

Feels a little ugly but I'm headed in the right direction for now