[object Object]

← back to Cli Printing Press

fix(cli): descend --select into list-envelope responses (#1379)

3bcb9d9009596fa273e1fa88da80fa06176fbcc6 · 2026-05-14 01:34:53 -0700 · Trevin Chow

The runtime `filterFieldsRec` helper emitted into every printed CLI
matched selector heads only against an object's top-level keys, so
`--select id,name` against `{"projects":[...]}` (orgo), `{"data":[...]}`
(stripe), `{"total_count":N,"items":[...]}` (github), or
`{"object":"list","results":[...]}` (notion) returned `{}` because
`id` and `name` lived inside the wrapped array, not on the envelope.

When no top-level key matches and at least one non-null array sibling
exists, the helper now treats the object as a list envelope: it applies
the selector inside each array and preserves non-array siblings
verbatim so envelope metadata (counts, pagination cursors) stays
visible. The `arr != nil` check rejects JSON null, which
`json.Unmarshal` otherwise accepts into a `[]json.RawMessage` as a
nil slice and would coerce a null pagination cursor into `[]`.

Test coverage lives in two places:

* `root_test.go.tmpl` ships table-driven coverage with every printed
  CLI's `internal/cli/root_test.go`, exercising bare arrays, direct
  objects, single/multi-array envelopes, metadata-sibling envelopes
  (stripe/github shapes), null-cursor preservation, the
  envelope-key-as-selector case (suppresses descent), dotted-path
  descent, the flat-no-match `{}` invariant, and the
  intentionally-not-descended nested-object case.
* `internal/generator/filter_fields_envelope_test.go` runs the
  emitted helper inside cli-printing-press's own CI by generating a
  CLI to a temp dir, writing a fixture `_test.go` alongside the
  helper, and invoking `go test` on the generated module — modeled
  on `TestRootFlagsPrintJSONHonorsOutputFlags`.

Closes #1143

Files touched

Diff

commit 3bcb9d9009596fa273e1fa88da80fa06176fbcc6
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Thu May 14 01:34:53 2026 -0700

    fix(cli): descend --select into list-envelope responses (#1379)
    
    The runtime `filterFieldsRec` helper emitted into every printed CLI
    matched selector heads only against an object's top-level keys, so
    `--select id,name` against `{"projects":[...]}` (orgo), `{"data":[...]}`
    (stripe), `{"total_count":N,"items":[...]}` (github), or
    `{"object":"list","results":[...]}` (notion) returned `{}` because
    `id` and `name` lived inside the wrapped array, not on the envelope.
    
    When no top-level key matches and at least one non-null array sibling
    exists, the helper now treats the object as a list envelope: it applies
    the selector inside each array and preserves non-array siblings
    verbatim so envelope metadata (counts, pagination cursors) stays
    visible. The `arr != nil` check rejects JSON null, which
    `json.Unmarshal` otherwise accepts into a `[]json.RawMessage` as a
    nil slice and would coerce a null pagination cursor into `[]`.
    
    Test coverage lives in two places:
    
    * `root_test.go.tmpl` ships table-driven coverage with every printed
      CLI's `internal/cli/root_test.go`, exercising bare arrays, direct
      objects, single/multi-array envelopes, metadata-sibling envelopes
      (stripe/github shapes), null-cursor preservation, the
      envelope-key-as-selector case (suppresses descent), dotted-path
      descent, the flat-no-match `{}` invariant, and the
      intentionally-not-descended nested-object case.
    * `internal/generator/filter_fields_envelope_test.go` runs the
      emitted helper inside cli-printing-press's own CI by generating a
      CLI to a temp dir, writing a fixture `_test.go` alongside the
      helper, and invoking `go test` on the generated module — modeled
      on `TestRootFlagsPrintJSONHonorsOutputFlags`.
    
    Closes #1143
---
 internal/generator/filter_fields_envelope_test.go  | 108 +++++++++++++++++
 internal/generator/templates/helpers.go.tmpl       |  30 +++++
 internal/generator/templates/root_test.go.tmpl     | 128 +++++++++++++++++++++
 .../embedded-paged-api/internal/cli/helpers.go     |  30 +++++
 .../internal/cli/helpers.go                        |  30 +++++
 .../internal/cli/root_test.go                      | 128 +++++++++++++++++++++
 .../printing-press-golden/internal/cli/helpers.go  |  30 +++++
 .../internal/cli/root_test.go                      | 128 +++++++++++++++++++++
 8 files changed, 612 insertions(+)

diff --git a/internal/generator/filter_fields_envelope_test.go b/internal/generator/filter_fields_envelope_test.go
new file mode 100644
index 00000000..5efa35e6
--- /dev/null
+++ b/internal/generator/filter_fields_envelope_test.go
@@ -0,0 +1,108 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+// TestFilterFieldsEnvelopeDescent_EmittedHelper guards the runtime behavior
+// of filterFields against list-envelope responses inside the cli-printing-press
+// repo's own test suite. The function is emitted into every printed CLI's
+// internal/cli/helpers.go from helpers.go.tmpl. Without this gate, regressions
+// in the envelope-descent fallback only surface when a user runs `go test ./...`
+// inside a generated CLI, which slows the feedback loop and risks shipping a
+// broken --select to every CLI built from a future bad commit.
+//
+// The test follows the TestRootFlagsPrintJSONHonorsOutputFlags pattern: it
+// generates a CLI to a temp dir, writes a fixture _test.go alongside the
+// emitted helpers, then runs `go test` on the generated module.
+func TestFilterFieldsEnvelopeDescent_EmittedHelper(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("envelope-descent")
+	outputDir := filepath.Join(t.TempDir(), "envelope-descent-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	testPath := filepath.Join(outputDir, "internal", "cli", "filter_fields_envelope_test.go")
+	require.NoError(t, os.WriteFile(testPath, []byte(`package cli
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+// TestFilterFieldsEnvelopeDescent covers the four shapes printed CLIs see in
+// practice. The envelope cases pin the regression where wrapper-key + array
+// responses returned `+"`{}`"+` because the selector heads matched the inner
+// record fields, not the wrapper key.
+func TestFilterFieldsEnvelopeDescent(t *testing.T) {
+	cases := []struct {
+		name   string
+		input  string
+		fields string
+		want   string
+	}{
+		{
+			"bare array element-wise",
+			`+"`"+`[{"id":"a","name":"x","other":"y"}]`+"`"+`,
+			"id,name",
+			`+"`"+`[{"id":"a","name":"x"}]`+"`"+`,
+		},
+		{
+			"envelope single array sibling",
+			`+"`"+`{"projects":[{"id":"a","name":"x","other":"y"}]}`+"`"+`,
+			"id,name",
+			`+"`"+`{"projects":[{"id":"a","name":"x"}]}`+"`"+`,
+		},
+		{
+			"envelope with metadata sibling preserves count",
+			`+"`"+`{"total_count":2,"items":[{"id":"a","other":"y"}]}`+"`"+`,
+			"id",
+			`+"`"+`{"items":[{"id":"a"}],"total_count":2}`+"`"+`,
+		},
+		{
+			"envelope preserves null pagination cursor verbatim",
+			`+"`"+`{"items":[{"id":"a"}],"next_cursor":null}`+"`"+`,
+			"id",
+			`+"`"+`{"items":[{"id":"a"}],"next_cursor":null}`+"`"+`,
+		},
+		{
+			"flat object no match returns empty",
+			`+"`"+`{"a":1,"b":2}`+"`"+`,
+			"c",
+			`+"`"+`{}`+"`"+`,
+		},
+		{
+			"selector matches envelope key suppresses descent",
+			`+"`"+`{"projects":[{"id":"a","other":"y"}]}`+"`"+`,
+			"projects",
+			`+"`"+`{"projects":[{"id":"a","other":"y"}]}`+"`"+`,
+		},
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			got := filterFields(json.RawMessage(tc.input), tc.fields)
+			var gotV, wantV interface{}
+			if err := json.Unmarshal(got, &gotV); err != nil {
+				t.Fatalf("invalid json output: %v (raw=%s)", err, string(got))
+			}
+			if err := json.Unmarshal([]byte(tc.want), &wantV); err != nil {
+				t.Fatalf("invalid want json: %v (raw=%s)", err, tc.want)
+			}
+			gotBytes, _ := json.Marshal(gotV)
+			wantBytes, _ := json.Marshal(wantV)
+			if string(gotBytes) != string(wantBytes) {
+				t.Errorf("filterFields(%q, %q) = %s, want %s",
+					tc.input, tc.fields, string(gotBytes), string(wantBytes))
+			}
+		})
+	}
+}
+`), 0o644))
+
+	runGoCommand(t, outputDir, "test", "./internal/cli", "-run", "TestFilterFieldsEnvelopeDescent", "-count=1")
+}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 65074cf5..abdca004 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -993,11 +993,13 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
 			}
 		}
 		filtered := map[string]json.RawMessage{}
+		matchedAny := false
 		for k, v := range obj {
 			matched := matchSelectSegment(k, keepWhole, subPaths)
 			if matched == "" {
 				continue
 			}
+			matchedAny = true
 			if keepWhole[matched] {
 				filtered[k] = v
 				continue
@@ -1006,6 +1008,34 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
 				filtered[k] = filterFieldsRec(v, subs)
 			}
 		}
+		// Envelope fallback: when no top-level keys matched but at least one
+		// sibling is a non-null array, treat the object as a list envelope
+		// (`{"items":[...]}`, `{"data":[...]}`, `{"total_count":N,"items":[...]}`)
+		// and apply the selector inside the array(s). Non-array siblings pass
+		// through verbatim so envelope metadata (counts, null pagination
+		// cursors) stays visible. The foundArray guard preserves the prior
+		// empty-object result for flat objects where no key matches and no
+		// array exists. The `arr != nil` check rejects JSON null, which
+		// json.Unmarshal otherwise accepts into a []json.RawMessage as a
+		// nil slice and would coerce to `[]`.
+		if !matchedAny {
+			pending := map[string]json.RawMessage{}
+			foundArray := false
+			for k, v := range obj {
+				var arr []json.RawMessage
+				if json.Unmarshal(v, &arr) == nil && arr != nil {
+					foundArray = true
+					pending[k] = filterFieldsRec(v, paths)
+				} else {
+					pending[k] = v
+				}
+			}
+			if foundArray {
+				for k, v := range pending {
+					filtered[k] = v
+				}
+			}
+		}
 		result, _ := json.Marshal(filtered)
 		return result
 	}
