|
1 | 1 | package cli |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "context" |
5 | 6 | "encoding/json" |
6 | 7 | "errors" |
7 | 8 | "fmt" |
8 | 9 | "io" |
| 10 | + "strconv" |
9 | 11 | "strings" |
10 | 12 |
|
11 | 13 | "charm.land/huh/v2" |
@@ -199,32 +201,142 @@ func renderCoreList[T any](cmd *cobra.Command, empty string, headers []string, r |
199 | 201 | } |
200 | 202 | } |
201 | 203 |
|
| 204 | +// coreListFetchBudget bounds how many entries a bounded list command fetches |
| 205 | +// by default. The control plane pages but cannot filter or sort these lists, |
| 206 | +// so without a bound every call would walk the entire collection — thousands |
| 207 | +// of requests on a large org. Commands that stop at the budget must disclose |
| 208 | +// the partial window on stderr and offer --all. |
| 209 | +const coreListFetchBudget = 1000 |
| 210 | + |
202 | 211 | // fetchAllPages drives a keyset-paginated list endpoint to completion: it |
203 | 212 | // calls fetch with an empty cursor, then re-calls it with each returned |
204 | 213 | // nextPageToken until the cursor comes back empty, concatenating every page. |
205 | 214 | // The control plane caps the page size (and may cap it further than a caller |
206 | 215 | // requests), so a single call only returns one page — list commands must loop |
207 | | -// or they silently truncate. The next==cursor guard turns a misbehaving server |
208 | | -// that fails to advance the cursor into an error instead of an infinite loop. |
| 216 | +// or they silently truncate. |
209 | 217 | func fetchAllPages[T any](ctx context.Context, fetch func(ctx context.Context, cursor string) (items []T, next string, err error)) ([]T, error) { |
| 218 | + items, _, err := fetchPagesBounded(ctx, 0, fetch) |
| 219 | + return items, err |
| 220 | +} |
| 221 | + |
| 222 | +// fetchPagesBounded is fetchAllPages with a fetch budget: the cursor walk |
| 223 | +// stops once at least budget entries have been fetched (a page is never split, |
| 224 | +// so the result can overshoot by up to one page). partial reports that the |
| 225 | +// walk stopped with a cursor remaining — entries exist beyond the returned |
| 226 | +// slice and the caller must disclose that. budget <= 0 means unbounded. The |
| 227 | +// next==cursor guard turns a misbehaving server that fails to advance the |
| 228 | +// cursor into an error instead of an infinite loop. |
| 229 | +func fetchPagesBounded[T any](ctx context.Context, budget int, fetch func(ctx context.Context, cursor string) (items []T, next string, err error)) (items []T, partial bool, err error) { |
210 | 230 | var all []T |
211 | 231 | cursor := "" |
212 | 232 | for { |
213 | | - items, next, err := fetch(ctx, cursor) |
| 233 | + page, next, err := fetch(ctx, cursor) |
214 | 234 | if err != nil { |
215 | | - return nil, err |
| 235 | + return nil, false, err |
216 | 236 | } |
217 | | - all = append(all, items...) |
| 237 | + all = append(all, page...) |
218 | 238 | if next == "" { |
219 | | - return all, nil |
| 239 | + return all, false, nil |
| 240 | + } |
| 241 | + if budget > 0 && len(all) >= budget { |
| 242 | + return all, true, nil |
220 | 243 | } |
221 | 244 | if next == cursor { |
222 | | - return nil, fmt.Errorf("pagination did not advance (cursor %q repeated)", next) |
| 245 | + return nil, false, fmt.Errorf("pagination did not advance (cursor %q repeated)", next) |
223 | 246 | } |
224 | 247 | cursor = next |
225 | 248 | } |
226 | 249 | } |
227 | 250 |
|
| 251 | +// listPage is the --json envelope for single-page (cursor passthrough) list |
| 252 | +// output: rows plus the cursor to resume from, omitted on the last page. Page |
| 253 | +// mode cannot emit the bare array the walk modes use — the caller needs the |
| 254 | +// cursor to continue, and stdout is the only machine-readable channel. |
| 255 | +type listPage[T any] struct { |
| 256 | + Items []T `json:"items"` |
| 257 | + NextPageToken string `json:"nextPageToken,omitempty"` |
| 258 | +} |
| 259 | + |
| 260 | +// renderCoreListPage renders one fetched page of a list command: --json emits |
| 261 | +// the listPage envelope; the table view prints the usual table, preceded by a |
| 262 | +// stderr resume hint carrying the cursor when more entries exist. |
| 263 | +func renderCoreListPage[T any](cmd *cobra.Command, empty string, headers []string, row func(T) []string, items []T, next string) error { |
| 264 | + if jsonRequested(cmd) { |
| 265 | + if items == nil { |
| 266 | + items = []T{} // a nil slice encodes as null; scripts expect [] |
| 267 | + } |
| 268 | + return printJSON(cmd.OutOrStdout(), listPage[T]{Items: items, NextPageToken: next}) |
| 269 | + } |
| 270 | + if next != "" { |
| 271 | + fmt.Fprintf(cmd.ErrOrStderr(), "More entries available: resume with --page-token %s\n", next) |
| 272 | + } |
| 273 | + if len(items) == 0 { |
| 274 | + fmt.Fprintln(cmd.OutOrStdout(), empty) |
| 275 | + return nil |
| 276 | + } |
| 277 | + return printTable(cmd.OutOrStdout(), headers, items, row) |
| 278 | +} |
| 279 | + |
| 280 | +// pageModeFlags wires the single-page cursor-passthrough flags onto a list |
| 281 | +// command and excludes them from the walk flags (--all, --limit): one call = |
| 282 | +// one request, so a walk bound makes no sense alongside them. Callers validate |
| 283 | +// pageSize positivity in PreRunE via validatePageSize. |
| 284 | +func pageModeFlags(cmd *cobra.Command, pageSize *int, pageToken *string) { |
| 285 | + cmd.Flags().IntVar(pageSize, "page-size", 0, "Fetch a single page of at most N entries (1-"+strconv.Itoa(coreListPageSizeMax)+"; the server may cap N further) and print the resume cursor") |
| 286 | + cmd.Flags().StringVar(pageToken, "page-token", "", "Fetch the single page at this cursor (from a previous run's nextPageToken)") |
| 287 | + cmd.MarkFlagsMutuallyExclusive("page-size", "all") |
| 288 | + cmd.MarkFlagsMutuallyExclusive("page-token", "all") |
| 289 | + cmd.MarkFlagsMutuallyExclusive("page-size", "limit") |
| 290 | + cmd.MarkFlagsMutuallyExclusive("page-token", "limit") |
| 291 | +} |
| 292 | + |
| 293 | +// pageModeRequested reports whether the caller opted into single-page mode: |
| 294 | +// either page flag was explicitly set. Checked by Changed, not value — a |
| 295 | +// script's resume loop naturally passes --page-token "" for its first page, |
| 296 | +// and a value check would silently reroute that call to the multi-page walk, |
| 297 | +// flipping the --json shape from the {items, nextPageToken} envelope to a |
| 298 | +// bare array (and the request count from one to many). |
| 299 | +func pageModeRequested(cmd *cobra.Command) bool { |
| 300 | + return cmd.Flags().Changed("page-size") || cmd.Flags().Changed("page-token") |
| 301 | +} |
| 302 | + |
| 303 | +// coreListPageSizeMax mirrors the OpenAPI `maximum: 500` on the list |
| 304 | +// endpoints' pageSize param (see internal/coreapi/spec). The generated client |
| 305 | +// does not validate params, so without this local bound an oversized |
| 306 | +// --page-size goes on the wire and comes back as a server 4xx naming the wire |
| 307 | +// param instead of the flag. |
| 308 | +const coreListPageSizeMax = 500 |
| 309 | + |
| 310 | +// validatePageSize rejects an explicitly set out-of-range --page-size; an |
| 311 | +// unset flag passes. |
| 312 | +func validatePageSize(cmd *cobra.Command, pageSize int) error { |
| 313 | + if cmd.Flags().Changed("page-size") && (pageSize <= 0 || pageSize > coreListPageSizeMax) { |
| 314 | + return fmt.Errorf("--page-size must be between 1 and %d, got %d", coreListPageSizeMax, pageSize) |
| 315 | + } |
| 316 | + return nil |
| 317 | +} |
| 318 | + |
| 319 | +// flushThroughPager runs run with the command's stdout captured, then flushes |
| 320 | +// the captured output — through a pager when stdout is a real terminal and the |
| 321 | +// content is taller than the screen (see outputWithPager), directly otherwise. |
| 322 | +// --json output never pages: a machine consumer driving a PTY would hang |
| 323 | +// waiting on the pager's keyboard, and JSON is not for reading. Output is |
| 324 | +// flushed even when run errors, so partial renders are not swallowed. |
| 325 | +func flushThroughPager(cmd *cobra.Command, noPager bool, run func() error) error { |
| 326 | + finalOut := cmd.OutOrStdout() |
| 327 | + var buf bytes.Buffer |
| 328 | + cmd.SetOut(&buf) |
| 329 | + err := run() |
| 330 | + cmd.SetOut(finalOut) |
| 331 | + content := buf.String() |
| 332 | + if noPager || jsonRequested(cmd) { |
| 333 | + fmt.Fprint(finalOut, content) |
| 334 | + } else { |
| 335 | + outputWithPager(finalOut, content) |
| 336 | + } |
| 337 | + return err |
| 338 | +} |
| 339 | + |
228 | 340 | // runCoreObject fetches a single value via fn and renders it as a vertical |
229 | 341 | // field/value list (default) or raw JSON (--json), reusing the same column |
230 | 342 | // definition as the matching list view. |
@@ -321,6 +433,43 @@ func printTable[T any](w io.Writer, headers []string, items []T, row func(T) []s |
321 | 433 | return nil |
322 | 434 | } |
323 | 435 |
|
| 436 | +// preStyleTable pre-colors table headers and a row function against w's color |
| 437 | +// capability, so a command that renders its table into a pager buffer keeps |
| 438 | +// its color. printTable/renderCoreListPage decide color from the writer they |
| 439 | +// render into; under flushThroughPager that writer is an in-memory buffer, |
| 440 | +// which never looks like a TTY, so a straight render there is always plain. |
| 441 | +// Pre-styling against the real output writer here and letting the buffered |
| 442 | +// render pass the ANSI through unchanged (its own color gate is off, so it |
| 443 | +// never re-styles) restores it — the same approach the mirror-list view takes. |
| 444 | +// Identity (no wrapping) when color is off, so pipes, tests, and NO_COLOR see |
| 445 | +// bare text byte for byte. |
| 446 | +func preStyleTable[T any](w io.Writer, headers []string, row func(T) []string) ([]string, func(T) []string) { |
| 447 | + return styleTableWith(newTableStyles(w), headers, row) |
| 448 | +} |
| 449 | + |
| 450 | +// styleTableWith is the pure core of preStyleTable: it applies st's header and |
| 451 | +// per-column styles to the headers and row cells, matching how printTable |
| 452 | +// colors a direct render. Split out from the writer-facing wrapper so the |
| 453 | +// enabled path is unit-testable without a real terminal. Identity when st is |
| 454 | +// disabled, so plain output stays byte-for-byte unchanged. |
| 455 | +func styleTableWith[T any](st tableStyles, headers []string, row func(T) []string) ([]string, func(T) []string) { |
| 456 | + if !st.enabled { |
| 457 | + return headers, row |
| 458 | + } |
| 459 | + styledHeaders := make([]string, len(headers)) |
| 460 | + for i, h := range headers { |
| 461 | + styledHeaders[i] = st.style(st.header, h) |
| 462 | + } |
| 463 | + styledRow := func(t T) []string { |
| 464 | + cells := row(t) |
| 465 | + for i := range cells { |
| 466 | + cells[i] = st.style(st.columnStyle(i), cells[i]) |
| 467 | + } |
| 468 | + return cells |
| 469 | + } |
| 470 | + return styledHeaders, styledRow |
| 471 | +} |
| 472 | + |
324 | 473 | // printFields writes a single record as aligned "FIELD value" lines: the |
325 | 474 | // label in header gray, the value in the same primary/secondary color the |
326 | 475 | // list view would give that column. |
|
0 commit comments