← back to Cli Printing Press
fix(cli): complete bearer refresh and path handling (#584)
ebfa6bf1ff71f7b88d6acdf62627da5c784c29d1 · 2026-05-04 17:55:48 -0700 · Trevin Chow
Files touched
M AGENTS.mdM docs/CATALOG.mdM internal/catalog/catalog.goM internal/catalog/catalog_test.goM internal/cli/catalog.goM internal/cli/root.goM internal/generator/endpoint_is_write_test.goM internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/config.go.tmplM internal/generator/templates/doctor.go.tmplM internal/generator/templates/readme.md.tmplM internal/generator/templates/root.go.tmplM internal/generator/templates/skill.md.tmplM internal/spec/spec.goM internal/spec/spec_test.go
Diff
commit ebfa6bf1ff71f7b88d6acdf62627da5c784c29d1
Author: Trevin Chow <trevin@trevinchow.com>
Date: Mon May 4 17:55:48 2026 -0700
fix(cli): complete bearer refresh and path handling (#584)
---
AGENTS.md | 3 +-
docs/CATALOG.md | 6 +
internal/catalog/catalog.go | 34 ++++++
internal/catalog/catalog_test.go | 41 +++++++
internal/cli/catalog.go | 3 +
internal/cli/root.go | 6 +
internal/generator/endpoint_is_write_test.go | 35 ++++++
internal/generator/generator.go | 33 +++---
internal/generator/generator_test.go | 111 ++++++++++++++++++
.../generator/templates/command_promoted.go.tmpl | 13 ++-
internal/generator/templates/config.go.tmpl | 44 +++++++
internal/generator/templates/doctor.go.tmpl | 126 +++++++++++++++++++++
internal/generator/templates/readme.md.tmpl | 12 ++
internal/generator/templates/root.go.tmpl | 3 +
internal/generator/templates/skill.md.tmpl | 9 ++
internal/spec/spec.go | 96 +++++++++++-----
internal/spec/spec_test.go | 92 ++++++++++++++-
17 files changed, 621 insertions(+), 46 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 7ef91d61..7bd40ed5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -114,8 +114,9 @@ When adding or editing `catalog/*.yaml`, the entry must pass `internal/catalog`
- `spec_url`, when present, must use HTTPS.
- `category` must be one of `ai`, `auth`, `cloud`, `commerce`, `developer-tools`, `devices`, `food-and-dining`, `marketing`, `media-and-entertainment`, `monitoring`, `payments`, `productivity`, `project-management`, `sales-and-crm`, `social-and-messaging`, `travel`, or `other`. The validator also accepts `example` as a test-only catch-all; do not use it for real catalog entries.
- `tier` must be `official` or `community`.
+- `bearer_refresh`, when present, must include `bundle_url` and `pattern`; `bundle_url` must use HTTPS, and `pattern` must compile as a Go regexp.
- Rebuild the binary after editing; `catalog.FS` is a Go embed.
-See [`docs/CATALOG.md`](docs/CATALOG.md) for validation rationale and the wrapper-only entry shape.
+See [`docs/CATALOG.md`](docs/CATALOG.md) for validation rationale, the wrapper-only entry shape, and bearer-refresh metadata.
## Testing
When you change code, check for a `_test.go` file in the same package. If one exists, read it; your change likely requires a test update. If tests fail after your change, investigate whether it is a bug in your code or a stale test; do not just delete the test.
diff --git a/docs/CATALOG.md b/docs/CATALOG.md
index de32ebc9..754ccd7d 100644
--- a/docs/CATALOG.md
+++ b/docs/CATALOG.md
@@ -18,3 +18,9 @@ Wrapper-only entries are the carve-out where `spec_url` and `spec_format` stop b
- `spec_format` is optional, but if present it must still be one of the allowed formats.
Use the wrapper-only carve-out only when the API is genuinely reached through wrapper libraries rather than a direct spec. If the validator or enum values change, update both this doc and the inline `AGENTS.md` rule together.
+
+## Bearer refresh metadata
+
+Catalog entries for browser-facing APIs with rotating public client bearer tokens may declare `bearer_refresh`. When present, both `bearer_refresh.bundle_url` and `bearer_refresh.pattern` are required, the bundle URL must use HTTPS, and the pattern must compile as a Go regexp.
+
+The generator copies this metadata into the printed CLI so `doctor --refresh-bearer` and the agent-accessible `refresh-bearer` command can refresh the user's stored token from the live source bundle.
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index cee4b78d..b71f6a5d 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -90,6 +90,15 @@ type WrapperLibrary struct {
Notes string `yaml:"notes,omitempty"`
}
+type BearerRefresh struct {
+ BundleURL string `yaml:"bundle_url,omitempty"`
+ Pattern string `yaml:"pattern,omitempty"`
+}
+
+func (b BearerRefresh) enabled() bool {
+ return strings.TrimSpace(b.BundleURL) != "" || strings.TrimSpace(b.Pattern) != ""
+}
+
var validIntegrationModes = map[string]struct{}{
"native": {},
"subprocess": {},
@@ -124,6 +133,9 @@ type Entry struct {
// ProxyRoutes maps path prefixes to backend service names for proxy-envelope APIs.
// Only relevant when ClientPattern is "proxy-envelope".
ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty"`
+ // BearerRefresh describes how a printed CLI can fetch a rotating public
+ // client bearer token from the source site's browser bundle.
+ BearerRefresh BearerRefresh `yaml:"bearer_refresh,omitempty"`
// WrapperLibraries lists reverse-engineered community libraries the generator
// can use as implementation backing when no official spec exists. When this
// list is non-empty, spec_url and spec_format are optional.
@@ -279,10 +291,32 @@ func (e *Entry) Validate() error {
return fmt.Errorf("http_transport must be one of: standard, browser-chrome, browser-chrome-h3")
}
}
+ if err := validateBearerRefresh(e.BearerRefresh); err != nil {
+ return err
+ }
return nil
}
+func validateBearerRefresh(cfg BearerRefresh) error {
+ if !cfg.enabled() {
+ return nil
+ }
+ if strings.TrimSpace(cfg.BundleURL) == "" {
+ return fmt.Errorf("bearer_refresh.bundle_url is required when bearer_refresh is declared")
+ }
+ if strings.TrimSpace(cfg.Pattern) == "" {
+ return fmt.Errorf("bearer_refresh.pattern is required when bearer_refresh is declared")
+ }
+ if !strings.HasPrefix(cfg.BundleURL, "https://") {
+ return fmt.Errorf(`bearer_refresh.bundle_url must start with "https://"`)
+ }
+ if _, err := regexp.Compile(cfg.Pattern); err != nil {
+ return fmt.Errorf("bearer_refresh.pattern is not a valid regexp: %w", err)
+ }
+ return nil
+}
+
// PublicCategories returns the sorted list of user-facing categories.
// It excludes "example", which is internal-only for test fixtures.
func PublicCategories() []string {
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index 73d8067e..6c0af93a 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -133,6 +133,29 @@ func TestValidateEntry(t *testing.T) {
},
wantErr: "http_transport must be one of",
},
+ {
+ name: "bearer refresh missing bundle URL",
+ mutate: func(e *Entry) {
+ e.BearerRefresh.Pattern = `AAAA[^"]+`
+ },
+ wantErr: "bearer_refresh.bundle_url is required",
+ },
+ {
+ name: "bearer refresh non https bundle URL",
+ mutate: func(e *Entry) {
+ e.BearerRefresh.BundleURL = "http://example.com/main.js"
+ e.BearerRefresh.Pattern = `AAAA[^"]+`
+ },
+ wantErr: `bearer_refresh.bundle_url must start with "https://"`,
+ },
+ {
+ name: "bearer refresh invalid pattern",
+ mutate: func(e *Entry) {
+ e.BearerRefresh.BundleURL = "https://example.com/main.js"
+ e.BearerRefresh.Pattern = `[`
+ },
+ wantErr: "bearer_refresh.pattern is not a valid regexp",
+ },
}
for _, tt := range tests {
@@ -269,6 +292,24 @@ func TestOptionalFieldsOmittedValid(t *testing.T) {
assert.Nil(t, entry.AuthRequired)
assert.Empty(t, entry.ClientPattern)
assert.Empty(t, entry.HTTPTransport)
+ assert.Empty(t, entry.BearerRefresh.BundleURL)
+}
+
+func TestBearerRefreshValid(t *testing.T) {
+ entry := Entry{
+ Name: "browser-api",
+ DisplayName: "Browser API",
+ Description: "A browser-facing API with a rotating public bearer",
+ Category: "social-and-messaging",
+ SpecURL: "https://example.com/openapi.yaml",
+ SpecFormat: "yaml",
+ Tier: "community",
+ BearerRefresh: BearerRefresh{
+ BundleURL: "https://example.com/main.js",
+ Pattern: `"(AAAAAAAA[^"]+)"`,
+ },
+ }
+ require.NoError(t, entry.Validate())
}
func TestWrapperOnlyEntryValid(t *testing.T) {
diff --git a/internal/cli/catalog.go b/internal/cli/catalog.go
index ad02bd7b..817ac132 100644
--- a/internal/cli/catalog.go
+++ b/internal/cli/catalog.go
@@ -132,6 +132,9 @@ func newCatalogShowCmd() *cobra.Command {
if entry.AuthRequired != nil {
fmt.Printf("Auth Required: %v\n", *entry.AuthRequired)
}
+ if entry.BearerRefresh.BundleURL != "" {
+ fmt.Printf("Bearer Refresh: %s\n", entry.BearerRefresh.BundleURL)
+ }
if entry.Notes != "" {
fmt.Printf("Notes: %s\n", entry.Notes)
}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index cfc26a65..50d66bd8 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -1102,4 +1102,10 @@ func enrichSpecFromCatalog(apiSpec *spec.APISpec) {
if entry.HTTPTransport != "" && apiSpec.HTTPTransport == "" {
apiSpec.HTTPTransport = entry.HTTPTransport
}
+ if entry.BearerRefresh.BundleURL != "" && apiSpec.BearerRefresh.BundleURL == "" {
+ apiSpec.BearerRefresh.BundleURL = entry.BearerRefresh.BundleURL
+ }
+ if entry.BearerRefresh.Pattern != "" && apiSpec.BearerRefresh.Pattern == "" {
+ apiSpec.BearerRefresh.Pattern = entry.BearerRefresh.Pattern
+ }
}
diff --git a/internal/generator/endpoint_is_write_test.go b/internal/generator/endpoint_is_write_test.go
index ed02dd01..109949d9 100644
--- a/internal/generator/endpoint_is_write_test.go
+++ b/internal/generator/endpoint_is_write_test.go
@@ -331,6 +331,41 @@ func TestPromotedCommandVerbBranching(t *testing.T) {
}
}
+func TestPromotedCommandSubstitutesFlagPathParams(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("promoted-path-param")
+ apiSpec.Resources = map[string]spec.Resource{
+ "followers": {
+ Description: "Followers",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/users/{userId}/followers",
+ Description: "List followers",
+ Params: []spec.Param{
+ {Name: "userId", Type: "string", PathParam: true, Default: "me", Description: "User ID"},
+ {Name: "limit", Type: "int", Description: "Page size"},
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "promoted-path-param-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ src := readPromotedCommandFile(t, outputDir)
+ assert.Contains(t, src, `path = replacePathParam(path, "userId", fmt.Sprintf("%v", flagUserId))`,
+ "promoted command must substitute flag-backed path params before making the request")
+ assert.Contains(t, src, `params["limit"] = fmt.Sprintf("%v", flagLimit)`,
+ "ordinary non-positional flags still belong in query params")
+ assert.NotContains(t, src, `params["userId"]`,
+ "path params must not also be sent as query params")
+ assert.NotContains(t, src, `"userId": fmt.Sprintf("%v", flagUserID)`,
+ "path params must not be passed to paginated query maps")
+}
+
// readPromotedCommandFile finds the single promoted_*.go file the generator
// emits for a fixture spec with one resource. Naming varies (resource name
// vs. kebabed endpoint name vs. camelCase), so the lookup glob-matches.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index d8ae7beb..58fb758e 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -543,8 +543,10 @@ func computeHelperFlags(s *spec.APISpec) HelperFlags {
}
positionalCount := 0
for _, p := range e.Params {
- if p.Positional {
+ if p.Positional || p.PathParam {
flags.HasPathParams = true
+ }
+ if p.Positional {
positionalCount++
}
}
@@ -562,8 +564,10 @@ func computeHelperFlags(s *spec.APISpec) HelperFlags {
}
positionalCount := 0
for _, p := range e.Params {
- if p.Positional {
+ if p.Positional || p.PathParam {
flags.HasPathParams = true
+ }
+ if p.Positional {
positionalCount++
}
}
@@ -3136,18 +3140,19 @@ type PromotedCommand struct {
// builtinCommands lists command names that must not be used for promoted commands
// because they collide with the CLI's own built-in commands.
var builtinCommands = map[string]bool{
- "version": true,
- "help": true,
- "doctor": true,
- "auth": true,
- "sync": true,
- "search": true,
- "export": true,
- "import": true,
- "completion": true,
- "workflow": true,
- "tail": true,
- "analytics": true,
+ "version": true,
+ "help": true,
+ "doctor": true,
+ "auth": true,
+ "sync": true,
+ "search": true,
+ "export": true,
+ "import": true,
+ "completion": true,
+ "refresh-bearer": true,
+ "workflow": true,
+ "tail": true,
+ "analytics": true,
}
// buildPromotedCommands scans spec resources and returns safe top-level shortcuts.
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index b3efd4b8..ac27bbfa 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -567,6 +567,112 @@ func TestGeneratedOutput_READMEBearerTokenMCPSetup(t *testing.T) {
assert.NotContains(t, content, "bearer-pp-cli auth login\n\nclaude mcp add bearer bearer-pp-mcp")
}
+func TestGenerateBearerRefreshDoctorCommand(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "refreshbearer",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ BearerRefresh: spec.BearerRefreshConfig{
+ BundleURL: "https://cdn.example.com/main.js",
+ Pattern: `"(AAAAAAAA[^"]+)"`,
+ },
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVars: []string{"REFRESHBEARER_PUBLIC_BEARER"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/refreshbearer-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "items": {
+ Description: "Manage items",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/items",
+ Description: "List items",
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+ require.NoError(t, err)
+ doctor := string(doctorGo)
+ assert.Contains(t, doctor, `"refresh-bearer"`)
+ assert.Contains(t, doctor, `"https://cdn.example.com/main.js"`)
+ assert.Contains(t, doctor, "func newRefreshBearerCmd(")
+ assert.Contains(t, doctor, "func runBearerRefresh(")
+ assert.Contains(t, doctor, "func extractBearerToken(")
+
+ rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(rootGo), "rootCmd.AddCommand(newRefreshBearerCmd(flags))",
+ "bearer refresh must be a non-framework command so the Cobra-tree MCP mirror exposes it")
+
+ configGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ configContent := string(configGo)
+ assert.Contains(t, configContent, "BearerTokenRefreshedAt time.Time")
+ assert.Contains(t, configContent, "func (c *Config) SaveBearerToken(")
+ assert.Contains(t, configContent, `c.AuthSource = "bearer_refresh"`)
+ assert.Contains(t, configContent, "c.RefreshbearerPublicBearer = \"\"")
+
+ readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+ require.NoError(t, err)
+ assert.Contains(t, string(readme), "refreshbearer-pp-cli doctor --refresh-bearer")
+ assert.Contains(t, string(readme), "refreshbearer-pp-cli refresh-bearer")
+
+ configTest := `package config
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestBearerRefreshSavedTokenWinsOverEnvBackedField(t *testing.T) {
+ cfg := &Config{AccessToken: "fresh-token", RefreshbearerPublicBearer: "stale-token"}
+ if got := cfg.AuthHeader(); got != "Bearer fresh-token" {
+ t.Fatalf("AuthHeader() = %q, want refreshed token", got)
+ }
+ if cfg.AuthSource != "bearer_refresh" {
+ t.Fatalf("AuthSource = %q, want bearer_refresh", cfg.AuthSource)
+ }
+}
+
+func TestSaveBearerTokenClearsEnvBackedField(t *testing.T) {
+ cfg := &Config{
+ Path: filepath.Join(t.TempDir(), "config.toml"),
+ RefreshbearerPublicBearer: "stale-token",
+ }
+ if err := cfg.SaveBearerToken("fresh-token", time.Unix(123, 0)); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.RefreshbearerPublicBearer != "" {
+ t.Fatalf("RefreshbearerPublicBearer = %q, want cleared", cfg.RefreshbearerPublicBearer)
+ }
+ if got := cfg.AuthHeader(); got != "Bearer fresh-token" {
+ t.Fatalf("AuthHeader() = %q, want refreshed token", got)
+ }
+}
+`
+ require.NoError(t, os.WriteFile(filepath.Join(outputDir, "internal", "config", "bearer_refresh_test.go"), []byte(configTest), 0o644))
+
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "test", "./internal/config", "-run", "TestBearerRefresh")
+ runGoCommand(t, outputDir, "build", "./...")
+}
+
func TestGenerateOAuth2ClientCredentialsAuthTemplate(t *testing.T) {
t.Parallel()
@@ -5003,6 +5109,11 @@ func TestGenerateOperationRoutingPathParamDefault(t *testing.T) {
`path = replacePathParam(path, "pathQueryId", fmt.Sprintf("%v", flagPathQueryId))`,
"generated command must substitute the path template before calling the API")
+ helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(helpersGo), "func replacePathParam(path, name, value string) string",
+ "helpers.go must emit replacePathParam for flag-shaped path params")
+
mcpGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "tools.go"))
require.NoError(t, err)
assert.Regexp(t,
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 6127d5ef..b6a521f5 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -109,6 +109,11 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- end}}
{{- end}}
{{- end}}
+{{- range .Endpoint.Params}}
+{{- if and .PathParam (not .Positional)}}
+ path = replacePathParam(path, "{{.Name}}", fmt.Sprintf("%v", flag{{camel (paramIdent .)}}))
+{{- end}}
+{{- end}}
{{- if .Endpoint.UsesHTMLResponse}}
htmlRequestParams := map[string]string{}
@@ -116,7 +121,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
htmlRequestParams["{{.Name}}"] = args[{{$i}}]
{{- end}}
-{{- if not .Positional}}
+{{- if and (not .Positional) (not .PathParam)}}
if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
htmlRequestParams["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
}
@@ -131,7 +136,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
"{{.Name}}": args[{{$i}}],
{{- end}}
-{{- if not .Positional}}
+{{- if and (not .Positional) (not .PathParam)}}
"{{.Name}}": fmt.Sprintf("%v", flag{{camel (paramIdent .)}}),
{{- end}}
{{- end}}
@@ -142,7 +147,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
"{{.Name}}": args[{{$i}}],
{{- end}}
-{{- if not .Positional}}
+{{- if and (not .Positional) (not .PathParam)}}
"{{.Name}}": fmt.Sprintf("%v", flag{{camel (paramIdent .)}}),
{{- end}}
{{- end}}
@@ -155,7 +160,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
params["{{.Name}}"] = args[{{$i}}]
{{- end}}
-{{- if not .Positional}}
+{{- if and (not .Positional) (not .PathParam)}}
if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
}
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 1dfcd814..dd55f37d 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -25,6 +25,9 @@ type Config struct {
AccessToken string `{{configTag .Config.Format}}:"access_token"`
RefreshToken string `{{configTag .Config.Format}}:"refresh_token"`
TokenExpiry time.Time `{{configTag .Config.Format}}:"token_expiry"`
+{{- if .BearerRefresh.Enabled}}
+ BearerTokenRefreshedAt time.Time `{{configTag .Config.Format}}:"bearer_token_refreshed_at"`
+{{- end}}
ClientID string `{{configTag .Config.Format}}:"client_id"`
ClientSecret string `{{configTag .Config.Format}}:"client_secret"`
Path string `{{configTag .Config.Format}}:"-"`
@@ -112,9 +115,15 @@ func Load(configPath string) (*Config, error) {
}
func (c *Config) AuthHeader() string {
+{{- if and .BearerRefresh.Enabled (eq .Auth.Type "bearer_token") (ne .Auth.EffectiveOAuth2Grant "client_credentials")}}
+ if c.AuthHeaderVal != "" && c.AccessToken == "" {
+ return c.AuthHeaderVal
+ }
+{{- else}}
if c.AuthHeaderVal != "" {
return c.AuthHeaderVal
}
+{{- end}}
{{- if eq .Auth.Type "api_key"}}
{{- if gt (len .Auth.EnvVars) 0}}
token := c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
@@ -172,8 +181,21 @@ func (c *Config) AuthHeader() string {
}
{{- end}}
return ""
+{{- else}}
+{{- if .BearerRefresh.Enabled}}
+ // Refreshed public bearer tokens must win over stale generated env-var
+ // fields so a successful refresh changes subsequent API calls.
+ if c.AccessToken != "" {
+ c.AuthSource = "bearer_refresh"
+ {{- if .Auth.Format}}
+ return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
+ {{- else}}
+ return "Bearer " + c.AccessToken
+ {{- end}}
+ }
{{- else}}
// Env-var token wins over file-stored AccessToken (env > config convention).
+{{- end}}
{{- if gt (len .Auth.EnvVars) 0}}
if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
@@ -188,6 +210,7 @@ func (c *Config) AuthHeader() string {
{{- end}}
}
{{- end}}
+{{- if not .BearerRefresh.Enabled}}
if c.AccessToken != "" {
c.AuthSource = "oauth2"
{{- if .Auth.Format}}
@@ -196,6 +219,7 @@ func (c *Config) AuthHeader() string {
return "Bearer " + c.AccessToken
{{- end}}
}
+{{- end}}
return ""
{{- end}}
{{- else if eq .Auth.Type "cookie"}}
@@ -251,10 +275,30 @@ func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken st
return c.save()
}
+{{- if .BearerRefresh.Enabled}}
+func (c *Config) SaveBearerToken(accessToken string, refreshedAt time.Time) error {
+ c.AuthHeaderVal = ""
+ c.AccessToken = accessToken
+ c.RefreshToken = ""
+ c.TokenExpiry = time.Time{}
+{{- range .Auth.EnvVars}}
+{{- if not (envVarIsBuiltinField .)}}
+ c.{{envVarField .}} = ""
+{{- end}}
+{{- end}}
+ c.BearerTokenRefreshedAt = refreshedAt
+ return c.save()
+}
+
+{{- end}}
+
func (c *Config) ClearTokens() error {
c.AccessToken = ""
c.RefreshToken = ""
c.TokenExpiry = time.Time{}
+{{- if .BearerRefresh.Enabled}}
+ c.BearerTokenRefreshedAt = time.Time{}
+{{- end}}
return c.save()
}
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 1bb0107a..6efad5c9 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -19,6 +19,11 @@ import (
"fmt"
{{- if .HasStore}}
"io"
+{{- else if .BearerRefresh.Enabled}}
+ "io"
+{{- end}}
+{{- if .BearerRefresh.Enabled}}
+ "net/http"
{{- end}}
{{- if or .Auth.EnvVars .HasStore .Auth.RequiresBrowserSession .HasTierRouting}}
"os"
@@ -28,10 +33,15 @@ import (
{{- end}}
{{- if .Auth.RequiresBrowserSession}}
"path/filepath"
+{{- end}}
+{{- if .BearerRefresh.Enabled}}
+ "regexp"
{{- end}}
"strings"
{{- if .HasStore}}
"time"
+{{- else if .BearerRefresh.Enabled}}
+ "time"
{{- end}}
"{{modulePath}}/internal/client"
@@ -88,6 +98,11 @@ func looksLikeDoctorInterstitial(body []byte) string {
func newDoctorCmd(flags *rootFlags) *cobra.Command {
var failOn string
+{{- if .BearerRefresh.Enabled}}
+ var refreshBearer bool
+ var refreshBearerBundleURL string
+ var refreshBearerPattern string
+{{- end}}
cmd := &cobra.Command{
Use: "doctor",
Short: "Check CLI health",
@@ -106,6 +121,14 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
report["config_path"] = cfg.Path
report["base_url"] = cfg.BaseURL
}
+{{- if .BearerRefresh.Enabled}}
+ if refreshBearer {
+ if cfg == nil {
+ return configErr(fmt.Errorf("config unavailable"))
+ }
+ return runBearerRefresh(cmd, cfg, refreshBearerBundleURL, refreshBearerPattern, flags)
+ }
+{{- end}}
// Check auth
{{- if or (eq .Auth.Type "") (eq .Auth.Type "none")}}
@@ -422,9 +445,112 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&failOn, "fail-on", "", "Exit non-zero when a health level is reached: stale, error. Default is never.")
+{{- if .BearerRefresh.Enabled}}
+ cmd.Flags().BoolVar(&refreshBearer, "refresh-bearer", false, "Fetch and store the current public bearer token")
+ cmd.Flags().StringVar(&refreshBearerBundleURL, "bearer-bundle-url", {{printf "%q" .BearerRefresh.BundleURL}}, "Override the browser bundle URL used by --refresh-bearer")
+ cmd.Flags().StringVar(&refreshBearerPattern, "bearer-pattern", {{printf "%q" .BearerRefresh.Pattern}}, "Override the regexp used by --refresh-bearer")
+{{- end}}
return cmd
}
+{{- if .BearerRefresh.Enabled}}
+func newRefreshBearerCmd(flags *rootFlags) *cobra.Command {
+ bundleURL := {{printf "%q" .BearerRefresh.BundleURL}}
+ pattern := {{printf "%q" .BearerRefresh.Pattern}}
+ cmd := &cobra.Command{
+ Use: "refresh-bearer",
+ Short: "Fetch and store the current public bearer token",
+ Args: cobra.NoArgs,
+ Example: ` {{.Name}}-pp-cli refresh-bearer
+ {{.Name}}-pp-cli refresh-bearer --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+ return runBearerRefresh(cmd, cfg, bundleURL, pattern, flags)
+ },
+ }
+ cmd.Flags().StringVar(&bundleURL, "bearer-bundle-url", bundleURL, "Override the browser bundle URL used to refresh the bearer token")
+ cmd.Flags().StringVar(&pattern, "bearer-pattern", pattern, "Override the regexp used to refresh the bearer token")
+ return cmd
+}
+
+func runBearerRefresh(cmd *cobra.Command, cfg *config.Config, bundleURL, pattern string, flags *rootFlags) error {
+ if bundleURL == "" {
+ return configErr(fmt.Errorf("bearer refresh bundle URL is not configured"))
+ }
+ if pattern == "" {
+ return configErr(fmt.Errorf("bearer refresh pattern is not configured"))
+ }
+ req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, bundleURL, nil)
+ if err != nil {
+ return configErr(fmt.Errorf("preparing bearer refresh request: %w", err))
+ }
+ req.Header.Set("User-Agent", "{{.Name}}-pp-cli")
+
+ httpClient := &http.Client{Timeout: 30 * time.Second}
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return apiErr(fmt.Errorf("fetching bearer bundle: %w", err))
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return apiErr(fmt.Errorf("fetching bearer bundle: HTTP %d", resp.StatusCode))
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
+ if err != nil {
+ return apiErr(fmt.Errorf("reading bearer bundle: %w", err))
+ }
+ token, err := extractBearerToken(body, pattern)
+ if err != nil {
+ return apiErr(err)
+ }
+ if err := cfg.SaveBearerToken(token, time.Now().UTC()); err != nil {
+ return configErr(fmt.Errorf("saving bearer token: %w", err))
+ }
+
+ out := map[string]any{
+ "refreshed": true,
+ "config_path": cfg.Path,
+ "source": bundleURL,
+ }
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Bearer token refreshed and saved to %s\n", cfg.Path)
+ return nil
+}
+
+func extractBearerToken(body []byte, pattern string) (string, error) {
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return "", fmt.Errorf("bearer refresh pattern is not a valid regexp: %w", err)
+ }
+ matches := re.FindSubmatch(body)
+ if len(matches) == 0 {
+ return "", fmt.Errorf("bearer token not found in bundle")
+ }
+ token := ""
+ for _, match := range matches[1:] {
+ if len(match) > 0 {
+ token = string(match)
+ break
+ }
+ }
+ if token == "" {
+ token = string(matches[0])
+ }
+ token = strings.Trim(token, " \t\r\n\"'`")
+ if token == "" {
+ return "", fmt.Errorf("bearer token match was empty")
+ }
+ return token, nil
+}
+
+{{- end}}
+
{{- if .Auth.RequiresBrowserSession}}
type doctorBrowserSessionProof struct {
APIName string `json:"api_name"`
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index 5e344f08..2d43cd24 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -97,6 +97,15 @@ Get your access token from your API provider's developer portal, then store it:
```bash
{{.Name}}-pp-cli auth set-token YOUR_TOKEN_HERE
```
+{{- if .BearerRefresh.Enabled}}
+
+If commands start returning auth errors after working for a while, refresh the public client bearer token from the live web bundle:
+
+```bash
+{{.Name}}-pp-cli doctor --refresh-bearer
+{{.Name}}-pp-cli refresh-bearer
+```
+{{- end}}
{{- if .Auth.EnvVars}}
Or set it via environment variable:
@@ -439,6 +448,9 @@ Environment variables:
{{- if .HasAuth}}
**Authentication errors (exit code 4)**
- Run `{{.Name}}-pp-cli doctor` to check credentials
+{{- if .BearerRefresh.Enabled}}
+- Run `{{.Name}}-pp-cli refresh-bearer` if a long-working public bearer token may have rotated
+{{- end}}
{{- if .Auth.EnvVars}}
- Verify the environment variable is set: `echo ${{index .Auth.EnvVars 0}}`
{{- end}}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index cb00a9bb..ca935e29 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -249,6 +249,9 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
{{- end}}
{{- end}}
rootCmd.AddCommand(newDoctorCmd(flags))
+{{- if .BearerRefresh.Enabled}}
+ rootCmd.AddCommand(newRefreshBearerCmd(flags))
+{{- end}}
{{- if .HasAuthCommand}}
rootCmd.AddCommand(newAuthCmd(flags))
{{- end}}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index e8418a9a..07b95437 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -186,6 +186,15 @@ Store your access token:
```bash
{{.Name}}-pp-cli auth set-token YOUR_TOKEN_HERE
```
+{{- if .BearerRefresh.Enabled}}
+
+If authenticated commands start returning token errors after previously working, refresh the public client bearer token:
+
+```bash
+{{.Name}}-pp-cli doctor --refresh-bearer
+{{.Name}}-pp-cli refresh-bearer
+```
+{{- end}}
{{- if .Auth.EnvVars}}
Or set `{{index .Auth.EnvVars 0}}` as an environment variable.
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 649044c5..ab718ed1 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -105,6 +105,7 @@ type APISpec struct {
ClientPattern string `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
HTTPTransport string `yaml:"http_transport,omitempty" json:"http_transport,omitempty"` // standard (default for official APIs), browser-chrome, or browser-chrome-h3
ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"` // path prefix → service name for proxy-envelope routing
+ BearerRefresh BearerRefreshConfig `yaml:"bearer_refresh,omitempty" json:"bearer_refresh,omitzero"` // live-source metadata for rotating public client bearer tokens
WebsiteURL string `yaml:"website_url,omitempty" json:"website_url,omitempty"` // product/company website (not the API base URL)
Category string `yaml:"category,omitempty" json:"category,omitempty"` // catalog category (e.g., productivity, developer-tools) — used for library install path
Auth AuthConfig `yaml:"auth" json:"auth"`
@@ -393,6 +394,15 @@ type RequiredHeader struct {
Value string `yaml:"value" json:"value"`
}
+type BearerRefreshConfig struct {
+ BundleURL string `yaml:"bundle_url,omitempty" json:"bundle_url,omitempty"`
+ Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
+}
+
+func (c BearerRefreshConfig) Enabled() bool {
+ return strings.TrimSpace(c.BundleURL) != "" || strings.TrimSpace(c.Pattern) != ""
+}
+
type AuthConfig struct {
Type string `yaml:"type" json:"type"` // api_key, oauth2, bearer_token, cookie, composed, session_handshake, none
Header string `yaml:"header" json:"header"`
@@ -888,6 +898,7 @@ var ReservedCLIResourceNames = map[string]struct{}{
"html_extract": {},
"import": {},
"profile": {},
+ "refresh_bearer": {},
"root": {},
"search": {},
"share_commands": {},
@@ -906,32 +917,33 @@ var ReservedCLIResourceNames = map[string]struct{}{
// cobra-Use shadowing (kebab-case). Hand-maintained; drift invariants
// enforced by tests in reserved_drift_test.go.
var ReservedCobraUseNames = map[string]struct{}{
- "about": {},
- "agent-context": {},
- "analytics": {},
- "api": {},
- "auth": {},
- "completion": {},
- "doctor": {},
- "export": {},
- "feedback": {},
- "health": {},
- "help": {},
- "import": {},
- "jobs": {},
- "load": {},
- "orphans": {},
- "profile": {},
- "search": {},
- "share": {},
- "similar": {},
- "sql": {},
- "stale": {},
- "sync": {},
- "tail": {},
- "version": {},
- "which": {},
- "workflow": {},
+ "about": {},
+ "agent-context": {},
+ "analytics": {},
+ "api": {},
+ "auth": {},
+ "completion": {},
+ "doctor": {},
+ "export": {},
+ "feedback": {},
+ "health": {},
+ "help": {},
+ "import": {},
+ "jobs": {},
+ "load": {},
+ "orphans": {},
+ "profile": {},
+ "refresh-bearer": {},
+ "search": {},
+ "share": {},
+ "similar": {},
+ "sql": {},
+ "stale": {},
+ "sync": {},
+ "tail": {},
+ "version": {},
+ "which": {},
+ "workflow": {},
}
// validateReservedNames rejects specs whose top-level resource names would
@@ -1227,6 +1239,9 @@ func (s *APISpec) Validate() error {
if err := validateThrottling(s.Throttling); err != nil {
return err
}
+ if err := validateBearerRefresh(s); err != nil {
+ return err
+ }
if err := validateOAuth2Grant(s.Auth); err != nil {
return err
}
@@ -1274,6 +1289,35 @@ func (s *APISpec) Validate() error {
return nil
}
+func validateBearerRefresh(s *APISpec) error {
+ cfg := s.BearerRefresh
+ if !cfg.Enabled() {
+ return nil
+ }
+ if s.Auth.Type != "bearer_token" {
+ return fmt.Errorf(`bearer_refresh requires auth.type "bearer_token"`)
+ }
+ if s.Auth.OAuth2Grant == OAuth2GrantClientCredentials {
+ return fmt.Errorf("bearer_refresh is incompatible with auth.oauth2_grant %q", OAuth2GrantClientCredentials)
+ }
+ if s.HasTierRouting() {
+ return fmt.Errorf("bearer_refresh is incompatible with tier_routing auth")
+ }
+ if strings.TrimSpace(cfg.BundleURL) == "" {
+ return fmt.Errorf("bearer_refresh.bundle_url is required when bearer_refresh is declared")
+ }
+ if strings.TrimSpace(cfg.Pattern) == "" {
+ return fmt.Errorf("bearer_refresh.pattern is required when bearer_refresh is declared")
+ }
+ if !strings.HasPrefix(cfg.BundleURL, "https://") {
+ return fmt.Errorf(`bearer_refresh.bundle_url must start with "https://"`)
+ }
+ if _, err := regexp.Compile(cfg.Pattern); err != nil {
+ return fmt.Errorf("bearer_refresh.pattern is not a valid regexp: %w", err)
+ }
+ return nil
+}
+
func validateTierRouting(s *APISpec) error {
if s == nil || !s.HasTierRouting() {
return nil
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 2022cee4..c97c34cc 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -97,6 +97,96 @@ func TestThrottleShapeShopifyValue(t *testing.T) {
"changing this value requires updating graphql_client.go.tmpl's gate to match")
}
+func TestBearerRefreshValidate(t *testing.T) {
+ base := APISpec{
+ Name: "browser-api",
+ BaseURL: "https://api.example.com",
+ Auth: AuthConfig{Type: "bearer_token"},
+ Resources: map[string]Resource{
+ "items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+ },
+ }
+
+ valid := base
+ valid.BearerRefresh = BearerRefreshConfig{
+ BundleURL: "https://example.com/main.js",
+ Pattern: `"(AAAAAAAA[^"]+)"`,
+ }
+ require.NoError(t, valid.Validate())
+
+ missingPattern := base
+ missingPattern.BearerRefresh = BearerRefreshConfig{BundleURL: "https://example.com/main.js"}
+ require.ErrorContains(t, missingPattern.Validate(), "bearer_refresh.pattern is required")
+
+ invalidURL := base
+ invalidURL.BearerRefresh = BearerRefreshConfig{BundleURL: "http://example.com/main.js", Pattern: `AAAA`}
+ require.ErrorContains(t, invalidURL.Validate(), `bearer_refresh.bundle_url must start with "https://"`)
+
+ invalidPattern := base
+ invalidPattern.BearerRefresh = BearerRefreshConfig{BundleURL: "https://example.com/main.js", Pattern: `[`}
+ require.ErrorContains(t, invalidPattern.Validate(), "bearer_refresh.pattern is not a valid regexp")
+
+ for _, tc := range []struct {
+ name string
+ mutate func(*APISpec)
+ wantErr string
+ }{
+ {
+ name: "api key auth",
+ mutate: func(s *APISpec) {
+ s.Auth.Type = "api_key"
+ },
+ wantErr: `bearer_refresh requires auth.type "bearer_token"`,
+ },
+ {
+ name: "no auth",
+ mutate: func(s *APISpec) {
+ s.Auth.Type = "none"
+ },
+ wantErr: `bearer_refresh requires auth.type "bearer_token"`,
+ },
+ {
+ name: "cookie auth",
+ mutate: func(s *APISpec) {
+ s.Auth.Type = "cookie"
+ },
+ wantErr: `bearer_refresh requires auth.type "bearer_token"`,
+ },
+ {
+ name: "composed auth",
+ mutate: func(s *APISpec) {
+ s.Auth.Type = "composed"
+ },
+ wantErr: `bearer_refresh requires auth.type "bearer_token"`,
+ },
+ {
+ name: "client credentials",
+ mutate: func(s *APISpec) {
+ s.Auth.OAuth2Grant = OAuth2GrantClientCredentials
+ },
+ wantErr: `bearer_refresh is incompatible with auth.oauth2_grant "client_credentials"`,
+ },
+ {
+ name: "tier routing",
+ mutate: func(s *APISpec) {
+ s.TierRouting = TierRoutingConfig{
+ DefaultTier: "free",
+ Tiers: map[string]TierConfig{
+ "free": {Auth: AuthConfig{Type: "none"}},
+ },
+ }
+ },
+ wantErr: "bearer_refresh is incompatible with tier_routing auth",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ candidate := valid
+ tc.mutate(&candidate)
+ require.ErrorContains(t, candidate.Validate(), tc.wantErr)
+ })
+ }
+}
+
// TestThrottlingValidate guards the named-adapter contract: enabling
// throttling without a Shape (or with an unrecognized one) must fail at
// spec-load time, not silently emit Shopify-shape parser code for an API
@@ -2205,7 +2295,7 @@ resources:
// Pin a baseline. Removing any of these from ReservedCLIResourceNames
// without first removing the corresponding generator template is a
// regression that will reintroduce silent overwrites.
- mustReserve := []string{"feedback", "doctor", "auth", "helpers", "agent_context", "profile", "deliver", "which", "sync", "tail", "search", "client", "cache", "export", "import"}
+ mustReserve := []string{"feedback", "doctor", "auth", "helpers", "agent_context", "profile", "deliver", "which", "sync", "tail", "search", "client", "cache", "export", "import", "refresh_bearer"}
for _, name := range mustReserve {
_, ok := ReservedCLIResourceNames[name]
assert.True(t, ok, "%q must remain in ReservedCLIResourceNames — losing it would reintroduce silent template overwrites", name)
← 67c977d8 test(cli): live-dogfood resolve-success and search error_pat
·
back to Cli Printing Press
·
chore(main): release 3.9.0 (#531) 76d32e83 →