diff --git a/internal/generator/templates/root_test.go.tmpl b/internal/generator/templates/root_test.go.tmpl
index c40194da..b1cd048e 100644
--- a/internal/generator/templates/root_test.go.tmpl
+++ b/internal/generator/templates/root_test.go.tmpl
@@ -4,6 +4,7 @@
 package cli
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"testing"
@@ -75,3 +76,130 @@ func TestExitCode_UsageError_WrappedAsCode2(t *testing.T) {
 		t.Errorf("ExitCode(usageErr(...)) = %d, want 2 (POSIX usage convention)", got)
 	}
 }
+
+// TestFilterFields covers --select projection against the four payload
+// shapes printed CLIs see in practice: bare arrays, direct objects,
+// list envelopes (Stripe/GitHub/Notion-style wrapper + array), and
+// flat objects. The envelope cases guard against a regression where
+// wrapper-key + array responses returned `{}` because the selector
+// heads matched the inner record fields, not the wrapper key.
+func TestFilterFields(t *testing.T) {
+	t.Parallel()
+	cases := []struct {
+		name   string
+		input  string
+		fields string
+		want   string
+	}{
+		{
+			name:   "bare array element-wise",
+			input:  `[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]`,
+			fields: "id,name",
+			want:   `[{"id":"a","name":"x"},{"id":"b","name":"z"}]`,
+		},
+		{
+			name:   "direct object top-level match",
+			input:  `{"id":"a","name":"b","other":"c"}`,
+			fields: "id,name",
+			want:   `{"id":"a","name":"b"}`,
+		},
+		{
+			name:   "envelope single array sibling (orgo {projects:[...]})",
+			input:  `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "id,name",
+			want:   `{"projects":[{"id":"a","name":"x"}]}`,
+		},
+		{
+			name:   "envelope with metadata sibling (github {total_count,items:[...]})",
+			input:  `{"total_count":2,"items":[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]}`,
+			fields: "id,name",
+			want:   `{"items":[{"id":"a","name":"x"},{"id":"b","name":"z"}],"total_count":2}`,
+		},
+		{
+			name:   "envelope with metadata sibling (stripe {object,data:[...]})",
+			input:  `{"object":"list","data":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "id,name",
+			want:   `{"data":[{"id":"a","name":"x"}],"object":"list"}`,
+		},
+		{
+			name:   "selector matches envelope key (no descent into array)",
+			input:  `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "projects",
+			want:   `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+		},
+		{
+			name:   "dotted-path through envelope key still descends",
+			input:  `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "projects.id",
+			want:   `{"projects":[{"id":"a"}]}`,
+		},
+		{
+			name:   "flat object no match returns empty (no array fallback)",
+			input:  `{"a":1,"b":2}`,
+			fields: "c",
+			want:   `{}`,
+		},
+		{
+			// Null pagination cursors are common envelope metadata.
+			// json.Unmarshal accepts JSON null into []json.RawMessage as
+			// a nil slice without error, so the array check must reject
+			// nil explicitly or null siblings would be coerced to `[]`.
+			name:   "envelope preserves null sibling verbatim",
+			input:  `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+			fields: "id,name",
+			want:   `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+		},
+		{
+			// Without a real array sibling the envelope fallback does not
+			// fire, so a flat object whose only "extra" key is null still
+			// returns {} for a non-matching selector.
+			name:   "flat object with null sibling no match returns empty",
+			input:  `{"a":1,"b":null}`,
+			fields: "c",
+			want:   `{}`,
+		},
+		{
+			// Multiple array siblings at the same level each receive the
+			// selector independently. Documents that the projection fans
+			// out across every array, not just the first one found.
+			name:   "envelope with two array siblings filters both",
+			input:  `{"events":[{"id":"e1","other":"x"}],"speakers":[{"id":"s1","other":"y"}]}`,
+			fields: "id",
+			want:   `{"events":[{"id":"e1"}],"speakers":[{"id":"s1"}]}`,
+		},
+		{
+			// Envelope fallback is intentionally one level deep. A nested
+			// object envelope like {"data":{"items":[...]}} surfaces no
+			// array at the outer level, so the fallback does not fire and
+			// the result is the empty-object that flat-no-match would
+			// produce. Pins the boundary so a future deeper-walk change
+			// is an explicit decision, not an accident.
+			name:   "nested object envelope returns empty (one-level only)",
+			input:  `{"data":{"items":[{"id":"a","other":"y"}]}}`,
+			fields: "id",
+			want:   `{}`,
+		},
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			got := filterFields(json.RawMessage(tc.input), tc.fields)
+			// Normalize both sides through json.Unmarshal+Marshal so
+			// map-iteration order does not produce false negatives.
+			var gotV, wantV interface{}
+			if err := json.Unmarshal(got, &gotV); err != nil {
+				t.Fatalf("got is invalid json: %v (raw=%s)", err, string(got))
+			}
+			if err := json.Unmarshal([]byte(tc.want), &wantV); err != nil {
+				t.Fatalf("want is invalid json: %v (raw=%s)", err, tc.want)
+			}
+			gotBytes, _ := json.Marshal(gotV)
+			wantBytes, _ := json.Marshal(wantV)
+			if string(gotBytes) != string(wantBytes) {
+				t.Errorf("filterFields(%q, %q) = %s, want %s",
+					tc.input, tc.fields, string(gotBytes), string(wantBytes))
+			}
+		})
+	}
+}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
index 73b178a8..662fafb7 100644
--- a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
@@ -792,11 +792,13 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
 			}
 		}
 		filtered := map[string]json.RawMessage{}
+		matchedAny := false
 		for k, v := range obj {
 			matched := matchSelectSegment(k, keepWhole, subPaths)
 			if matched == "" {
 				continue
 			}
+			matchedAny = true
 			if keepWhole[matched] {
 				filtered[k] = v
 				continue
@@ -805,6 +807,34 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
 				filtered[k] = filterFieldsRec(v, subs)
 			}
 		}
+		// Envelope fallback: when no top-level keys matched but at least one
+		// sibling is a non-null array, treat the object as a list envelope
+		// (`{"items":[...]}`, `{"data":[...]}`, `{"total_count":N,"items":[...]}`)
+		// and apply the selector inside the array(s). Non-array siblings pass
+		// through verbatim so envelope metadata (counts, null pagination
+		// cursors) stays visible. The foundArray guard preserves the prior
+		// empty-object result for flat objects where no key matches and no
+		// array exists. The `arr != nil` check rejects JSON null, which
+		// json.Unmarshal otherwise accepts into a []json.RawMessage as a
+		// nil slice and would coerce to `[]`.
+		if !matchedAny {
+			pending := map[string]json.RawMessage{}
+			foundArray := false
+			for k, v := range obj {
+				var arr []json.RawMessage
+				if json.Unmarshal(v, &arr) == nil && arr != nil {
+					foundArray = true
+					pending[k] = filterFieldsRec(v, paths)
+				} else {
+					pending[k] = v
+				}
+			}
+			if foundArray {
+				for k, v := range pending {
+					filtered[k] = v
+				}
+			}
+		}
 		result, _ := json.Marshal(filtered)
 		return result
 	}
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
index 33ef4ad3..5826f23a 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
@@ -655,11 +655,13 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
 			}
 		}
 		filtered := map[string]json.RawMessage{}
+		matchedAny := false
 		for k, v := range obj {
 			matched := matchSelectSegment(k, keepWhole, subPaths)
 			if matched == "" {
 				continue
 			}
+			matchedAny = true
 			if keepWhole[matched] {
 				filtered[k] = v
 				continue
@@ -668,6 +670,34 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
 				filtered[k] = filterFieldsRec(v, subs)
 			}
 		}
+		// Envelope fallback: when no top-level keys matched but at least one
+		// sibling is a non-null array, treat the object as a list envelope
+		// (`{"items":[...]}`, `{"data":[...]}`, `{"total_count":N,"items":[...]}`)
+		// and apply the selector inside the array(s). Non-array siblings pass
+		// through verbatim so envelope metadata (counts, null pagination
+		// cursors) stays visible. The foundArray guard preserves the prior
+		// empty-object result for flat objects where no key matches and no
+		// array exists. The `arr != nil` check rejects JSON null, which
+		// json.Unmarshal otherwise accepts into a []json.RawMessage as a
+		// nil slice and would coerce to `[]`.
+		if !matchedAny {
+			pending := map[string]json.RawMessage{}
+			foundArray := false
+			for k, v := range obj {
+				var arr []json.RawMessage
+				if json.Unmarshal(v, &arr) == nil && arr != nil {
+					foundArray = true
+					pending[k] = filterFieldsRec(v, paths)
+				} else {
+					pending[k] = v
+				}
+			}
+			if foundArray {
+				for k, v := range pending {
+					filtered[k] = v
+				}
+			}
+		}
 		result, _ := json.Marshal(filtered)
 		return result
 	}
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/root_test.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/root_test.go
index d04efeed..e5336e44 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/root_test.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/root_test.go
@@ -4,6 +4,7 @@
 package cli
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"testing"
@@ -75,3 +76,130 @@ func TestExitCode_UsageError_WrappedAsCode2(t *testing.T) {
 		t.Errorf("ExitCode(usageErr(...)) = %d, want 2 (POSIX usage convention)", got)
 	}
 }
+
+// TestFilterFields covers --select projection against the four payload
+// shapes printed CLIs see in practice: bare arrays, direct objects,
+// list envelopes (Stripe/GitHub/Notion-style wrapper + array), and
+// flat objects. The envelope cases guard against a regression where
+// wrapper-key + array responses returned `{}` because the selector
+// heads matched the inner record fields, not the wrapper key.
+func TestFilterFields(t *testing.T) {
+	t.Parallel()
+	cases := []struct {
+		name   string
+		input  string
+		fields string
+		want   string
+	}{
+		{
+			name:   "bare array element-wise",
+			input:  `[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]`,
+			fields: "id,name",
+			want:   `[{"id":"a","name":"x"},{"id":"b","name":"z"}]`,
+		},
+		{
+			name:   "direct object top-level match",
+			input:  `{"id":"a","name":"b","other":"c"}`,
+			fields: "id,name",
+			want:   `{"id":"a","name":"b"}`,
+		},
+		{
+			name:   "envelope single array sibling (orgo {projects:[...]})",
+			input:  `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "id,name",
+			want:   `{"projects":[{"id":"a","name":"x"}]}`,
+		},
+		{
+			name:   "envelope with metadata sibling (github {total_count,items:[...]})",
+			input:  `{"total_count":2,"items":[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]}`,
+			fields: "id,name",
+			want:   `{"items":[{"id":"a","name":"x"},{"id":"b","name":"z"}],"total_count":2}`,
+		},
+		{
+			name:   "envelope with metadata sibling (stripe {object,data:[...]})",
+			input:  `{"object":"list","data":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "id,name",
+			want:   `{"data":[{"id":"a","name":"x"}],"object":"list"}`,
+		},
+		{
+			name:   "selector matches envelope key (no descent into array)",
+			input:  `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "projects",
+			want:   `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+		},
+		{
+			name:   "dotted-path through envelope key still descends",
+			input:  `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "projects.id",
+			want:   `{"projects":[{"id":"a"}]}`,
+		},
+		{
+			name:   "flat object no match returns empty (no array fallback)",
+			input:  `{"a":1,"b":2}`,
+			fields: "c",
+			want:   `{}`,
+		},
+		{
+			// Null pagination cursors are common envelope metadata.
+			// json.Unmarshal accepts JSON null into []json.RawMessage as
+			// a nil slice without error, so the array check must reject
+			// nil explicitly or null siblings would be coerced to `[]`.
+			name:   "envelope preserves null sibling verbatim",
+			input:  `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+			fields: "id,name",
+			want:   `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+		},
+		{
+			// Without a real array sibling the envelope fallback does not
+			// fire, so a flat object whose only "extra" key is null still
+			// returns {} for a non-matching selector.
+			name:   "flat object with null sibling no match returns empty",
+			input:  `{"a":1,"b":null}`,
+			fields: "c",
+			want:   `{}`,
+		},
+		{
+			// Multiple array siblings at the same level each receive the
+			// selector independently. Documents that the projection fans
+			// out across every array, not just the first one found.
+			name:   "envelope with two array siblings filters both",
+			input:  `{"events":[{"id":"e1","other":"x"}],"speakers":[{"id":"s1","other":"y"}]}`,
+			fields: "id",
+			want:   `{"events":[{"id":"e1"}],"speakers":[{"id":"s1"}]}`,
+		},
+		{
+			// Envelope fallback is intentionally one level deep. A nested
+			// object envelope like {"data":{"items":[...]}} surfaces no
+			// array at the outer level, so the fallback does not fire and
+			// the result is the empty-object that flat-no-match would
+			// produce. Pins the boundary so a future deeper-walk change
+			// is an explicit decision, not an accident.
+			name:   "nested object envelope returns empty (one-level only)",
+			input:  `{"data":{"items":[{"id":"a","other":"y"}]}}`,
+			fields: "id",
+			want:   `{}`,
+		},
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			got := filterFields(json.RawMessage(tc.input), tc.fields)
+			// Normalize both sides through json.Unmarshal+Marshal so
+			// map-iteration order does not produce false negatives.
+			var gotV, wantV interface{}
+			if err := json.Unmarshal(got, &gotV); err != nil {
+				t.Fatalf("got is invalid json: %v (raw=%s)", err, string(got))
+			}
+			if err := json.Unmarshal([]byte(tc.want), &wantV); err != nil {
+				t.Fatalf("want is invalid json: %v (raw=%s)", err, tc.want)
+			}
+			gotBytes, _ := json.Marshal(gotV)
+			wantBytes, _ := json.Marshal(wantV)
+			if string(gotBytes) != string(wantBytes) {
+				t.Errorf("filterFields(%q, %q) = %s, want %s",
+					tc.input, tc.fields, string(gotBytes), string(wantBytes))
+			}
+		})
+	}
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
index 3dcd6f7b..c2b796cd 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -662,11 +662,13 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
 			}
 		}
 		filtered := map[string]json.RawMessage{}
