fix(summer): give delegated commands the terminal and keep their stderr apart

- appStreams hands the app binary summer's own stdout and stderr on a
  terminal, so it asks the production question, prints styled summaries
  and animates its widgets
- off a terminal the binary's stdout goes through summer's Output and its
  stderr to summer's stderr, so --json and -q output carry no log lines
- newAppCmd (unexported) takes the two writers; docs say the streams stay
  apart (CR-03)
This commit is contained in:
Jakub Zych
2026-10-22 02:19:01 +02:00
parent 323413c741
commit 9c89705747
4 changed files with 83 additions and 12 deletions

View File

@@ -141,7 +141,22 @@ func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error
return fmt.Errorf("summer: refusing to recurse into %s", bin)
}
}
return runAppCmd(newAppCmd(ctx, bin, appDir, argv, out), argv)
stdout, stderr := appStreams(out, bonfire.IsTerminal(out))
return runAppCmd(newAppCmd(ctx, bin, appDir, argv, stdout, stderr), argv)
}
// appStreams picks the app binary's stdout and stderr. On a terminal
// (terminal is bonfire.IsTerminal(out)) the binary gets summer's own
// streams, so it sees the terminal: it asks the production question, prints
// styled summaries and animates its widgets. Otherwise its stdout goes
// through out (tests and bonfire.Call roots capture it) and its stderr to
// summer's stderr, so log lines and error blocks never mix into data such
// as --json output.
func appStreams(out bonfire.Output, terminal bool) (stdout, stderr io.Writer) {
if terminal {
return os.Stdout, os.Stderr
}
return out, os.Stderr
}
// runAppCmd runs cmd, the app binary started with argv, forwarding SIGINT
@@ -170,15 +185,16 @@ func runAppCmd(cmd *exec.Cmd, argv []string) error {
// timeout of serve and the workers, so they finish a graceful shutdown.
const appShutdownGrace = 15 * time.Second
// newAppCmd builds the command that runs the app binary bin with argv in dir.
// The child inherits summer's stdin, so the hidden password prompts of
// admin:create and admin:reset-password read from the terminal. Cancelling
// ctx sends SIGTERM, and the child is killed only after appShutdownGrace.
func newAppCmd(ctx context.Context, bin, dir string, argv []string, out io.Writer) *exec.Cmd {
// newAppCmd builds the command that runs the app binary bin with argv in
// dir, writing to stdout and stderr (see appStreams). The child inherits
// summer's stdin, so the hidden password prompts of admin:create and
// admin:reset-password read from the terminal. Cancelling ctx sends
// SIGTERM, and the child is killed only after appShutdownGrace.
func newAppCmd(ctx context.Context, bin, dir string, argv []string, stdout, stderr io.Writer) *exec.Cmd {
cmd := exec.CommandContext(ctx, bin, argv...)
cmd.Dir = dir
cmd.Stdout = out
cmd.Stderr = out
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Stdin = os.Stdin
cmd.Env = os.Environ()
cmd.Cancel = func() error {

View File

@@ -5,9 +5,11 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"slices"
"strings"
@@ -189,7 +191,7 @@ func TestRunChildForwardsSignals(t *testing.T) {
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} {
t.Run(sig.String(), func(t *testing.T) {
var out syncBuffer
cmd := newAppCmd(context.Background(), os.Args[0], ".", helperArgv(), &out)
cmd := newAppCmd(context.Background(), os.Args[0], ".", helperArgv(), &out, &out)
helperEnv(cmd)
sigs := make(chan os.Signal, 1)
done := make(chan error, 1)
@@ -218,7 +220,7 @@ func TestNewAppCmdCancelSendsSIGTERM(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var out syncBuffer
cmd := newAppCmd(ctx, os.Args[0], ".", helperArgv(), &out)
cmd := newAppCmd(ctx, os.Args[0], ".", helperArgv(), &out, &out)
helperEnv(cmd)
done := make(chan error, 1)
go func() { done <- runChild(cmd, nil) }()
@@ -311,7 +313,8 @@ func exitHelperRunner(t *testing.T, mode string) {
t.Helper()
prev := appRunner
appRunner = func(ctx context.Context, argv []string, out bonfire.Output) error {
cmd := newAppCmd(ctx, os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, out)
stdout, stderr := appStreams(out, bonfire.IsTerminal(out))
cmd := newAppCmd(ctx, os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, stdout, stderr)
cmd.Env = append(os.Environ(), "SUMMER_EXIT_HELPER="+mode)
return runAppCmd(cmd, argv)
}
@@ -360,3 +363,53 @@ func TestDelegateExitCodePassthrough(t *testing.T) {
}
})
}
// TestDelegateStreams (CR-03): off a terminal the app binary's stdout goes
// through summer's Output and its stderr to summer's stderr, never into
// summer's stdout.
func TestDelegateStreams(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the fake app binary is a shell script")
}
app := t.TempDir()
if err := os.WriteFile(filepath.Join(app, "summer.yaml"), []byte("module: example.com/acme\nbinary: acme\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(app, "bin"), 0o755); err != nil {
t.Fatal(err)
}
script := "#!/bin/sh\necho data-line\necho log-line >&2\n"
if err := os.WriteFile(filepath.Join(app, "bin", "acme"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Chdir(app)
out, err := runSummer(t, "route:list", "--json")
if err != nil {
t.Fatalf("route:list: %v\n%s", err, out)
}
if !strings.Contains(out, "data-line") {
t.Errorf("the binary's stdout did not reach summer's Output: %q", out)
}
if strings.Contains(out, "log-line") {
t.Errorf("the binary's stderr landed in summer's stdout: %q", out)
}
}
// TestAppStreams (CR-03): on a terminal the app binary gets summer's own
// stdout and stderr; off a terminal its stdout goes through the Output and
// its stderr to summer's stderr.
func TestAppStreams(t *testing.T) {
var buf bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
if bonfire.IsTerminal(out) {
t.Fatal("a buffer console reports a terminal")
}
stdout, stderr := appStreams(out, true)
if stdout != os.Stdout || stderr != os.Stderr {
t.Errorf("terminal: streams %v, %v; want os.Stdout, os.Stderr", stdout, stderr)
}
stdout, stderr = appStreams(out, false)
if stdout != io.Writer(out) || stderr != os.Stderr {
t.Errorf("not a terminal: streams %v, %v; want the Output, os.Stderr", stdout, stderr)
}
}