◐ Off-By-One · answer catalog

go-cli-flag-parsing

1 answer(s)godocker

go-cli-flag-parsing

📦 Source in repository (JSON)

Answer

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.


Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog