From 95dd5d5b335cac6512ff6cafd6808464b9324e85 Mon Sep 17 00:00:00 2001 From: David Stotijn Date: Thu, 30 Apr 2026 18:34:51 +0200 Subject: [PATCH] cli: render OpTracker as plain lines on non-interactive stderr The build progress UI uses ANSI cursor save/restore for in-place spinner updates, which only works on a TTY. When stderr is piped into mise, a log aggregator, or CI, every 100ms refresh becomes a new line and floods the output. Detect the CLI's stderr TTY state and pass it through to the daemon as a new non_interactive bool on RunRequest, ExecScriptRequest, and ExecSpecRequest. The OpTracker now renders one plain line per state transition (start / done / failed) instead of the spinner when interactive is false. --- cli/cmd/encore/exec.go | 27 +++++++------ cli/cmd/encore/run.go | 1 + cli/daemon/exec_script.go | 4 +- cli/daemon/run.go | 2 +- internal/optracker/optracker.go | 63 +++++++++++++++++++++++++----- proto/encore/daemon/daemon.pb.go | 66 ++++++++++++++++++++++++-------- proto/encore/daemon/daemon.proto | 13 +++++++ 7 files changed, 136 insertions(+), 40 deletions(-) diff --git a/cli/cmd/encore/exec.go b/cli/cmd/encore/exec.go index b6f964b586..90db36bd03 100644 --- a/cli/cmd/encore/exec.go +++ b/cli/cmd/encore/exec.go @@ -8,6 +8,7 @@ import ( "os/signal" "github.com/spf13/cobra" + "golang.org/x/term" "encr.dev/cli/cmd/encore/cmdutil" "encr.dev/cli/cmd/encore/root" @@ -66,12 +67,13 @@ func execScript(appRoot, relWD string, args []string) { defer func() { _ = os.RemoveAll(tempDir) }() stream, err := daemon.ExecSpec(ctx, &daemonpb.ExecSpecRequest{ - AppRoot: appRoot, - WorkingDir: relWD, - ScriptArgs: args, - Environ: os.Environ(), - Namespace: nonZeroPtr(nsName), - TempDir: tempDir, + AppRoot: appRoot, + WorkingDir: relWD, + ScriptArgs: args, + Environ: os.Environ(), + Namespace: nonZeroPtr(nsName), + TempDir: tempDir, + NonInteractive: !term.IsTerminal(int(os.Stderr.Fd())), }) if err != nil { fatal(err) @@ -120,12 +122,13 @@ func execScript(appRoot, relWD string, args []string) { // For Go apps, use the streaming ExecScript RPC. stream, err := daemon.ExecScript(ctx, &daemonpb.ExecScriptRequest{ - AppRoot: appRoot, - WorkingDir: relWD, - ScriptArgs: args, - Environ: os.Environ(), - TraceFile: root.TraceFile, - Namespace: nonZeroPtr(nsName), + AppRoot: appRoot, + WorkingDir: relWD, + ScriptArgs: args, + Environ: os.Environ(), + TraceFile: root.TraceFile, + Namespace: nonZeroPtr(nsName), + NonInteractive: !term.IsTerminal(int(os.Stderr.Fd())), }) if err != nil { fatal(err) diff --git a/cli/cmd/encore/run.go b/cli/cmd/encore/run.go index e199a5ac85..3636f5acb1 100644 --- a/cli/cmd/encore/run.go +++ b/cli/cmd/encore/run.go @@ -137,6 +137,7 @@ func runApp(appRoot, wd string) { Browser: browserMode, LogLevel: nonZeroPtr(logLevel.Value), ScrubSensitiveData: scrubSensitiveData, + NonInteractive: !term.IsTerminal(int(os.Stderr.Fd())), }) if err != nil { fatal(err) diff --git a/cli/daemon/exec_script.go b/cli/daemon/exec_script.go index 2f574ad9e3..11164c6aa9 100644 --- a/cli/daemon/exec_script.go +++ b/cli/daemon/exec_script.go @@ -56,7 +56,7 @@ func (s *Server) ExecScript(req *daemonpb.ExecScriptRequest, stream daemonpb.Dae return nil } - ops := optracker.New(stderr, stream) + ops := optracker.New(stderr, stream, !req.NonInteractive) defer ops.AllDone() // Kill the tracker when we exit this function testResults := make(chan error, 1) @@ -163,7 +163,7 @@ func (s *Server) ExecSpec(req *daemonpb.ExecSpecRequest, stream daemonpb.Daemon_ return nil } - ops := optracker.New(stderr, adapter) + ops := optracker.New(stderr, adapter, !req.NonInteractive) defer ops.AllDone() defer func() { diff --git a/cli/daemon/run.go b/cli/daemon/run.go index 5ccdbd2594..e5af31a2e7 100644 --- a/cli/daemon/run.go +++ b/cli/daemon/run.go @@ -95,7 +95,7 @@ func (s *Server) Run(req *daemonpb.RunRequest, stream daemonpb.Daemon_RunServer) return nil } - ops := optracker.New(stderr, stream) + ops := optracker.New(stderr, stream, !req.NonInteractive) defer ops.AllDone() // Kill the tracker when we exit this function // Check for available update before we start the proc diff --git a/internal/optracker/optracker.go b/internal/optracker/optracker.go index 7372083b04..4a5e61a94c 100644 --- a/internal/optracker/optracker.go +++ b/internal/optracker/optracker.go @@ -20,10 +20,17 @@ type OutputStream interface { Send(*daemonpb.CommandMessage) error } -func New(w io.Writer, stream OutputStream) *OpTracker { +// New returns a new OpTracker. +// +// When interactive is true the tracker renders an in-place spinner UI using +// ANSI cursor manipulation. When false, it emits one plain line per state +// change (start / done / failed), suitable for piping into log aggregators +// or non-TTY environments. +func New(w io.Writer, stream OutputStream, interactive bool) *OpTracker { return &OpTracker{ - w: w, - stream: stream, + w: w, + stream: stream, + interactive: interactive, } } @@ -35,6 +42,7 @@ type OpTracker struct { quit bool // quit indicates that the tracker has been stopped (this should only be set by AllDone) savedCursor sync.Once stream OutputStream + interactive bool } type OperationID int @@ -90,7 +98,7 @@ func (t *OpTracker) Add(msg string, minStart time.Time) OperationID { t.ops = append(t.ops, op) t.refresh() - if !t.started { + if t.interactive && !t.started { go t.spin() t.started = true } @@ -147,6 +155,14 @@ func (t *OpTracker) Cancel(id OperationID) { // refresh refreshes the display by writing to t.w. // The mutex must be held by the caller. func (t *OpTracker) refresh() { + if t.interactive { + t.refreshTTY() + } else { + t.refreshPlain() + } +} + +func (t *OpTracker) refreshTTY() { t.savedCursor.Do(func() { fmt.Fprint(t.w, ansi.SaveCursorPosition) }) @@ -200,6 +216,33 @@ func (t *OpTracker) refresh() { } } +// refreshPlain emits a single line per state transition (start / done / failed). +// Subsequent calls for the same op are no-ops, so it is safe to invoke from +// every Add/Done/Fail without producing duplicate output. +func (t *OpTracker) refreshPlain() { + for _, o := range t.ops { + if !o.startedPrinted { + fmt.Fprintf(t.w, " %s...\n", o.msg) + o.startedPrinted = true + } + if !o.donePrinted && !o.done.IsZero() { + switch { + case errors.Is(o.err, ErrCanceled): + fmt.Fprintf(t.w, " %s %s: Canceled\n", canceled, o.msg) + case o.err != nil: + if el := errlist.Convert(o.err); el != nil && len(el.List) > 0 { + fmt.Fprintf(t.w, " %s %s: Failed: %v\n", fail, o.msg, el.List[0].Title()) + } else { + fmt.Fprintf(t.w, " %s %s: Failed: %v\n", fail, o.msg, o.err) + } + default: + fmt.Fprintf(t.w, " %s %s: Done\n", success, o.msg) + } + o.donePrinted = true + } + } +} + func (t *OpTracker) spin() { refresh := 100 * time.Millisecond if runtime.GOOS == "windows" { @@ -221,11 +264,13 @@ func (t *OpTracker) spin() { } type slowOp struct { - msg string - err error - spinIdx int - start time.Time - done time.Time + msg string + err error + spinIdx int + start time.Time + done time.Time + startedPrinted bool // plain mode only + donePrinted bool // plain mode only } var ( diff --git a/proto/encore/daemon/daemon.pb.go b/proto/encore/daemon/daemon.pb.go index e661e7059c..0a37e988b3 100644 --- a/proto/encore/daemon/daemon.pb.go +++ b/proto/encore/daemon/daemon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v6.32.1 +// protoc-gen-go v1.36.11 +// protoc v7.34.1 // source: encore/daemon/daemon.proto package daemon @@ -649,8 +649,12 @@ type RunRequest struct { LogLevel *string `protobuf:"bytes,12,opt,name=log_level,json=logLevel,proto3,oneof" json:"log_level,omitempty"` // scrub_sensitive_data, if true, scrubs sensitive data from local traces. ScrubSensitiveData bool `protobuf:"varint,13,opt,name=scrub_sensitive_data,json=scrubSensitiveData,proto3" json:"scrub_sensitive_data,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // non_interactive, if true, signals that the CLI is not connected to an + // interactive terminal. The build progress UI is rendered as plain + // one-line-per-event output instead of the spinner. + NonInteractive bool `protobuf:"varint,14,opt,name=non_interactive,json=nonInteractive,proto3" json:"non_interactive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RunRequest) Reset() { @@ -760,6 +764,13 @@ func (x *RunRequest) GetScrubSensitiveData() bool { return false } +func (x *RunRequest) GetNonInteractive() bool { + if x != nil { + return x.NonInteractive + } + return false +} + type TestRequest struct { state protoimpl.MessageState `protogen:"open.v1"` AppRoot string `protobuf:"bytes,1,opt,name=app_root,json=appRoot,proto3" json:"app_root,omitempty"` @@ -1012,9 +1023,12 @@ type ExecScriptRequest struct { TraceFile *string `protobuf:"bytes,6,opt,name=trace_file,json=traceFile,proto3,oneof" json:"trace_file,omitempty"` // namespace is the infrastructure namespace to use. // If empty the active namespace is used. - Namespace *string `protobuf:"bytes,7,opt,name=namespace,proto3,oneof" json:"namespace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Namespace *string `protobuf:"bytes,7,opt,name=namespace,proto3,oneof" json:"namespace,omitempty"` + // non_interactive, if true, signals that the CLI is not connected to an + // interactive terminal. See RunRequest.non_interactive for details. + NonInteractive bool `protobuf:"varint,8,opt,name=non_interactive,json=nonInteractive,proto3" json:"non_interactive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExecScriptRequest) Reset() { @@ -1089,6 +1103,13 @@ func (x *ExecScriptRequest) GetNamespace() string { return "" } +func (x *ExecScriptRequest) GetNonInteractive() bool { + if x != nil { + return x.NonInteractive + } + return false +} + type ExecSpecRequest struct { state protoimpl.MessageState `protogen:"open.v1"` AppRoot string `protobuf:"bytes,1,opt,name=app_root,json=appRoot,proto3" json:"app_root,omitempty"` @@ -1102,9 +1123,12 @@ type ExecSpecRequest struct { Namespace *string `protobuf:"bytes,7,opt,name=namespace,proto3,oneof" json:"namespace,omitempty"` // temp_dir is a temp dir that will be cleaned up by the CLI after the command // has been executed, to write things like app meta and runtime config etc. - TempDir string `protobuf:"bytes,8,opt,name=temp_dir,json=tempDir,proto3" json:"temp_dir,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + TempDir string `protobuf:"bytes,8,opt,name=temp_dir,json=tempDir,proto3" json:"temp_dir,omitempty"` + // non_interactive, if true, signals that the CLI is not connected to an + // interactive terminal. See RunRequest.non_interactive for details. + NonInteractive bool `protobuf:"varint,9,opt,name=non_interactive,json=nonInteractive,proto3" json:"non_interactive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExecSpecRequest) Reset() { @@ -1179,6 +1203,13 @@ func (x *ExecSpecRequest) GetTempDir() string { return "" } +func (x *ExecSpecRequest) GetNonInteractive() bool { + if x != nil { + return x.NonInteractive + } + return false +} + type ExecSpecMessage struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Msg: @@ -4032,7 +4063,7 @@ const file_encore_daemon_daemon_proto_rawDesc = "" + "\btemplate\x18\x02 \x01(\tR\btemplate\x12\x1a\n" + "\btutorial\x18\x03 \x01(\bR\btutorial\"*\n" + "\x11CreateAppResponse\x12\x15\n" + - "\x06app_id\x18\x01 \x01(\tR\x05appId\"\xf1\x04\n" + + "\x06app_id\x18\x01 \x01(\tR\x05appId\"\x9a\x05\n" + "\n" + "RunRequest\x12\x19\n" + "\bapp_root\x18\x01 \x01(\tR\aappRoot\x12\x1f\n" + @@ -4050,7 +4081,8 @@ const file_encore_daemon_daemon_proto_rawDesc = "" + "\n" + "debug_mode\x18\v \x01(\x0e2#.encore.daemon.RunRequest.DebugModeR\tdebugMode\x12 \n" + "\tlog_level\x18\f \x01(\tH\x02R\blogLevel\x88\x01\x01\x120\n" + - "\x14scrub_sensitive_data\x18\r \x01(\bR\x12scrubSensitiveData\"F\n" + + "\x14scrub_sensitive_data\x18\r \x01(\bR\x12scrubSensitiveData\x12'\n" + + "\x0fnon_interactive\x18\x0e \x01(\bR\x0enonInteractive\"F\n" + "\vBrowserMode\x12\x10\n" + "\fBROWSER_AUTO\x10\x00\x12\x11\n" + "\rBROWSER_NEVER\x10\x01\x12\x12\n" + @@ -4085,7 +4117,7 @@ const file_encore_daemon_daemon_proto_rawDesc = "" + "\x10TestSpecResponse\x12\x18\n" + "\acommand\x18\x01 \x01(\tR\acommand\x12\x12\n" + "\x04args\x18\x02 \x03(\tR\x04args\x12\x18\n" + - "\aenviron\x18\x03 \x03(\tR\aenviron\"\xee\x01\n" + + "\aenviron\x18\x03 \x03(\tR\aenviron\"\x97\x02\n" + "\x11ExecScriptRequest\x12\x19\n" + "\bapp_root\x18\x01 \x01(\tR\aappRoot\x12\x1f\n" + "\vworking_dir\x18\x02 \x01(\tR\n" + @@ -4095,10 +4127,11 @@ const file_encore_daemon_daemon_proto_rawDesc = "" + "\aenviron\x18\x05 \x03(\tR\aenviron\x12\"\n" + "\n" + "trace_file\x18\x06 \x01(\tH\x00R\ttraceFile\x88\x01\x01\x12!\n" + - "\tnamespace\x18\a \x01(\tH\x01R\tnamespace\x88\x01\x01B\r\n" + + "\tnamespace\x18\a \x01(\tH\x01R\tnamespace\x88\x01\x01\x12'\n" + + "\x0fnon_interactive\x18\b \x01(\bR\x0enonInteractiveB\r\n" + "\v_trace_fileB\f\n" + "\n" + - "_namespace\"\xd4\x01\n" + + "_namespace\"\xfd\x01\n" + "\x0fExecSpecRequest\x12\x19\n" + "\bapp_root\x18\x01 \x01(\tR\aappRoot\x12\x1f\n" + "\vworking_dir\x18\x02 \x01(\tR\n" + @@ -4107,7 +4140,8 @@ const file_encore_daemon_daemon_proto_rawDesc = "" + "scriptArgs\x12\x18\n" + "\aenviron\x18\x05 \x03(\tR\aenviron\x12!\n" + "\tnamespace\x18\a \x01(\tH\x00R\tnamespace\x88\x01\x01\x12\x19\n" + - "\btemp_dir\x18\b \x01(\tR\atempDirB\f\n" + + "\btemp_dir\x18\b \x01(\tR\atempDir\x12'\n" + + "\x0fnon_interactive\x18\t \x01(\bR\x0enonInteractiveB\f\n" + "\n" + "_namespace\"\x87\x01\n" + "\x0fExecSpecMessage\x126\n" + diff --git a/proto/encore/daemon/daemon.proto b/proto/encore/daemon/daemon.proto index ca05e25af1..ce9b623327 100644 --- a/proto/encore/daemon/daemon.proto +++ b/proto/encore/daemon/daemon.proto @@ -125,6 +125,11 @@ message RunRequest { // scrub_sensitive_data, if true, scrubs sensitive data from local traces. bool scrub_sensitive_data = 13; + // non_interactive, if true, signals that the CLI is not connected to an + // interactive terminal. The build progress UI is rendered as plain + // one-line-per-event output instead of the spinner. + bool non_interactive = 14; + enum BrowserMode { BROWSER_AUTO = 0; BROWSER_NEVER = 1; @@ -197,6 +202,10 @@ message ExecScriptRequest { // namespace is the infrastructure namespace to use. // If empty the active namespace is used. optional string namespace = 7; + + // non_interactive, if true, signals that the CLI is not connected to an + // interactive terminal. See RunRequest.non_interactive for details. + bool non_interactive = 8; } message ExecSpecRequest { @@ -215,6 +224,10 @@ message ExecSpecRequest { // temp_dir is a temp dir that will be cleaned up by the CLI after the command // has been executed, to write things like app meta and runtime config etc. string temp_dir = 8; + + // non_interactive, if true, signals that the CLI is not connected to an + // interactive terminal. See RunRequest.non_interactive for details. + bool non_interactive = 9; } message ExecSpecMessage {