Skip to content
Open
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,20 @@ Top-level non-resource commands:
- `-o table|json|yaml` (default `auto`: table on TTY, JSON when piped).
- Errors are emitted as JSON on stderr with shape `{error, message, status, response, request_id}`. Stdout stays clean for piping.

### Pagination

Operations with a `page_cursor` parameter support `--all` to collect every
page and `--no-paginate` to explicitly fetch one page. Without either flag,
table output on an interactive terminal renders one complete page, then asks
`next page? [Y/n]` on stderr when a next cursor is present. Enter or `y`
loads the next page; `n`, other answers, or end-of-input stops.

Prompts require terminal stdin, stdout and stderr. JSON/YAML output, pipes,
redirected streams and `--file -` never prompt, preserving single-page
machine-readable responses and request-body stdin. `--all` still works in
those modes and emits one combined result. `--all` and `--no-paginate`
cannot be combined. Dry runs do not fetch or prompt.

### Exit codes

| Code | Meaning |
Expand Down
92 changes: 92 additions & 0 deletions cmd/twoctl/cli/interactive_pagination.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package cli

import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"strings"

"github.com/getkin/kin-openapi/openapi3"
"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/two-inc/twoctl-cli/internal/output"
)

// Kept injectable so CI can exercise terminal policy without a real terminal.
var paginationTerminal = func(stream any) bool {
fd, ok := stream.(interface{ Fd() uintptr })
return ok && term.IsTerminal(int(fd.Fd()))
}

func shouldPromptPagination(c *cobra.Command, b *opBindings) bool {
if flagBool(c, "no-paginate", "twoctl-no-paginate") || flagBool(c, "all", "twoctl-all") {
return false
}
// Never consume request-body stdin as an answer, even if it is a TTY.
if b.bodyFile != nil && *b.bodyFile == "-" {
return false
}
format, err := resolveOutputFormat(c)
return err == nil && format == output.FormatTable &&
paginationTerminal(c.InOrStdin()) &&
paginationTerminal(c.OutOrStdout()) &&
paginationTerminal(c.ErrOrStderr())
}

func runInteractivePages(c *cobra.Command, client *http.Client, baseURL, method, path string, op *openapi3.Operation, b *opBindings, body []byte, cursor *string) error {
input := bufio.NewReader(c.InOrStdin())
_, err := fetchPages(c, client, baseURL, method, path, op, b, body, cursor, func(raw []byte, next string) (bool, error) {
// Preserve the complete page (including metadata and numeric precision)
// using the existing table renderer's JSON fallback for envelopes.
if err := output.Render(c.OutOrStdout(), output.FormatTable, raw); err != nil {
return false, err
}
if next == "" {
return false, nil
}
return askNextPage(c.Context(), input, c.ErrOrStderr())
})
return err
}

func askNextPage(ctx context.Context, input *bufio.Reader, stderr io.Writer) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
if _, err := fmt.Fprint(stderr, "next page? [Y/n] "); err != nil {
return false, err
}
type reply struct {
answer string
err error
}
replies := make(chan reply, 1)
go func() {
answer, err := input.ReadString('\n')
replies <- reply{answer, err}
}()
var answer string
var err error
select {
case <-ctx.Done():
return false, ctx.Err()
case r := <-replies:
answer, err = r.answer, r.err
}
if err != nil {
// EOF is a decline, not the empty-line default: do not drain pages
// when a terminal closes or the command has no more input.
if err == io.EOF {
return false, nil
}
return false, err
}
if err := ctx.Err(); err != nil {
return false, err
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "" || answer == "y" || answer == "yes", nil
}
252 changes: 252 additions & 0 deletions cmd/twoctl/cli/interactive_pagination_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
package cli

import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/getkin/kin-openapi/openapi3"
"github.com/spf13/cobra"
)

