Files
logwisp/cmd/lw/cli.go
T
lixen 9e86d7999d
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s
v0.24.0 cli long and short options, http sink viewer and new logo
2026-10-04 18:28:47 -04:00

265 lines
7.7 KiB
Go

package main
import (
"cmp"
"errors"
"flag"
"fmt"
"io"
"slices"
"strings"
"unicode"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/toml"
)
// command is a group of subcommands, recognized only as lw's first argument
type command struct {
name, summary, footer string
subcommands []subcommand
}
// subcommand is one `lw COMMAND NAME`: define registers its flags and
// returns the work, which runs once they are parsed and checked.
type subcommand struct {
name, synopsis, summary string
required []string
define func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error
}
var commands = []command{
{"auth", "SCRAM users, bearer tokens, tcp sink viewing",
"Credential changes apply on SIGHUP.", authCommands},
{"tls", "A CA and certificates for TLS and mTLS",
"Files are never overwritten; keys are written mode 0600.", tlsCommands},
{"preset", "Show the pipeline a preset makes, as TOML",
"lw --preset NAME,key=value runs one; flags here are its keys.", presetCommands()},
}
// shorts are lw's one-letter flags, each its long flag in every command that
// defines that flag; lw's own command line takes the top ones
var shorts = map[string]struct {
long string
top bool
}{
"c": {"config", true}, "h": {"help", true}, "p": {"preset", true},
"q": {"quiet", true}, "t": {"check", true}, "V": {"version", true},
"u": {"user", false},
}
// usageError is a command-line mistake: exit status 2 rather than 1
type usageError string
func (e usageError) Error() string { return string(e) }
// run runs a subcommand and returns the exit status: 0 success or help,
// 1 failure, 2 usage error.
func (c *command) run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 || slices.Contains([]string{"-h", "-help", "--help", "help"}, args[0]) {
c.usage(stderr)
if len(args) == 0 {
return 2
}
return 0
}
i := slices.IndexFunc(c.subcommands, func(s subcommand) bool { return s.name == args[0] })
if i < 0 {
fmt.Fprintf(stderr, "lw %s: unknown command %q\n\n", c.name, args[0])
c.usage(stderr)
return 2
}
s := c.subcommands[i]
fs := flag.NewFlagSet("lw "+c.name+" "+s.name, flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprintf(stderr, "Usage: %s %s\n\n%s.\n\n", fs.Name(), s.synopsis, s.summary)
printFlags(stderr, fs)
}
work := s.define(fs)
for letter, short := range shorts {
if f := fs.Lookup(short.long); f != nil {
fs.Var(f.Value, letter, "") // no usage: printFlags shows it with its long flag
}
}
if err := fs.Parse(args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2 // already reported by flag, with the usage
}
err := checkArgs(fs, s.required)
if err == nil {
err = work(stdout, stderr)
}
if err == nil {
return 0
}
fmt.Fprintf(stderr, "%s: %v\n", fs.Name(), err)
if _, ok := errors.AsType[usageError](err); ok {
fs.Usage()
return 2
}
return 1
}
func (c *command) usage(w io.Writer) {
fmt.Fprintf(w, "Usage: lw %s COMMAND [flags]\n\n", c.name)
for _, s := range c.subcommands {
fmt.Fprintf(w, " %-12s %s\n", s.name, s.summary)
}
fmt.Fprintf(w, "\nRun lw %s COMMAND -h for its flags. %s\n"+
"Exit status: 0 success, 1 failure, 2 usage error.\n", c.name, c.footer)
}
// printFlags lists fs's flags as " -u, --user NAME" or " --credentials FILE"
func printFlags(w io.Writer, fs *flag.FlagSet) {
fs.VisitAll(func(f *flag.Flag) {
if f.Usage == "" {
return
}
head := " "
for letter, short := range shorts {
if short.long == f.Name {
head = "-" + letter + ", "
}
}
arg, usage := flag.UnquoteUsage(f)
// Only a bool flag has no argument; its "false" default goes unsaid
if f.DefValue != "" && (arg != "" || f.DefValue != "false") {
usage += " (default: " + f.DefValue + ")"
}
if arg == "string" {
arg = "value"
}
if arg != "" {
arg = " " + strings.ToUpper(arg)
}
fmt.Fprintf(w, " %s--%s%s\n %s\n", head, f.Name, arg, usage)
})
}
func checkArgs(fs *flag.FlagSet, required []string) error {
if fs.NArg() > 0 {
return usageError(fmt.Sprintf("unexpected argument %q", fs.Arg(0)))
}
for _, name := range required {
if fs.Lookup(name).Value.String() == "" {
return usageError("--" + name + " is required")
}
}
return nil
}
// invocation is a parsed command line: a command with its arguments, a help
// request, or what config.Load reads.
type invocation struct {
command *command
args []string
help bool
load config.Args
}
// parseCommandLine reads lw's own flags, a short one as its long one; any
// other --flag is a --path=value setting for lixenwraith/config, which reports
// what it does not know. A pipeline flag takes the next argument unless it
// starts with '-'. A help request wins over a malformed flag.
func parseCommandLine(argv []string) (inv invocation, err error) {
defer func() {
if inv.help {
err = nil
}
}()
if len(argv) > 0 {
if i := slices.IndexFunc(commands, func(c command) bool { return c.name == argv[0] }); i >= 0 {
return invocation{command: &commands[i], args: argv[1:]}, nil
}
inv.help = argv[0] == "help"
}
specFlags := config.SpecFlags()
next := func(i *int) string {
if *i+1 < len(argv) && !strings.HasPrefix(argv[*i+1], "-") {
*i++
return argv[*i]
}
return ""
}
for i := 0; i < len(argv); i++ {
arg := argv[i]
name, value, inline := strings.Cut(arg, "=")
typed := name
if short, ok := shorts[strings.TrimPrefix(name, "-")]; ok && short.top && len(name) == 2 {
arg, name = "--"+short.long+arg[len(name):], "--"+short.long
}
switch {
case arg == "--":
inv.load.Overrides = append(inv.load.Overrides, argv[i:]...)
return inv, err
case arg == "--help":
inv.help = true
case name == "--config":
if !inline {
value = next(&i)
}
if value == "" {
err = cmp.Or(err, fmt.Errorf("%s requires a configuration file path", typed))
}
inv.load.File = value
case arg == "--color":
// Bare, it means always: config takes no bare flag for a string key
inv.load.Overrides = append(inv.load.Overrides, "--color="+cmp.Or(next(&i), "always"))
case strings.HasPrefix(name, "--") && slices.Contains(specFlags, name[2:]):
if !inline {
value = next(&i)
}
inv.load.Specs = append(inv.load.Specs, config.Spec{Flag: name[2:], Value: value})
case len(name) > 1 && name[0] == '-' && unicode.IsLetter(rune(name[1])):
// config reads only --flags; a negative number stays a value
err = cmp.Or(err, fmt.Errorf("unknown option %s: long options take two dashes, and a value that starts with '-' follows '=' (lw --help lists them)", typed))
default:
inv.load.Overrides = append(inv.load.Overrides, arg)
}
}
return inv, err
}
// presetCommands makes each preset a subcommand whose flags are its keys
func presetCommands() []subcommand {
var subs []subcommand
for _, p := range config.Presets() {
s := subcommand{name: p.Name, summary: p.Summary}
var synopsis []string
for _, k := range p.Params {
if k.Required {
s.required = append(s.required, k.Name)
synopsis = append(synopsis, "--"+k.Name+" VALUE")
}
}
s.synopsis = strings.Join(append(synopsis, "[--KEY VALUE...]"), " ")
s.define = func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
values := map[string]*string{}
for _, k := range p.Params {
values[k.Name] = fs.String(k.Name, k.Default, k.Help)
}
return func(stdout, _ io.Writer) error {
set := map[string]string{}
for name, v := range values {
set[name] = *v
}
pipeline, err := config.ExpandPreset(p.Name, set)
if err != nil {
return usageError(err.Error())
}
data, err := toml.Marshal(map[string]any{"pipelines": []config.PipelineConfig{pipeline}})
if err == nil {
_, err = stdout.Write(data)
}
return err
}
}
subs = append(subs, s)
}
return subs
}