← back to Cli Printing Press
fix(cli): scope-aware sync --param dispatch with --global-param fallback (#1529)
4e304df5654e4432b48d04f74a28e0e4506108d8 · 2026-05-16 13:37:13 -0700 · Trevin Chow
* fix(cli): scope-aware sync --param dispatch with --global-param fallback
Asana, Jira, GitHub, and similar APIs reject path-scoped dependent
requests when a top-level scope param is re-injected on top of the path
context (e.g. /projects/<gid>/tasks?workspace=<gid> trips Asana's
"Must specify exactly one of project, tag, ..." error). The sync
command's --param flag injected indiscriminately into every request,
forcing a manual two-phase workaround.
Split syncUserParams.global into flatGlobal (--param) and trueGlobal
(--global-param). applyTo gains an isDependent bool: dependent
path-scoped calls skip flatGlobal but still apply trueGlobal and
per-resource params. Existing single-phase syncs on APIs without
dependent resources behave identically.
Operators who relied on the old apply-everywhere semantic opt back in
with --global-param key=value.
Closes #1393
* refactor(cli): rename parseSyncUserParams perResourceFlags param
Make the parameter name in the helper signature match the call-site name resourceParamFlags. No behavior change; just removes a name-mismatch readability snag inside the function body.
---------
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Files touched
M internal/generator/sync_param_passthrough_test.goM internal/generator/templates/helpers.go.tmplM internal/generator/templates/sync.go.tmplM testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api/dogfood.jsonM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.goM testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
Diff
commit 4e304df5654e4432b48d04f74a28e0e4506108d8
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat May 16 13:37:13 2026 -0700
fix(cli): scope-aware sync --param dispatch with --global-param fallback (#1529)
* fix(cli): scope-aware sync --param dispatch with --global-param fallback
Asana, Jira, GitHub, and similar APIs reject path-scoped dependent
requests when a top-level scope param is re-injected on top of the path
context (e.g. /projects/<gid>/tasks?workspace=<gid> trips Asana's
"Must specify exactly one of project, tag, ..." error). The sync
command's --param flag injected indiscriminately into every request,
forcing a manual two-phase workaround.
Split syncUserParams.global into flatGlobal (--param) and trueGlobal
(--global-param). applyTo gains an isDependent bool: dependent
path-scoped calls skip flatGlobal but still apply trueGlobal and
per-resource params. Existing single-phase syncs on APIs without
dependent resources behave identically.
Operators who relied on the old apply-everywhere semantic opt back in
with --global-param key=value.
Closes #1393
* refactor(cli): rename parseSyncUserParams perResourceFlags param
Make the parameter name in the helper signature match the call-site name resourceParamFlags. No behavior change; just removes a name-mismatch readability snag inside the function body.
---------
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
internal/generator/sync_param_passthrough_test.go | 147 +++++++++++++--------
internal/generator/templates/helpers.go.tmpl | 77 ++++++++---
internal/generator/templates/sync.go.tmpl | 14 +-
.../embedded-paged-api/internal/cli/helpers.go | 77 ++++++++---
.../internal/cli/helpers.go | 77 ++++++++---
.../expected/generate-golden-api/dogfood.json | 2 +-
.../printing-press-golden/internal/cli/helpers.go | 77 ++++++++---
.../printing-press-golden/internal/cli/sync.go | 14 +-
.../sync-walker-golden/internal/cli/sync.go | 14 +-
.../tier-routing-golden/internal/cli/sync.go | 10 +-
10 files changed, 357 insertions(+), 152 deletions(-)
diff --git a/internal/generator/sync_param_passthrough_test.go b/internal/generator/sync_param_passthrough_test.go
index f23f80f9..44433b25 100644
--- a/internal/generator/sync_param_passthrough_test.go
+++ b/internal/generator/sync_param_passthrough_test.go
@@ -15,10 +15,11 @@ import (
)
// TestGenerateSyncParamPassthrough verifies the sync template emits the
-// --param / --resource-param flags, parses them through parseSyncUserParams,
-// and applies them after spec-derived params. Some APIs mark filter params
-// optional in the spec but reject requests without them at runtime; without
-// this passthrough, the only workaround is hand-editing the generated client.
+// --param / --resource-param / --global-param flags, parses them through
+// parseSyncUserParams, and applies them after spec-derived params. Some
+// APIs mark filter params optional in the spec but reject requests
+// without them at runtime; without this passthrough, the only workaround
+// is hand-editing the generated client.
func TestGenerateSyncParamPassthrough(t *testing.T) {
t.Parallel()
@@ -37,6 +38,8 @@ func TestGenerateSyncParamPassthrough(t *testing.T) {
"sync should expose a repeatable --param flag")
assert.Contains(t, syncSrc, `cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param"`,
"sync should expose a repeatable --resource-param flag")
+ assert.Contains(t, syncSrc, `cmd.Flags().StringArrayVar(&globalParamFlags, "global-param"`,
+ "sync should expose a repeatable --global-param flag for the apply-everywhere semantic")
assert.Contains(t, syncSrc, "key=value",
"--param help text should describe the key=value shape")
assert.Contains(t, syncSrc, "resource:key=value",
@@ -44,9 +47,9 @@ func TestGenerateSyncParamPassthrough(t *testing.T) {
// Parsing runs before client construction so a malformed flag fails fast
// (and as usageErr, not a generic runtime error).
- assert.Contains(t, syncSrc, "parseSyncUserParams(paramFlags, resourceParamFlags)",
- "sync must parse user params at RunE entry")
- parseIdx := strings.Index(syncSrc, "parseSyncUserParams(paramFlags, resourceParamFlags)")
+ assert.Contains(t, syncSrc, "parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)",
+ "sync must parse user params at RunE entry with all three flag slices")
+ parseIdx := strings.Index(syncSrc, "parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)")
newClientIdx := strings.Index(syncSrc, "flags.newClient()")
require.NotEqual(t, -1, parseIdx)
require.NotEqual(t, -1, newClientIdx)
@@ -62,7 +65,7 @@ func TestGenerateSyncParamPassthrough(t *testing.T) {
// applyTo is called in the page loop AFTER cursor/since/limit are set,
// so user flags win on conflict.
loopIdx := strings.Index(syncSrc, "params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)")
- applyIdx := strings.Index(syncSrc, "userParams.applyTo(resource, params)")
+ applyIdx := strings.Index(syncSrc, "userParams.applyTo(resource, params, false)")
require.NotEqual(t, -1, loopIdx, "page loop should set the limit param")
require.NotEqual(t, -1, applyIdx, "syncResource should apply user params before c.Get")
assert.Less(t, loopIdx, applyIdx,
@@ -76,6 +79,89 @@ func TestGenerateSyncParamPassthrough(t *testing.T) {
"knownSyncResourceNames helper must be emitted alongside defaultSyncResources")
}
+// dependentResourceSpec builds a minimal spec with a parent + child
+// resource so syncDependentResource is actually emitted. The
+// dependent-resource profiler requires paginated list endpoints (not
+// bare GETs) with a parameterized child path for the child to be
+// classified as syncable.
+func dependentResourceSpec(name string) *spec.APISpec {
+ paginated := func(path string) spec.Endpoint {
+ return spec.Endpoint{
+ Method: "GET",
+ Path: path,
+ Response: spec.ResponseDef{Type: "array"},
+ Pagination: &spec.Pagination{Type: "cursor", LimitParam: "limit", CursorParam: "after"},
+ }
+ }
+ apiSpec := minimalSpec(name)
+ apiSpec.Resources = map[string]spec.Resource{
+ "projects": {
+ Description: "Projects",
+ Endpoints: map[string]spec.Endpoint{"list": paginated("/projects")},
+ },
+ "tasks": {
+ Description: "Tasks (child of projects)",
+ Endpoints: map[string]spec.Endpoint{"list": paginated("/projects/{project_id}/tasks")},
+ },
+ }
+ return apiSpec
+}
+
+// TestGenerateSyncDependentSkipsFlatGlobalParam verifies the dependent-
+// resource sync path calls applyTo with isDependent=true so --param
+// (flatGlobal) is skipped on path-scoped requests. Without this gate, a
+// top-level scope flag like --param workspace=<gid> double-applies to
+// dependent calls like /projects/<gid>/tasks, and Asana-style APIs
+// reject the call ("Must specify exactly one of project, tag, ...").
+func TestGenerateSyncDependentSkipsFlatGlobalParam(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := dependentResourceSpec("dependent-param")
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+ require.NoError(t, err)
+ syncSrc := string(syncGo)
+
+ require.Contains(t, syncSrc, "func syncDependentResource(",
+ "dependent-resource sync should render when the spec has a {parent_id} child path")
+ assert.Contains(t, syncSrc, "userParams.applyTo(dep.Name, params, true)",
+ "dependent-resource call site must pass isDependent=true so --param is skipped on path-scoped calls")
+ assert.Contains(t, syncSrc, "userParams.applyTo(resource, params, false)",
+ "flat-list call site must pass isDependent=false so --param applies as before")
+}
+
+// TestGenerateSyncUserParamsHelperRespectsFlatVsTrueGlobal pins the
+// emitted applyTo helper: flatGlobal entries (--param) skip when
+// isDependent=true, while trueGlobal entries (--global-param) and
+// perResource entries always apply.
+func TestGenerateSyncUserParamsHelperRespectsFlatVsTrueGlobal(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("scope-helper")
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+ require.NoError(t, err)
+ helpersSrc := string(helpersGo)
+
+ require.Contains(t, helpersSrc, "type syncUserParams struct",
+ "syncUserParams struct must render")
+ for _, want := range []string{
+ "flatGlobal map[string]string",
+ "trueGlobal map[string]string",
+ "perResource map[string]map[string]string",
+ } {
+ assert.Contains(t, helpersSrc, want, "syncUserParams field %q must render", want)
+ }
+ assert.Contains(t, helpersSrc, "func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool)",
+ "applyTo signature must include the isDependent flag")
+ assert.Contains(t, helpersSrc, "if !isDependent {",
+ "applyTo must gate flatGlobal on isDependent=false")
+}
+
// TestGenerateSyncErrorJSONIncludesAPIBody verifies that the sync_error JSON
// event surfaces the API response body as a structured field, not just
// embedded inside an opaque err.Error() string. Without this surfacing, a
@@ -128,50 +214,7 @@ func TestGenerateSyncErrorJSONIncludesAPIBody(t *testing.T) {
func TestGenerateSyncDependentErrorNotSilent(t *testing.T) {
t.Parallel()
- // Build a spec with a parent + child resource so syncDependentResource
- // is actually emitted. The dependent-resource profiler requires paginated
- // list endpoints (not bare GETs) for the parameterized child path to be
- // classified as syncable; see profiler.detectDependentResources.
- paginated := func(path string) spec.Endpoint {
- return spec.Endpoint{
- Method: "GET",
- Path: path,
- Response: spec.ResponseDef{Type: "array"},
- Pagination: &spec.Pagination{Type: "cursor", LimitParam: "limit", CursorParam: "after"},
- }
- }
- apiSpec := &spec.APISpec{
- Name: "dependent-err",
- Version: "0.1.0",
- BaseURL: "https://api.example.test/v1",
- Owner: "test-owner",
- OwnerName: "Test Author",
- Auth: spec.AuthConfig{
- Type: "api_key",
- Header: "Authorization",
- Format: "Bearer {token}",
- EnvVars: []string{"DEPENDENT_ERR_API_KEY"},
- },
- Config: spec.ConfigSpec{
- Format: "toml",
- Path: "~/.config/dependent-err-pp-cli/config.toml",
- },
- Resources: map[string]spec.Resource{
- "teams": {
- Description: "Teams",
- Endpoints: map[string]spec.Endpoint{
- "list": paginated("/teams"),
- },
- },
- "players": {
- Description: "Players (child of teams)",
- Endpoints: map[string]spec.Endpoint{
- "list": paginated("/teams/{team_id}/players"),
- },
- },
- },
- }
-
+ apiSpec := dependentResourceSpec("dependent-err")
outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
gen := New(apiSpec, outputDir)
require.NoError(t, gen.Generate())
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 7ef3fd34..8b17ae4c 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -212,29 +212,43 @@ func syncErrorJSON(resource, parent string, err error) string {
return string(out)
}
-// syncUserParams carries user-supplied query parameters injected into every
-// sync HTTP request. perResource entries win over global on key conflict.
+// syncUserParams carries user-supplied query parameters injected into sync
+// HTTP requests. flatGlobal entries come from --param and inject into
+// flat-list requests only; trueGlobal entries come from --global-param and
+// inject into every request including dependent path-scoped calls.
+// perResource entries win over both on key conflict.
+//
+// The flat/dependent split avoids a real failure mode: a top-level scope
+// like workspace=<gid> belongs on flat-list requests (/projects, /tags) but
+// re-injecting it onto a path-scoped dependent request
+// (/projects/<gid>/tasks?workspace=<gid>) makes APIs like Asana reject the
+// call ("Must specify exactly one of project, tag, ..."). Operators who
+// need the old "apply everywhere" semantic opt back in with --global-param.
type syncUserParams struct {
- global map[string]string
+ flatGlobal map[string]string
+ trueGlobal map[string]string
perResource map[string]map[string]string
}
-// parseSyncUserParams parses the repeatable --param key=value and
-// --resource-param resource:key=value flags. Returns usage errors keyed on the
-// specific invalid token so the user sees which entry was rejected.
-func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParams, error) {
+// parseSyncUserParams parses the repeatable --param key=value,
+// --global-param key=value, and --resource-param resource:key=value flags.
+// Returns usage errors keyed on the specific invalid token so the user
+// sees which entry was rejected.
+func parseSyncUserParams(flatGlobalFlags, resourceParamFlags, trueGlobalFlags []string) (*syncUserParams, error) {
+ flatGlobal, err := parseSyncKVFlags(flatGlobalFlags, "--param")
+ if err != nil {
+ return nil, err
+ }
+ trueGlobal, err := parseSyncKVFlags(trueGlobalFlags, "--global-param")
+ if err != nil {
+ return nil, err
+ }
p := &syncUserParams{
- global: map[string]string{},
+ flatGlobal: flatGlobal,
+ trueGlobal: trueGlobal,
perResource: map[string]map[string]string{},
}
- for _, kv := range globalFlags {
- k, v, ok := strings.Cut(kv, "=")
- if !ok || k == "" {
- return nil, fmt.Errorf("invalid --param %q: expected key=value", kv)
- }
- p.global[k] = v
- }
- for _, spec := range perResourceFlags {
+ for _, spec := range resourceParamFlags {
resource, kv, ok := strings.Cut(spec, ":")
if !ok || resource == "" {
return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
@@ -251,13 +265,36 @@ func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParam
return p, nil
}
-// applyTo merges user params into the request map. Called after spec-derived
-// params (cursor, since, page-size, dates) so user flags can override them.
-func (p *syncUserParams) applyTo(resource string, params map[string]string) {
+// parseSyncKVFlags parses a slice of "key=value" tokens into a map. The
+// flagName label flows into the usage error so a malformed entry tells
+// the user which flag was at fault.
+func parseSyncKVFlags(flags []string, flagName string) (map[string]string, error) {
+ out := map[string]string{}
+ for _, kv := range flags {
+ k, v, ok := strings.Cut(kv, "=")
+ if !ok || k == "" {
+ return nil, fmt.Errorf("invalid %s %q: expected key=value", flagName, kv)
+ }
+ out[k] = v
+ }
+ return out, nil
+}
+
+// applyTo merges user params into the request map. Called after
+// spec-derived params (cursor, since, page-size, dates) so user flags can
+// override them. isDependent=true skips flatGlobal (--param), which
+// targets flat-list endpoints; trueGlobal (--global-param) and perResource
+// always apply.
+func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool) {
if p == nil {
return
}
- for k, v := range p.global {
+ if !isDependent {
+ for k, v := range p.flatGlobal {
+ params[k] = v
+ }
+ }
+ for k, v := range p.trueGlobal {
params[k] = v
}
for k, v := range p.perResource[resource] {
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 1ab413e3..b55d02cb 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -68,6 +68,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
var strict bool
var paramFlags []string
var resourceParamFlags []string
+ var globalParamFlags []string
{{- if .Pagination.DateRangeParam}}
var dates string
{{- end}}
@@ -124,7 +125,7 @@ Resource scoping:
# Sync historical data for a date range
{{.Name}}-pp-cli sync --dates 20250101-20250131{{end}}`,
RunE: func(cmd *cobra.Command, args []string) error {
- userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags)
+ userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)
if err != nil {
return usageErr(err)
}
@@ -371,8 +372,9 @@ Resource scoping:
cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
- cmd.Flags().StringArrayVar(¶mFlags, "param", nil, "Extra query param to inject into every sync request (repeatable, key=value). Use for APIs whose spec marks a filter optional but the endpoint rejects calls without it (e.g. --param mine=true). 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 when both define the same key.")
+ cmd.Flags().StringArrayVar(¶mFlags, "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 .Pagination.DateRangeParam}}
cmd.Flags().StringVar(&dates, "dates", "", "Date or date range to sync (passed as {{.Pagination.DateRangeParam}} query parameter)")
{{- end}}
@@ -519,7 +521,7 @@ func syncResource(c interface {
// Apply user-supplied --param / --resource-param overrides last so they
// win over spec-derived defaults (e.g. forcing mine=true on a list
// endpoint whose OpenAPI spec marks the filter optional).
- userParams.applyTo(resource, params)
+ userParams.applyTo(resource, params, false)
data, err := c.Get(path, params)
if err != nil {
@@ -1324,7 +1326,9 @@ func syncDependentResource(c interface {
{{- end}}
// Apply user flags last so they win over spec-derived cursor/since/limit.
- userParams.applyTo(dep.Name, params)
+ // Dependent path: --param is skipped (already scoped by the parent path
+ // segment); --global-param and --resource-param still apply.
+ userParams.applyTo(dep.Name, params, true)
data, err := c.Get(path, params)
if err != nil {
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
index 23502a66..5182dbc1 100644
--- a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
@@ -194,29 +194,43 @@ func syncErrorJSON(resource, parent string, err error) string {
return string(out)
}
-// syncUserParams carries user-supplied query parameters injected into every
-// sync HTTP request. perResource entries win over global on key conflict.
+// syncUserParams carries user-supplied query parameters injected into sync
+// HTTP requests. flatGlobal entries come from --param and inject into
+// flat-list requests only; trueGlobal entries come from --global-param and
+// inject into every request including dependent path-scoped calls.
+// perResource entries win over both on key conflict.
+//
+// The flat/dependent split avoids a real failure mode: a top-level scope
+// like workspace=<gid> belongs on flat-list requests (/projects, /tags) but
+// re-injecting it onto a path-scoped dependent request
+// (/projects/<gid>/tasks?workspace=<gid>) makes APIs like Asana reject the
+// call ("Must specify exactly one of project, tag, ..."). Operators who
+// need the old "apply everywhere" semantic opt back in with --global-param.
type syncUserParams struct {
- global map[string]string
+ flatGlobal map[string]string
+ trueGlobal map[string]string
perResource map[string]map[string]string
}
-// parseSyncUserParams parses the repeatable --param key=value and
-// --resource-param resource:key=value flags. Returns usage errors keyed on the
-// specific invalid token so the user sees which entry was rejected.
-func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParams, error) {
+// parseSyncUserParams parses the repeatable --param key=value,
+// --global-param key=value, and --resource-param resource:key=value flags.
+// Returns usage errors keyed on the specific invalid token so the user
+// sees which entry was rejected.
+func parseSyncUserParams(flatGlobalFlags, resourceParamFlags, trueGlobalFlags []string) (*syncUserParams, error) {
+ flatGlobal, err := parseSyncKVFlags(flatGlobalFlags, "--param")
+ if err != nil {
+ return nil, err
+ }
+ trueGlobal, err := parseSyncKVFlags(trueGlobalFlags, "--global-param")
+ if err != nil {
+ return nil, err
+ }
p := &syncUserParams{
- global: map[string]string{},
+ flatGlobal: flatGlobal,
+ trueGlobal: trueGlobal,
perResource: map[string]map[string]string{},
}
- for _, kv := range globalFlags {
- k, v, ok := strings.Cut(kv, "=")
- if !ok || k == "" {
- return nil, fmt.Errorf("invalid --param %q: expected key=value", kv)
- }
- p.global[k] = v
- }
- for _, spec := range perResourceFlags {
+ for _, spec := range resourceParamFlags {
resource, kv, ok := strings.Cut(spec, ":")
if !ok || resource == "" {
return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
@@ -233,13 +247,36 @@ func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParam
return p, nil
}
-// applyTo merges user params into the request map. Called after spec-derived
-// params (cursor, since, page-size, dates) so user flags can override them.
-func (p *syncUserParams) applyTo(resource string, params map[string]string) {
+// parseSyncKVFlags parses a slice of "key=value" tokens into a map. The
+// flagName label flows into the usage error so a malformed entry tells
+// the user which flag was at fault.
+func parseSyncKVFlags(flags []string, flagName string) (map[string]string, error) {
+ out := map[string]string{}
+ for _, kv := range flags {
+ k, v, ok := strings.Cut(kv, "=")
+ if !ok || k == "" {
+ return nil, fmt.Errorf("invalid %s %q: expected key=value", flagName, kv)
+ }
+ out[k] = v
+ }
+ return out, nil
+}
+
+// applyTo merges user params into the request map. Called after
+// spec-derived params (cursor, since, page-size, dates) so user flags can
+// override them. isDependent=true skips flatGlobal (--param), which
+// targets flat-list endpoints; trueGlobal (--global-param) and perResource
+// always apply.
+func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool) {
if p == nil {
return
}
- for k, v := range p.global {
+ if !isDependent {
+ for k, v := range p.flatGlobal {
+ params[k] = v
+ }
+ }
+ for k, v := range p.trueGlobal {
params[k] = v
}
for k, v := range p.perResource[resource] {
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
index 85b8e7df..3d01225d 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
@@ -193,29 +193,43 @@ func syncErrorJSON(resource, parent string, err error) string {
return string(out)
}
-// syncUserParams carries user-supplied query parameters injected into every
-// sync HTTP request. perResource entries win over global on key conflict.
+// syncUserParams carries user-supplied query parameters injected into sync
+// HTTP requests. flatGlobal entries come from --param and inject into
+// flat-list requests only; trueGlobal entries come from --global-param and
+// inject into every request including dependent path-scoped calls.
+// perResource entries win over both on key conflict.
+//
+// The flat/dependent split avoids a real failure mode: a top-level scope
+// like workspace=<gid> belongs on flat-list requests (/projects, /tags) but
+// re-injecting it onto a path-scoped dependent request
+// (/projects/<gid>/tasks?workspace=<gid>) makes APIs like Asana reject the
+// call ("Must specify exactly one of project, tag, ..."). Operators who
+// need the old "apply everywhere" semantic opt back in with --global-param.
type syncUserParams struct {
- global map[string]string
+ flatGlobal map[string]string
+ trueGlobal map[string]string
perResource map[string]map[string]string
}
-// parseSyncUserParams parses the repeatable --param key=value and
-// --resource-param resource:key=value flags. Returns usage errors keyed on the
-// specific invalid token so the user sees which entry was rejected.
-func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParams, error) {
+// parseSyncUserParams parses the repeatable --param key=value,
+// --global-param key=value, and --resource-param resource:key=value flags.
+// Returns usage errors keyed on the specific invalid token so the user
+// sees which entry was rejected.
+func parseSyncUserParams(flatGlobalFlags, resourceParamFlags, trueGlobalFlags []string) (*syncUserParams, error) {
+ flatGlobal, err := parseSyncKVFlags(flatGlobalFlags, "--param")
+ if err != nil {
+ return nil, err
+ }
+ trueGlobal, err := parseSyncKVFlags(trueGlobalFlags, "--global-param")
+ if err != nil {
+ return nil, err
+ }
p := &syncUserParams{
- global: map[string]string{},
+ flatGlobal: flatGlobal,
+ trueGlobal: trueGlobal,
perResource: map[string]map[string]string{},
}
- for _, kv := range globalFlags {
- k, v, ok := strings.Cut(kv, "=")
- if !ok || k == "" {
- return nil, fmt.Errorf("invalid --param %q: expected key=value", kv)
- }
- p.global[k] = v
- }
- for _, spec := range perResourceFlags {
+ for _, spec := range resourceParamFlags {
resource, kv, ok := strings.Cut(spec, ":")
if !ok || resource == "" {
return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
@@ -232,13 +246,36 @@ func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParam
return p, nil
}
-// applyTo merges user params into the request map. Called after spec-derived
-// params (cursor, since, page-size, dates) so user flags can override them.
-func (p *syncUserParams) applyTo(resource string, params map[string]string) {
+// parseSyncKVFlags parses a slice of "key=value" tokens into a map. The
+// flagName label flows into the usage error so a malformed entry tells
+// the user which flag was at fault.
+func parseSyncKVFlags(flags []string, flagName string) (map[string]string, error) {
+ out := map[string]string{}
+ for _, kv := range flags {
+ k, v, ok := strings.Cut(kv, "=")
+ if !ok || k == "" {
+ return nil, fmt.Errorf("invalid %s %q: expected key=value", flagName, kv)
+ }
+ out[k] = v
+ }
+ return out, nil
+}
+
+// applyTo merges user params into the request map. Called after
+// spec-derived params (cursor, since, page-size, dates) so user flags can
+// override them. isDependent=true skips flatGlobal (--param), which
+// targets flat-list endpoints; trueGlobal (--global-param) and perResource
+// always apply.
+func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool) {
if p == nil {
return
}
- for k, v := range p.global {
+ if !isDependent {
+ for k, v := range p.flatGlobal {
+ params[k] = v
+ }
+ }
+ for k, v := range p.trueGlobal {
params[k] = v
}
for k, v := range p.perResource[resource] {
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index 697673ba..d3c0383b 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -16,7 +16,7 @@
},
"dead_functions": {
"dead": 0,
- "total": 59
+ "total": 60
},
"dir": "<ARTIFACT_DIR>/generate-golden-api/printing-press-golden",
"example_check": {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
index 9000da76..6bdf7e9f 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -194,29 +194,43 @@ func syncErrorJSON(resource, parent string, err error) string {
return string(out)
}
-// syncUserParams carries user-supplied query parameters injected into every
-// sync HTTP request. perResource entries win over global on key conflict.
+// syncUserParams carries user-supplied query parameters injected into sync
+// HTTP requests. flatGlobal entries come from --param and inject into
+// flat-list requests only; trueGlobal entries come from --global-param and
+// inject into every request including dependent path-scoped calls.
+// perResource entries win over both on key conflict.
+//
+// The flat/dependent split avoids a real failure mode: a top-level scope
+// like workspace=<gid> belongs on flat-list requests (/projects, /tags) but
+// re-injecting it onto a path-scoped dependent request
+// (/projects/<gid>/tasks?workspace=<gid>) makes APIs like Asana reject the
+// call ("Must specify exactly one of project, tag, ..."). Operators who
+// need the old "apply everywhere" semantic opt back in with --global-param.
type syncUserParams struct {
- global map[string]string
+ flatGlobal map[string]string
+ trueGlobal map[string]string
perResource map[string]map[string]string
}
-// parseSyncUserParams parses the repeatable --param key=value and
-// --resource-param resource:key=value flags. Returns usage errors keyed on the
-// specific invalid token so the user sees which entry was rejected.
-func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParams, error) {
+// parseSyncUserParams parses the repeatable --param key=value,
+// --global-param key=value, and --resource-param resource:key=value flags.
+// Returns usage errors keyed on the specific invalid token so the user
+// sees which entry was rejected.
+func parseSyncUserParams(flatGlobalFlags, resourceParamFlags, trueGlobalFlags []string) (*syncUserParams, error) {
+ flatGlobal, err := parseSyncKVFlags(flatGlobalFlags, "--param")
+ if err != nil {
+ return nil, err
+ }
+ trueGlobal, err := parseSyncKVFlags(trueGlobalFlags, "--global-param")
+ if err != nil {
+ return nil, err
+ }
p := &syncUserParams{
- global: map[string]string{},
+ flatGlobal: flatGlobal,
+ trueGlobal: trueGlobal,
perResource: map[string]map[string]string{},
}
- for _, kv := range globalFlags {
- k, v, ok := strings.Cut(kv, "=")
- if !ok || k == "" {
- return nil, fmt.Errorf("invalid --param %q: expected key=value", kv)
- }
- p.global[k] = v
- }
- for _, spec := range perResourceFlags {
+ for _, spec := range resourceParamFlags {
resource, kv, ok := strings.Cut(spec, ":")
if !ok || resource == "" {
return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
@@ -233,13 +247,36 @@ func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParam
return p, nil
}
-// applyTo merges user params into the request map. Called after spec-derived
-// params (cursor, since, page-size, dates) so user flags can override them.
-func (p *syncUserParams) applyTo(resource string, params map[string]string) {
+// parseSyncKVFlags parses a slice of "key=value" tokens into a map. The
+// flagName label flows into the usage error so a malformed entry tells
+// the user which flag was at fault.
+func parseSyncKVFlags(flags []string, flagName string) (map[string]string, error) {
+ out := map[string]string{}
+ for _, kv := range flags {
+ k, v, ok := strings.Cut(kv, "=")
+ if !ok || k == "" {
+ return nil, fmt.Errorf("invalid %s %q: expected key=value", flagName, kv)
+ }
+ out[k] = v
+ }
+ return out, nil
+}
+
+// applyTo merges user params into the request map. Called after
+// spec-derived params (cursor, since, page-size, dates) so user flags can
+// override them. isDependent=true skips flatGlobal (--param), which
+// targets flat-list endpoints; trueGlobal (--global-param) and perResource
+// always apply.
+func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool) {
if p == nil {
return
}
- for k, v := range p.global {
+ if !isDependent {
+ for k, v := range p.flatGlobal {
+ params[k] = v
+ }
+ }
+ for k, v := range p.trueGlobal {
params[k] = v
}
for k, v := range p.perResource[resource] {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
index a7a8c097..22400033 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
@@ -48,6 +48,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
var strict bool
var paramFlags []string
var resourceParamFlags []string
+ var globalParamFlags []string
cmd := &cobra.Command{
Use: "sync",
@@ -97,7 +98,7 @@ Resource scoping:
# Latest-only: refresh head of each resource, no historical backfill
printing-press-golden-pp-cli sync --latest-only`,
RunE: func(cmd *cobra.Command, args []string) error {
- userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags)
+ userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)
if err != nil {
return usageErr(err)
}
@@ -333,8 +334,9 @@ Resource scoping:
cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
- cmd.Flags().StringArrayVar(¶mFlags, "param", nil, "Extra query param to inject into every sync request (repeatable, key=value). Use for APIs whose spec marks a filter optional but the endpoint rejects calls without it (e.g. --param mine=true). 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 when both define the same key.")
+ cmd.Flags().StringArrayVar(¶mFlags, "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.")
return cmd
}
@@ -458,7 +460,7 @@ func syncResource(c interface {
// Apply user-supplied --param / --resource-param overrides last so they
// win over spec-derived defaults (e.g. forcing mine=true on a list
// endpoint whose OpenAPI spec marks the filter optional).
- userParams.applyTo(resource, params)
+ userParams.applyTo(resource, params, false)
data, err := c.Get(path, params)
if err != nil {
@@ -1184,7 +1186,9 @@ func syncDependentResource(c interface {
}
// Apply user flags last so they win over spec-derived cursor/since/limit.
- userParams.applyTo(dep.Name, params)
+ // Dependent path: --param is skipped (already scoped by the parent path
+ // segment); --global-param and --resource-param still apply.
+ userParams.applyTo(dep.Name, params, true)
data, err := c.Get(path, params)
if err != nil {
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
index c349184b..eb60d6ea 100644
--- a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
@@ -48,6 +48,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
var strict bool
var paramFlags []string
var resourceParamFlags []string
+ var globalParamFlags []string
cmd := &cobra.Command{
Use: "sync",
@@ -97,7 +98,7 @@ Resource scoping:
# Latest-only: refresh head of each resource, no historical backfill
sync-walker-golden-pp-cli sync --latest-only`,
RunE: func(cmd *cobra.Command, args []string) error {
- userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags)
+ userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)
if err != nil {
return usageErr(err)
}
@@ -333,8 +334,9 @@ Resource scoping:
cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
- cmd.Flags().StringArrayVar(¶mFlags, "param", nil, "Extra query param to inject into every sync request (repeatable, key=value). Use for APIs whose spec marks a filter optional but the endpoint rejects calls without it (e.g. --param mine=true). 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 when both define the same key.")
+ cmd.Flags().StringArrayVar(¶mFlags, "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.")
return cmd
}
@@ -458,7 +460,7 @@ func syncResource(c interface {
// Apply user-supplied --param / --resource-param overrides last so they
// win over spec-derived defaults (e.g. forcing mine=true on a list
// endpoint whose OpenAPI spec marks the filter optional).
- userParams.applyTo(resource, params)
+ userParams.applyTo(resource, params, false)
data, err := c.Get(path, params)
if err != nil {
@@ -1176,7 +1178,9 @@ func syncDependentResource(c interface {
}
// Apply user flags last so they win over spec-derived cursor/since/limit.
- userParams.applyTo(dep.Name, params)
+ // Dependent path: --param is skipped (already scoped by the parent path
+ // segment); --global-param and --resource-param still apply.
+ userParams.applyTo(dep.Name, params, true)
data, err := c.Get(path, params)
if err != nil {
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
index f4c9835a..90d43f1c 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
@@ -49,6 +49,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
var strict bool
var paramFlags []string
var resourceParamFlags []string
+ var globalParamFlags []string
cmd := &cobra.Command{
Use: "sync",
@@ -98,7 +99,7 @@ Resource scoping:
# Latest-only: refresh head of each resource, no historical backfill
tier-routing-golden-pp-cli sync --latest-only`,
RunE: func(cmd *cobra.Command, args []string) error {
- userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags)
+ userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)
if err != nil {
return usageErr(err)
}
@@ -307,8 +308,9 @@ Resource scoping:
cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
- cmd.Flags().StringArrayVar(¶mFlags, "param", nil, "Extra query param to inject into every sync request (repeatable, key=value). Use for APIs whose spec marks a filter optional but the endpoint rejects calls without it (e.g. --param mine=true). 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 when both define the same key.")
+ cmd.Flags().StringArrayVar(¶mFlags, "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.")
return cmd
}
@@ -432,7 +434,7 @@ func syncResource(c interface {
// Apply user-supplied --param / --resource-param overrides last so they
// win over spec-derived defaults (e.g. forcing mine=true on a list
// endpoint whose OpenAPI spec marks the filter optional).
- userParams.applyTo(resource, params)
+ userParams.applyTo(resource, params, false)
data, err := c.Get(path, params)
if err != nil {
← 7396d66f fix(skills): add NULL-safe SQL scan guidance to Phase 3 stor
·
back to Cli Printing Press
·
fix(ci): require all review threads resolved before queue + a8fa80a3 →