← 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
M internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/auto_refresh.go.tmplM internal/naming/naming.goM internal/naming/naming_test.goM internal/openapi/parser.goM internal/openapi/parser_test.go
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 →