Skip to content

Commit 51324ef

Browse files
release: 0.6.0 (#13)
* feat(cli): automatic streaming for paginated endpoints * release: 0.6.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com>
1 parent 74675ee commit 51324ef

File tree

16 files changed

+263
-127
lines changed

16 files changed

+263
-127
lines changed

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "0.5.1"
2+
".": "0.6.0"
33
}

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 0.6.0 (2025-12-06)
4+
5+
Full Changelog: [v0.5.1...v0.6.0](https://github.com/onkernel/hypeman-cli/compare/v0.5.1...v0.6.0)
6+
7+
### Features
8+
9+
* **cli:** automatic streaming for paginated endpoints ([9af6924](https://github.com/onkernel/hypeman-cli/commit/9af69246d62010c32d39583c8b1eba39a663d3fa))
10+
311
## 0.5.1 (2025-12-05)
412

513
Full Changelog: [v0.5.0...v0.5.1](https://github.com/onkernel/hypeman-cli/compare/v0.5.0...v0.5.1)

cmd/hypeman/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func main() {
2828
fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode))
2929
format := app.String("format-error")
3030
json := gjson.Parse(apierr.RawJSON())
31-
show_err := cmd.ShowJSON("Error", json, format, app.String("transform-error"))
31+
show_err := cmd.ShowJSON(os.Stdout, "Error", json, format, app.String("transform-error"))
3232
if show_err != nil {
3333
// Just print the original error:
3434
fmt.Fprintf(os.Stderr, "%s\n", err.Error())

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ require (
1818
github.com/tidwall/sjson v1.2.5
1919
github.com/urfave/cli-docs/v3 v3.0.0-alpha6
2020
github.com/urfave/cli/v3 v3.3.2
21+
golang.org/x/sys v0.38.0
2122
golang.org/x/term v0.37.0
2223
)
2324

@@ -68,7 +69,6 @@ require (
6869
go.opentelemetry.io/otel/metric v1.38.0 // indirect
6970
go.opentelemetry.io/otel/trace v1.38.0 // indirect
7071
golang.org/x/sync v0.18.0 // indirect
71-
golang.org/x/sys v0.38.0 // indirect
7272
golang.org/x/text v0.31.0 // indirect
7373
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
7474
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect

pkg/cmd/cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717

1818
var (
1919
Command *cli.Command
20-
OutputFormats = []string{"auto", "explore", "json", "pretty", "raw", "yaml"}
20+
OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"}
2121
)
2222

2323
func init() {

pkg/cmd/cmdutil.go

Lines changed: 145 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
74194
func 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
}

pkg/cmd/cmdutil_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func TestStreamOutput(t *testing.T) {
9+
t.Setenv("PAGER", "cat")
10+
err := streamOutput("stream test", func(w *os.File) error {
11+
_, writeErr := w.WriteString("Hello world\n")
12+
return writeErr
13+
})
14+
if err != nil {
15+
t.Errorf("streamOutput failed: %v", err)
16+
}
17+
}

pkg/cmd/health.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package cmd
55
import (
66
"context"
77
"fmt"
8+
"os"
89

910
"github.com/onkernel/hypeman-cli/internal/apiquery"
1011
"github.com/onkernel/hypeman-go"
@@ -24,6 +25,7 @@ var healthCheck = cli.Command{
2425
func handleHealthCheck(ctx context.Context, cmd *cli.Command) error {
2526
client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
2627
unusedArgs := cmd.Args().Slice()
28+
2729
if len(unusedArgs) > 0 {
2830
return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs)
2931
}
@@ -36,15 +38,16 @@ func handleHealthCheck(ctx context.Context, cmd *cli.Command) error {
3638
if err != nil {
3739
return err
3840
}
41+
3942
var res []byte
4043
options = append(options, option.WithResponseBodyInto(&res))
4144
_, err = client.Health.Check(ctx, options...)
4245
if err != nil {
4346
return err
4447
}
4548

46-
json := gjson.Parse(string(res))
49+
obj := gjson.ParseBytes(res)
4750
format := cmd.Root().String("format")
4851
transform := cmd.Root().String("transform")
49-
return ShowJSON("health check", json, format, transform)
52+
return ShowJSON(os.Stdout, "health check", obj, format, transform)
5053
}

0 commit comments

Comments
 (0)