Reimplement the CLI with flags first package.

CLI configuration may now be managed with env vars.

improved CLI help output
This commit is contained in:
onyx-and-iris 2026-02-17 16:09:53 +00:00
parent 0b9546ee0e
commit fce6fa43fc
4 changed files with 80 additions and 46 deletions

1
.gitignore vendored
View File

@ -24,6 +24,7 @@ go.work.sum
# env file # env file
.env .env
.envrc
# Added by goreleaser init: # Added by goreleaser init:
dist/ dist/

View File

@ -2,6 +2,7 @@ package main
import ( import (
"bufio" "bufio"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -11,12 +12,41 @@ import (
"time" "time"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/peterbourgon/ff/v4"
"github.com/peterbourgon/ff/v4/ffhelp"
"github.com/onyx-and-iris/q3rcon" "github.com/onyx-and-iris/q3rcon"
) )
var version string // Version will be set at build time var version string // Version will be set at build time
type Flags struct {
Host string
Port int
Rconpass string
Interactive bool
LogLevel string
Version bool
}
func (f Flags) Validate() error {
if f.Port < 1024 || f.Port > 65535 {
return fmt.Errorf(
"invalid port value, got: (%d) expected: in range 1024-65535",
f.Port,
)
}
if len(f.Rconpass) < 8 {
return fmt.Errorf(
"invalid rcon password, got: (%s) expected: at least 8 characters",
f.Rconpass,
)
}
return nil
}
func main() { func main() {
var exitCode int var exitCode int
@ -39,71 +69,67 @@ func main() {
// run executes the main logic of the application and returns a cleanup function and an error if any. // run executes the main logic of the application and returns a cleanup function and an error if any.
func run() (func(), error) { func run() (func(), error) {
var ( var flags Flags
host string
port int
rconpass string
interactive bool
loglevel string
versionFlag bool
)
flag.StringVar(&host, "host", "localhost", "hostname of the gameserver") fs := ff.NewFlagSet("q3rcon - A command-line RCON client for Quake 3 Arena")
flag.StringVar(&host, "H", "localhost", "hostname of the gameserver (shorthand)") fs.StringVar(&flags.Host, 'H', "host", "localhost", "hostname of the gameserver")
flag.IntVar(&port, "port", 28960, "port on which the gameserver resides, default is 28960") fs.IntVar(
flag.IntVar( &flags.Port,
&port, 'p',
"p", "port",
28960, 28960,
"port on which the gameserver resides, default is 28960 (shorthand)", "port on which the gameserver resides, default is 28960",
) )
flag.StringVar(&rconpass, "rconpass", os.Getenv("RCON_PASS"), "rcon password of the gameserver") fs.StringVar(
flag.StringVar( &flags.Rconpass,
&rconpass, 'r',
"r", "rconpass",
os.Getenv("RCON_PASS"), "",
"rcon password of the gameserver (shorthand)", "rcon password of the gameserver",
) )
flag.BoolVar(&interactive, "interactive", false, "run in interactive mode") fs.BoolVar(&flags.Interactive, 'i', "interactive", "run in interactive mode")
flag.BoolVar(&interactive, "i", false, "run in interactive mode (shorthand)") fs.StringVar(
&flags.LogLevel,
'l',
"loglevel",
"info",
"Log level (debug, info, warn, error, fatal, panic)",
)
fs.BoolVar(&flags.Version, 'v', "version", "print version information and exit")
flag.StringVar(&loglevel, "loglevel", "warn", "log level") err := ff.Parse(fs, os.Args[1:],
flag.StringVar(&loglevel, "l", "warn", "log level (shorthand)") ff.WithEnvVarPrefix("Q3RCON"),
)
switch {
case errors.Is(err, ff.ErrHelp):
fmt.Fprintf(os.Stderr, "%s\n", ffhelp.Flags(fs, "q3rcon [flags] <vban commands>"))
return nil, nil
case err != nil:
return nil, fmt.Errorf("failed to parse flags: %w", err)
}
flag.BoolVar(&versionFlag, "version", false, "print version information and exit") if flags.Version {
flag.BoolVar(&versionFlag, "v", false, "print version information and exit (shorthand)")
flag.Parse()
if versionFlag {
fmt.Printf("q3rcon version: %s\n", versionFromBuild()) fmt.Printf("q3rcon version: %s\n", versionFromBuild())
return nil, nil return nil, nil
} }
level, err := log.ParseLevel(loglevel) if err := flags.Validate(); err != nil {
return nil, err
}
level, err := log.ParseLevel(flags.LogLevel)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid log level: %s", loglevel) return nil, fmt.Errorf("invalid log level: %s", flags.LogLevel)
} }
log.SetLevel(level) log.SetLevel(level)
if port < 1024 || port > 65535 { client, closer, err := connectRcon(flags.Host, flags.Port, flags.Rconpass)
return nil, fmt.Errorf("invalid port value, got: (%d) expected: in range 1024-65535", port)
}
if len(rconpass) < 8 {
return nil, fmt.Errorf(
"invalid rcon password, got: (%s) expected: at least 8 characters",
rconpass,
)
}
client, closer, err := connectRcon(host, port, rconpass)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to rcon: %w", err) return nil, fmt.Errorf("failed to connect to rcon: %w", err)
} }
if interactive { if flags.Interactive {
fmt.Printf("Enter 'Q' to exit.\n>> ") fmt.Printf("Enter 'Q' to exit.\n>> ")
err := interactiveMode(client, os.Stdin) err := interactiveMode(client, os.Stdin)
if err != nil { if err != nil {

1
go.mod
View File

@ -4,6 +4,7 @@ go 1.25.0
require ( require (
github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/log v0.4.2
github.com/peterbourgon/ff/v4 v4.0.0-beta.1
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
) )

6
go.sum
View File

@ -25,6 +25,10 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/peterbourgon/ff/v4 v4.0.0-beta.1 h1:hV8qRu3V7YfiSMsBSfPfdcznAvPQd3jI5zDddSrDoUc=
github.com/peterbourgon/ff/v4 v4.0.0-beta.1/go.mod h1:onQJUKipvCyFmZ1rIYwFAh1BhPOvftb1uhvSI7krNLc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@ -45,6 +49,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=