v0.26.4 cli enhancements

This commit is contained in:
lixen committed 2026-10-05 15:12:19 -04:00
1 parent c00c076d9b
commit b2f11f6104
11 files changed
+174 -82

No files matched your search

+42 -17
View File
@@ -6,6 +6,7 @@ import (
"flag"
"fmt"
"io"
"regexp"
"slices"
"strings"
"unicode"
@@ -72,8 +73,10 @@ func (c *command) run(args []string, stdout, stderr io.Writer) int {
}
s := c.subcommands[i]
fs := flag.NewFlagSet("lw "+c.name+" "+s.name, flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
// flag names a long flag with one dash: its errors, and the usage, print below
fs.SetOutput(io.Discard)
fs.Usage = func() {}
usage := func() {
fmt.Fprintf(stderr, "Usage: %s %s\n\n%s.\n\n", fs.Name(), s.synopsis, s.summary)
printFlags(stderr, fs)
}
@@ -83,13 +86,16 @@ func (c *command) run(args []string, stdout, stderr io.Writer) int {
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 := fs.Parse(args[1:])
switch {
case errors.Is(err, flag.ErrHelp):
usage()
return 0
case err != nil:
err = usageError(longFlag.ReplaceAllString(err.Error(), "$1--$2"))
default:
err = checkArgs(fs, s.required)
}
err := checkArgs(fs, s.required)
if err == nil {
err = work(stdout, stderr)
}
@@ -98,12 +104,15 @@ func (c *command) run(args []string, stdout, stderr io.Writer) int {
}
fmt.Fprintf(stderr, "%s: %v\n", fs.Name(), err)
if _, ok := errors.AsType[usageError](err); ok {
fs.Usage()
usage()
return 2
}
return 1
}
// longFlag is a flag name of two or more letters in flag's errors
var longFlag = regexp.MustCompile(`(^|\s)-(\w[\w-]+)`)
func (c *command) usage(w io.Writer) {
fmt.Fprintf(w, "Usage: lw %s COMMAND [flags]\n\n", c.name)
for _, s := range c.subcommands {
@@ -162,9 +171,9 @@ type invocation struct {
}
// 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.
// other --flag is a setting, passed to lixenwraith/config as --path=value or a
// bare switch so it never guesses. A flag's value is the next argument unless
// that starts with '-'. Every error is a usage error; a help request wins.
func parseCommandLine(argv []string) (inv invocation, err error) {
defer func() {
if inv.help {
@@ -175,9 +184,11 @@ func parseCommandLine(argv []string) (inv invocation, err error) {
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()
specFlags, settings := config.SpecFlags(), config.Settings()
unexpected := func(word string) error {
return fmt.Errorf("unexpected argument %q: lw takes options only (a configuration file is -c FILE, a switch's value follows '=')", word)
}
next := func(i *int) string {
if *i+1 < len(argv) && !strings.HasPrefix(argv[*i+1], "-") {
*i++
@@ -194,9 +205,11 @@ func parseCommandLine(argv []string) (inv invocation, err error) {
}
switch {
case arg == "--":
inv.load.Overrides = append(inv.load.Overrides, argv[i:]...)
if i+1 < len(argv) {
err = cmp.Or(err, unexpected(argv[i+1]))
}
return inv, err
case arg == "--help":
case arg == "--help" || i == 0 && arg == "help":
inv.help = true
case name == "--config":
if !inline {
@@ -215,9 +228,21 @@ func parseCommandLine(argv []string) (inv invocation, err error) {
}
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
// lw's only single-dash options are the shorts
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))
case !strings.HasPrefix(name, "--"):
err = cmp.Or(err, unexpected(arg))
default:
isSwitch, known := settings[name[2:]]
if !known {
err = cmp.Or(err, fmt.Errorf("unknown option %s (lw --help lists them)", typed))
}
if !inline && !isSwitch {
if value = next(&i); value == "" && known {
err = cmp.Or(err, fmt.Errorf("%s needs a value; one that starts with '-' follows '='", typed))
}
arg += "=" + value
}
inv.load.Overrides = append(inv.load.Overrides, arg)
}
}
+68 -10
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
@@ -16,9 +17,9 @@ import (
)
// lw's own flags: a short is its long flag, an unknown single-dash one fails,
// the last -c wins, a pipeline flag or --color takes the next argument unless
// it starts with '-', -- ends lw's flags, and a command is only the first
// argument. Everything else is a setting for config.
// the last -c wins, a flag takes the next argument unless it starts with '-',
// a switch takes none, and a command is only the first argument. A setting
// reaches config as --path=value or a bare switch.
func TestCommandLineGrammar(t *testing.T) {
for _, c := range []struct {
argv []string
@@ -30,15 +31,14 @@ func TestCommandLineGrammar(t *testing.T) {
invocation{load: config.Args{Specs: []config.Spec{{Flag: "source", Value: "null"}, {Flag: "sink", Value: "http,port=1"}, {Flag: "filter"}, {Flag: "sink"}}}}},
{[]string{"--preset", "tail,path=x", "--pipeline", "b"}, invocation{load: config.Args{Specs: []config.Spec{{Flag: "preset", Value: "tail,path=x"}, {Flag: "pipeline", Value: "b"}}}}},
{[]string{"--color", "--color", "never", "--color=auto"}, invocation{load: config.Args{Overrides: []string{"--color=always", "--color=never", "--color=auto"}}}},
{[]string{"--quiet", "--", "-c", "x", "--source", "null"}, invocation{load: config.Args{Overrides: []string{"--quiet", "--", "-c", "x", "--source", "null"}}}},
{[]string{"--quiet", "--"}, invocation{load: config.Args{Overrides: []string{"--quiet"}}}},
{[]string{"--quiet", "-h"}, invocation{help: true, load: config.Args{Overrides: []string{"--quiet"}}}},
{[]string{"-c", "-h"}, invocation{help: true}},
{[]string{"help"}, invocation{help: true, load: config.Args{Overrides: []string{"help"}}}},
{[]string{"help"}, invocation{help: true}},
{[]string{"tls", "ca", "-h"}, invocation{command: &commands[1], args: []string{"ca", "-h"}}},
{[]string{"--quiet", "tls"}, invocation{load: config.Args{Overrides: []string{"--quiet", "tls"}}}},
{[]string{"-q", "-t", "-V", "-p", "tail,path=x", "-c=a.toml", "--logging.file.retention_hours", "-1"},
{[]string{"-q", "-t", "-V", "-p", "tail,path=x", "-c=a.toml", "--logging.level", "debug", "--logging.file.retention_hours=-1", "--dump=false"},
invocation{load: config.Args{File: "a.toml", Specs: []config.Spec{{Flag: "preset", Value: "tail,path=x"}},
Overrides: []string{"--quiet", "--check", "--version", "--logging.file.retention_hours", "-1"}}}},
Overrides: []string{"--quiet", "--check", "--version", "--logging.level=debug", "--logging.file.retention_hours=-1", "--dump=false"}}}},
} {
got, err := parseCommandLine(c.argv)
if err != nil || !reflect.DeepEqual(got, c.want) {
@@ -46,13 +46,56 @@ func TestCommandLineGrammar(t *testing.T) {
}
}
for _, argv := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"},
{"-v"}, {"-qt"}, {"-u", "x"}, {"-config", "x"}} {
{"-v"}, {"-qt"}, {"-u", "x"}, {"-config", "x"}, {"--logging.level"}, {"--logging.file.retention_hours", "-1"}} {
if _, err := parseCommandLine(argv); err == nil {
t.Errorf("%q accepted", argv)
}
}
}
// lw takes no positional argument: a word no flag takes is named, not passed
// to config, whose bool would take it as its value
func TestAWordNoFlagTakesIsAnError(t *testing.T) {
for _, c := range []struct {
argv []string
word string
}{
{[]string{"tail"}, "tail"}, {[]string{"-t", "x.toml"}, "x.toml"}, {[]string{"--quiet", "tls"}, "tls"},
{[]string{"--status_reporter", "false"}, "false"}, {[]string{"--logging.level", "debug", "x"}, "x"},
{[]string{"--color=never", "x"}, "x"}, {[]string{"--", "-c"}, "-c"}, {[]string{"-1"}, "-1"},
} {
_, err := parseCommandLine(c.argv)
if err == nil || !strings.Contains(err.Error(), "unexpected argument "+strconv.Quote(c.word)) {
t.Errorf("%q: %v", c.argv, err)
}
}
}
// An option lw does not know is an error, whatever follows it
func TestAnUnknownOptionIsAnError(t *testing.T) {
for _, argv := range [][]string{{"--nosuch"}, {"--nosuch=1"}, {"--nosuch", "x"}, {"--check", "--nosuch"}} {
_, err := parseCommandLine(argv)
if err == nil || err.Error() != "unknown option --nosuch (lw --help lists them)" {
t.Errorf("%q: %v", argv, err)
}
}
}
// flag writes a long flag with one dash; lw names it with two, then the usage
func TestFlagErrorsNameLongFlagsWithTwoDashes(t *testing.T) {
for args, want := range map[string]string{
"--user": "lw auth add-user: flag needs an argument: --user\nUsage: ",
"-u": "lw auth add-user: flag needs an argument: -u\nUsage: ",
"-nosuch": "lw auth add-user: flag provided but not defined: --nosuch\nUsage: ",
"--user=x": "lw auth add-user: --credentials is required\nUsage: ",
} {
code, _, stderr := runCommand(t, "auth", "add-user", args)
if code != 2 || !strings.HasPrefix(stderr, want) || strings.Count(stderr, "Usage: ") != 1 {
t.Errorf("lw auth add-user %s: exit %d:\n%s", args, code, stderr)
}
}
}
// Every subcommand's -h lists each flag once, GNU style, with its short form
// beside it: " -u, --user NAME" or " --credentials FILE"
func TestUsageListsEachFlagOnce(t *testing.T) {
@@ -63,6 +106,9 @@ func TestUsageListsEachFlagOnce(t *testing.T) {
s.define(fs)
want := 0
fs.VisitAll(func(f *flag.Flag) {
if f.Usage == "" {
return // an earlier name, unlisted
}
want++
head := " --" + f.Name
for letter, short := range shorts {
@@ -139,13 +185,25 @@ func TestEveryPresetBuilds(t *testing.T) {
}
}
// --host, the earlier name of lw tls cert --hosts, still sets the hosts
func TestTLSCertHostIsHosts(t *testing.T) {
dir := t.TempDir()
if code, _, stderr := runCommand(t, "tls", "ca", "--dir", dir); code != 0 {
t.Fatalf("ca: exit %d: %s", code, stderr)
}
code, _, stderr := runCommand(t, "tls", "cert", "--ca-dir", dir, "--name", "agg", "--server", "--host", "a.example")
if code != 0 || !strings.Contains(stderr, "for [a.example CN=agg]") {
t.Fatalf("exit %d: %s", code, stderr)
}
}
// lw tls ca and lw tls cert write a chain a dialer verifies by the CA file,
// keys private, and never replace a file
func TestTLSCommandsIssueAVerifiableChain(t *testing.T) {
dir := t.TempDir()
steps := [][]string{
{"ca", "-dir", dir, "-name", "test CA"},
{"cert", "-ca-dir", dir, "-name", "agg", "-server", "-host", "127.0.0.1"},
{"cert", "-ca-dir", dir, "-name", "agg", "-server", "-hosts", "127.0.0.1"},
}
for _, args := range steps {
if code, _, stderr := runCommand(t, "tls", args...); code != 0 {
+4 -5
View File
@@ -23,8 +23,8 @@ Usage:
Commands (lw COMMAND -h lists its subcommands):
%s
Options: a short option is its long one; a value follows as the next argument
or after '=' (-c FILE, --config=FILE), one that starts with '-' after '=';
short options do not combine.
or after '=' (-c FILE, --config=FILE), one that starts with '-' or a switch's
after '='; short options do not combine. lw takes options only.
-c, --config FILE Configuration file (default:
~/.config/logwisp/logwisp.toml if it exists,
else ./logwisp.toml)
@@ -94,9 +94,8 @@ Signals:
Exit codes:
0 success, including the end of input
1 general error, including an error in lw's own options (lw COMMAND
exits 2 on a usage error)
2 configuration file not found
1 general error, including a configuration that does not load
2 usage error, or a named configuration file not found
`
func printHelp() {
+1 -1
View File
@@ -24,7 +24,7 @@ func main() {
switch {
case err != nil:
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
os.Exit(2) // every command-line error is a usage error
case inv.command != nil:
os.Exit(inv.command.run(inv.args, os.Stdout, os.Stderr))
case inv.help:
+3 -2
View File
@@ -23,7 +23,7 @@ var tlsCommands = []subcommand{
{"ca", "--dir DIR [--name NAME] [--days N]",
"Create a certificate authority, DIR/ca.crt and DIR/ca.key, that signs certificates only",
[]string{"dir"}, defineCA},
{"cert", "--ca-dir DIR --name NAME [--host NAME,...] [--server] [--client] [--days N] [--out DIR]",
{"cert", "--ca-dir DIR --name NAME [--hosts NAME,...] [--server] [--client] [--days N] [--out DIR]",
"Issue a certificate and key, OUT/NAME.crt and OUT/NAME.key, from DIR/ca.crt and DIR/ca.key",
[]string{"ca-dir", "name"}, defineCert},
}
@@ -53,7 +53,8 @@ func defineCA(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
func defineCert(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
caDir := fs.String("ca-dir", "", "`dir`ectory holding ca.crt and ca.key (lw tls ca)")
name := fs.String("name", "", "subject common `name`, the mtls identity, and the file names")
hosts := fs.String("host", "", "DNS `names` and IP addresses, ',' between them (default with --server: --name)")
hosts := fs.String("hosts", "", "DNS `names` and IP addresses, ',' between them (default with --server: --name)")
fs.Var(fs.Lookup("hosts").Value, "host", "") // its earlier name, unlisted like a short
server := fs.Bool("server", false, "for listeners (TLS server authentication)")
client := fs.Bool("client", false, "for dialers presenting a certificate (TLS client authentication)")
days := fs.Int("days", int(tlsx.LeafValidity/(24*time.Hour)), "validity in `n` days, at most the CA's")