+		matchedAny := false
 		for k, v := range obj {
 			matched := matchSelectSegment(k, keepWhole, subPaths)
 			if matched == "" {
 				continue
 			}
+			matchedAny = true
 			if keepWhole[matched] {
 				filtered[k] = v
 				continue
@@ -675,6 +677,34 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
 				filtered[k] = filterFieldsRec(v, subs)
 			}
 		}
+		// Envelope fallback: when no top-level keys matched but at least one
+		// sibling is a non-null array, treat the object as a list envelope
+		// (`{"items":[...]}`, `{"data":[...]}`, `{"total_count":N,"items":[...]}`)
+		// and apply the selector inside the array(s). Non-array siblings pass
+		// through verbatim so envelope metadata (counts, null pagination
+		// cursors) stays visible. The foundArray guard preserves the prior
+		// empty-object result for flat objects where no key matches and no
+		// array exists. The `arr != nil` check rejects JSON null, which
+		// json.Unmarshal otherwise accepts into a []json.RawMessage as a
+		// nil slice and would coerce to `[]`.
+		if !matchedAny {
+			pending := map[string]json.RawMessage{}
+			foundArray := false
+			for k, v := range obj {
+				var arr []json.RawMessage
+				if json.Unmarshal(v, &arr) == nil && arr != nil {
+					foundArray = true
+					pending[k] = filterFieldsRec(v, paths)
+				} else {
+					pending[k] = v
+				}
+			}
+			if foundArray {
+				for k, v := range pending {
+					filtered[k] = v
+				}
+			}
+		}
 		result, _ := json.Marshal(filtered)
 		return result
 	}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root_test.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root_test.go
