package cli import ( "errors" "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 // exitCodeInterrupted is the exit status of a command that SIGINT or // SIGTERM stopped. It is 128 plus SIGINT's number, 2, which is what a // shell reports for a command stopped by Ctrl-C. const exitCodeInterrupted = 130 // Entry is the main entry point for the CLI application. // It prints the startup banner to stderr (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. // The banner goes to stderr because stdout carries only the output the // user asked for, such as a completion script or a `config get` value. // // It returns the process exit code (0 on success, 130 when interrupted, // 1 on any other error) rather than calling os.Exit, so that main's // deferred profile writers run before the process ends. See run in // cmd/vaultik/main.go. func Entry() int { emitStartupBanner(os.Args[1:], os.Stderr) rootCmd := NewRootCommand() rootCmd.SilenceErrors = true err := rootCmd.Execute() if err != nil { // An operation that ran inside the fx app has already reported // its own failure (`snapshot verify --json` puts it in the // document instead); errReported says so. Printing it again // here would double the error line. // Every other error — bad arguments, a config that would not // load, an interrupt — reaches Entry unreported, so it is shown // here. if !errors.Is(err, errReported) { ReportErrorf("%s", err.Error()) } if errors.Is(err, errInterrupted) { return exitCodeInterrupted } return 1 } return 0 } // 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 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. 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 }