[object Object]

← back to Cli Printing Press

fix(cli): normalize generated env var prefixes (#294)

0aafafa34b7f7ad6d07eb0c6b9afb1b179debd43 · 2026-04-25 16:33:05 -0700 · Trevin Chow

Files touched

Diff

commit 0aafafa34b7f7ad6d07eb0c6b9afb1b179debd43
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Apr 25 16:33:05 2026 -0700

    fix(cli): normalize generated env var prefixes (#294)
---
 internal/generator/generator.go                   |  2 +-
 internal/generator/generator_test.go              | 30 +++++++++++++++--
 internal/generator/templates/auto_refresh.go.tmpl |  7 ++--
 internal/naming/naming.go                         | 39 +++++++++++++++++++++++
 internal/naming/naming_test.go                    | 19 +++++++++++
 internal/openapi/parser.go                        |  9 +++---
 internal/openapi/parser_test.go                   | 25 +++++++++++++++
 7 files changed, 119 insertions(+), 12 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 58b2e32d..84ae2de1 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -252,7 +252,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		},
 		"jsonStringParam":    isJSONStringParam,
 		"jsonEnumSuggestion": jsonEnumSuggestion,
-		"envName":            func(s string) string { return strings.ToUpper(strings.ReplaceAll(s, "-", "_")) },
+		"envName":            naming.EnvPrefix,
 		"safeName":           safeSQLName,
 		"hasDomainUpsert": func(name string) bool {
 			return domainUpsertMethodName(name) != "UpsertBatch"
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index b6a1f1f2..9e01ea40 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -155,9 +155,7 @@ func TestGenerateFreshnessHelperEmitted(t *testing.T) {
 		"func runAutoRefresh(",
 		`"stytch-pp-cli dashboard": {`,
 		`"users",`,
-		// Env opt-out is derived at runtime from the CLI name; probe the
-		// expression that yields e.g. "STYTCH_NO_AUTO_REFRESH".
-		`strings.ReplaceAll(strings.ToUpper("stytch"), "-", "_") + "_NO_AUTO_REFRESH"`,
+		`envOptOut := "STYTCH_NO_AUTO_REFRESH"`,
 	} {
 		assert.Contains(t, src, snippet, "auto_refresh.go missing %q", snippet)
 	}
@@ -196,6 +194,32 @@ func TestGenerateFreshnessHelperEmitted(t *testing.T) {
 	runGoCommand(t, outputDir, "test", "./internal/cliutil/...")
 }
 
+func TestGenerateFreshnessOptOutUsesASCIIPrefix(t *testing.T) {
+	t.Parallel()
+
+	apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "stytch.yaml"))
+	require.NoError(t, err)
+	apiSpec.Name = "pokéapi"
+	apiSpec.Cache = spec.CacheConfig{Enabled: true}
+	apiSpec.Config.Path = "~/.config/pokeapi-pp-cli/config.toml"
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	autoRefresh, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auto_refresh.go"))
+	require.NoError(t, err)
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+
+	for _, content := range []string{string(autoRefresh), string(readme), string(skill)} {
+		assert.Contains(t, content, "POKEAPI_NO_AUTO_REFRESH")
+		assert.NotContains(t, content, "POKÉAPI_NO_AUTO_REFRESH")
+	}
+}
+
 func TestGenerateFreshnessRejectsGeneratedCommandCollision(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/auto_refresh.go.tmpl b/internal/generator/templates/auto_refresh.go.tmpl
index 43a2b44e..fff303a9 100644
--- a/internal/generator/templates/auto_refresh.go.tmpl
+++ b/internal/generator/templates/auto_refresh.go.tmpl
@@ -54,10 +54,9 @@ func cachePolicy() cliutil.Policy {
 {{- if .Cache.EnvOptOut}}
 	envOptOut := "{{.Cache.EnvOptOut}}"
 {{- else}}
-	// Default env opt-out name is the CLI name, upper-cased with dashes
-	// normalised to underscores, plus the _NO_AUTO_REFRESH suffix. For
-	// "cal-com" that yields "CAL_COM_NO_AUTO_REFRESH".
-	envOptOut := strings.ReplaceAll(strings.ToUpper("{{.Name}}"), "-", "_") + "_NO_AUTO_REFRESH"
+	// Default env opt-out name is the CLI name normalized with the same
+	// ASCII-safe convention used in generated docs and config env vars.
+	envOptOut := "{{envName .Name}}_NO_AUTO_REFRESH"
 {{- end}}
 	return cliutil.Policy{
 		StaleAfter:   staleAfter,
diff --git a/internal/naming/naming.go b/internal/naming/naming.go
index 8cb7dbbf..b75ea278 100644
--- a/internal/naming/naming.go
+++ b/internal/naming/naming.go
@@ -3,6 +3,9 @@ package naming
 import (
 	"regexp"
 	"strings"
+	"unicode"
+
+	"golang.org/x/text/unicode/norm"
 )
 
 const (
@@ -27,6 +30,42 @@ func ValidationBinary(name string) string {
 	return CLI(name) + "-validation"
 }
 
+// EnvPrefix returns an ASCII-only shell-safe environment variable prefix.
+// API display names and OpenAPI titles can contain accents or punctuation
+// ("PokéAPI", "Cal.com", "1Password"); generated env vars must not.
+func EnvPrefix(name string) string {
+	var b strings.Builder
+	lastUnderscore := false
+	for _, r := range norm.NFD.String(name) {
+		switch {
+		case r >= 'a' && r <= 'z':
+			b.WriteRune(r - ('a' - 'A'))
+			lastUnderscore = false
+		case r >= 'A' && r <= 'Z':
+			b.WriteRune(r)
+			lastUnderscore = false
+		case r >= '0' && r <= '9':
+			b.WriteRune(r)
+			lastUnderscore = false
+		case unicode.Is(unicode.Mn, r):
+			continue
+		default:
+			if !lastUnderscore && b.Len() > 0 {
+				b.WriteByte('_')
+				lastUnderscore = true
+			}
+		}
+	}
+	out := strings.Trim(b.String(), "_")
+	if out == "" {
+		return "API"
+	}
+	if out[0] >= '0' && out[0] <= '9' {
+		return "API_" + out
+	}
+	return out
+}
+
 func DogfoodBinary(name string) string {
 	return CLI(name) + "-dogfood"
 }
diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go
index 4c242352..ba838d64 100644
--- a/internal/naming/naming_test.go
+++ b/internal/naming/naming_test.go
@@ -48,6 +48,25 @@ func TestMCP(t *testing.T) {
 	}
 }
 
+func TestEnvPrefix(t *testing.T) {
+	tests := map[string]string{
+		"pokeapi":       "POKEAPI",
+		"pokéapi":       "POKEAPI",
+		"PokéAPI":       "POKEAPI",
+		"cal-com":       "CAL_COM",
+		"Cal.com":       "CAL_COM",
+		"food & dining": "FOOD_DINING",
+		"1password":     "API_1PASSWORD",
+		"!!!":           "API",
+	}
+
+	for input, want := range tests {
+		if got := EnvPrefix(input); got != want {
+			t.Fatalf("EnvPrefix(%q) = %q, want %q", input, got, want)
+		}
+	}
+}
+
 func TestIsCLIDirName(t *testing.T) {
 	if !IsCLIDirName("stripe-pp-cli-3") {
 		t.Fatal("expected suffixed pp-cli directory to be recognized")
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 094c54a0..b7086f52 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -13,6 +13,7 @@ import (
 	"unicode"
 
 	"github.com/getkin/kin-openapi/openapi3"
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
@@ -387,7 +388,7 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
 		}
 	}
 
-	envPrefix := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+	envPrefix := naming.EnvPrefix(name)
 	switch auth.Type {
 	case "api_key":
 		// Use scheme name for more specific env var (e.g. BotToken -> DISCORD_BOT_TOKEN)
@@ -469,7 +470,7 @@ func inferQueryParamAuth(doc *openapi3.T, name string, fallback spec.AuthConfig)
 		return fallback
 	}
 
-	envPrefix := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+	envPrefix := naming.EnvPrefix(name)
 	return spec.AuthConfig{
 		Type:    "api_key",
 		In:      "query",
@@ -690,7 +691,7 @@ func inferDescriptionAuth(doc *openapi3.T, name string, fallback spec.AuthConfig
 		return fallback
 	}
 
-	envPrefix := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+	envPrefix := naming.EnvPrefix(name)
 
 	// Check bearer keywords first (stronger signal for Bearer-prefix auth).
 	// Scan all occurrences — a negated first mention ("does not require bearer")
@@ -767,7 +768,7 @@ func inferAuthHeaderParam(doc *openapi3.T, name string, fallback spec.AuthConfig
 		return fallback
 	}
 
-	envPrefix := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+	envPrefix := naming.EnvPrefix(name)
 	return spec.AuthConfig{
 		Type:     "bearer_token",
 		Header:   "Authorization",
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 4f2668bf..e8517771 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -637,6 +637,31 @@ func TestInferDescriptionAuth(t *testing.T) {
 	})
 }
 
+func TestInferredAuthEnvVarsAreASCIISafe(t *testing.T) {
+	t.Parallel()
+
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: PokéAPI
+  version: "1.0.0"
+  description: Authenticate with your API key in the Authorization header.
+servers:
+  - url: https://api.example.com
+paths:
+  /pokemon:
+    get:
+      summary: List pokemon
+      responses:
+        "200":
+          description: OK
+`)
+	parsed, err := Parse(yamlSpec)
+	require.NoError(t, err)
+
+	require.NotEmpty(t, parsed.Auth.EnvVars)
+	assert.Equal(t, "POKEAPI_API_KEY", parsed.Auth.EnvVars[0])
+}
+
 func TestInferAuthHeaderParam(t *testing.T) {
 	t.Parallel()
 

← a18088ad chore(main): release 2.3.4 (#292)  ·  back to Cli Printing Press  ·  chore(main): release 2.3.5 (#295) 19e996ae →