diff --git a/example_test.go b/example_test.go index 5272393..4bd7632 100644 --- a/example_test.go +++ b/example_test.go @@ -496,3 +496,45 @@ func Example_allSupportedTypes() { // output: } + +func Example_envVarOnly() { + os.Args = split("./example") + _ = os.Setenv("AUTH_KEY", "my_key") + + defer os.Unsetenv("AUTH_KEY") + + var args struct { + AuthKey string `arg:"--,env:AUTH_KEY"` + } + + MustParse(&args) + + fmt.Println(args.AuthKey) + // output: my_key +} + +func Example_envVarOnlyShouldIgnoreFlag() { + os.Args = split("./example --=my_key") + + var args struct { + AuthKey string `arg:"--,env:AUTH_KEY"` + } + + err := Parse(&args) + + fmt.Println(err) + // output: unknown argument --=my_key +} + +func Example_envVarOnlyShouldIgnoreShortFlag() { + os.Args = split("./example -=my_key") + + var args struct { + AuthKey string `arg:"--,env:AUTH_KEY"` + } + + err := Parse(&args) + + fmt.Println(err) + // output: unknown argument -=my_key +} diff --git a/parse.go b/parse.go index be77924..63cfab3 100644 --- a/parse.go +++ b/parse.go @@ -343,6 +343,7 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) { // Look at the tag var isSubcommand bool // tracks whether this field is a subcommand + for _, key := range strings.Split(tag, ",") { if key == "" { continue @@ -360,7 +361,7 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) { case strings.HasPrefix(key, "--"): spec.long = key[2:] case strings.HasPrefix(key, "-"): - if len(key) != 2 { + if len(key) > 2 { errs = append(errs, fmt.Sprintf("%s.%s: short arguments must be one character only", t.Name(), field.Name)) return false @@ -661,7 +662,7 @@ func (p *Parser) process(args []string) error { // lookup the spec for this option (note that the "specs" slice changes as // we expand subcommands so it is better not to use a map) spec := findOption(specs, opt) - if spec == nil { + if spec == nil || opt == "" { return fmt.Errorf("unknown argument %s", arg) } wasPresent[spec] = true @@ -750,10 +751,16 @@ func (p *Parser) process(args []string) error { } if spec.required { + if spec.short == "" && spec.long == "" { + msg := fmt.Sprintf("environment variable %s is required", spec.env) + return errors.New(msg) + } + msg := fmt.Sprintf("%s is required", name) if spec.env != "" { msg += " (or environment variable " + spec.env + ")" } + return errors.New(msg) } diff --git a/parse_test.go b/parse_test.go index d368b17..77a034f 100644 --- a/parse_test.go +++ b/parse_test.go @@ -227,6 +227,14 @@ func TestRequiredWithEnv(t *testing.T) { require.Error(t, err, "--foo is required (or environment variable FOO)") } +func TestRequiredWithEnvOnly(t *testing.T) { + var args struct { + Foo string `arg:"required,--,-,env:FOO"` + } + _, err := parseWithEnv("", []string{}, &args) + require.Error(t, err, "environment variable FOO is required") +} + func TestShortFlag(t *testing.T) { var args struct { Foo string `arg:"-f"` @@ -845,6 +853,24 @@ func TestDefaultValuesIgnored(t *testing.T) { assert.Equal(t, "", args.Foo) } +func TestRequiredEnvironmentOnlyVariableIsMissing(t *testing.T) { + var args struct { + Foo string `arg:"required,--,env:FOO"` + } + + _, err := parseWithEnv("", []string{""}, &args) + assert.Error(t, err) +} + +func TestOptionalEnvironmentOnlyVariable(t *testing.T) { + var args struct { + Foo string `arg:"env:FOO"` + } + + _, err := parseWithEnv("", []string{}, &args) + assert.NoError(t, err) +} + func TestEnvironmentVariableInSubcommandIgnored(t *testing.T) { var args struct { Sub *struct { diff --git a/usage.go b/usage.go index 43d6231..0498910 100644 --- a/usage.go +++ b/usage.go @@ -208,7 +208,7 @@ func (p *Parser) WriteHelpForSubcommand(w io.Writer, subcommand ...string) error // writeHelp writes the usage string for the given subcommand func (p *Parser) writeHelpForSubcommand(w io.Writer, cmd *command) { - var positionals, longOptions, shortOptions []*spec + var positionals, longOptions, shortOptions, envOnlyOptions []*spec for _, spec := range cmd.specs { switch { case spec.positional: @@ -217,6 +217,8 @@ func (p *Parser) writeHelpForSubcommand(w io.Writer, cmd *command) { longOptions = append(longOptions, spec) case spec.short != "": shortOptions = append(shortOptions, spec) + case spec.short == "" && spec.long == "": + envOnlyOptions = append(envOnlyOptions, spec) } } @@ -275,6 +277,14 @@ func (p *Parser) writeHelpForSubcommand(w io.Writer, cmd *command) { }) } + // write the list of environment only variables + if len(envOnlyOptions) > 0 { + fmt.Fprint(w, "\nEnvironment variables:\n") + for _, spec := range envOnlyOptions { + p.printEnvOnlyVar(w, spec) + } + } + // write the list of subcommands if len(cmd.subcommands) > 0 { fmt.Fprint(w, "\nCommands:\n") @@ -301,6 +311,21 @@ func (p *Parser) printOption(w io.Writer, spec *spec) { } } +func (p *Parser) printEnvOnlyVar(w io.Writer, spec *spec) { + ways := make([]string, 0, 2) + if spec.required { + ways = append(ways, "Required.") + } else { + ways = append(ways, "Optional.") + } + + if spec.help != "" { + ways = append(ways, spec.help) + } + + printTwoCols(w, spec.env, strings.Join(ways, " "), spec.defaultString, "") +} + // lookupCommand finds a subcommand based on a sequence of subcommand names. The // first string should be a top-level subcommand, the next should be a child // subcommand of that subcommand, and so on. If no strings are given then the diff --git a/usage_test.go b/usage_test.go index 69feac2..1a64ad4 100644 --- a/usage_test.go +++ b/usage_test.go @@ -56,6 +56,10 @@ Options: --testenv TESTENV, -a TESTENV [env: TEST_ENV] --file FILE, -f FILE File with mandatory extension [default: scratch.txt] --help, -h display this help and exit + +Environment variables: + API_KEY Required. Only via env-var for security reasons + TRACE Optional. Record low-level trace ` var args struct { @@ -70,6 +74,8 @@ Options: Values []float64 `help:"Values"` Workers int `arg:"-w,env:WORKERS" help:"number of workers to start" default:"10"` TestEnv string `arg:"-a,env:TEST_ENV"` + ApiKey string `arg:"required,-,--,env:API_KEY" help:"Only via env-var for security reasons"` + Trace bool `arg:"-,--,env" help:"Record low-level trace"` File *NameDotName `arg:"-f" help:"File with mandatory extension"` } args.Name = "Foo Bar" @@ -552,10 +558,16 @@ Usage: example [-s SHORT] Options: -s SHORT [env: SHORT] --help, -h display this help and exit + +Environment variables: + ENVONLY Optional. + ENVONLY2 Optional. + CUSTOM Optional. ` var args struct { Short string `arg:"--,-s,env"` EnvOnly string `arg:"--,env"` + EnvOnly2 string `arg:"--,-,env"` EnvOnlyOverriden string `arg:"--,env:CUSTOM"` } @@ -571,6 +583,35 @@ Options: assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) } +func TestEnvOnlyArgs(t *testing.T) { + expectedUsage := "Usage: example [--arg ARG]" + + expectedHelp := ` +Usage: example [--arg ARG] + +Options: + --arg ARG, -a ARG [env: MY_ARG] + --help, -h display this help and exit + +Environment variables: + AUTH_KEY Required. +` + var args struct { + ArgParam string `arg:"-a,--arg,env:MY_ARG"` + AuthKey string `arg:"required,--,env:AUTH_KEY"` + } + p, err := NewParser(Config{Program: "example"}, &args) + assert.NoError(t, err) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + var usage bytes.Buffer + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) +} + func TestFail(t *testing.T) { var stdout bytes.Buffer var exitCode int