diff --git a/cmd/internal/helpers.go b/cmd/internal/helpers.go index 1fef2ebe..946cf35a 100644 --- a/cmd/internal/helpers.go +++ b/cmd/internal/helpers.go @@ -57,6 +57,9 @@ func MarkFlagFilename(_ *context.Context, cmd *cobra.Command, name string) { } func TUIEnabled(ctx *context.Context, cmd *cobra.Command) bool { + if !flowIO.TTYAttached(ctx.StdIn(), ctx.StdOut()) { + return false + } if flags.HasFlag(cmd, *flags.OutputFormatFlag) { format := flags.ValueFor[string](cmd, *flags.OutputFormatFlag, false) if format == "yaml" || format == "yml" || format == "json" { diff --git a/cmd/internal/helpers_internal_test.go b/cmd/internal/helpers_internal_test.go new file mode 100644 index 00000000..3b58145f --- /dev/null +++ b/cmd/internal/helpers_internal_test.go @@ -0,0 +1,59 @@ +package internal + +import ( + "os" + "testing" + + "github.com/spf13/cobra" + + "github.com/flowexec/flow/v2/cmd/internal/flags" + flowIO "github.com/flowexec/flow/v2/internal/io" + "github.com/flowexec/flow/v2/pkg/context" + "github.com/flowexec/flow/v2/types/config" +) + +// nonTTYContext builds a context whose config asks for the TUI but whose streams +// are regular files — the shape of every piped, redirected, or agent-driven run. +func nonTTYContext(t *testing.T) *context.Context { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "flow-tui-test") + if err != nil { + t.Fatalf("unable to create temp file: %v", err) + } + t.Cleanup(func() { _ = f.Close() }) + + ctx := &context.Context{ + Config: &config.Config{Interactive: &config.Interactive{Enabled: true}}, + } + ctx.SetIO(f, f) + return ctx +} + +func TestTUIEnabled_NonTTY(t *testing.T) { + cases := []struct { + name string + format string + }{ + {name: "no output flag set", format: ""}, + {name: "explicit tui does not override", format: "tui"}, + {name: "explicit json", format: "json"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(flowIO.DisableInteractiveEnvKey, "") + ctx := nonTTYContext(t) + + cmd := &cobra.Command{Use: "test"} + RegisterFlag(ctx, cmd, *flags.OutputFormatFlag) + if tc.format != "" { + if err := cmd.Flags().Set(flags.OutputFormatFlag.Name, tc.format); err != nil { + t.Fatalf("unable to set output flag: %v", err) + } + } + + if TUIEnabled(ctx, cmd) { + t.Error("TUIEnabled() = true without a terminal, want false") + } + }) + } +} diff --git a/docs/guides/executables.md b/docs/guides/executables.md index 2fd71f99..19ca9607 100644 --- a/docs/guides/executables.md +++ b/docs/guides/executables.md @@ -599,9 +599,10 @@ executables: | `env` | `map[string]string` | Params and environment variables from the executable | | `data` | `any` | Parsed contents of `templateDataFile` (nil if not set) | -By default a `render` opens an interactive viewer. To use one non-interactively — in CI, a -script, or piped into another command — set `DISABLE_FLOW_INTERACTIVE=true`, which makes it -write plain text to stdout. See [Interactive UI](./interactive#disabling-the-tui). +By default a `render` opens an interactive viewer, and falls back to writing plain text to +stdout when it isn't attached to a terminal — in CI, a script, or piped into another command. +To force plain text while attached to a terminal, set `DISABLE_FLOW_INTERACTIVE=true`. See +[Interactive UI](./interactive#disabling-the-tui). `data` is typed based on the file content — a JSON object becomes a map, a JSON array becomes a slice. Access fields with bracket notation: `data["key"]` or `data[0]["field"]`. diff --git a/docs/guides/interactive.md b/docs/guides/interactive.md index 3fa30036..cb71e8e1 100644 --- a/docs/guides/interactive.md +++ b/docs/guides/interactive.md @@ -100,7 +100,8 @@ flow secret list --output yaml ### Disabling the TUI -For scripts, CI/CD, or personal preference: +flow drops to plain output on its own when stdin or stdout isn't a terminal — in CI, in a +script, or when piped into another command. To disable the TUI while attached to a terminal: ```shell # Permanently disable TUI diff --git a/go.mod b/go.mod index 3329381f..b428b052 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( go.uber.org/mock v0.6.0 golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 golang.org/x/text v0.41.0 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/v3 v3.13.1 @@ -97,6 +98,5 @@ require ( golang.org/x/mod v0.40.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.45.0 // indirect golang.org/x/tools v0.49.0 // indirect ) diff --git a/internal/io/io.go b/internal/io/io.go index c570ba6c..f7179ee2 100644 --- a/internal/io/io.go +++ b/internal/io/io.go @@ -1,6 +1,10 @@ package io -import "os" +import ( + "os" + + "golang.org/x/term" +) const DisableInteractiveEnvKey = "DISABLE_FLOW_INTERACTIVE" @@ -8,3 +12,15 @@ var ( Stdout = os.Stdout Stdin = os.Stdin ) + +// TTYAttached reports whether both streams are real terminals. +// +// The TUI reads key events from stdin and paints stdout, so a pipe on either end +// leaves it unusable: it writes escape sequences into whatever is consuming the +// output and then blocks until the container readiness timeout expires. +func TTYAttached(in, out *os.File) bool { + if in == nil || out == nil { + return false + } + return term.IsTerminal(int(in.Fd())) && term.IsTerminal(int(out.Fd())) +} diff --git a/internal/io/io_test.go b/internal/io/io_test.go new file mode 100644 index 00000000..051133a7 --- /dev/null +++ b/internal/io/io_test.go @@ -0,0 +1,57 @@ +package io_test + +import ( + "os" + "testing" + + flowIO "github.com/flowexec/flow/v2/internal/io" +) + +func TestTTYAttached(t *testing.T) { + regular, err := os.CreateTemp(t.TempDir(), "flow-tty-test") + if err != nil { + t.Fatalf("unable to create temp file: %v", err) + } + t.Cleanup(func() { _ = regular.Close() }) + + pipeR, pipeW, err := os.Pipe() + if err != nil { + t.Fatalf("unable to create pipe: %v", err) + } + t.Cleanup(func() { _ = pipeR.Close(); _ = pipeW.Close() }) + + cases := []struct { + name string + in *os.File + out *os.File + want bool + }{ + {name: "nil input", in: nil, out: regular, want: false}, + {name: "nil output", in: regular, out: nil, want: false}, + {name: "regular files", in: regular, out: regular, want: false}, + {name: "pipes", in: pipeR, out: pipeW, want: false}, + {name: "redirected output only", in: pipeR, out: regular, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := flowIO.TTYAttached(tc.in, tc.out); got != tc.want { + t.Errorf("TTYAttached() = %v, want %v", got, tc.want) + } + }) + } +} + +// TestTTYAttachedWithTerminal guards against the negative cases above being +// satisfied by a function that always returns false. It needs a controlling +// terminal, which CI and container runs do not have, so it skips there. +func TestTTYAttachedWithTerminal(t *testing.T) { + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + t.Skipf("no controlling terminal available: %v", err) + } + t.Cleanup(func() { _ = tty.Close() }) + + if !flowIO.TTYAttached(tty, tty) { + t.Error("TTYAttached() = false for /dev/tty, want true") + } +} diff --git a/internal/runner/render/render.go b/internal/runner/render/render.go index 9ee00eba..b37fb53c 100644 --- a/internal/runner/render/render.go +++ b/internal/runner/render/render.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "github.com/flowexec/tuikit/views" "github.com/jahvon/expression" @@ -127,7 +128,7 @@ func (r *renderRunner) Exec( logger.Log().Infof("Rendering content from file %s", contentFile) - if !ctx.Config.ShowTUI() || InteractiveDisabled() { + if !ctx.Config.ShowTUI() || InteractiveDisabled() || !io.TTYAttached(ctx.StdIn(), ctx.StdOut()) { renderPlain(contentFile, data) return nil } @@ -148,9 +149,12 @@ func (r *renderRunner) Exec( // callers scraping log output can extract the block deterministically. func renderPlain(contentFile, data string) { log := logger.Log() - log.Print(fmt.Sprintf("%s file=%s", PlainBeginMarker, filepath.Base(contentFile))) + log.Println(fmt.Sprintf("%s file=%s", PlainBeginMarker, filepath.Base(contentFile))) + if !strings.HasSuffix(data, "\n") { + data += "\n" + } log.Print(data) - log.Print(PlainEndMarker) + log.Println(PlainEndMarker) } func readDataFile(dir, path string) (interface{}, error) { diff --git a/internal/runner/render/render_test.go b/internal/runner/render/render_test.go index 1fe165e6..9a9c1fc7 100644 --- a/internal/runner/render/render_test.go +++ b/internal/runner/render/render_test.go @@ -84,9 +84,9 @@ var _ = Describe("Render Runner", func() { ctx.Logger.EXPECT().Infof(gomock.Any(), gomock.Any()).Times(1) // Begin marker + rendered content + end marker, in order. gomock.InOrder( - ctx.Logger.EXPECT().Print(gomock.Regex("^"+regexEscape(render.PlainBeginMarker)+" file=tmpl.md$")), + ctx.Logger.EXPECT().Println(gomock.Regex("^"+regexEscape(render.PlainBeginMarker)+" file=tmpl.md$")), ctx.Logger.EXPECT().Print(gomock.Eq("# Hello\n\nworld\n")), - ctx.Logger.EXPECT().Print(gomock.Eq(render.PlainEndMarker)), + ctx.Logger.EXPECT().Println(gomock.Eq(render.PlainEndMarker)), ) Expect(renderRnr.Exec(ctx.Ctx, e, mockEngine, map[string]string{}, nil)).To(Succeed()) @@ -102,9 +102,9 @@ var _ = Describe("Render Runner", func() { ctx.Logger.EXPECT().Infof(gomock.Any(), gomock.Any()).Times(1) gomock.InOrder( - ctx.Logger.EXPECT().Print(gomock.Regex(regexEscape(render.PlainBeginMarker))), - ctx.Logger.EXPECT().Print(gomock.Eq("Name: flow")), - ctx.Logger.EXPECT().Print(gomock.Eq(render.PlainEndMarker)), + ctx.Logger.EXPECT().Println(gomock.Regex(regexEscape(render.PlainBeginMarker))), + ctx.Logger.EXPECT().Print(gomock.Eq("Name: flow\n")), + ctx.Logger.EXPECT().Println(gomock.Eq(render.PlainEndMarker)), ) Expect(renderRnr.Exec(ctx.Ctx, e, mockEngine, map[string]string{}, nil)).To(Succeed()) @@ -120,9 +120,9 @@ var _ = Describe("Render Runner", func() { ctx.Logger.EXPECT().Infof(gomock.Any(), gomock.Any()).Times(1) gomock.InOrder( - ctx.Logger.EXPECT().Print(gomock.Regex(regexEscape(render.PlainBeginMarker))), - ctx.Logger.EXPECT().Print(gomock.Eq("Env: prod")), - ctx.Logger.EXPECT().Print(gomock.Eq(render.PlainEndMarker)), + ctx.Logger.EXPECT().Println(gomock.Regex(regexEscape(render.PlainBeginMarker))), + ctx.Logger.EXPECT().Print(gomock.Eq("Env: prod\n")), + ctx.Logger.EXPECT().Println(gomock.Eq(render.PlainEndMarker)), ) Expect(renderRnr.Exec(ctx.Ctx, e, mockEngine, map[string]string{}, nil)).To(Succeed()) @@ -157,9 +157,9 @@ var _ = Describe("Render Runner", func() { ctx.Logger.EXPECT().Infof(gomock.Any(), gomock.Any()).Times(1) gomock.InOrder( - ctx.Logger.EXPECT().Print(gomock.Regex(regexEscape(render.PlainBeginMarker))), - ctx.Logger.EXPECT().Print(gomock.Eq("hello")), - ctx.Logger.EXPECT().Print(gomock.Eq(render.PlainEndMarker)), + ctx.Logger.EXPECT().Println(gomock.Regex(regexEscape(render.PlainBeginMarker))), + ctx.Logger.EXPECT().Print(gomock.Eq("hello\n")), + ctx.Logger.EXPECT().Println(gomock.Eq(render.PlainEndMarker)), ) Expect(renderRnr.Exec(ctx.Ctx, e, mockEngine, map[string]string{}, nil)).To(Succeed())