@@ -7,7 +7,11 @@ import (
77 "net/http"
88 "net/http/httputil"
99 "os"
10+ "os/exec"
11+ "os/signal"
12+ "runtime"
1013 "strings"
14+ "syscall"
1115
1216 "github.com/onkernel/hypeman-cli/pkg/jsonview"
1317 "github.com/onkernel/hypeman-go/option"
@@ -16,6 +20,7 @@ import (
1620 "github.com/tidwall/gjson"
1721 "github.com/tidwall/pretty"
1822 "github.com/urfave/cli/v3"
23+ "golang.org/x/sys/unix"
1924 "golang.org/x/term"
2025)
2126
@@ -71,9 +76,123 @@ func isTerminal(w io.Writer) bool {
7176 }
7277}
7378
79+ func streamOutput (label string , generateOutput func (w * os.File ) error ) error {
80+ // For non-tty output (probably a pipe), write directly to stdout
81+ if ! isTerminal (os .Stdout ) {
82+ return streamToStdout (generateOutput )
83+ }
84+
85+ pagerInput , outputFile , isSocketPair , err := createPagerFiles ()
86+ if err != nil {
87+ return err
88+ }
89+ defer pagerInput .Close ()
90+ defer outputFile .Close ()
91+
92+ cmd , err := startPagerCommand (pagerInput , label , isSocketPair )
93+ if err != nil {
94+ return err
95+ }
96+
97+ if err := pagerInput .Close (); err != nil {
98+ return err
99+ }
100+
101+ // If the pager exits before reading all input, then generateOutput() will
102+ // produce a broken pipe error, which is fine and we don't want to propagate it.
103+ if err := generateOutput (outputFile ); err != nil && ! strings .Contains (err .Error (), "broken pipe" ) {
104+ return err
105+ }
106+
107+ return cmd .Wait ()
108+ }
109+
110+ func streamToStdout (generateOutput func (w * os.File ) error ) error {
111+ signal .Ignore (syscall .SIGPIPE )
112+ err := generateOutput (os .Stdout )
113+ if err != nil && strings .Contains (err .Error (), "broken pipe" ) {
114+ return nil
115+ }
116+ return err
117+ }
118+
119+ func createPagerFiles () (* os.File , * os.File , bool , error ) {
120+ // Windows lacks UNIX socket APIs, so we fall back to pipes there or if
121+ // socket creation fails. We prefer sockets when available because they
122+ // allow for smaller buffer sizes, preventing unnecessary data streaming
123+ // from the backend. Pipes typically have large buffers but serve as a
124+ // decent alternative when sockets aren't available.
125+ if runtime .GOOS != "windows" {
126+ pagerInput , outputFile , isSocketPair , err := createSocketPair ()
127+ if err == nil {
128+ return pagerInput , outputFile , isSocketPair , nil
129+ }
130+ }
131+
132+ r , w , err := os .Pipe ()
133+ return r , w , false , err
134+ }
135+
136+ // In order to avoid large buffers on pipes, this function create a pair of
137+ // files for reading and writing through a barely buffered socket.
138+ func createSocketPair () (* os.File , * os.File , bool , error ) {
139+ fds , err := unix .Socketpair (unix .AF_UNIX , unix .SOCK_STREAM , 0 )
140+ if err != nil {
141+ return nil , nil , false , err
142+ }
143+
144+ parentSock , childSock := fds [0 ], fds [1 ]
145+
146+ // Use small buffer sizes so we don't ask the server for more paginated
147+ // values than we actually need.
148+ if err := unix .SetsockoptInt (parentSock , unix .SOL_SOCKET , unix .SO_SNDBUF , 128 ); err != nil {
149+ return nil , nil , false , err
150+ }
151+ if err := unix .SetsockoptInt (childSock , unix .SOL_SOCKET , unix .SO_RCVBUF , 128 ); err != nil {
152+ return nil , nil , false , err
153+ }
154+
155+ pagerInput := os .NewFile (uintptr (childSock ), "child_socket" )
156+ outputFile := os .NewFile (uintptr (parentSock ), "parent_socket" )
157+ return pagerInput , outputFile , true , nil
158+ }
159+
160+ // Start a subprocess running the user's preferred pager (or `less` if `$PAGER` is unset)
161+ func startPagerCommand (pagerInput * os.File , label string , useSocketpair bool ) (* exec.Cmd , error ) {
162+ pagerProgram := os .Getenv ("PAGER" )
163+ if pagerProgram == "" {
164+ pagerProgram = "less"
165+ }
166+
167+ if shouldUseColors (os .Stdout ) {
168+ os .Setenv ("FORCE_COLOR" , "1" )
169+ }
170+
171+ var cmd * exec.Cmd
172+ if useSocketpair {
173+ cmd = exec .Command (pagerProgram , fmt .Sprintf ("/dev/fd/%d" , pagerInput .Fd ()))
174+ cmd .ExtraFiles = []* os.File {pagerInput }
175+ } else {
176+ cmd = exec .Command (pagerProgram )
177+ cmd .Stdin = pagerInput
178+ }
179+
180+ cmd .Stdout = os .Stdout
181+ cmd .Stderr = os .Stderr
182+ cmd .Env = append (os .Environ (),
183+ "LESS=-r -f -P " + label ,
184+ "MORE=-r -f -P " + label ,
185+ )
186+
187+ if err := cmd .Start (); err != nil {
188+ return nil , err
189+ }
190+
191+ return cmd , nil
192+ }
193+
74194func shouldUseColors (w io.Writer ) bool {
75195 force , ok := os .LookupEnv ("FORCE_COLOR" )
76-
77196 if ok {
78197 if force == "1" {
79198 return true
@@ -82,11 +201,10 @@ func shouldUseColors(w io.Writer) bool {
82201 return false
83202 }
84203 }
85-
86204 return isTerminal (w )
87205}
88206
89- func ShowJSON (title string , res gjson.Result , format string , transform string ) error {
207+ func ShowJSON (out * os. File , title string , res gjson.Result , format string , transform string ) error {
90208 if format != "raw" && transform != "" {
91209 transformed := res .Get (transform )
92210 if transformed .Exists () {
@@ -95,31 +213,45 @@ func ShowJSON(title string, res gjson.Result, format string, transform string) e
95213 }
96214 switch strings .ToLower (format ) {
97215 case "auto" :
98- return ShowJSON (title , res , "json" , "" )
216+ return ShowJSON (out , title , res , "json" , "" )
99217 case "explore" :
100218 return jsonview .ExploreJSON (title , res )
101219 case "pretty" :
102- jsonview .DisplayJSON (title , res )
103- return nil
220+ _ , err := out . WriteString ( jsonview .RenderJSON (title , res ) + " \n " )
221+ return err
104222 case "json" :
105223 prettyJSON := pretty .Pretty ([]byte (res .Raw ))
106- if shouldUseColors (os .Stdout ) {
107- fmt .Print (string (pretty .Color (prettyJSON , pretty .TerminalStyle )))
224+ if shouldUseColors (out ) {
225+ _ , err := out .Write (pretty .Color (prettyJSON , pretty .TerminalStyle ))
226+ return err
108227 } else {
109- fmt .Print (string (prettyJSON ))
228+ _ , err := out .Write (prettyJSON )
229+ return err
230+ }
231+ case "jsonl" :
232+ // @ugly is gjson syntax for "no whitespace", so it fits on one line
233+ oneLineJSON := res .Get ("@ugly" ).Raw
234+ if shouldUseColors (out ) {
235+ bytes := append (pretty .Color ([]byte (oneLineJSON ), pretty .TerminalStyle ), '\n' )
236+ _ , err := out .Write (bytes )
237+ return err
238+ } else {
239+ _ , err := out .Write ([]byte (oneLineJSON + "\n " ))
240+ return err
110241 }
111- return nil
112242 case "raw" :
113- fmt .Println (res .Raw )
243+ if _ , err := out .Write ([]byte (res .Raw + "\n " )); err != nil {
244+ return err
245+ }
114246 return nil
115247 case "yaml" :
116248 input := strings .NewReader (res .Raw )
117249 var yaml strings.Builder
118250 if err := json2yaml .Convert (& yaml , input ); err != nil {
119251 return err
120252 }
121- fmt . Print ( yaml .String ())
122- return nil
253+ _ , err := out . Write ([] byte ( yaml .String () ))
254+ return err
123255 default :
124256 return fmt .Errorf ("Invalid format: %s, valid formats are: %s" , format , strings .Join (OutputFormats , ", " ))
125257 }
0 commit comments