← back to Cli Printing Press
feat(cli): GraphQL credential probe in doctor + actionable copy (#1701)
b534884fcd9707d1cdcd72698df6f0312efdbfdb · 2026-05-19 19:01:32 -0700 · Trevin Chow
* feat(cli): verify GraphQL credentials in doctor + actionable unverified copy
The doctor Credentials check emitted `WARN Credentials: present (not
verified — set auth.verify_path in spec ...)`. Two problems: the operator
running the CLI doesn't own the spec, and there was no next step. GraphQL
APIs (Linear, GitHub, Shopify) also couldn't declare a probe at all since
there's no REST verify endpoint to point at.
- Add `auth.verify_query` spec field. When set, doctor POSTs
`{"query": "<value>"}` to base_url and treats HTTP 2xx + no top-level
`errors` as verified, 401/403 as rejected. Routed through
PostQueryWithParamsAndHeaders so verify-mode doesn't suppress the read.
VerifyPath wins when both are set (REST probe is cheaper).
- Replace the unverified-state copy with a runnable suggestion resolved at
doctor-time: walk the cobra tree for the first endpoint-mirror leaf with a
list/get verb and no positional args. Gated on the pp:endpoint annotation
so it never suggests local-read framework commands, and recurses through
Hidden resource parents (whose endpoint leaves stay runnable).
- Downgrade the unverified state WARN -> INFO; "we didn't check" is not a
warning and shouldn't render yellow in CI dashboards.
Additive: behavior for CLIs that already set auth.verify_path is unchanged.
* fix(cli): split 403 from 401 in doctor GraphQL credential probe
The GraphQL probe collapsed HTTP 401 and 403 into "invalid — check your
credentials". 403 means a valid-but-scope-limited token, so that copy told
operators to replace a working token when they only needed to add scopes.
Mirror the REST probe's split: 401 -> invalid, 403 -> scope-limited.
Files touched
M internal/generator/auth_status_test.goA internal/generator/doctor_credentials_probe_test.goM internal/generator/generator_test.goM internal/generator/templates/doctor.go.tmplM internal/spec/spec.goM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
Diff
commit b534884fcd9707d1cdcd72698df6f0312efdbfdb
Author: Trevin Chow <trevin@trevinchow.com>
Date: Tue May 19 19:01:32 2026 -0700
feat(cli): GraphQL credential probe in doctor + actionable copy (#1701)
* feat(cli): verify GraphQL credentials in doctor + actionable unverified copy
The doctor Credentials check emitted `WARN Credentials: present (not
verified — set auth.verify_path in spec ...)`. Two problems: the operator
running the CLI doesn't own the spec, and there was no next step. GraphQL
APIs (Linear, GitHub, Shopify) also couldn't declare a probe at all since
there's no REST verify endpoint to point at.
- Add `auth.verify_query` spec field. When set, doctor POSTs
`{"query": "<value>"}` to base_url and treats HTTP 2xx + no top-level
`errors` as verified, 401/403 as rejected. Routed through
PostQueryWithParamsAndHeaders so verify-mode doesn't suppress the read.
VerifyPath wins when both are set (REST probe is cheaper).
- Replace the unverified-state copy with a runnable suggestion resolved at
doctor-time: walk the cobra tree for the first endpoint-mirror leaf with a
list/get verb and no positional args. Gated on the pp:endpoint annotation
so it never suggests local-read framework commands, and recurses through
Hidden resource parents (whose endpoint leaves stay runnable).
- Downgrade the unverified state WARN -> INFO; "we didn't check" is not a
warning and shouldn't render yellow in CI dashboards.
Additive: behavior for CLIs that already set auth.verify_path is unchanged.
* fix(cli): split 403 from 401 in doctor GraphQL credential probe
The GraphQL probe collapsed HTTP 401 and 403 into "invalid — check your
credentials". 403 means a valid-but-scope-limited token, so that copy told
operators to replace a working token when they only needed to add scopes.
Mirror the REST probe's split: 401 -> invalid, 403 -> scope-limited.
---
internal/generator/auth_status_test.go | 15 +-
.../generator/doctor_credentials_probe_test.go | 220 +++++++++++++++++++++
internal/generator/generator_test.go | 8 +-
internal/generator/templates/doctor.go.tmpl | 139 ++++++++++++-
internal/spec/spec.go | 11 ++
.../internal/cli/doctor.go | 85 +++++++-
.../printing-press-golden/internal/cli/doctor.go | 85 +++++++-
.../tier-routing-golden/internal/cli/doctor.go | 85 +++++++-
8 files changed, 633 insertions(+), 15 deletions(-)
diff --git a/internal/generator/auth_status_test.go b/internal/generator/auth_status_test.go
index 33e8693b..f17bed88 100644
--- a/internal/generator/auth_status_test.go
+++ b/internal/generator/auth_status_test.go
@@ -20,10 +20,17 @@ func TestDoctorWithoutVerifyPathDoesNotClaimCredentialsValid(t *testing.T) {
require.NoError(t, err)
src := string(doctorSrc)
- require.Contains(t, src, `report["credentials"] = "present (not verified — set auth.verify_path in spec for an API acceptance check)"`,
- "doctor must not report API credential validity from a bare base URL probe")
- require.NotContains(t, src, `report["credentials"] = "valid"`,
- "without auth.verify_path, a 2xx base URL response does not prove the API accepted the credentials")
+ require.Contains(t, src, `"present, not verified. Run `,
+ "doctor must not report API credential validity from a bare base URL probe; the unverified copy must use the new INFO phrasing")
+ require.Contains(t, src, "suggestion := suggestReadCommand(cmd.Root())",
+ "doctor must walk the cobra tree for a runnable read command instead of nagging the user to edit a spec they don't own")
+ require.NotContains(t, src, `set auth.verify_path in spec`,
+ "the old WARN copy that pointed users at a spec field they can't edit must be gone")
+ // Both probe branches (VerifyPath and VerifyQuery) emit `report["credentials"] = "valid"`
+ // when the probe succeeds; anchor on the verify-path-shaped invocation so this
+ // assertion stays specific to "no REST probe ran" rather than "the literal `valid` never appears."
+ require.NotContains(t, src, `c.GetWithHeaders(verifyPath`,
+ "without auth.verify_path, doctor must not emit a REST verification call")
require.NotContains(t, src, "but auth was accepted",
"without auth.verify_path, non-auth HTTP statuses do not prove the API accepted the credentials")
}
diff --git a/internal/generator/doctor_credentials_probe_test.go b/internal/generator/doctor_credentials_probe_test.go
new file mode 100644
index 00000000..30f78dd5
--- /dev/null
+++ b/internal/generator/doctor_credentials_probe_test.go
@@ -0,0 +1,220 @@
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestGeneratedDoctor_EmitsGraphQLProbeWhenVerifyQuerySet wires the new
+// Auth.VerifyQuery spec field through Generate(): a GraphQL CLI that opts
+// in by setting verify_query must emit a doctor.go that POSTs the declared
+// query and parses the response envelope for top-level errors.
+func TestGeneratedDoctor_EmitsGraphQLProbeWhenVerifyQuerySet(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("doctor-graphql-probe")
+ apiSpec.Auth.VerifyQuery = "{ viewer { id } }"
+
+ outputDir := filepath.Join(t.TempDir(), "doctor-graphql-probe-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+ require.NoError(t, err)
+ content := string(doctorGo)
+
+ assert.Contains(t, content, `verifyBody := map[string]string{"query": "{ viewer { id } }"}`,
+ "doctor should serialize the spec-declared verify_query into the request body")
+ assert.Contains(t, content, `c.PostQueryWithParamsAndHeaders("",`,
+ "doctor should route the GraphQL probe through PostQueryWithParamsAndHeaders so verify-mode does not short-circuit reads")
+ assert.Contains(t, content, `Errors []json.RawMessage `+"`json:\"errors\"`",
+ "doctor should parse the response for a top-level errors array")
+ assert.Contains(t, content, `"rejected — GraphQL response contained top-level errors"`,
+ "doctor must distinguish a 2xx-with-errors GraphQL response from a true valid token")
+ assert.NotContains(t, content, `"present, not verified.`,
+ "the unverified-fallback branch must not be rendered when verify_query is set")
+
+ // The GraphQL probe must split 401 from 403 the same way the REST probe
+ // does: 403 means a valid-but-scope-limited token, so telling the operator
+ // to "check your credentials" (replace the token) would be wrong guidance.
+ assert.Contains(t, content, `case gqlAPIErr.StatusCode == 401:`,
+ "GraphQL probe must keep a dedicated 401 branch so an invalid token reads as invalid")
+ assert.Contains(t, content, `case gqlAPIErr.StatusCode == 403:`,
+ "GraphQL probe must split 403 so a scope-limited token is not misreported as invalid")
+ assert.Contains(t, content, `scope-limited (HTTP %d) — credentials are valid but lack permission for this endpoint.`,
+ "GraphQL probe's 403 message must point at scope, not the credential value")
+ assert.NotContains(t, content, `gqlAPIErr.StatusCode == 401 || gqlAPIErr.StatusCode == 403`,
+ "GraphQL probe must not collapse 401 and 403 into a single invalid branch")
+}
+
+// TestGeneratedDoctor_PreferVerifyPathOverVerifyQuery pins the precedence
+// order: when a spec author declares both, the REST probe wins because it's
+// cheaper than a GraphQL POST. The verify_query branch must not emit.
+func TestGeneratedDoctor_PreferVerifyPathOverVerifyQuery(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("doctor-both-probes")
+ apiSpec.Auth.VerifyPath = "/v1/account"
+ apiSpec.Auth.VerifyQuery = "{ viewer { id } }"
+
+ outputDir := filepath.Join(t.TempDir(), "doctor-both-probes-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+ require.NoError(t, err)
+ content := string(doctorGo)
+
+ assert.Contains(t, content, `verifyPath := "/v1/account"`,
+ "VerifyPath should win when both probes are declared")
+ assert.NotContains(t, content, `verifyBody := map[string]string{"query":`,
+ "the GraphQL probe must not emit when a REST verify_path is declared")
+}
+
+// TestGeneratedDoctor_UnverifiedMessageSuggestsReadCommand checks the
+// no-probe path: when neither VerifyPath nor VerifyQuery is set, the doctor
+// must suggest a runtime-discovered read command instead of nagging the
+// user to edit a spec they don't own.
+func TestGeneratedDoctor_UnverifiedMessageSuggestsReadCommand(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("doctor-unverified-message")
+
+ outputDir := filepath.Join(t.TempDir(), "doctor-unverified-message-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+ require.NoError(t, err)
+ content := string(doctorGo)
+
+ assert.Contains(t, content, `func suggestReadCommand(root *cobra.Command) string`,
+ "doctor template must emit the runtime helper that picks a read command")
+ assert.Contains(t, content, `suggestion := suggestReadCommand(cmd.Root())`,
+ "the unverified branch must call the helper at doctor-time")
+ assert.Contains(t, content, `"present, not verified. Run `,
+ "the unverified message must use the new INFO copy")
+ assert.NotContains(t, content, `"present (not verified — set auth.verify_path in spec for an API acceptance check)"`,
+ "the old WARN copy that scolded the user for spec settings must be gone")
+
+ // minimalSpec ships an items.list endpoint; the helper plus the renderer
+ // switch make "not verified" render as yellow INFO, not WARN.
+ assert.Contains(t, content, `case strings.Contains(s, "not verified"):`,
+ "the renderer must downgrade the unverified-state to INFO; without the case it falls through to the WARN catch-all")
+ assert.Contains(t, content, `indicator = yellow("INFO")`,
+ "the unverified case must paint yellow INFO, not red FAIL or yellow WARN")
+}
+
+// TestGeneratedDoctor_SuggestReadCommandHelperGatesOnEndpointAndArgs guards
+// the shape of the runtime helper. It MUST:
+//
+// - Only suggest commands carrying the pp:endpoint annotation, so the
+// suggestion actually exercises the token rather than reading a local
+// file (`feedback list`, `profile list`). Without this gate the doctor
+// could recreate the false-confidence failure mode that the new copy
+// was designed to prevent.
+// - Reject Use strings containing `<` or `[` (the positional-arg markers
+// emitted for `get <id>`-style endpoint commands), because their runtime
+// body prints help when args are empty, so the suggestion would not
+// actually call the API.
+// - Restrict to list/get verbs.
+// - Probe the Args validator with [] as a final defense.
+func TestGeneratedDoctor_SuggestReadCommandHelperGatesOnEndpointAndArgs(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("doctor-suggest-helper")
+
+ outputDir := filepath.Join(t.TempDir(), "doctor-suggest-helper-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+ require.NoError(t, err)
+ content := string(doctorGo)
+
+ assert.Contains(t, content, `if cmd.Annotations["pp:endpoint"] == ""`,
+ "helper must reject framework commands without a pp:endpoint annotation")
+ assert.Contains(t, content, `strings.ContainsAny(cmd.Use, "<[")`,
+ "helper must reject endpoint commands whose Use advertises positional path params")
+ assert.Contains(t, content, `verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])`,
+ "helper must extract the leading verb from Use, not the full string")
+ assert.Contains(t, content, `if verb != "list" && verb != "get"`,
+ "helper must restrict to list/get verbs only")
+ assert.Contains(t, content, `cmd.Args(cmd, []string{}) == nil`,
+ "helper must probe the Args validator with an empty arg list as a final defense")
+
+ // Hidden-parent traversal: printed CLIs mark raw resource parents Hidden
+ // to keep --help curated, but their endpoint leaves stay runnable. The
+ // walk MUST recurse into hidden parents (otherwise the suggestion is ""
+ // in nearly every CLI), while isSuggestableReadLeaf MUST still reject a
+ // leaf that is itself hidden.
+ assert.Contains(t, content, `if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable()`,
+ "isSuggestableReadLeaf must reject a leaf that is itself hidden")
+ assert.NotContains(t, content, `for _, child := range cmd.Commands() {
+ if child.Hidden {
+ continue
+ }`,
+ "the walk must recurse through hidden resource parents, not skip them — skipping makes the suggestion empty in nearly every CLI")
+}
+
+// TestGeneratedDoctor_SuggestHelperOnlyEmittedWhenNeeded pins the template
+// gate that wraps the helper functions: when a spec declares any probe
+// (VerifyPath or VerifyQuery), the helpers are dead code in the printed
+// CLI and should not emit at all. The opposite case (no probe declared)
+// must still emit them, because the unverified-state branch calls into
+// suggestReadCommand at doctor-time.
+func TestGeneratedDoctor_SuggestHelperOnlyEmittedWhenNeeded(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ configure func(*minimalDoctorSpec)
+ wantEmit bool
+ }{
+ {
+ name: "no probe — helpers required by the unverified branch",
+ configure: func(s *minimalDoctorSpec) {},
+ wantEmit: true,
+ },
+ {
+ name: "verify_path set — helpers are dead code",
+ configure: func(s *minimalDoctorSpec) { s.VerifyPath = "/v1/account" },
+ wantEmit: false,
+ },
+ {
+ name: "verify_query set — helpers are dead code",
+ configure: func(s *minimalDoctorSpec) { s.VerifyQuery = "{ viewer { id } }" },
+ wantEmit: false,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ apiSpec := minimalSpec("doctor-helper-gate")
+ cfg := &minimalDoctorSpec{}
+ tc.configure(cfg)
+ apiSpec.Auth.VerifyPath = cfg.VerifyPath
+ apiSpec.Auth.VerifyQuery = cfg.VerifyQuery
+
+ outputDir := filepath.Join(t.TempDir(), "doctor-helper-gate-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+ require.NoError(t, err)
+ content := string(doctorGo)
+
+ if tc.wantEmit {
+ assert.Contains(t, content, `func suggestReadCommand(root *cobra.Command) string`,
+ "helper must emit when no probe is declared (unverified branch calls it)")
+ } else {
+ assert.NotContains(t, content, `func suggestReadCommand(root *cobra.Command) string`,
+ "helper must not emit when a probe is declared — dead code in the printed CLI")
+ }
+ })
+ }
+}
+
+type minimalDoctorSpec struct {
+ VerifyPath string
+ VerifyQuery string
+}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index a3d85a88..70550f1d 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -6986,11 +6986,15 @@ func TestGeneratedDoctor_NoVerifyPathReportsCredentialsPresent(t *testing.T) {
content := string(doctorGo)
// Without spec verify_path, doctor reports local credential presence
- // without pretending the API accepted the key.
+ // without pretending the API accepted the key. The "not verified" copy
+ // now suggests a read command (resolved at doctor-time from the cobra
+ // tree) instead of pointing the user at a spec field they don't own.
assert.NotContains(t, content, `verifyPath := "/"`)
assert.NotContains(t, content, `c.GetWithHeaders(verifyPath`)
assert.NotContains(t, content, `&http.Client{`)
- assert.Contains(t, content, `"present (not verified — set auth.verify_path in spec for an API acceptance check)"`)
+ assert.Contains(t, content, `"present, not verified. Run `)
+ assert.NotContains(t, content, `set auth.verify_path in spec`,
+ "the old WARN copy that scolded the user about a spec field must be gone")
assert.NotContains(t, content, `"inconclusive (HTTP %d from base URL — set auth.verify_path in spec for a definitive probe)"`)
assert.NotContains(t, content, `"invalid (HTTP %d) — check your credentials"`)
}
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 2f11b088..9d63de1c 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -11,6 +11,8 @@ import (
{{- if .Auth.RequiresBrowserSession}}
"crypto/sha256"
"encoding/hex"
+{{- end}}
+{{- if or .Auth.RequiresBrowserSession .Auth.VerifyQuery}}
"encoding/json"
{{- end}}
{{- if .HasStore}}
@@ -98,6 +100,82 @@ func looksLikeDoctorInterstitial(body []byte) string {
return ""
}
+{{- if and (not .Auth.VerifyPath) (not .Auth.VerifyQuery)}}
+// suggestReadCommand walks the Cobra tree to find an endpoint-mirror command
+// an operator can run to confirm credentials work end-to-end. Picks the
+// first leaf that (a) carries the `pp:endpoint` annotation, so it actually
+// dials the API rather than reading a local file like `feedback list` or
+// `profile list`; (b) has a list/get verb; and (c) takes no positional
+// arguments, so the suggestion is copy-paste runnable. Returns the dotted
+// command path (e.g. "issues list") or "" when no such command exists —
+// common in mutation-only CLIs and in CLIs where every read command has
+// required positional arguments.
+func suggestReadCommand(root *cobra.Command) string {
+ if root == nil {
+ return ""
+ }
+ var found string
+ var walk func(*cobra.Command, []string)
+ walk = func(cmd *cobra.Command, path []string) {
+ if found != "" {
+ return
+ }
+ for _, child := range cmd.Commands() {
+ childPath := append(append([]string{}, path...), child.Name())
+ if isSuggestableReadLeaf(child) {
+ found = strings.Join(childPath, " ")
+ return
+ }
+ // Recurse even into Hidden parents: printed CLIs mark raw
+ // resource parents Hidden to keep --help curated, but their
+ // endpoint leaves remain runnable (`<cli> projects list`
+ // works). Skipping hidden subtrees would make this return ""
+ // in nearly every CLI. isSuggestableReadLeaf still rejects a
+ // leaf that is itself Hidden.
+ walk(child, childPath)
+ if found != "" {
+ return
+ }
+ }
+ }
+ walk(root, nil)
+ return found
+}
+
+func isSuggestableReadLeaf(cmd *cobra.Command) bool {
+ if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable() {
+ return false
+ }
+ // Only endpoint-mirror commands count; framework commands like
+ // `feedback list` and `profile list` read local files and would
+ // recreate the false-confidence failure mode the suggestion is
+ // supposed to avoid.
+ if cmd.Annotations["pp:endpoint"] == "" {
+ return false
+ }
+ verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])
+ if verb != "list" && verb != "get" {
+ return false
+ }
+ // Endpoint commands with positional path params advertise them in
+ // Use as `<id>` (required) or `[id]` (optional). The runtime body
+ // rejects empty args by printing help, so suggesting one would not
+ // actually exercise the token — reject before the Args probe below.
+ if strings.ContainsAny(cmd.Use, "<[") {
+ return false
+ }
+ // Probe the Args validator with an empty positional-arg list. A nil
+ // validator accepts anything (including zero args); a non-nil validator
+ // that returns nil for [] accepts zero args. Either qualifies — the
+ // suggestion `<cli> list` is then a complete command.
+ if cmd.Args == nil {
+ return true
+ }
+ return cmd.Args(cmd, []string{}) == nil
+}
+
+{{- end}}
+
func newDoctorCmd(flags *rootFlags) *cobra.Command {
var failOn string
{{- if .BearerRefresh.Enabled}}
@@ -415,11 +493,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
} else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {
report["credentials"] = "skipped (API unreachable)"
} else {
-{{- if .Auth.VerifyPath}}
- verifyPath := "{{.Auth.VerifyPath}}"
- if !strings.HasPrefix(verifyPath, "/") {
- verifyPath = "/" + verifyPath
- }
+{{- if or .Auth.VerifyPath .Auth.VerifyQuery}}
+ // Shared auth-header setup for both probe variants below.
+ // Kept hoisted out of the per-probe branches because the
+ // per-API auth-placement, RequiredHeaders, and User-Agent
+ // fallback logic is independent of which verb the probe
+ // dials.
authParams := map[string]string{}
authHeaders := map[string]string{}
{{- if and .Auth .Auth.In (eq .Auth.In "query")}}
@@ -446,6 +525,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
{{- if and (not .UsesBrowserManagedUserAgent) (not (.HasRequiredHeader "User-Agent")) (not $authUsesUA)}}
authHeaders["User-Agent"] = "{{.Name}}-pp-cli"
{{- end}}
+{{- end}}
+{{- if .Auth.VerifyPath}}
+ verifyPath := "{{.Auth.VerifyPath}}"
+ if !strings.HasPrefix(verifyPath, "/") {
+ verifyPath = "/" + verifyPath
+ }
_, authErr := c.GetWithHeaders(verifyPath, authParams, authHeaders)
var authAPIErr *client.APIError
switch {
@@ -464,8 +549,45 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
default:
report["credentials"] = fmt.Sprintf("error: %s", authErr)
}
+{{- else if .Auth.VerifyQuery}}
+ // GraphQL credential probe. POST a spec-declared query to
+ // base_url and treat HTTP 2xx with no top-level `errors`
+ // array as verified. Goes through doRead/PostQueryWith…
+ // so the verify-mode short-circuit doesn't suppress the
+ // probe under PRINTING_PRESS_VERIFY=1.
+ authHeaders["Content-Type"] = "application/json"
+ verifyBody := map[string]string{"query": {{printf "%q" .Auth.VerifyQuery}}}
+ respBody, _, gqlErr := c.PostQueryWithParamsAndHeaders("", authParams, verifyBody, authHeaders)
+ var gqlAPIErr *client.APIError
+ switch {
+ case gqlErr == nil:
+ var envelope struct {
+ Errors []json.RawMessage `json:"errors"`
+ }
+ if jerr := json.Unmarshal(respBody, &envelope); jerr == nil && len(envelope.Errors) > 0 {
+ report["credentials"] = "rejected — GraphQL response contained top-level errors"
+ } else {
+ report["credentials"] = "valid"
+ }
+ case errors.As(gqlErr, &gqlAPIErr):
+ switch {
+ case gqlAPIErr.StatusCode == 401:
+ report["credentials"] = fmt.Sprintf("invalid (HTTP %d) — check your credentials", gqlAPIErr.StatusCode)
+ case gqlAPIErr.StatusCode == 403:
+ report["credentials"] = fmt.Sprintf("scope-limited (HTTP %d) — credentials are valid but lack permission for this endpoint. Check your dashboard's API key scope.", gqlAPIErr.StatusCode)
+ default:
+ report["credentials"] = fmt.Sprintf("ok (HTTP %d from GraphQL probe, but auth was accepted)", gqlAPIErr.StatusCode)
+ }
+ default:
+ report["credentials"] = fmt.Sprintf("error: %s", gqlErr)
+ }
{{- else}}
- report["credentials"] = "present (not verified — set auth.verify_path in spec for an API acceptance check)"
+ suggestion := suggestReadCommand(cmd.Root())
+ if suggestion != "" {
+ report["credentials"] = fmt.Sprintf("present, not verified. Run `%s %s` to confirm the token works end-to-end.", "{{.Name}}-pp-cli", suggestion)
+ } else {
+ report["credentials"] = "present, not verified. Run any read command to confirm the token works end-to-end."
+ }
{{- end}}
{{- end}}
}
@@ -536,6 +658,11 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
indicator = yellow("INFO")
case strings.Contains(s, "scope-limited"):
indicator = yellow("WARN")
+ case strings.Contains(s, "not verified"):
+ // "present, not verified" — credentials are loaded but no
+ // probe ran. Informational, not a warning; a clean config
+ // shouldn't render yellow WARN in CI dashboards.
+ indicator = yellow("INFO")
case strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing"):
indicator = red("FAIL")
case s == "not required":
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 71d48e9d..b34985cc 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -593,6 +593,17 @@ type AuthConfig struct {
// still demands credentials in a meaningful context).
VerifyPath string `yaml:"verify_path,omitempty" json:"verify_path,omitempty"`
+ // VerifyQuery is an optional GraphQL document the doctor command POSTs as
+ // {"query": "<VerifyQuery>"} against base_url to validate credentials.
+ // GraphQL APIs that don't expose a REST verify endpoint can opt in by
+ // setting this to a small viewer-style query (Linear, GitHub, Shopify
+ // conventionally use `{ viewer { id } }`, but the field is opaque to the
+ // generator — any query that 2xx-and-no-`errors` for a valid token works).
+ // When set, the doctor treats HTTP 2xx with no top-level `errors` array
+ // as verified and 401/403 as rejected. Mutually informative with
+ // VerifyPath: if both are set, VerifyPath wins (REST probe is cheaper).
+ VerifyQuery string `yaml:"verify_query,omitempty" json:"verify_query,omitempty"`
+
// Browser-session verification fields. Used when a website-facing CLI
// depends on browser-derived cookies or clearance state for its required
// happy path. The generator emits validation and proof handling, and the
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
index dd5b2e38..f41f26b4 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
@@ -64,6 +64,79 @@ func looksLikeDoctorInterstitial(body []byte) string {
return ""
}
+// suggestReadCommand walks the Cobra tree to find an endpoint-mirror command
+// an operator can run to confirm credentials work end-to-end. Picks the
+// first leaf that (a) carries the `pp:endpoint` annotation, so it actually
+// dials the API rather than reading a local file like `feedback list` or
+// `profile list`; (b) has a list/get verb; and (c) takes no positional
+// arguments, so the suggestion is copy-paste runnable. Returns the dotted
+// command path (e.g. "issues list") or "" when no such command exists —
+// common in mutation-only CLIs and in CLIs where every read command has
+// required positional arguments.
+func suggestReadCommand(root *cobra.Command) string {
+ if root == nil {
+ return ""
+ }
+ var found string
+ var walk func(*cobra.Command, []string)
+ walk = func(cmd *cobra.Command, path []string) {
+ if found != "" {
+ return
+ }
+ for _, child := range cmd.Commands() {
+ childPath := append(append([]string{}, path...), child.Name())
+ if isSuggestableReadLeaf(child) {
+ found = strings.Join(childPath, " ")
+ return
+ }
+ // Recurse even into Hidden parents: printed CLIs mark raw
+ // resource parents Hidden to keep --help curated, but their
+ // endpoint leaves remain runnable (`<cli> projects list`
+ // works). Skipping hidden subtrees would make this return ""
+ // in nearly every CLI. isSuggestableReadLeaf still rejects a
+ // leaf that is itself Hidden.
+ walk(child, childPath)
+ if found != "" {
+ return
+ }
+ }
+ }
+ walk(root, nil)
+ return found
+}
+
+func isSuggestableReadLeaf(cmd *cobra.Command) bool {
+ if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable() {
+ return false
+ }
+ // Only endpoint-mirror commands count; framework commands like
+ // `feedback list` and `profile list` read local files and would
+ // recreate the false-confidence failure mode the suggestion is
+ // supposed to avoid.
+ if cmd.Annotations["pp:endpoint"] == "" {
+ return false
+ }
+ verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])
+ if verb != "list" && verb != "get" {
+ return false
+ }
+ // Endpoint commands with positional path params advertise them in
+ // Use as `<id>` (required) or `[id]` (optional). The runtime body
+ // rejects empty args by printing help, so suggesting one would not
+ // actually exercise the token — reject before the Args probe below.
+ if strings.ContainsAny(cmd.Use, "<[") {
+ return false
+ }
+ // Probe the Args validator with an empty positional-arg list. A nil
+ // validator accepts anything (including zero args); a non-nil validator
+ // that returns nil for [] accepts zero args. Either qualifies — the
+ // suggestion `<cli> list` is then a complete command.
+ if cmd.Args == nil {
+ return true
+ }
+ return cmd.Args(cmd, []string{}) == nil
+}
+
func newDoctorCmd(flags *rootFlags) *cobra.Command {
var failOn string
cmd := &cobra.Command{
@@ -235,7 +308,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
} else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {
report["credentials"] = "skipped (API unreachable)"
} else {
- report["credentials"] = "present (not verified — set auth.verify_path in spec for an API acceptance check)"
+ suggestion := suggestReadCommand(cmd.Root())
+ if suggestion != "" {
+ report["credentials"] = fmt.Sprintf("present, not verified. Run `%s %s` to confirm the token works end-to-end.", "printing-press-rich-pp-cli", suggestion)
+ } else {
+ report["credentials"] = "present, not verified. Run any read command to confirm the token works end-to-end."
+ }
}
}
} else if cfg != nil && cfg.BaseURL == "" {
@@ -298,6 +376,11 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
indicator = yellow("INFO")
case strings.Contains(s, "scope-limited"):
indicator = yellow("WARN")
+ case strings.Contains(s, "not verified"):
+ // "present, not verified" — credentials are loaded but no
+ // probe ran. Informational, not a warning; a clean config
+ // shouldn't render yellow WARN in CI dashboards.
+ indicator = yellow("INFO")
case strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing"):
indicator = red("FAIL")
case s == "not required":
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
index 7d2cd071..b88a448d 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
@@ -64,6 +64,79 @@ func looksLikeDoctorInterstitial(body []byte) string {
return ""
}
+// suggestReadCommand walks the Cobra tree to find an endpoint-mirror command
+// an operator can run to confirm credentials work end-to-end. Picks the
+// first leaf that (a) carries the `pp:endpoint` annotation, so it actually
+// dials the API rather than reading a local file like `feedback list` or
+// `profile list`; (b) has a list/get verb; and (c) takes no positional
+// arguments, so the suggestion is copy-paste runnable. Returns the dotted
+// command path (e.g. "issues list") or "" when no such command exists —
+// common in mutation-only CLIs and in CLIs where every read command has
+// required positional arguments.
+func suggestReadCommand(root *cobra.Command) string {
+ if root == nil {
+ return ""
+ }
+ var found string
+ var walk func(*cobra.Command, []string)
+ walk = func(cmd *cobra.Command, path []string) {
+ if found != "" {
+ return
+ }
+ for _, child := range cmd.Commands() {
+ childPath := append(append([]string{}, path...), child.Name())
+ if isSuggestableReadLeaf(child) {
+ found = strings.Join(childPath, " ")
+ return
+ }
+ // Recurse even into Hidden parents: printed CLIs mark raw
+ // resource parents Hidden to keep --help curated, but their
+ // endpoint leaves remain runnable (`<cli> projects list`
+ // works). Skipping hidden subtrees would make this return ""
+ // in nearly every CLI. isSuggestableReadLeaf still rejects a
+ // leaf that is itself Hidden.
+ walk(child, childPath)
+ if found != "" {
+ return
+ }
+ }
+ }
+ walk(root, nil)
+ return found
+}
+
+func isSuggestableReadLeaf(cmd *cobra.Command) bool {
+ if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable() {
+ return false
+ }
+ // Only endpoint-mirror commands count; framework commands like
+ // `feedback list` and `profile list` read local files and would
+ // recreate the false-confidence failure mode the suggestion is
+ // supposed to avoid.
+ if cmd.Annotations["pp:endpoint"] == "" {
+ return false
+ }
+ verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])
+ if verb != "list" && verb != "get" {
+ return false
+ }
+ // Endpoint commands with positional path params advertise them in
+ // Use as `<id>` (required) or `[id]` (optional). The runtime body
+ // rejects empty args by printing help, so suggesting one would not
+ // actually exercise the token — reject before the Args probe below.
+ if strings.ContainsAny(cmd.Use, "<[") {
+ return false
+ }
+ // Probe the Args validator with an empty positional-arg list. A nil
+ // validator accepts anything (including zero args); a non-nil validator
+ // that returns nil for [] accepts zero args. Either qualifies — the
+ // suggestion `<cli> list` is then a complete command.
+ if cmd.Args == nil {
+ return true
+ }
+ return cmd.Args(cmd, []string{}) == nil
+}
+
func newDoctorCmd(flags *rootFlags) *cobra.Command {
var failOn string
cmd := &cobra.Command{
@@ -183,7 +256,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
} else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {
report["credentials"] = "skipped (API unreachable)"
} else {
- report["credentials"] = "present (not verified — set auth.verify_path in spec for an API acceptance check)"
+ suggestion := suggestReadCommand(cmd.Root())
+ if suggestion != "" {
+ report["credentials"] = fmt.Sprintf("present, not verified. Run `%s %s` to confirm the token works end-to-end.", "printing-press-golden-pp-cli", suggestion)
+ } else {
+ report["credentials"] = "present, not verified. Run any read command to confirm the token works end-to-end."
+ }
}
}
} else if cfg != nil && cfg.BaseURL == "" {
@@ -246,6 +324,11 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
indicator = yellow("INFO")
case strings.Contains(s, "scope-limited"):
indicator = yellow("WARN")
+ case strings.Contains(s, "not verified"):
+ // "present, not verified" — credentials are loaded but no
+ // probe ran. Informational, not a warning; a clean config
+ // shouldn't render yellow WARN in CI dashboards.
+ indicator = yellow("INFO")
case strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing"):
indicator = red("FAIL")
case s == "not required":
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
index 8a4b0bcf..6f9d47f5 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
@@ -64,6 +64,79 @@ func looksLikeDoctorInterstitial(body []byte) string {
return ""
}
+// suggestReadCommand walks the Cobra tree to find an endpoint-mirror command
+// an operator can run to confirm credentials work end-to-end. Picks the
+// first leaf that (a) carries the `pp:endpoint` annotation, so it actually
+// dials the API rather than reading a local file like `feedback list` or
+// `profile list`; (b) has a list/get verb; and (c) takes no positional
+// arguments, so the suggestion is copy-paste runnable. Returns the dotted
+// command path (e.g. "issues list") or "" when no such command exists —
+// common in mutation-only CLIs and in CLIs where every read command has
+// required positional arguments.
+func suggestReadCommand(root *cobra.Command) string {
+ if root == nil {
+ return ""
+ }
+ var found string
+ var walk func(*cobra.Command, []string)
+ walk = func(cmd *cobra.Command, path []string) {
+ if found != "" {
+ return
+ }
+ for _, child := range cmd.Commands() {
+ childPath := append(append([]string{}, path...), child.Name())
+ if isSuggestableReadLeaf(child) {
+ found = strings.Join(childPath, " ")
+ return
+ }
+ // Recurse even into Hidden parents: printed CLIs mark raw
+ // resource parents Hidden to keep --help curated, but their
+ // endpoint leaves remain runnable (`<cli> projects list`
+ // works). Skipping hidden subtrees would make this return ""
+ // in nearly every CLI. isSuggestableReadLeaf still rejects a
+ // leaf that is itself Hidden.
+ walk(child, childPath)
+ if found != "" {
+ return
+ }
+ }
+ }
+ walk(root, nil)
+ return found
+}
+
+func isSuggestableReadLeaf(cmd *cobra.Command) bool {
+ if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable() {
+ return false
+ }
+ // Only endpoint-mirror commands count; framework commands like
+ // `feedback list` and `profile list` read local files and would
+ // recreate the false-confidence failure mode the suggestion is
+ // supposed to avoid.
+ if cmd.Annotations["pp:endpoint"] == "" {
+ return false
+ }
+ verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])
+ if verb != "list" && verb != "get" {
+ return false
+ }
+ // Endpoint commands with positional path params advertise them in
+ // Use as `<id>` (required) or `[id]` (optional). The runtime body
+ // rejects empty args by printing help, so suggesting one would not
+ // actually exercise the token — reject before the Args probe below.
+ if strings.ContainsAny(cmd.Use, "<[") {
+ return false
+ }
+ // Probe the Args validator with an empty positional-arg list. A nil
+ // validator accepts anything (including zero args); a non-nil validator
+ // that returns nil for [] accepts zero args. Either qualifies — the
+ // suggestion `<cli> list` is then a complete command.
+ if cmd.Args == nil {
+ return true
+ }
+ return cmd.Args(cmd, []string{}) == nil
+}
+
func newDoctorCmd(flags *rootFlags) *cobra.Command {
var failOn string
cmd := &cobra.Command{
@@ -205,7 +278,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
} else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {
report["credentials"] = "skipped (API unreachable)"
} else {
- report["credentials"] = "present (not verified — set auth.verify_path in spec for an API acceptance check)"
+ suggestion := suggestReadCommand(cmd.Root())
+ if suggestion != "" {
+ report["credentials"] = fmt.Sprintf("present, not verified. Run `%s %s` to confirm the token works end-to-end.", "tier-routing-golden-pp-cli", suggestion)
+ } else {
+ report["credentials"] = "present, not verified. Run any read command to confirm the token works end-to-end."
+ }
}
}
} else if cfg != nil && cfg.BaseURL == "" {
@@ -268,6 +346,11 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
indicator = yellow("INFO")
case strings.Contains(s, "scope-limited"):
indicator = yellow("WARN")
+ case strings.Contains(s, "not verified"):
+ // "present, not verified" — credentials are loaded but no
+ // probe ran. Informational, not a warning; a clean config
+ // shouldn't render yellow WARN in CI dashboards.
+ indicator = yellow("INFO")
case strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing"):
indicator = red("FAIL")
case s == "not required":
← 5261ae73 fix(cli): return failure exit from verify json (#1693)
·
back to Cli Printing Press
·
fix(cli): resolve shipcheck CLI binary name from .printing-p 9eff1e20 →