index d04efeed..e5336e44 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root_test.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root_test.go
@@ -4,6 +4,7 @@
 package cli
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"testing"
@@ -75,3 +76,130 @@ func TestExitCode_UsageError_WrappedAsCode2(t *testing.T) {
 		t.Errorf("ExitCode(usageErr(...)) = %d, want 2 (POSIX usage convention)", got)
 	}
 }
+
+// TestFilterFields covers --select projection against the four payload
+// shapes printed CLIs see in practice: bare arrays, direct objects,
+// list envelopes (Stripe/GitHub/Notion-style wrapper + array), and
+// flat objects. The envelope cases guard against a regression where
+// wrapper-key + array responses returned `{}` because the selector
+// heads matched the inner record fields, not the wrapper key.
+func TestFilterFields(t *testing.T) {
+	t.Parallel()
+	cases := []struct {
+		name   string
+		input  string
+		fields string
+		want   string
+	}{
+		{
+			name:   "bare array element-wise",
+			input:  `[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]`,
+			fields: "id,name",
+			want:   `[{"id":"a","name":"x"},{"id":"b","name":"z"}]`,
+		},
+		{
+			name:   "direct object top-level match",
+			input:  `{"id":"a","name":"b","other":"c"}`,
+			fields: "id,name",
+			want:   `{"id":"a","name":"b"}`,
+		},
+		{
+			name:   "envelope single array sibling (orgo {projects:[...]})",
+			input:  `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "id,name",
+			want:   `{"projects":[{"id":"a","name":"x"}]}`,
+		},
+		{
+			name:   "envelope with metadata sibling (github {total_count,items:[...]})",
+			input:  `{"total_count":2,"items":[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]}`,
+			fields: "id,name",
+			want:   `{"items":[{"id":"a","name":"x"},{"id":"b","name":"z"}],"total_count":2}`,
+		},
+		{
+			name:   "envelope with metadata sibling (stripe {object,data:[...]})",
+			input:  `{"object":"list","data":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "id,name",
+			want:   `{"data":[{"id":"a","name":"x"}],"object":"list"}`,
+		},
+		{
+			name:   "selector matches envelope key (no descent into array)",
+			input:  `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "projects",
+			want:   `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+		},
+		{
+			name:   "dotted-path through envelope key still descends",
+			input:  `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+			fields: "projects.id",
+			want:   `{"projects":[{"id":"a"}]}`,
+		},
+		{
+			name:   "flat object no match returns empty (no array fallback)",
+			input:  `{"a":1,"b":2}`,
+			fields: "c",
+			want:   `{}`,
+		},
+		{
+			// Null pagination cursors are common envelope metadata.
+			// json.Unmarshal accepts JSON null into []json.RawMessage as
+			// a nil slice without error, so the array check must reject
+			// nil explicitly or null siblings would be coerced to `[]`.
+			name:   "envelope preserves null sibling verbatim",
+			input:  `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+			fields: "id,name",
+			want:   `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+		},
+		{
+			// Without a real array sibling the envelope fallback does not
+			// fire, so a flat object whose only "extra" key is null still
+			// returns {} for a non-matching selector.
+			name:   "flat object with null sibling no match returns empty",
+			input:  `{"a":1,"b":null}`,
+			fields: "c",
+			want:   `{}`,
+		},
+		{
+			// Multiple array siblings at the same level each receive the
+			// selector independently. Documents that the projection fans
+			// out across every array, not just the first one found.
+			name:   "envelope with two array siblings filters both",
+			input:  `{"events":[{"id":"e1","other":"x"}],"speakers":[{"id":"s1","other":"y"}]}`,
+			fields: "id",
+			want:   `{"events":[{"id":"e1"}],"speakers":[{"id":"s1"}]}`,
+		},
+		{
+			// Envelope fallback is intentionally one level deep. A nested
+			// object envelope like {"data":{"items":[...]}} surfaces no
+			// array at the outer level, so the fallback does not fire and
+			// the result is the empty-object that flat-no-match would
+			// produce. Pins the boundary so a future deeper-walk change
+			// is an explicit decision, not an accident.
+			name:   "nested object envelope returns empty (one-level only)",
+			input:  `{"data":{"items":[{"id":"a","other":"y"}]}}`,
+			fields: "id",
+			want:   `{}`,
+		},
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			got := filterFields(json.RawMessage(tc.input), tc.fields)
+			// Normalize both sides through json.Unmarshal+Marshal so
+			// map-iteration order does not produce false negatives.
+			var gotV, wantV interface{}
+			if err := json.Unmarshal(got, &gotV); err != nil {
+				t.Fatalf("got is invalid json: %v (raw=%s)", err, string(got))
+			}
+			if err := json.Unmarshal([]byte(tc.want), &wantV); err != nil {
+				t.Fatalf("want is invalid json: %v (raw=%s)", err, tc.want)
+			}
+			gotBytes, _ := json.Marshal(gotV)
+			wantBytes, _ := json.Marshal(wantV)
+			if string(gotBytes) != string(wantBytes) {
+				t.Errorf("filterFields(%q, %q) = %s, want %s",
+					tc.input, tc.fields, string(gotBytes), string(wantBytes))
+			}
+		})
+	}
+}

← 295fd03f fix(cli): drop shell line comments + skip happy_path on miss  ·  back to Cli Printing Press  ·  fix(generator): support single-env basic auth credentials (# ac83f0fd →