← back to Cli Printing Press
fix(generator): three template defaults polish has to fix manually (#480) (#485)
aab364be44d72284b99785cd4a541fddf34dc7e2 · 2026-05-01 21:35:25 -0700 · Trevin Chow
Three distinct generator-side defaults that polish was reaching in to
fix on every CLI. Caught during the producthunt v3.2.1 redo where the
published manifest had wrong brand casing, stale catalog description,
and silent file-token-wins-over-env-var auth precedence.
1. populateMCPMetadata DisplayName precedence (climanifest.go)
Previously: m.DisplayName = parsed.EffectiveDisplayName() — an
unconditional overwrite. EffectiveDisplayName falls back to
naming.HumanName(slug) when the spec has no explicit DisplayName,
so a producthunt spec with no DisplayName collapsed the catalog's
curated "Product Hunt" to "Producthunt" via the title-case slug
transform.
New precedence:
spec.DisplayName (explicit) > existing m.DisplayName (catalog) > EffectiveDisplayName fallback
Preserves the prior intent for mcp-sync (spec author updates
DisplayName → manifest reflects it on next sync) without clobbering
the catalog when the spec is silent.
2. populateMCPMetadata Description from spec.cli_description
(climanifest.go)
Previously: m.Description was sourced only from catalog.Description.
The spec's cli_description field — explicitly designed to carry the
CLI-shaped value-prop (vs the API-shaped catalog description) — was
never read into the manifest. End result: MCP host marketplaces
(Claude Desktop, Cursor) showed every CLI's stale catalog blurb
instead of the actual CLI value-prop.
New precedence:
spec.cli_description (explicit) > existing m.Description (catalog)
3. AuthHeader env-var precedence (config.go.tmpl)
Previously: bearer_token / cookie / composed branches checked
c.AccessToken (file-stored) BEFORE the env-var-backed field. A
freshly-exported PRODUCT_HUNT_TOKEN was silently shadowed by the
stale access_token from a prior session's config.toml — confusing
debugging trap.
New order: env var wins over file token, matching the conventional
CLI ecosystem precedence (kubectl, gh, aws, gcloud all do
env > config > defaults). AuthSource now reflects "env:VAR_NAME"
when the env var path is taken, making the active credential
source visible in `auth status` / `doctor`.
Tests added:
- TestPopulateMCPMetadataDisplayNamePrecedence (3 sub-cases)
- TestPopulateMCPMetadataCLIDescription (3 sub-cases)
- TestAuthHeader_EnvVarWinsOverFileToken (3 sub-cases: bearer_token, cookie, composed)
go test ./... + go vet + scripts/golden.sh verify all pass.
Closes #480
Files touched
A internal/generator/auth_env_precedence_test.goM internal/generator/templates/config.go.tmplM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.go
Diff
commit aab364be44d72284b99785cd4a541fddf34dc7e2
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 1 21:35:25 2026 -0700
fix(generator): three template defaults polish has to fix manually (#480) (#485)
Three distinct generator-side defaults that polish was reaching in to
fix on every CLI. Caught during the producthunt v3.2.1 redo where the
published manifest had wrong brand casing, stale catalog description,
and silent file-token-wins-over-env-var auth precedence.
1. populateMCPMetadata DisplayName precedence (climanifest.go)
Previously: m.DisplayName = parsed.EffectiveDisplayName() — an
unconditional overwrite. EffectiveDisplayName falls back to
naming.HumanName(slug) when the spec has no explicit DisplayName,
so a producthunt spec with no DisplayName collapsed the catalog's
curated "Product Hunt" to "Producthunt" via the title-case slug
transform.
New precedence:
spec.DisplayName (explicit) > existing m.DisplayName (catalog) > EffectiveDisplayName fallback
Preserves the prior intent for mcp-sync (spec author updates
DisplayName → manifest reflects it on next sync) without clobbering
the catalog when the spec is silent.
2. populateMCPMetadata Description from spec.cli_description
(climanifest.go)
Previously: m.Description was sourced only from catalog.Description.
The spec's cli_description field — explicitly designed to carry the
CLI-shaped value-prop (vs the API-shaped catalog description) — was
never read into the manifest. End result: MCP host marketplaces
(Claude Desktop, Cursor) showed every CLI's stale catalog blurb
instead of the actual CLI value-prop.
New precedence:
spec.cli_description (explicit) > existing m.Description (catalog)
3. AuthHeader env-var precedence (config.go.tmpl)
Previously: bearer_token / cookie / composed branches checked
c.AccessToken (file-stored) BEFORE the env-var-backed field. A
freshly-exported PRODUCT_HUNT_TOKEN was silently shadowed by the
stale access_token from a prior session's config.toml — confusing
debugging trap.
New order: env var wins over file token, matching the conventional
CLI ecosystem precedence (kubectl, gh, aws, gcloud all do
env > config > defaults). AuthSource now reflects "env:VAR_NAME"
when the env var path is taken, making the active credential
source visible in `auth status` / `doctor`.
Tests added:
- TestPopulateMCPMetadataDisplayNamePrecedence (3 sub-cases)
- TestPopulateMCPMetadataCLIDescription (3 sub-cases)
- TestAuthHeader_EnvVarWinsOverFileToken (3 sub-cases: bearer_token, cookie, composed)
go test ./... + go vet + scripts/golden.sh verify all pass.
Closes #480
---
internal/generator/auth_env_precedence_test.go | 75 ++++++++++++++++++++++++++
internal/generator/templates/config.go.tmpl | 36 +++++++------
internal/pipeline/climanifest.go | 18 +++++--
internal/pipeline/climanifest_test.go | 56 +++++++++++++++++++
4 files changed, 166 insertions(+), 19 deletions(-)
diff --git a/internal/generator/auth_env_precedence_test.go b/internal/generator/auth_env_precedence_test.go
new file mode 100644
index 00000000..4c8a6af3
--- /dev/null
+++ b/internal/generator/auth_env_precedence_test.go
@@ -0,0 +1,75 @@
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestAuthHeader_EnvVarWinsOverFileToken pins that the generated
+// Config.AuthHeader() checks the env-var-backed field BEFORE the
+// file-stored AccessToken for bearer_token, cookie, and composed auth
+// types — env > config convention (kubectl, gh, aws, gcloud).
+func TestAuthHeader_EnvVarWinsOverFileToken(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ authType string
+ envVar string
+ }{
+ {"bearer_token", "bearer_token", "BEARER_AUTH_TEST_TOKEN"},
+ {"cookie", "cookie", "COOKIE_AUTH_TEST_TOKEN"},
+ {"composed", "composed", "COMPOSED_AUTH_TEST_TOKEN"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec(tc.name + "-precedence")
+ apiSpec.Auth = spec.AuthConfig{
+ Type: tc.authType,
+ Header: "Authorization",
+ EnvVars: []string{tc.envVar},
+ }
+
+ outputDir := filepath.Join(t.TempDir(), tc.name+"-precedence-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ content := string(cfgSrc)
+
+ envCheck := "if c." + resolveEnvVarField(tc.envVar) + ` != ""`
+ tokenCheck := `if c.AccessToken != ""`
+
+ require.Contains(t, content, envCheck,
+ "AuthHeader must check the env-var field for type %q", tc.authType)
+ require.Contains(t, content, tokenCheck,
+ "AuthHeader must still check AccessToken for type %q", tc.authType)
+
+ authHeaderStart := strings.Index(content, "func (c *Config) AuthHeader() string {")
+ require.NotEqual(t, -1, authHeaderStart, "AuthHeader function must be emitted")
+ body := content[authHeaderStart:]
+ // Bound the search to AuthHeader's body. Skip the first byte so
+ // the "next func" lookup finds the func AFTER AuthHeader, not
+ // AuthHeader's own opener.
+ if next := strings.Index(body[1:], "\nfunc "); next != -1 {
+ body = body[:next+1]
+ }
+
+ envIdx := strings.Index(body, envCheck)
+ tokenIdx := strings.Index(body, tokenCheck)
+ require.NotEqual(t, -1, envIdx)
+ require.NotEqual(t, -1, tokenIdx)
+ assert.Less(t, envIdx, tokenIdx,
+ "env-var check must appear BEFORE AccessToken check in AuthHeader for type %q", tc.authType)
+ })
+ }
+}
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 776914aa..5101caa5 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -141,16 +141,10 @@ func (c *Config) AuthHeader() string {
return ""
{{- end}}
{{- else if eq .Auth.Type "bearer_token"}}
- if c.AccessToken != "" {
- c.AuthSource = "oauth2"
- {{- if .Auth.Format}}
- return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
- {{- else}}
- return "Bearer " + c.AccessToken
- {{- end}}
- }
+ // Env-var token wins over file-stored AccessToken (env > config convention).
{{- if gt (len .Auth.EnvVars) 0}}
if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
+ c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
{{- if .Auth.Format}}
return applyAuthFormat("{{.Auth.Format}}", map[string]string{
"{{envVarPlaceholder (index .Auth.EnvVars 0)}}": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
@@ -162,28 +156,40 @@ func (c *Config) AuthHeader() string {
{{- end}}
}
{{- end}}
- return ""
-{{- else if eq .Auth.Type "cookie"}}
if c.AccessToken != "" {
- c.AuthSource = "browser"
- return c.AccessToken
+ c.AuthSource = "oauth2"
+ {{- if .Auth.Format}}
+ return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
+ {{- else}}
+ return "Bearer " + c.AccessToken
+ {{- end}}
}
+ return ""
+{{- else if eq .Auth.Type "cookie"}}
+ // Env-var token wins over file-stored AccessToken (env > config convention).
{{- if gt (len .Auth.EnvVars) 0}}
if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
+ c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
return c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
}
{{- end}}
- return ""
-{{- else if eq .Auth.Type "composed"}}
if c.AccessToken != "" {
- c.AuthSource = "chrome-composed"
+ c.AuthSource = "browser"
return c.AccessToken
}
+ return ""
+{{- else if eq .Auth.Type "composed"}}
+ // Env-var token wins over file-stored AccessToken (env > config convention).
{{- if gt (len .Auth.EnvVars) 0}}
if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
+ c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
return c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
}
{{- end}}
+ if c.AccessToken != "" {
+ c.AuthSource = "chrome-composed"
+ return c.AccessToken
+ }
return ""
{{- else}}
return ""
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 349422a2..9036330c 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -251,10 +251,20 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
m.EndpointTemplateVars = parsed.EndpointTemplateVars
m.AuthKeyURL = parsed.Auth.KeyURL
m.AuthOptional = parsed.Auth.Optional
- // Always refresh from parsed; an empty-check here would let a
- // stale .printing-press.json overwrite the spec on the next
- // mcp-sync cycle.
- m.DisplayName = parsed.EffectiveDisplayName()
+ // DisplayName precedence: explicit spec field > catalog-set existing
+ // value > slug-derived fallback. Unconditional fallback would clobber a
+ // curated catalog value when the spec is silent.
+ if parsed.DisplayName != "" {
+ m.DisplayName = parsed.DisplayName
+ } else if m.DisplayName == "" {
+ m.DisplayName = parsed.EffectiveDisplayName()
+ }
+ // CLIDescription overrides existing m.Description so the spec's
+ // CLI-shaped copy ships in manifest.json instead of the API-shaped
+ // catalog default.
+ if parsed.CLIDescription != "" {
+ m.Description = parsed.CLIDescription
+ }
}
// GenerateManifestParams holds the information available at generate time
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index 62e0ae48..bf8ff778 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -1042,3 +1042,59 @@ func TestPopulateMCPMetadata(t *testing.T) {
assert.Equal(t, "cookie", m.AuthType)
assert.Equal(t, []string{"TEST_AUTH"}, m.AuthEnvVars)
}
+
+// TestPopulateMCPMetadataDisplayNamePrecedence pins:
+//
+// spec.DisplayName (explicit) > existing m.DisplayName (catalog) > EffectiveDisplayName fallback
+func TestPopulateMCPMetadataDisplayNamePrecedence(t *testing.T) {
+ t.Run("spec explicit wins over existing catalog value", func(t *testing.T) {
+ m := CLIManifest{DisplayName: "Catalog Name"}
+ populateMCPMetadata(&m, &spec.APISpec{
+ Name: "test",
+ DisplayName: "Spec Name",
+ Auth: spec.AuthConfig{Type: "none"},
+ })
+ assert.Equal(t, "Spec Name", m.DisplayName)
+ })
+
+ t.Run("existing catalog value preserved when spec is silent", func(t *testing.T) {
+ m := CLIManifest{DisplayName: "Catalog Name"}
+ populateMCPMetadata(&m, &spec.APISpec{
+ Name: "twoword",
+ Auth: spec.AuthConfig{Type: "none"},
+ })
+ assert.Equal(t, "Catalog Name", m.DisplayName)
+ })
+
+ t.Run("title-case fallback fires only when both spec and existing are empty", func(t *testing.T) {
+ var m CLIManifest
+ populateMCPMetadata(&m, &spec.APISpec{
+ Name: "test-api",
+ Auth: spec.AuthConfig{Type: "none"},
+ })
+ assert.Equal(t, "Test Api", m.DisplayName)
+ })
+}
+
+// TestPopulateMCPMetadataCLIDescription pins that spec.cli_description
+// overrides existing m.Description (catalog default).
+func TestPopulateMCPMetadataCLIDescription(t *testing.T) {
+ t.Run("cli_description overrides catalog description", func(t *testing.T) {
+ m := CLIManifest{Description: "API-shaped catalog description."}
+ populateMCPMetadata(&m, &spec.APISpec{
+ Name: "test",
+ CLIDescription: "CLI-shaped description.",
+ Auth: spec.AuthConfig{Type: "none"},
+ })
+ assert.Equal(t, "CLI-shaped description.", m.Description)
+ })
+
+ t.Run("empty cli_description leaves catalog description in place", func(t *testing.T) {
+ m := CLIManifest{Description: "Catalog description."}
+ populateMCPMetadata(&m, &spec.APISpec{
+ Name: "test",
+ Auth: spec.AuthConfig{Type: "none"},
+ })
+ assert.Equal(t, "Catalog description.", m.Description)
+ })
+}
← c76d3ec9 fix(cli): scorecard APIName drops binary suffix (#489)
·
back to Cli Printing Press
·
fix(scorer): live-check treats CLI's graceful empty-handling bd102cf0 →