package bonfire import ( "context" "errors" "strings" tea "charm.land/bubbletea/v2" ) // consoleState is the optional side of an Output that knows the running // command's global options. The bonfire console implements it; other // Output implementations give the zero values. type consoleState interface { consoleGlobals() Globals consoleInteractive() bool consoleTerminal() bool } func (c *console) consoleGlobals() Globals { return c.globals } func (c *console) consoleInteractive() bool { return c.canPrompt() } func (c *console) consoleTerminal() bool { return c.tty } // GlobalsOf returns the global options of the command out belongs to, or // the zero Globals when out is not a bonfire console. func GlobalsOf(out Output) Globals { if s, ok := out.(consoleState); ok { return s.consoleGlobals() } return Globals{} } // VerbosityOf returns the -v level (0 to 3) of the command out belongs to. func VerbosityOf(out Output) int { return GlobalsOf(out).Verbosity } // IsQuiet reports whether the command runs with -q. func IsQuiet(out Output) bool { return GlobalsOf(out).Quiet } // Interactive reports whether out can ask a person: stdin and stdout are // terminals and neither -n nor -q is set. It is false for other Output // implementations and inside bonfire.Call. func Interactive(out Output) bool { if s, ok := out.(consoleState); ok { return s.consoleInteractive() } return false } // IsTerminal reports whether out writes to a terminal (its stdout is one). // It is false for other Output implementations and inside bonfire.Call. // Long-running commands use it to choose between a styled summary block for // a person and log records for journald. func IsTerminal(out Output) bool { if s, ok := out.(consoleState); ok { return s.consoleTerminal() } return false } // Detail is one line of a Details block: a label and its value. Warn // prints the value in the warning color. type Detail struct { Label string Value string Warn bool } // Details prints title (when not empty) and rows as aligned label/value // lines in the console palette: muted labels padded to the longest one, // values in the default color or, with Warn, in the warning color. It // prints nothing under -q. serve, queue:work and summer dev use it for // their startup summaries and shutdown reports. func Details(out Output, title string, rows []Detail) { if IsQuiet(out) { return } width := 0 for _, r := range rows { width = max(width, len([]rune(r.Label))) } c, styled := out.(*console) var b strings.Builder if title != "" { if styled { b.WriteString(c.palette.Header.Render(title)) } else { b.WriteString(title) } b.WriteByte('\n') } for _, r := range rows { label := r.Label + strings.Repeat(" ", width-len([]rune(r.Label))) value := r.Value if styled { label = c.palette.Muted.Render(label) if r.Warn { value = c.palette.Warning.Render(value) } } b.WriteString(" " + label + " " + value + "\n") } if styled { c.emit(c.out, b.String()) return } out.Printf("%s", b.String()) } // ConfirmProceed asks question before a destructive step, defaulting to // no, only when out is Interactive. Off a terminal, with -n or -q and in // bonfire.Call it returns true without asking, so deploys over SSH and // scheduled runs never block (D-28). func ConfirmProceed(out Output, question string) (bool, error) { if !Interactive(out) { return true, nil } return out.Confirm(question, false) } // Current tells p which item is being worked on, when p is an ItemProgress. func Current(p Progress, item string) { if ip, ok := p.(ItemProgress); ok { ip.Current(item) } } // Failed records a failed item on p, when p is an ItemProgress. func Failed(p Progress, item string, err error) { if ip, ok := p.(ItemProgress); ok { ip.Fail(item, err) } } // Process runs fn for each item in turn. On a live console it shows SSU's // process view: one row per item (waiting, running, done or failed with its // error) and a progress bar labelled operation. Otherwise it prints one line // per item, "✓ item" through Output.Success or "✗ item: error" through // Output.Error, so -q prints only the failures. It stops early when ctx is // cancelled and returns errors.Join of the failures. func Process(ctx context.Context, out Output, operation string, items []string, fn func(ctx context.Context, item string) error) error { if ctx == nil { ctx = context.Background() } if c, ok := out.(*console); ok && c.live() && len(items) > 0 { var errs []error _ = runLive(ctx, c.out, c, newProcessModel(c.palette, operation, items), func(send func(tea.Msg)) error { for i, item := range items { if err := ctx.Err(); err != nil { errs = append(errs, err) return nil } send(stepMsg{item: item, state: itemRunning, done: i}) if err := fn(ctx, item); err != nil { errs = append(errs, failure(item, err)) send(stepMsg{item: item, state: itemFailed, err: err, done: i + 1}) continue } send(stepMsg{item: item, state: itemDone, done: i + 1}) } return nil }) return errors.Join(errs...) } var errs []error for _, item := range items { if err := ctx.Err(); err != nil { errs = append(errs, err) break } if err := fn(ctx, item); err != nil { errs = append(errs, failure(item, err)) out.Error(failureLine(item, err)) continue } out.Success(item) } return errors.Join(errs...) } // failure wraps err with the item it belongs to. func failure(item string, err error) error { return &itemError{item: item, err: err} } type itemError struct { item string err error } func (e *itemError) Error() string { return failureLine(e.item, e.err) } func (e *itemError) Unwrap() error { return e.err } // PrintAbove prints line above the running live view (spinner, progress or // process view) and reports whether one was running. diary's console // channel uses it so log lines from other goroutines never tear a view; it // writes directly when PrintAbove returns false. func PrintAbove(line string) bool { return printAboveLines(line) }