package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// logLineMsg is one line of output from a running step's subprocess,
// tagged with which step it belongs to — same pattern as
// cmd/gitfed-install/exec.go.
type logLineMsg struct {
stream string
line string
}
// stepResultMsg reports a step's subprocess finishing, successfully or not.
type stepResultMsg struct {
stream string
err error
}
// listen drains the shared message channel one value at a time — see
// cmd/gitfed-install/exec.go for the full rationale (same idiom, reused
// verbatim: one channel, one listener loop, re-issued after every message).
func listen(ch chan tea.Msg) tea.Cmd {
return func() tea.Msg { return <-ch }
}
// runStreamed runs name(args...) with dir as its working directory (empty
// for the current one), sending each line of its combined output as a
// logLineMsg tagged stream, then exactly one stepResultMsg when it exits.
// Meant to be launched with `go runStreamed(...)` from a tea.Cmd.
//
// Also returns the same error it put in the stepResultMsg, so a multi-
// command sequence can bail out after the first failure.
func runStreamed(ctx context.Context, ch chan<- tea.Msg, stream, dir, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
if dir != "" {
cmd.Dir = dir
}
stdout, err := cmd.StdoutPipe()
if err != nil {
ch <- stepResultMsg{stream: stream, err: err}
return err
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
ch <- stepResultMsg{stream: stream, err: err}
return err
}
scanner := bufio.NewScanner(stdout)
// Docker build progress lines can be long; bump the buffer so a single
// unusually long line never aborts the scan mid-stream.
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
ch <- logLineMsg{stream: stream, line: scanner.Text()}
}
err = cmd.Wait()
ch <- stepResultMsg{stream: stream, err: err}
return err
}
// runQuiet runs name(args...) and returns its combined output and error,
// for a step that needs a command's result but not a live log of it.
func runQuiet(ctx context.Context, dir, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
return string(out), err
}
// runPiped runs first | second without a shell — first's stdout feeds
// second's stdin directly through an in-process pipe. This replaces a
// former `sh -c "cmd1 '<arg>' | cmd2"` pattern whose quoting (a bare wrap
// in single quotes) didn't escape an embedded single quote, letting a
// crafted argument (e.g. a VERSION file containing one) break out and
// inject arbitrary shell commands running as root. No shell involved
// here, so there's nothing to escape.
func runPiped(ctx context.Context, first, second []string) error {
c1 := exec.CommandContext(ctx, first[0], first[1:]...)
c2 := exec.CommandContext(ctx, second[0], second[1:]...)
pr, pw := io.Pipe()
c1.Stdout = pw
c2.Stdin = pr
var stderr1, stderr2 bytes.Buffer
c1.Stderr = &stderr1
c2.Stderr = &stderr2
if err := c2.Start(); err != nil {
return fmt.Errorf("%s: %w", second[0], err)
}
if err := c1.Start(); err != nil {
return fmt.Errorf("%s: %w", first[0], err)
}
err1 := c1.Wait()
pw.Close()
err2 := c2.Wait()
if err1 != nil {
return fmt.Errorf("%s: %w: %s", first[0], err1, strings.TrimSpace(stderr1.String()))
}
if err2 != nil {
return fmt.Errorf("%s: %w: %s", second[0], err2, strings.TrimSpace(stderr2.String()))
}
return nil
}
// runToFile runs name(args...) and streams its raw stdout straight into w
// — used for the one step whose output is binary (backup.go's `tar` pull),
// where treating it as line-oriented text like runStreamed would corrupt
// it. stderr is captured separately (not mixed into w, since w is expected
// to be exactly the command's stdout bytes) and folded into the returned
// error so a failure here isn't just a bare "exit status 1" either.
func runToFile(ctx context.Context, w io.Writer, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = w
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if stderr.Len() > 0 {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
return err
}
return nil
}