Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.5.1"
".": "0.6.0"
}
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.6.0 (2025-12-06)

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

### Features

* **cli:** automatic streaming for paginated endpoints ([9af6924](https://github.com/onkernel/hypeman-cli/commit/9af69246d62010c32d39583c8b1eba39a663d3fa))

## 0.5.1 (2025-12-05)

Full Changelog: [v0.5.0...v0.5.1](https://github.com/onkernel/hypeman-cli/compare/v0.5.0...v0.5.1)
Expand Down
2 changes: 1 addition & 1 deletion cmd/hypeman/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func main() {
fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode))
format := app.String("format-error")
json := gjson.Parse(apierr.RawJSON())
show_err := cmd.ShowJSON("Error", json, format, app.String("transform-error"))
show_err := cmd.ShowJSON(os.Stdout, "Error", json, format, app.String("transform-error"))
if show_err != nil {
// Just print the original error:
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/tidwall/sjson v1.2.5
github.com/urfave/cli-docs/v3 v3.0.0-alpha6
github.com/urfave/cli/v3 v3.3.2
golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
)

Expand Down Expand Up @@ -68,7 +69,6 @@ require (
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

var (
Command *cli.Command
OutputFormats = []string{"auto", "explore", "json", "pretty", "raw", "yaml"}
OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"}
)

func init() {
Expand Down
158 changes: 145 additions & 13 deletions pkg/cmd/cmdutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import (
"net/http"
"net/http/httputil"
"os"
"os/exec"
"os/signal"
"runtime"
"strings"
"syscall"

"github.com/onkernel/hypeman-cli/pkg/jsonview"
"github.com/onkernel/hypeman-go/option"
Expand All @@ -16,6 +20,7 @@ import (
"github.com/tidwall/gjson"
"github.com/tidwall/pretty"
"github.com/urfave/cli/v3"
"golang.org/x/sys/unix"
"golang.org/x/term"
)

Expand Down Expand Up @@ -71,9 +76,123 @@ func isTerminal(w io.Writer) bool {
}
}

func streamOutput(label string, generateOutput func(w *os.File) error) error {
// For non-tty output (probably a pipe), write directly to stdout
if !isTerminal(os.Stdout) {
return streamToStdout(generateOutput)
}

pagerInput, outputFile, isSocketPair, err := createPagerFiles()
if err != nil {
return err
}
defer pagerInput.Close()
defer outputFile.Close()

cmd, err := startPagerCommand(pagerInput, label, isSocketPair)
if err != nil {
return err
}

if err := pagerInput.Close(); err != nil {
return err
}

// If the pager exits before reading all input, then generateOutput() will
// produce a broken pipe error, which is fine and we don't want to propagate it.
if err := generateOutput(outputFile); err != nil && !strings.Contains(err.Error(), "broken pipe") {
return err
}

return cmd.Wait()
}

func streamToStdout(generateOutput func(w *os.File) error) error {
signal.Ignore(syscall.SIGPIPE)
err := generateOutput(os.Stdout)
if err != nil && strings.Contains(err.Error(), "broken pipe") {
return nil
}
return err
}

func createPagerFiles() (*os.File, *os.File, bool, error) {
// Windows lacks UNIX socket APIs, so we fall back to pipes there or if
// socket creation fails. We prefer sockets when available because they
// allow for smaller buffer sizes, preventing unnecessary data streaming
// from the backend. Pipes typically have large buffers but serve as a
// decent alternative when sockets aren't available.
if runtime.GOOS != "windows" {
pagerInput, outputFile, isSocketPair, err := createSocketPair()
if err == nil {
return pagerInput, outputFile, isSocketPair, nil
}
}

r, w, err := os.Pipe()
return r, w, false, err
}

// In order to avoid large buffers on pipes, this function create a pair of
// files for reading and writing through a barely buffered socket.
func createSocketPair() (*os.File, *os.File, bool, error) {
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
if err != nil {
return nil, nil, false, err
}

parentSock, childSock := fds[0], fds[1]

// Use small buffer sizes so we don't ask the server for more paginated
// values than we actually need.
if err := unix.SetsockoptInt(parentSock, unix.SOL_SOCKET, unix.SO_SNDBUF, 128); err != nil {
return nil, nil, false, err
}
if err := unix.SetsockoptInt(childSock, unix.SOL_SOCKET, unix.SO_RCVBUF, 128); err != nil {
return nil, nil, false, err
}

pagerInput := os.NewFile(uintptr(childSock), "child_socket")
outputFile := os.NewFile(uintptr(parentSock), "parent_socket")
return pagerInput, outputFile, true, nil
}

// Start a subprocess running the user's preferred pager (or `less` if `$PAGER` is unset)
func startPagerCommand(pagerInput *os.File, label string, useSocketpair bool) (*exec.Cmd, error) {
pagerProgram := os.Getenv("PAGER")
if pagerProgram == "" {
pagerProgram = "less"
}

if shouldUseColors(os.Stdout) {
os.Setenv("FORCE_COLOR", "1")
}

var cmd *exec.Cmd
if useSocketpair {
cmd = exec.Command(pagerProgram, fmt.Sprintf("/dev/fd/%d", pagerInput.Fd()))
cmd.ExtraFiles = []*os.File{pagerInput}
} else {
cmd = exec.Command(pagerProgram)
cmd.Stdin = pagerInput
}

cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"LESS=-r -f -P "+label,
"MORE=-r -f -P "+label,
)

if err := cmd.Start(); err != nil {
return nil, err
}

return cmd, nil
}

func shouldUseColors(w io.Writer) bool {
force, ok := os.LookupEnv("FORCE_COLOR")

if ok {
if force == "1" {
return true
Expand All @@ -82,11 +201,10 @@ func shouldUseColors(w io.Writer) bool {
return false
}
}

return isTerminal(w)
}

func ShowJSON(title string, res gjson.Result, format string, transform string) error {
func ShowJSON(out *os.File, title string, res gjson.Result, format string, transform string) error {
if format != "raw" && transform != "" {
transformed := res.Get(transform)
if transformed.Exists() {
Expand All @@ -95,31 +213,45 @@ func ShowJSON(title string, res gjson.Result, format string, transform string) e
}
switch strings.ToLower(format) {
case "auto":
return ShowJSON(title, res, "json", "")
return ShowJSON(out, title, res, "json", "")
case "explore":
return jsonview.ExploreJSON(title, res)
case "pretty":
jsonview.DisplayJSON(title, res)
return nil
_, err := out.WriteString(jsonview.RenderJSON(title, res) + "\n")
return err
case "json":
prettyJSON := pretty.Pretty([]byte(res.Raw))
if shouldUseColors(os.Stdout) {
fmt.Print(string(pretty.Color(prettyJSON, pretty.TerminalStyle)))
if shouldUseColors(out) {
_, err := out.Write(pretty.Color(prettyJSON, pretty.TerminalStyle))
return err
} else {
fmt.Print(string(prettyJSON))
_, err := out.Write(prettyJSON)
return err
}
case "jsonl":
// @ugly is gjson syntax for "no whitespace", so it fits on one line
oneLineJSON := res.Get("@ugly").Raw
if shouldUseColors(out) {
bytes := append(pretty.Color([]byte(oneLineJSON), pretty.TerminalStyle), '\n')
_, err := out.Write(bytes)
return err
} else {
_, err := out.Write([]byte(oneLineJSON + "\n"))
return err
}
return nil
case "raw":
fmt.Println(res.Raw)
if _, err := out.Write([]byte(res.Raw + "\n")); err != nil {
return err
}
return nil
case "yaml":
input := strings.NewReader(res.Raw)
var yaml strings.Builder
if err := json2yaml.Convert(&yaml, input); err != nil {
return err
}
fmt.Print(yaml.String())
return nil
_, err := out.Write([]byte(yaml.String()))
return err
default:
return fmt.Errorf("Invalid format: %s, valid formats are: %s", format, strings.Join(OutputFormats, ", "))
}
Expand Down
17 changes: 17 additions & 0 deletions pkg/cmd/cmdutil_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package cmd

import (
"os"
"testing"
)

func TestStreamOutput(t *testing.T) {
t.Setenv("PAGER", "cat")
err := streamOutput("stream test", func(w *os.File) error {
_, writeErr := w.WriteString("Hello world\n")
return writeErr
})
if err != nil {
t.Errorf("streamOutput failed: %v", err)
}
}
7 changes: 5 additions & 2 deletions pkg/cmd/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cmd
import (
"context"
"fmt"
"os"

"github.com/onkernel/hypeman-cli/internal/apiquery"
"github.com/onkernel/hypeman-go"
Expand All @@ -24,6 +25,7 @@ var healthCheck = cli.Command{
func handleHealthCheck(ctx context.Context, cmd *cli.Command) error {
client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
unusedArgs := cmd.Args().Slice()

if len(unusedArgs) > 0 {
return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs)
}
Expand All @@ -36,15 +38,16 @@ func handleHealthCheck(ctx context.Context, cmd *cli.Command) error {
if err != nil {
return err
}

var res []byte
options = append(options, option.WithResponseBodyInto(&res))
_, err = client.Health.Check(ctx, options...)
if err != nil {
return err
}

json := gjson.Parse(string(res))
obj := gjson.ParseBytes(res)
format := cmd.Root().String("format")
transform := cmd.Root().String("transform")
return ShowJSON("health check", json, format, transform)
return ShowJSON(os.Stdout, "health check", obj, format, transform)
}
Loading