func interactiveTestCommand(t *testing.T, input string) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
resetGlobals(t)
old := paginationTerminal
paginationTerminal = func(any) bool { return true }
t.Cleanup(func() { paginationTerminal = old })
flagOutput = "table"
op := &openapi3.Operation{Parameters: openapi3.Parameters{
{Value: &openapi3.Parameter{Name: cursorParamName, In: "query"}},
}}
cmd := buildOperationCommand("list", "/items", "GET", op)
var out, stderr bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&stderr)
cmd.SetIn(strings.NewReader(input))
cmd.SetContext(context.Background())
return cmd, &out, &stderr
}

func TestInteractivePaginationAnswers(t *testing.T) {
for _, tc := range []struct {
name, input string
pages int
}{
{"default yes then no", "\nn\n", 2},
{"yes uppercase", "YES\n", 2},
{"no", "n\n", 1},
{"other", "maybe\n", 1},
{"eof", "", 1},
{"unfinished yes", "y", 1},
} {
t.Run(tc.name, func(t *testing.T) {
cmd, out, stderr := interactiveTestCommand(t, tc.input)
pages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pages++
if pages == 2 && r.URL.Query().Get(cursorParamName) != "second" {
t.Errorf("second page cursor: %s", r.URL.RawQuery)
}
next := "second"
if pages == 2 {
next = ""
}
fmt.Fprintf(w, `{"items":[{"id":9007199254740993,"page":%d}],"cursor_page_metadata":{"next_page_cursor":%q}}`, pages, next)
}))
defer srv.Close()
flagURL, flagAPIKey = srv.URL, "secret_test_x"
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if pages != tc.pages {
t.Fatalf("pages = %d, want %d", pages, tc.pages)
}
if !strings.Contains(out.String(), "9007199254740993") {
t.Fatal("numeric precision lost")
}
if strings.Contains(out.String(), "next page?") {
t.Fatal("prompt leaked to stdout")
}
if stderr.String() != "next page? [Y/n] " {
t.Fatalf("stderr: %q", stderr.String())
}
})
}
}

func TestInteractivePaginationPolicy(t *testing.T) {
for _, tc := range []struct {
name, format string
args []string
terminal bool
pages int
}{
{"json", "json", nil, true, 1},
{"yaml", "yaml", nil, true, 1},
{"pipe", "table", nil, false, 1},
{"single page", "table", []string{"--no-paginate"}, true, 1},
{"all json", "json", []string{"--all"}, true, 2},
{"all table", "table", []string{"--all"}, true, 2},
} {
t.Run(tc.name, func(t *testing.T) {
cmd, _, stderr := interactiveTestCommand(t, "y\n")
flagOutput = tc.format
paginationTerminal = func(any) bool { return tc.terminal }
pages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pages++
next := "second"
if pages == 2 {
next = ""
}
fmt.Fprintf(w, `{"items":[{"id":%d}],"cursor_page_metadata":{"next_page_cursor":%q}}`, pages, next)
}))
defer srv.Close()
flagURL, flagAPIKey = srv.URL, "secret_test_x"
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if pages != tc.pages || stderr.Len() != 0 {
t.Fatalf("pages=%d stderr=%q", pages, stderr.String())
}
})
}
}

func TestInteractivePaginationStreamAndBodyPolicy(t *testing.T) {
cmd, _, _ := interactiveTestCommand(t, "")
b := &opBindings{}
for _, stream := range []any{cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr()} {
paginationTerminal = func(candidate any) bool { return candidate != stream }
if shouldPromptPagination(cmd, b) {
t.Fatal("redirected stream allowed prompting")
}
}
paginationTerminal = func(any) bool { return true }
stdin := "-"
b.bodyFile = &stdin
if shouldPromptPagination(cmd, b) {
t.Fatal("request-body stdin allowed prompting")
}
}

