go-cli-flag-parsing
Root cause (GAP-015): config.Load() (whose requireEnv deliberately panics on missing env vars) ran at the top of each main(), so any invocation — including --version/--help — crashed with a goroutine dump before the flag package ever ran.
Fix (3 parts):
1. Parse stdlib flag before config.Load() in every cmd/<name>/main.go.
2. Handle --version / --help (custom flag.Usage) right after parse, exiting 0 before any config access.
3. Wrap config.Load() in a deferred recover at the process boundary that prints a one-line fatal: to stderr and os.Exit(1) — the library is untouched because its recover-based tests assert the panics.
Shared helper (internal/cli/cli.go) so every binary gets identical behavior:
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
// Version injected at build time:
// go build -ldflags "-X <module>/internal/cli.Version=1.2.3" ...
var Version = "dev"
func binName() string { return filepath.Base(os.Args[0]) }
// Parse runs BEFORE any config access. Exits for --version (0),
// --help/-h (0), and malformed flags (2, stdlib, with our Usage).
func Parse() {
version := flag.Bool("version", false, "print version and exit")
help := flag.Bool("help", false, "print this help and exit")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [flags]\n\nFlags:\n", binName())
flag.PrintDefaults()
}
flag.Parse()
if *version {
fmt.Printf("%s %s\n", binName(), Version)
os.Exit(0)
}
if *help {
flag.Usage()
os.Exit(0)
}
}
// FatalRecover converts any panic (config.Load's requireEnv) into a
// single-line fatal + exit 1. The library tests assert the panics, so
// we recover here instead of changing the library.
func FatalRecover() {
if r := recover(); r != nil {
msg := strings.ReplaceAll(fmt.Sprint(r), "\n", " ") // guarantee one line
fmt.Fprintf(os.Stderr, "fatal: %s\n", msg)
os.Exit(1)
}
}
Every cmd/<name>/main.go now follows this order:
package main
import (
"gap015/internal/cli"
"gap015/internal/config"
)
func main() {
cli.Parse() // 1. flags FIRST — --version/--help exit before config
defer cli.FatalRecover() // 2. process-boundary recover
cfg := config.Load() // 3. config — panic → one-line fatal, exit 1
run(cfg) // 4. normal startup
}
No change to the config library, so internal/config/config_test.go (which asserts Load() panics) keeps passing as-is.
The sandbox contained no live repo, so I built a faithful reproduction at `/tmp/gap015` (`internal/config` + `internal/cli` + `cmd/alpha`, `cmd/beta`, `cmd/oldalpha`) and verified the full matrix with real binaries: | Check | Command | Result | |---|---|---| | `--version` | `./bin/alpha --version` | `alpha 1.2.3`, exit **0** | | `--help` (custom Usage) | `./bin/alpha --help` | per-binary `Usage: alpha [flags]` + flags, exit **0** | | `-h` alias | `./bin/alpha -h` | custom Usage, exit **0** | | unknown flag | `./bin/alpha --bogus` | `flag provided but not defined: -bogus` + Usage, exit **2** | | missing env | `env -i ./bin/alpha` | `fatal: required environment variable APP_API_KEY is not set`, exit **1** | | one-line guarantee | `env -i ./bin/alpha 2>&1 \| wc -l` | **1** line (no goroutine dump) | | second binary | `./bin/beta --version` / `--help` | `beta 2.0.0` / usage, exit **0** | | only one var missing | `env -i APP_API_KEY=k ./bin/alpha` | `fatal: ... APP_REGION ...`, exit **1** | | happy path | `APP_API_KEY=… APP_REGION=eu-west-1 ./bin/alpha` | runs normally, exit **0** | | flags under `env -i` | `env -i ./bin/alpha --help` | exit **0** (flags work with no env) | | library contract | `go test ./internal/config/` | both panic-asserting tests **PASS** | | version injection | `-ldflags "-X gap015/internal/cli.Version=1.2.3"` | per-binary versions printed | **Contrast (pre-fix `oldalpha`, config.Load before flag parse):** `env -i ./bin/oldalpha --version` → `panic: required environment variable APP_API_KEY is not set` + `goroutine 1 [running]: …` dump, exit 2. Post-fix, same invocation prints `alpha 1.2.3`, exit 0 — the exact GAP-015 regression. **Edge cases covered:** `-h` vs `--help` aliasing; unknown-flag exit code 2 (stdlib default) with custom Usage; recover only guards code *after* parse so `--version`/`--help` can never be swallowed by the fatal path; multi-line panic values are flattened to one line; defer ordering (recover registered before `config.Load()`, so only its panic is caught, not flag handling).
{"model": "deepseek-v4-flash", "problem_class": "go-cli-flag-parsing", "result": "passed", "tests": 8}