package clibase import ( "bytes" "encoding/json" "os" "strings" "github.com/hashicorp/go-multierror" "github.com/spf13/pflag" "golang.org/x/xerrors" ) type ValueSource string const ( ValueSourceNone ValueSource = "" ValueSourceFlag ValueSource = "flag" ValueSourceEnv ValueSource = "env" ValueSourceYAML ValueSource = "yaml" ValueSourceDefault ValueSource = "default" ) // Option is a configuration option for a CLI application. type Option struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` // Required means this value must be set by some means. It requires // `ValueSource != ValueSourceNone` // If `Default` is set, then `Required` is ignored. Required bool `json:"required,omitempty"` // Flag is the long name of the flag used to configure this option. If unset, // flag configuring is disabled. Flag string `json:"flag,omitempty"` // FlagShorthand is the one-character shorthand for the flag. If unset, no // shorthand is used. FlagShorthand string `json:"flag_shorthand,omitempty"` // Env is the environment variable used to configure this option. If unset, // environment configuring is disabled. Env string `json:"env,omitempty"` // YAML is the YAML key used to configure this option. If unset, YAML // configuring is disabled. YAML string `json:"yaml,omitempty"` // Default is parsed into Value if set. Default string `json:"default,omitempty"` // Value includes the types listed in values.go. Value pflag.Value `json:"value,omitempty"` // Annotations enable extensions to clibase higher up in the stack. It's useful for // help formatting and documentation generation. Annotations Annotations `json:"annotations,omitempty"` // Group is a group hierarchy that helps organize this option in help, configs // and other documentation. Group *Group `json:"group,omitempty"` // UseInstead is a list of options that should be used instead of this one. // The field is used to generate a deprecation warning. UseInstead []Option `json:"use_instead,omitempty"` Hidden bool `json:"hidden,omitempty"` ValueSource ValueSource `json:"value_source,omitempty"` } // optionNoMethods is just a wrapper around Option so we can defer to the // default json.Unmarshaler behavior. type optionNoMethods Option func (o *Option) UnmarshalJSON(data []byte) error { // If an option has no values, we have no idea how to unmarshal it. // So just discard the json data. if o.Value == nil { o.Value = &DiscardValue } return json.Unmarshal(data, (*optionNoMethods)(o)) } func (o Option) YAMLPath() string { if o.YAML == "" { return "" } var gs []string for _, g := range o.Group.Ancestry() { gs = append(gs, g.YAML) } return strings.Join(append(gs, o.YAML), ".") } // OptionSet is a group of options that can be applied to a command. type OptionSet []Option // UnmarshalJSON implements json.Unmarshaler for OptionSets. Options have an // interface Value type that cannot handle unmarshalling because the types cannot // be inferred. Since it is a slice, instantiating the Options first does not // help. // // However, we typically do instantiate the slice to have the correct types. // So this unmarshaller will attempt to find the named option in the existing // set, if it cannot, the value is discarded. If the option exists, the value // is unmarshalled into the existing option, and replaces the existing option. // // The value is discarded if it's type cannot be inferred. This behavior just // feels "safer", although it should never happen if the correct option set // is passed in. The situation where this could occur is if a client and server // are on different versions with different options. func (optSet *OptionSet) UnmarshalJSON(data []byte) error { dec := json.NewDecoder(bytes.NewBuffer(data)) // Should be a json array, so consume the starting open bracket. t, err := dec.Token() if err != nil { return xerrors.Errorf("read array open bracket: %w", err) } if t != json.Delim('[') { return xerrors.Errorf("expected array open bracket, got %q", t) } // As long as json elements exist, consume them. The counter is used for // better errors. var i int OptionSetDecodeLoop: for dec.More() { var opt Option // jValue is a placeholder value that allows us to capture the // raw json for the value to attempt to unmarshal later. var jValue jsonValue opt.Value = &jValue err := dec.Decode(&opt) if err != nil { return xerrors.Errorf("decode %d option: %w", i, err) } // This counter is used to contextualize errors to show which element of // the array we failed to decode. It is only used in the error above, as // if the above works, we can instead use the Option.Name which is more // descriptive and useful. So increment here for the next decode. i++ // Try to see if the option already exists in the option set. // If it does, just update the existing option. for optIndex, have := range *optSet { if have.Name == opt.Name { if jValue != nil { err := json.Unmarshal(jValue, &(*optSet)[optIndex].Value) if err != nil { return xerrors.Errorf("decode option %q value: %w", have.Name, err) } // Set the opt's value opt.Value = (*optSet)[optIndex].Value } else { // Hopefully the user passed empty values in the option set. There is no easy way // to tell, and if we do not do this, it breaks json.Marshal if we do it again on // this new option set. opt.Value = (*optSet)[optIndex].Value } // Override the existing. (*optSet)[optIndex] = opt // Go to the next option to decode. continue OptionSetDecodeLoop } } // If the option doesn't exist, the value will be discarded. // We do this because we cannot infer the type of the value. opt.Value = DiscardValue *optSet = append(*optSet, opt) } t, err = dec.Token() if err != nil { return xerrors.Errorf("read array close bracket: %w", err) } if t != json.Delim(']') { return xerrors.Errorf("expected array close bracket, got %q", t) } return nil } // Add adds the given Options to the OptionSet. func (optSet *OptionSet) Add(opts ...Option) { *optSet = append(*optSet, opts...) } // Filter will only return options that match the given filter. (return true) func (optSet OptionSet) Filter(filter func(opt Option) bool) OptionSet { cpy := make(OptionSet, 0) for _, opt := range optSet { if filter(opt) { cpy = append(cpy, opt) } } return cpy } // FlagSet returns a pflag.FlagSet for the OptionSet. func (optSet *OptionSet) FlagSet() *pflag.FlagSet { if optSet == nil { return &pflag.FlagSet{} } fs := pflag.NewFlagSet("", pflag.ContinueOnError) for _, opt := range *optSet { if opt.Flag == "" { continue } var noOptDefValue string { no, ok := opt.Value.(NoOptDefValuer) if ok { noOptDefValue = no.NoOptDefValue() } } val := opt.Value if val == nil { val = DiscardValue } fs.AddFlag(&pflag.Flag{ Name: opt.Flag, Shorthand: opt.FlagShorthand, Usage: opt.Description, Value: val, DefValue: "", Changed: false, Deprecated: "", NoOptDefVal: noOptDefValue, Hidden: opt.Hidden, }) } fs.Usage = func() { _, _ = os.Stderr.WriteString("Override (*FlagSet).Usage() to print help text.\n") } return fs } // ParseEnv parses the given environment variables into the OptionSet. // Use EnvsWithPrefix to filter out prefixes. func (optSet *OptionSet) ParseEnv(vs []EnvVar) error { if optSet == nil { return nil } var merr *multierror.Error // We parse environment variables first instead of using a nested loop to // avoid N*M complexity when there are a lot of options and environment // variables. envs := make(map[string]string) for _, v := range vs { envs[v.Name] = v.Value } for i, opt := range *optSet { if opt.Env == "" { continue } envVal, ok := envs[opt.Env] if !ok { // Homebrew strips all environment variables that do not start with `HOMEBREW_`. // This prevented using brew to invoke the Coder agent, because the environment // variables to not get passed down. // // A customer wanted to use their custom tap inside a workspace, which was failing // because the agent lacked the environment variables to authenticate with Git. envVal, ok = envs[`HOMEBREW_`+opt.Env] } // Currently, empty values are treated as if the environment variable is // unset. This behavior is technically not correct as there is now no // way for a user to change a Default value to an empty string from // the environment. Unfortunately, we have old configuration files // that rely on the faulty behavior. // // TODO: We should remove this hack in May 2023, when deployments // have had months to migrate to the new behavior. if !ok || envVal == "" { continue } (*optSet)[i].ValueSource = ValueSourceEnv if err := opt.Value.Set(envVal); err != nil { merr = multierror.Append( merr, xerrors.Errorf("parse %q: %w", opt.Name, err), ) } } return merr.ErrorOrNil() } // SetDefaults sets the default values for each Option, skipping values // that already have a value source. func (optSet *OptionSet) SetDefaults() error { if optSet == nil { return nil } var merr *multierror.Error for i, opt := range *optSet { // Skip values that may have already been set by the user. if opt.ValueSource != ValueSourceNone { continue } if opt.Default == "" { continue } if opt.Value == nil { merr = multierror.Append( merr, xerrors.Errorf( "parse %q: no Value field set\nFull opt: %+v", opt.Name, opt, ), ) continue } (*optSet)[i].ValueSource = ValueSourceDefault if err := opt.Value.Set(opt.Default); err != nil { merr = multierror.Append( merr, xerrors.Errorf("parse %q: %w", opt.Name, err), ) } } return merr.ErrorOrNil() } // ByName returns the Option with the given name, or nil if no such option // exists. func (optSet *OptionSet) ByName(name string) *Option { for i := range *optSet { opt := &(*optSet)[i] if opt.Name == name { return opt } } return nil }