func TestPaginationFlagsConflictAndDryRun(t *testing.T) {
cmd, _, stderr := interactiveTestCommand(t, "y\n")
cmd.SetArgs([]string{"--all", "--no-paginate"})
var ae *apiError
if err := cmd.Execute(); !errors.As(err, &ae) || ae.exit != ExitUsage {
t.Fatalf("want usage error, got %v", err)
}
cmd, out, stderr := interactiveTestCommand(t, "y\n")
cmd.SetArgs([]string{"--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if stderr.Len() != 0 || !strings.Contains(out.String(), "has_body") {
t.Fatalf("dry run out=%q stderr=%q", out.String(), stderr.String())
}
}

func TestInteractivePaginationLoopAndCursorRestore(t *testing.T) {
cmd, _, _ := interactiveTestCommand(t, "\n\n\n")
pages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pages++
fmt.Fprint(w, `{"items":[{"id":1}],"cursor_page_metadata":{"next_page_cursor":"same"}}`)
}))
defer srv.Close()
flagURL, flagAPIKey = srv.URL, "secret_test_x"
cmd.SetArgs([]string{"--page-cursor", "original"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "loop detected") {
t.Fatalf("want loop error, got %v", err)
}
cursor, _ := cmd.Flags().GetString("page-cursor")
if pages != 2 || cursor != "original" {
t.Fatalf("pages=%d cursor=%q", pages, cursor)
}
}

func TestNextPageCancellation(t *testing.T) {
in := &paginationBlockedReader{entered: make(chan struct{}), release: make(chan struct{})}
defer close(in.release)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
_, err := askNextPage(ctx, bufio.NewReader(in), io.Discard)
done <- err
}()
select {
case <-in.entered:
case <-time.After(time.Second):
t.Fatal("prompt never entered input read")
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("prompt ignored cancellation")
}
}

type paginationBlockedReader struct {
entered chan struct{}
release chan struct{}
}

func (r *paginationBlockedReader) Read([]byte) (int, error) {
close(r.entered)
<-r.release
return 0, io.EOF
}

func TestBillingStatementSchemaPagination(t *testing.T) {
for _, all := range []bool{false, true} {
t.Run(fmt.Sprintf("all=%v", all), func(t *testing.T) {
interactiveTestCommand(t, "")
t.Setenv("TWOCTL_SKIP_UPGRADE_CHECK", "1")
Root().SetIn(strings.NewReader("\n"))
t.Cleanup(func() { Root().SetIn(nil) })
pages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pages++
next := "second"
if pages == 2 {
next = ""
if r.URL.Query().Get(cursorParamName) != "second" {
t.Errorf("cursor: %s", r.URL.RawQuery)
}
}
fmt.Fprintf(w, `{"statements":[{"statement_id":%d}],"cursor_page_metadata":{"next_page_cursor":%q,"limit":1}}`, pages, next)
}))
defer srv.Close()
args := []string{"--url", srv.URL, "--api-key", "secret_test_x", "-o", "table", "billing-statement", "get"}
if all {
args = append(args, "--all")
}
out, stderr, err := runRoot(t, args...)
if err != nil {
t.Fatal(err)
}
if pages != 2 || !strings.Contains(out, "2") {
t.Fatalf("pages=%d stdout=%q", pages, out)
}
if strings.Contains(stderr, "next page?") == all {
t.Fatalf("all=%v stderr=%q", all, stderr)
}
})
}
}
7 changes: 6 additions & 1 deletion cmd/twoctl/cli/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ func buildOperationCommand(name, path, method string, op *openapi3.Operation) *c
if hasPaginationParam(op) {
registerBoolUnlessTaken(cmd, "all", "twoctl-all",
"follow next_page_cursor and emit every page concatenated")
registerBoolUnlessTaken(cmd, "no-paginate", "twoctl-no-paginate",
"fetch only one page without prompting")
}

cmd.RunE = func(c *cobra.Command, args []string) error {
Expand All @@ -227,7 +229,10 @@ func buildOperationCommand(name, path, method string, op *openapi3.Operation) *c
if describe {
return printDescribe(c, method, path, op)
}
if all && !dryRun {
if all && flagBool(c, "no-paginate", "twoctl-no-paginate") {
return &apiError{code: "conflicting_pagination_flags", message: "--all and --no-paginate cannot be combined", exit: ExitUsage}
}
if !dryRun && (all || (hasPaginationParam(op) && shouldPromptPagination(c, bindings))) {
return runPaginated(c, method, path, op, bindings)
}
return runOperation(c, method, path, op, bindings, dryRun)
Expand Down
Loading
Loading