diff --git a/hathora/main.go b/hathora/main.go index dea31be..e1801f8 100644 --- a/hathora/main.go +++ b/hathora/main.go @@ -12,7 +12,9 @@ import ( ) func main() { - if err := commands.App().Run(context.Background(), os.Args); err != nil { + app := commands.App() + args := commands.NormalizeArgs(app, os.Args) + if err := app.Run(context.Background(), args); err != nil { red := color.New(color.FgRed) errStr := fmt.Sprintf("%v", err) errorLines := strings.Split(errStr, "\n") diff --git a/internal/commands/app.go b/internal/commands/app.go index 43ddfc1..7033d95 100644 --- a/internal/commands/app.go +++ b/internal/commands/app.go @@ -54,7 +54,8 @@ func App() *cli.Command { return nil } - err = altsrc.InitializeValueSourcesFromFlags(ctx, cmd, os.Args[1:]) + err = altsrc.InitializeValueSourcesFromFlags(ctx, cmd, + NormalizeArgs(cmd, os.Args)[1:]) if err != nil { return err } diff --git a/internal/commands/common.go b/internal/commands/common.go index 6c52a50..e205c90 100644 --- a/internal/commands/common.go +++ b/internal/commands/common.go @@ -264,3 +264,101 @@ func handleNewVersionAvailable(currentVersion string) { zap.L().Warn("Version " + release.TagName + " is available for download.") } } + +// NormalizeArgs accepts an args slice (e.g., os.Args) and transforms it to +// collapse any Boolean flags with separate value parameters into a single +// parameter=value arg. This allows users to provide arg lists like: +// --idle-timeout-enabled false +// which would normally ignore the provided value and always register as True, +// as Flag's required syntax for bools is strict: --idle-timeout-enabled=false +// In all cases, a new slice is returned. +func NormalizeArgs(cmd *cli.Command, args []string) []string { + l := len(args) + if l == 0 { + return make([]string, 0) + } + // this returns a map of boolean flag names from the command tree + bfl := getBooleanFlags(cmd) + if len(bfl) == 0 { + out := make([]string, l) + copy(out, args) + return out + } + // this checks if there are any boolean flags provided by the user where + // the next flag is a value instead of a flag. in those cases, this will + // merge the elements. so ["--my-boolean", "true"] becomes ["--my-boolean=true"] + var skipNext bool + out := make([]string, 0, l) + for i, arg := range args { + // this skips whenever the previous arg just merged the current into it + if skipNext { + skipNext = false + continue + } + // this adds the current arg to the output + out = append(out, arg) + // this moves on when the current arg is not a flag name + if !strings.HasPrefix(arg, "-") { + continue + } + // this provides the flag name without a leading - or -- + flagName := strings.TrimPrefix(strings.TrimPrefix(arg, "-"), "-") + // this skips any flag names that are not boolean + if _, ok := bfl[flagName]; !ok { + continue + } + // this skips when the iterator is on the last arg or whenever it's not + // the last but another flag name arg immediately follows this one + // nor try if the next element is a new flag name + if i >= l-1 || strings.HasPrefix(args[i+1], "-") { + continue + } + // the current arg must be a boolean flag name followed by a value arg. + // this therefore merges them into a single element with an = delimiter + // by updating the just-appended element and skipping the next element + out[len(out)-1] = fmt.Sprintf("%s=%s", arg, args[i+1]) + skipNext = true + } + return out +} + +// getBooleanFlags iterates the command tree and returns a lookup table of all +// boolean flags +func getBooleanFlags(cmd *cli.Command) map[string]any { + out := make(map[string]any) + getBooleanFlagsIter(cmd, out) + return out +} + +func getBooleanFlagsIter(cmd *cli.Command, dst map[string]any) { + // this skips over help, as it is allowed to have a value in the next arg + // (e.g., --help deploy) + omit := func(names []string) bool { + for _, name := range names { + if name == "help" { + return true + } + } + return false + } + // this iterates and registers the current Command's boolean Flag names + for _, flag := range cmd.Flags { + // this skips non-bools + if _, ok := flag.(*cli.BoolFlag); !ok { + continue + } + // this skips any other explicit omissions + fns := flag.Names() + if omit(fns) { + continue + } + // otherwise this add the Flag's names to the output map + for _, name := range fns { + dst[name] = nil + } + } + // this sends the passed command's subcommands through this iterator func + for _, sub := range cmd.Commands { + getBooleanFlagsIter(sub, dst) + } +} diff --git a/internal/commands/common_test.go b/internal/commands/common_test.go new file mode 100644 index 0000000..c51b19d --- /dev/null +++ b/internal/commands/common_test.go @@ -0,0 +1,49 @@ +package commands + +import ( + "fmt" + "reflect" + "testing" + + "github.com/urfave/cli/v3" +) + +func TestNormalizeArgs(t *testing.T) { + cmd := &cli.Command{ + Flags: []cli.Flag{ + idleTimeoutFlag, + }, + } + tests := []struct { + input, expected []string + }{ + { + input: []string{"main", "--config", "abcd", "--idle-timeout-enabled", "false"}, + expected: []string{"main", "--config", "abcd", "--idle-timeout-enabled=false"}, + }, + { + input: []string{"main", "--config", "abcd", "--idle-timeout-enabled", "--next-flag"}, + expected: []string{"main", "--config", "abcd", "--idle-timeout-enabled", "--next-flag"}, + }, + { + input: []string{"main", "--config", "abcd", "--idle-timeout-enabled", "-n", "abc"}, + expected: []string{"main", "--config", "abcd", "--idle-timeout-enabled", "-n", "abc"}, + }, + { + input: []string{"main", "--config", "abcd", "--idle-timeout-enabled", "T", "-n", "abc"}, + expected: []string{"main", "--config", "abcd", "--idle-timeout-enabled=T", "-n", "abc"}, + }, + { + input: []string{"main", "--config", "abcd", "--idle-timeout-enabled"}, + expected: []string{"main", "--config", "abcd", "--idle-timeout-enabled"}, + }, + } + for i, test := range tests { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + got := NormalizeArgs(cmd, test.input) + if !reflect.DeepEqual(got, test.expected) { + t.Errorf("got %v expected %v", got, test.expected) + } + }) + } +}