All checks were successful
check / check (pull_request) Successful in 2m46s
Entry writes the banner to stdout before cobra parses anything, and the scan that decides whether to write it knew --quiet, -q and --cron but not --json. Every --json document therefore arrived behind two lines of prose and a blank line, and `vaultik snapshot list --json | jq` failed. Passing opts.JSON as extraQuiet could not help: that reaches UI.SetQuiet through an fx OnStart hook, long after the banner is already written. With the logger moved to stderr in #82, this was the last writer that could put something on stdout the caller did not ask for. The design question the issue raised is answered in favour of extending the raw-argv scan rather than moving the banner after parsing. The banner is printed first deliberately, so that it still appears when cobra rejects the arguments and on --help; after parsing there is no single place that covers those paths, so "after parsing" means either reimplementing the banner in several handlers or losing it exactly where a human most wants to know which build just ran. The objection to the scan is that --json is a subcommand flag matched anywhere in the vector, but --cron is already in the list and is also a subcommand flag: it exists only on `snapshot create`. So this adds another instance of an imprecision the code already accepts, not a new kind of one. The two error directions are not symmetric either — a false positive loses a decorative banner, a false negative corrupts a document — so the scan errs toward suppression, and --json=false suppresses it exactly as --quiet=false already does. Three tests at the CLI layer, where internal/vaultik's existing guard cannot reach. TestEntryJSONStdoutIsExactlyOneDocument runs Entry itself over the process's real stdout descriptor, through cobra and the fx graph to the document, and asserts the capture decodes as one JSON value with nothing after it; it is hermetic because file:// storage needs no credentials and `snapshot list` treats a destination store with no metadata/ as an empty list rather than a failure. A second covers the argument vectors of all five --json commands plus the pre-subcommand and --json=true forms. A third asserts the banner is still printed without a suppressing flag, so the first cannot be satisfied by deleting it. AGENTS.md policy 9 still keyed the structured-log format on stdout's TTY-ness after #82 moved that decision to stderr; it now names the log stream. A rules file that misdescribes the code misleads exactly the readers who trust it most. Two smaller findings from the same review. bytesAttrKey's human-readable byte formatting stopped applying under an open group, because the key reaching the comparison is group-qualified: "bytes" logged under a group arrives as "transfer.bytes" and fell back to a bare number. The match is now made on the final dot-separated segment, tested both grouped and ungrouped. And listEnv.stderr in snapshot_list_test.go, assigned but never read since those tests began capturing the process's stderr, is removed. Vaultik.Stderr is kept — nothing writes to it today, which its comment now says outright rather than leaving the next reader to hunt for a writer that does not exist. `prune --json` still does not survive jq, for an unrelated reason found while verifying this: pruneLocalSnapshots writes three lines of prose to stdout with no --json awareness, on main and after this change alike, and -q never suppressed them either. Filed as #108 rather than fixed here, being a different writer on a different code path.
104 lines
3.2 KiB
Go
104 lines
3.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/vaultik/internal/globals"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
)
|
|
|
|
// shortCommitLen is the number of git commit hash characters shown in
|
|
// the startup banner.
|
|
const shortCommitLen = 12
|
|
|
|
// Entry is the main entry point for the CLI application.
|
|
// It prints the startup banner to stdout (unless a banner-suppressing
|
|
// flag is present in os.Args — see bannerSuppressedInArgs), executes the
|
|
// root cobra command, and routes any returned error through the
|
|
// ui.Writer so the user sees a properly formatted "🛑 ERROR:" line.
|
|
func Entry() {
|
|
emitStartupBanner(os.Args[1:], os.Stdout)
|
|
|
|
rootCmd := NewRootCommand()
|
|
rootCmd.SilenceErrors = true
|
|
|
|
err := rootCmd.Execute()
|
|
if err != nil {
|
|
ReportErrorf("%s", err.Error())
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// emitStartupBanner writes the startup banner to w unless args (the
|
|
// argument vector with the program name already stripped) contains a
|
|
// flag that suppresses it. Split out of Entry so that the decision — the
|
|
// only thing standing between a --json invocation and a parseable
|
|
// stdout — is reachable from a test without running the whole CLI.
|
|
func emitStartupBanner(args []string, w io.Writer) {
|
|
if bannerSuppressedInArgs(args) {
|
|
return
|
|
}
|
|
|
|
short := globals.Commit
|
|
if len(short) > shortCommitLen {
|
|
short = short[:shortCommitLen]
|
|
}
|
|
|
|
writeStartupBanner(ui.New(w), time.Now().UTC(), short)
|
|
}
|
|
|
|
// ReportErrorf emits a user-facing error to stderr in the standard
|
|
// 🛑 ERROR: format. Use it from goroutine error paths (where returning
|
|
// an error to cobra isn't an option) and anywhere else a CLI command
|
|
// must surface a failure outside the normal RunE return path.
|
|
func ReportErrorf(format string, args ...any) {
|
|
ui.New(os.Stderr).Errorf(format, args...)
|
|
}
|
|
|
|
// bannerSuppressedInArgs reports whether any of args is a flag that
|
|
// should suppress the startup banner (--quiet/-q/--cron/--json). Stops
|
|
// at the "--" argument terminator. Recognizes both long forms and short
|
|
// -q, including combined short flags like "-qv".
|
|
//
|
|
// This scans the raw argument vector because the banner is printed
|
|
// before cobra parses anything — deliberately, so that it still appears
|
|
// when cobra rejects the arguments and on --help. The consequence is
|
|
// that a flag is matched wherever it occurs in the vector, including
|
|
// positions where the command it belongs to would not accept it.
|
|
// --json is a subcommand flag rather than a persistent one, but so is
|
|
// --cron (it exists only on `snapshot create`), so this adds no new
|
|
// class of imprecision. The only cost of a false positive is a missing
|
|
// decorative banner; the cost of a false negative is a corrupt document
|
|
// on stdout, so the scan errs deliberately in that direction.
|
|
func bannerSuppressedInArgs(args []string) bool {
|
|
for _, a := range args {
|
|
if a == "--" {
|
|
return false
|
|
}
|
|
|
|
switch a {
|
|
case "--quiet", "-q", "--cron", "--json":
|
|
return true
|
|
}
|
|
|
|
if strings.HasPrefix(a, "--quiet=") ||
|
|
strings.HasPrefix(a, "--cron=") ||
|
|
strings.HasPrefix(a, "--json=") {
|
|
return true
|
|
}
|
|
// Combined short flags like -qv or -vq.
|
|
if len(a) > 1 && a[0] == '-' && a[1] != '-' {
|
|
for _, c := range a[1:] {
|
|
if c == 'q' {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|