[object Object]

← back to Cli Printing Press

feat(cli): add sync --path-context KEY=VAL runtime override for template placeholders (#1631)

766354e6b82c5a515c0eaceb4f921348b8f26365 · 2026-05-18 13:13:18 -0700 · Trevin Chow

* feat(cli): add sync --path-context KEY=VAL runtime override for template placeholders

The narrower spec-extension fix #1368 makes per-tenant sync work when the
spec declares `info.x-tenant-env-var` and the user's environment holds
the value. That covers the steady-state ServiceTitan case but leaves
three gaps named on the issue:

- One-off override at the call site (env says A, this run should target B).
- Placeholders the spec did NOT annotate with an env var, so the
  existing `EndpointTemplateVars` substitution has nothing to read.
- The workspace/org generalization beyond `{tenant}`.

The `--path-context KEY=VAL` flag (repeatable) on `sync` plugs all three
into the same generated machinery `EndpointTemplateVars` already uses.
At RunE time the values land in `c.Config.TemplateVars`, so the existing
`buildURL` substitution pipeline picks them up for every request without
new request-path manipulation.

Profiler-driven emission: gated on `.EndpointTemplateVars` being non-
empty so plain APIs (which have nothing to substitute) keep their
existing sync flag surface intact. The matching byte-compat test asserts
the flag is absent from plain CLIs.

Closes #1332

* fix(cli): warn on unknown --path-context keys; drop assertions belonging to #1632

Greptile flagged two findings on PR #1631:

1. Four test assertions referenced template output (emitCacheRefreshFailedEvent,
   cache_warning, refresh_failed) that does not exist on this branch. Those
   symbols come from PR #1632's auto_refresh template change and were
   accidentally included here. Removing them so the freshness-helper test
   passes on this branch; #1632 carries the matching assertions.

2. --path-context silently accepted keys outside endpointTemplateVarSet,
   so typos like --path-context tneant=... would store the value in
   Config.TemplateVars without substituting anywhere, leaving the run
   looking correct but using the env-resolved default. Emit a one-line
   stderr warning per unknown key. Non-fatal so callers passing
   forward-compat keys aren't blocked.

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Files touched

Diff

commit 766354e6b82c5a515c0eaceb4f921348b8f26365
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 18 13:13:18 2026 -0700

    feat(cli): add sync --path-context KEY=VAL runtime override for template placeholders (#1631)
    
    * feat(cli): add sync --path-context KEY=VAL runtime override for template placeholders
    
    The narrower spec-extension fix #1368 makes per-tenant sync work when the
    spec declares `info.x-tenant-env-var` and the user's environment holds
    the value. That covers the steady-state ServiceTitan case but leaves
    three gaps named on the issue:
    
    - One-off override at the call site (env says A, this run should target B).
    - Placeholders the spec did NOT annotate with an env var, so the
      existing `EndpointTemplateVars` substitution has nothing to read.
    - The workspace/org generalization beyond `{tenant}`.
    
    The `--path-context KEY=VAL` flag (repeatable) on `sync` plugs all three
    into the same generated machinery `EndpointTemplateVars` already uses.
    At RunE time the values land in `c.Config.TemplateVars`, so the existing
    `buildURL` substitution pipeline picks them up for every request without
    new request-path manipulation.
    
    Profiler-driven emission: gated on `.EndpointTemplateVars` being non-
    empty so plain APIs (which have nothing to substitute) keep their
    existing sync flag surface intact. The matching byte-compat test asserts
    the flag is absent from plain CLIs.
    
    Closes #1332
    
    * fix(cli): warn on unknown --path-context keys; drop assertions belonging to #1632
    
    Greptile flagged two findings on PR #1631:
    
    1. Four test assertions referenced template output (emitCacheRefreshFailedEvent,
       cache_warning, refresh_failed) that does not exist on this branch. Those
       symbols come from PR #1632's auto_refresh template change and were
       accidentally included here. Removing them so the freshness-helper test
       passes on this branch; #1632 carries the matching assertions.
    
    2. --path-context silently accepted keys outside endpointTemplateVarSet,
       so typos like --path-context tneant=... would store the value in
       Config.TemplateVars without substituting anywhere, leaving the run
       looking correct but using the env-resolved default. Emit a one-line
       stderr warning per unknown key. Non-fatal so callers passing
       forward-compat keys aren't blocked.
    
    ---------
    
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
 internal/generator/generator_test.go      | 29 ++++++++++++++++++++++
 internal/generator/templates/sync.go.tmpl | 40 +++++++++++++++++++++++++++++++
 2 files changed, 69 insertions(+)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index e93902ac..c6ca1cbb 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -9857,6 +9857,18 @@ func TestGenerateNoEndpointTemplateVarsByteCompat(t *testing.T) {
 	configGo := string(configGoBytes)
 	assert.NotContains(t, configGo, "TemplateVars",
 		"config struct must not carry TemplateVars when EndpointTemplateVars is empty")
+
+	// --path-context is profiler-driven: emitting it on a plain API would
+	// add a flag with nothing to substitute and bloat the help surface for
+	// every printed CLI that does not need it (#1332).
+	syncGoPath := filepath.Join(outputDir, "internal", "cli", "sync.go")
+	if syncGoBytes, syncErr := os.ReadFile(syncGoPath); syncErr == nil {
+		syncGo := string(syncGoBytes)
+		assert.NotContains(t, syncGo, "path-context",
+			"sync must not emit --path-context when EndpointTemplateVars is empty")
+		assert.NotContains(t, syncGo, "pathContextFlags",
+			"sync must not declare pathContextFlags when EndpointTemplateVars is empty")
+	}
 }
 
 // TestGenerateResourceBaseURLOverrideRoutesToOverrideHost — Open-Meteo
@@ -11438,11 +11450,28 @@ func TestGenerateEndpointTemplateEnvOverridesWireThrough(t *testing.T) {
 		"sync must declare the template-var set so the unresolved-key check ignores {tenant}")
 	assert.Contains(t, syncSrc, `slices.DeleteFunc`,
 		"sync must filter template-var placeholders out of the missing-keys list before warning")
+	// --path-context is the runtime override hatch for spec-declared
+	// EndpointTemplateVars (#1332). It must appear on the sync command when
+	// the spec has template-var placeholders so users can fill any {key} at
+	// the call site instead of re-exporting an env var.
+	assert.Contains(t, syncSrc, `&pathContextFlags, "path-context"`,
+		"sync must register the --path-context flag when EndpointTemplateVars is non-empty")
+	assert.Contains(t, syncSrc, `parseSyncKVFlags(pathContextFlags, "--path-context")`,
+		"sync RunE must parse --path-context entries via the shared KV parser")
+	assert.Contains(t, syncSrc, "c.Config.TemplateVars[k] = v",
+		"sync must merge --path-context values into Config.TemplateVars so buildURL substitution picks them up")
 
 	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
 	require.NoError(t, err)
 	assert.Contains(t, string(readme), "ST_TENANT_ID",
 		"README must surface the override env var name in the runtime endpoint instructions")
+
+	// Compile the generated tree so the --path-context emission and the
+	// Config.TemplateVars merge land as valid Go; a template-level bracket
+	// mistake would otherwise pass the string-Contains checks above but
+	// blow up at install time for every printed CLI.
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
 }
 
 // TestGenerateParentNoSubcommandRunE_WiredOnResourceParents: every generated
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 312a10fa..19d76c47 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -69,6 +69,9 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 	var paramFlags []string
 	var resourceParamFlags []string
 	var globalParamFlags []string
+{{- if .EndpointTemplateVars}}
+	var pathContextFlags []string
+{{- end}}
 {{- if .Pagination.DateRangeParam}}
 	var dates string
 {{- end}}
@@ -129,12 +132,46 @@ Resource scoping:
 			if err != nil {
 				return usageErr(err)
 			}
+{{- if .EndpointTemplateVars}}
+			pathContext, err := parseSyncKVFlags(pathContextFlags, "--path-context")
+			if err != nil {
+				return usageErr(err)
+			}
+{{- end}}
 
 			c, err := flags.newClient()
 			if err != nil {
 				return err
 			}
 			c.NoCache = true
+{{- if .EndpointTemplateVars}}
+			// Merge --path-context values into Config.TemplateVars so the
+			// existing buildURL substitution pipeline picks them up for every
+			// request (both BaseURL and request-path placeholders). Call-site
+			// values win over env-resolved defaults: the flag is the one-off
+			// override hatch when the env variable already holds a different
+			// value, and the only fill mechanism for placeholders the spec
+			// did not annotate with x-tenant-env-var.
+			if len(pathContext) > 0 {
+				if c.Config == nil {
+					return fmt.Errorf("--path-context requires a loaded config (no Config attached to client)")
+				}
+				if c.Config.TemplateVars == nil {
+					c.Config.TemplateVars = map[string]string{}
+				}
+				// Warn on keys outside endpointTemplateVarSet so typos
+				// (e.g. --path-context tneant=...) surface immediately
+				// instead of silently producing a run that used the
+				// env-resolved default. Non-fatal: the merge still proceeds
+				// so callers passing forward-compat keys aren't blocked.
+				for k, v := range pathContext {
+					if !endpointTemplateVarSet[k] {
+						fmt.Fprintf(os.Stderr, "warning: --path-context key %q is not a known endpoint placeholder; this value will not be substituted into any request path\n", k)
+					}
+					c.Config.TemplateVars[k] = v
+				}
+			}
+{{- end}}
 
 			if dbPath == "" {
 				dbPath = defaultDBPath("{{.Name}}-pp-cli")
@@ -391,6 +428,9 @@ Resource scoping:
 	cmd.Flags().StringArrayVar(&paramFlags, "param", nil, "Extra query param to inject into flat-list sync requests (repeatable, key=value). Skipped on path-scoped dependent requests so a top-level scope like workspace=<id> does not double up on /parents/<id>/children calls. Use --global-param to inject everywhere. Avoid pagination keys (limit/since/cursor) — overriding them corrupts resume state.")
 	cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param", nil, "Per-resource extra query param (repeatable, resource:key=value). Wins over --param and --global-param when keys conflict.")
 	cmd.Flags().StringArrayVar(&globalParamFlags, "global-param", nil, "Extra query param to inject into every sync request including dependent path-scoped calls (repeatable, key=value). Use when an API requires a scope on every call regardless of path nesting.")
+{{- if .EndpointTemplateVars}}
+	cmd.Flags().StringArrayVar(&pathContextFlags, "path-context", nil, "Fill a {key} placeholder in BaseURL or request paths from a supplied value (repeatable, key=value). Wins over env-resolved Config.TemplateVars values, so it doubles as a one-off override at the call site. Use it when an env variable already holds a different value, or when the spec did not annotate the placeholder with an env var.")
+{{- end}}
 {{- if .Pagination.DateRangeParam}}
 	cmd.Flags().StringVar(&dates, "dates", "", "Date or date range to sync (passed as {{.Pagination.DateRangeParam}} query parameter)")
 {{- end}}

← 187b6992 fix(cli): emit structured cache_warning JSON on auto-refresh  ·  back to Cli Printing Press  ·  docs(skills): document separate-file pattern for extending e af9ba8c7 →