From a796da1a1d42cc9d8aae28e805164e83a3e04b00 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Wed, 19 Aug 2026 09:51:57 -0500 Subject: [PATCH 1/4] feat(products): submit App Store products for Apple review Applying a store-state plan pushes desired configuration to App Store Connect but never submits anything for review, so products sit configured-but-not-purchasable. This adds `rc products store submit` to start Apple review for the named App Store products. Only the products passed as arguments are submitted, and the backend skips any that aren't ready yet (with a reason) instead of failing the whole run. The endpoint is a beta product-catalog route, so it's added to the beta overlay with a hand-written client method and response types alongside the existing store-state code. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/command-surface.md | 1 + docs/specs/cli-coverage.yaml | 2 + docs/specs/v2-beta-overlay.yaml | 56 +++++++++++ internal/api/paths_gen.go | 4 + internal/api/store_state_direct.go | 27 ++++++ internal/api/types_gen.go | 32 +++++++ internal/cli/products_store.go | 92 +++++++++++++++++++ internal/cli/products_store_lifecycle_test.go | 71 ++++++++++++++ 8 files changed, 285 insertions(+) diff --git a/docs/command-surface.md b/docs/command-surface.md index f7200732..01219d75 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -197,6 +197,7 @@ rc products store sync [app-id] # human flow: input → rc products store plan [app-id] # persist desired state + diff on the backend; accepts --file rc products store show # inspect the exact persisted plan from any process rc products store apply # apply that same reviewed plan; requires confirmation/--yes +rc products store submit ... # start Apple review for App Store products (--store app_store); requires confirmation/--yes rc products store discard # discard without applying; requires confirmation/--yes # Paywalls diff --git a/docs/specs/cli-coverage.yaml b/docs/specs/cli-coverage.yaml index ccafd5d1..a979da00 100644 --- a/docs/specs/cli-coverage.yaml +++ b/docs/specs/cli-coverage.yaml @@ -133,6 +133,8 @@ endpoints: path: /projects/{project_id}/products/{product_id} - method: DELETE path: /projects/{project_id}/products/{product_id} + - method: POST + path: /projects/{project_id}/products/actions/submit_to_store - method: POST path: /projects/{project_id}/products/{product_id}/actions/archive - method: POST diff --git a/docs/specs/v2-beta-overlay.yaml b/docs/specs/v2-beta-overlay.yaml index 185264c1..6cbe6636 100644 --- a/docs/specs/v2-beta-overlay.yaml +++ b/docs/specs/v2-beta-overlay.yaml @@ -7,6 +7,62 @@ info: title: RevenueCat v2 — beta overlay version: overlay paths: + /projects/{project_id}/products/actions/submit_to_store: + post: + summary: Submit products to store + description: "Submits App Store products for Apple review and returns a per-product\ + \ outcome. Products that are not yet ready for submission are skipped with a\ + \ reason rather than failing the whole request. All products must belong to\ + \ the same app.\n This endpoint requires the following permission(s): project_configuration:products:read_write.\ + \ This endpoint belongs to the Project Configuration domain,\ + \ which has a default rate limit of 60 requests per minute." + operationId: submit-products-to-store + x-revenuecat-rate-limiting-domain: project_configuration + x-scopes: + - project_configuration:products:read_write + x-release-status: development + x-revenuecat-mcp-include: false + x-revenuecat-release-gatekeeping: false + tags: + - Product + parameters: + - name: project_id + description: ID of the project + required: true + in: path + schema: + type: string + maxLength: 255 + example: proj1ab2c3d4 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - store + - product_ids + properties: + store: + description: The target store for this operation. + type: string + enum: + - app_store + example: app_store + product_ids: + description: Product IDs to attempt to submit to store. + type: array + minItems: 1 + maxItems: 200 + items: + type: string + minLength: 1 + maxLength: 255 + example: prod1a2b3c4d5e + responses: + '200': + description: OK /projects/{project_id}/products/{product_id}/test_store_prices: get: summary: 'List product prices (deprecated: use /prices instead)' diff --git a/internal/api/paths_gen.go b/internal/api/paths_gen.go index 028784f5..77424322 100644 --- a/internal/api/paths_gen.go +++ b/internal/api/paths_gen.go @@ -234,6 +234,10 @@ func pathProducts(projectID string) string { return encodePath("projects", projectID, "products") } +func pathProductsActionsSubmitToStore(projectID string) string { + return encodePath("projects", projectID, "products", "actions", "submit_to_store") +} + func pathProjects() string { return encodePath("projects") } diff --git a/internal/api/store_state_direct.go b/internal/api/store_state_direct.go index 5e904c8e..4041dc22 100644 --- a/internal/api/store_state_direct.go +++ b/internal/api/store_state_direct.go @@ -84,6 +84,33 @@ type TerritoryPrice struct { StartDate *string `json:"start_date"` } +// SubmitProductsToStoreResponse is the outcome of submitting products for +// store review. submitted_count counts only the products that were actually +// submitted; results carries the per-product outcome (including skips). +type SubmitProductsToStoreResponse struct { + Object string `json:"object"` + SubmittedCount int `json:"submitted_count"` + Results []SubmitProductToStoreResult `json:"results"` +} + +type SubmitProductToStoreResult struct { + Object string `json:"object"` + ProductID string `json:"product_id"` + Status string `json:"status"` + SubmissionID *string `json:"submission_id"` + Message *string `json:"message"` +} + +// SubmitToStore starts store review for the given products. The store is +// required by the API and only app_store is accepted today; products that are +// not yet ready come back with a skipped result rather than failing the call. +func (s *StoreStateService) SubmitToStore(ctx context.Context, projectID, store string, productIDs []string) (*SubmitProductsToStoreResponse, error) { + var out SubmitProductsToStoreResponse + body := map[string]any{"store": store, "product_ids": productIDs} + err := s.c.do(ctx, http.MethodPost, pathProductsActionsSubmitToStore(projectID), body, &out) + return &out, err +} + func (s *StoreStateService) Get(ctx context.Context, projectID, productID string) (*LiveStoreState, error) { var out LiveStoreState err := s.c.do(ctx, http.MethodGet, pathProductStoreState(projectID, productID), nil, &out) diff --git a/internal/api/types_gen.go b/internal/api/types_gen.go index 31a2871a..0a4da53a 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -27204,6 +27204,21 @@ func (e CreateProduct503JSONResponseBodyType) Valid() bool { } } +// Defines values for SubmitProductsToStoreJSONBodyStore. +const ( + SubmitProductsToStoreJSONBodyStoreAppStore SubmitProductsToStoreJSONBodyStore = "app_store" +) + +// Valid indicates whether the value is a known member of the SubmitProductsToStoreJSONBodyStore enum. +func (e SubmitProductsToStoreJSONBodyStore) Valid() bool { + switch e { + case SubmitProductsToStoreJSONBodyStoreAppStore: + return true + default: + return false + } +} + // Defines values for DeleteProduct400JSONResponseBodyObject. const ( DeleteProduct400JSONResponseBodyObjectError DeleteProduct400JSONResponseBodyObject = "error" @@ -43614,6 +43629,20 @@ type CreateProduct503JSONResponseBodyObject string // CreateProduct503JSONResponseBodyType defines parameters for CreateProduct. type CreateProduct503JSONResponseBodyType string +// SubmitProductsToStoreJSONBody defines parameters for SubmitProductsToStore. +type SubmitProductsToStoreJSONBody struct { + // ProductIds Product IDs to attempt to submit to store. + ProductIds []string `json:"product_ids"` + + // Store The target store for this operation. + // + // Example: app_store + Store SubmitProductsToStoreJSONBodyStore `json:"store"` +} + +// SubmitProductsToStoreJSONBodyStore defines parameters for SubmitProductsToStore. +type SubmitProductsToStoreJSONBodyStore string + // DeleteProduct400JSONResponseBodyObject defines parameters for DeleteProduct. type DeleteProduct400JSONResponseBodyObject string @@ -45287,6 +45316,9 @@ type CreatePaywallVersionJSONRequestBody CreatePaywallVersionJSONBody // CreateProductJSONRequestBody defines body for CreateProduct for application/json ContentType. type CreateProductJSONRequestBody CreateProductJSONBody +// SubmitProductsToStoreJSONRequestBody defines body for SubmitProductsToStore for application/json ContentType. +type SubmitProductsToStoreJSONRequestBody SubmitProductsToStoreJSONBody + // UpdateProductJSONRequestBody defines body for UpdateProduct for application/json ContentType. type UpdateProductJSONRequestBody UpdateProductJSONBody diff --git a/internal/cli/products_store.go b/internal/cli/products_store.go index be95fa75..eaef26f3 100644 --- a/internal/cli/products_store.go +++ b/internal/cli/products_store.go @@ -27,6 +27,7 @@ that exact plan. Files are optional; pass --file - to read CSV or JSON stdin.`, newProductsStorePlanCmd(), newProductsStoreShowCmd(), newProductsStoreApplyCmd(), + newProductsStoreSubmitCmd(), newProductsStoreDiscardCmd(), newProductsStoreScreenshotCmd(), newProductsStoreListCmd(), @@ -254,6 +255,97 @@ Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.` return cmd } +func newProductsStoreSubmitCmd() *cobra.Command { + var store string + cmd := &cobra.Command{ + Use: "submit ...", + Short: "Submit App Store products for Apple review", + Long: `Starts Apple review for the named App Store products. Applying a +store-state plan pushes configuration to App Store Connect but never submits +anything for review, so products stay configured-but-not-purchasable until this +command runs. + +Only the products passed as arguments are submitted, and they must all belong +to the same app. A product is submittable only once it exists in App Store +Connect; one that isn't ready comes back skipped with a reason instead of +failing the whole run. App Store only — Apple is the sole store that accepts +review submissions through this command. + +Reversibility: starts Apple review — manage the submission in App Store Connect. + +Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`, + Example: ` rc products store submit prod_abc prod_def --yes + rc products store submit prod_abc --yes --json --no-input`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + rt := RuntimeFrom(cmd.Context()) + if store != "app_store" { + return fmt.Errorf("only App Store products can be submitted for review; --store %q is not supported", store) + } + productIDs, err := cleanSubmitProductIDs(args) + if err != nil { + return err + } + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err + } + if err := confirmOrAbort(rt, fmt.Sprintf("Submit %d App Store product(s) for Apple review?", len(productIDs)), + "nothing was submitted"); err != nil { + return err + } + resp, err := client.StoreState.SubmitToStore(cmd.Context(), projectID, store, productIDs) + if err != nil { + return err + } + return renderStoreSubmitResult(rt, resp) + }, + } + cmd.Flags().StringVar(&store, "store", "app_store", "store to submit to (only app_store is supported)") + return cmd +} + +// cleanSubmitProductIDs trims the product IDs and enforces the server's bounds +// (non-empty, at most 200) before spending a round trip. +func cleanSubmitProductIDs(args []string) ([]string, error) { + ids := make([]string, 0, len(args)) + for _, arg := range args { + id := strings.TrimSpace(arg) + if id == "" { + return nil, fmt.Errorf("product IDs cannot be empty") + } + ids = append(ids, id) + } + if len(ids) > 200 { + return nil, fmt.Errorf("cannot submit more than 200 products at once; got %d", len(ids)) + } + return ids, nil +} + +func renderStoreSubmitResult(rt *Runtime, resp *api.SubmitProductsToStoreResponse) error { + rows := make([][]string, 0, len(resp.Results)) + for _, result := range resp.Results { + detail := "" + switch result.Status { + case "submitted": + detail = optionalString(result.SubmissionID, "") + default: + detail = optionalString(result.Message, "") + } + rows = append(rows, []string{result.ProductID, result.Status, detail}) + } + rt.Out.Info(fmt.Sprintf("Submitted %d of %d product(s) for review", resp.SubmittedCount, len(resp.Results))) + return rt.Out.RenderTable(output.Table{ + Columns: []string{"PRODUCT", "STATUS", "DETAIL"}, + Rows: rows, + Raw: resp, + }) +} + func newProductsStoreDiscardCmd() *cobra.Command { return &cobra.Command{ Use: "discard ", diff --git a/internal/cli/products_store_lifecycle_test.go b/internal/cli/products_store_lifecycle_test.go index 76726364..7d9ce2fb 100644 --- a/internal/cli/products_store_lifecycle_test.go +++ b/internal/cli/products_store_lifecycle_test.go @@ -157,6 +157,77 @@ func TestReadStoreStateJSON_RejectsAnotherApp(t *testing.T) { } } +func TestProductsStoreSubmit_ReportsPerProductOutcomes(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/projects/proj/products/actions/submit_to_store" { + http.Error(w, "unexpected request", http.StatusNotFound) + return + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"object":"submit_products_to_store_response","submitted_count":1,"results":[`+ + `{"object":"submit_product_to_store_result","product_id":"prod_abc","status":"submitted","submission_id":"sub_123","message":null},`+ + `{"object":"submit_product_to_store_result","product_id":"prod_def","status":"skipped","submission_id":null,"message":"not ready to submit"}]}`) + })) + t.Cleanup(server.Close) + + out, _, err := runStoreLifecycleCommand(t, server.URL, "", + "products", "store", "submit", "prod_abc", "prod_def", "--yes", "--json", "--no-input") + if err != nil { + t.Fatal(err) + } + if body["store"] != "app_store" { + t.Fatalf("store = %v, want app_store", body["store"]) + } + ids, _ := body["product_ids"].([]any) + if len(ids) != 2 || ids[0] != "prod_abc" || ids[1] != "prod_def" { + t.Fatalf("product_ids = %v, want [prod_abc prod_def]", body["product_ids"]) + } + if !strings.Contains(out, `"submitted_count": 1`) { + t.Fatalf("output missing submitted_count: %s", out) + } + if !strings.Contains(out, `"status": "skipped"`) || !strings.Contains(out, "not ready to submit") { + t.Fatalf("output missing skipped outcome: %s", out) + } +} + +func TestProductsStoreSubmit_RejectsNonAppStore(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "must not reach the API for a rejected store", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + _, _, err := runStoreLifecycleCommand(t, server.URL, "", + "products", "store", "submit", "prod_abc", "--store", "play_store", "--yes", "--json", "--no-input") + if err == nil || !strings.Contains(err.Error(), "only App Store products") { + t.Fatalf("error = %v, want App Store only rejection", err) + } +} + +func TestProductsStoreSubmit_SurfacesSubmissionFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"type":"parameter_error","message":"all products must belong to the same app"}`) + })) + t.Cleanup(server.Close) + + _, _, err := runStoreLifecycleCommand(t, server.URL, "", + "products", "store", "submit", "prod_abc", "prod_def", "--yes", "--json", "--no-input") + if err == nil { + t.Fatal("expected error for failed submission") + } + if code := ExitCodeFor(err); code == 0 { + t.Fatalf("exit code = %d, want non-zero", code) + } + if !strings.Contains(err.Error(), "same app") { + t.Fatalf("error = %v, want the API message surfaced", err) + } +} + type staticStoreStatePlanService struct{ plan *api.StoreStatePlan } func (s staticStoreStatePlanService) Get(context.Context, string, string) (*api.StoreStatePlan, error) { From ebe2fe464e0088fa7ed51ee88c05f88225f1f881 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Wed, 19 Aug 2026 10:05:47 -0500 Subject: [PATCH 2/4] review: next-step hint on skips, validation tests, result doc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-submitted products now get a follow-up hint pointing at the DETAIL reason and the apply-first remedy. Adds unit coverage for the ID validation (trim, empty, >200 cap) and asserts a fully-skipped response still exits 0. Documents the per-product result type (no per-product failure — hard failures are HTTP errors). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/api/store_state_direct.go | 3 ++ internal/cli/products_store.go | 11 +++++- internal/cli/products_store_lifecycle_test.go | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/internal/api/store_state_direct.go b/internal/api/store_state_direct.go index 4041dc22..a5fdea21 100644 --- a/internal/api/store_state_direct.go +++ b/internal/api/store_state_direct.go @@ -93,6 +93,9 @@ type SubmitProductsToStoreResponse struct { Results []SubmitProductToStoreResult `json:"results"` } +// SubmitProductToStoreResult is one product's outcome. Status is "submitted" +// or "skipped"; SubmissionID is set on submitted, Message carries the reason on +// skipped. There is no per-product failure — hard failures are HTTP errors. type SubmitProductToStoreResult struct { Object string `json:"object"` ProductID string `json:"product_id"` diff --git a/internal/cli/products_store.go b/internal/cli/products_store.go index eaef26f3..f7445673 100644 --- a/internal/cli/products_store.go +++ b/internal/cli/products_store.go @@ -339,11 +339,18 @@ func renderStoreSubmitResult(rt *Runtime, resp *api.SubmitProductsToStoreRespons rows = append(rows, []string{result.ProductID, result.Status, detail}) } rt.Out.Info(fmt.Sprintf("Submitted %d of %d product(s) for review", resp.SubmittedCount, len(resp.Results))) - return rt.Out.RenderTable(output.Table{ + if err := rt.Out.RenderTable(output.Table{ Columns: []string{"PRODUCT", "STATUS", "DETAIL"}, Rows: rows, Raw: resp, - }) + }); err != nil { + return err + } + if resp.SubmittedCount < len(resp.Results) { + skipped := len(resp.Results) - resp.SubmittedCount + rt.Out.Hint(fmt.Sprintf("%d product(s) were not submitted — see the DETAIL column for why. Confirm each exists in App Store Connect (apply its plan first), then re-run submit.", skipped)) + } + return nil } func newProductsStoreDiscardCmd() *cobra.Command { diff --git a/internal/cli/products_store_lifecycle_test.go b/internal/cli/products_store_lifecycle_test.go index 7d9ce2fb..53248b0f 100644 --- a/internal/cli/products_store_lifecycle_test.go +++ b/internal/cli/products_store_lifecycle_test.go @@ -228,6 +228,44 @@ func TestProductsStoreSubmit_SurfacesSubmissionFailure(t *testing.T) { } } +func TestCleanSubmitProductIDs(t *testing.T) { + if got, err := cleanSubmitProductIDs([]string{" prod_abc ", "prod_def"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } else if len(got) != 2 || got[0] != "prod_abc" || got[1] != "prod_def" { + t.Fatalf("trim = %v, want [prod_abc prod_def]", got) + } + + if _, err := cleanSubmitProductIDs([]string{"prod_abc", " "}); err == nil { + t.Fatal("expected error for a whitespace-only product ID") + } + + tooMany := make([]string, 201) + for i := range tooMany { + tooMany[i] = "prod" + } + if _, err := cleanSubmitProductIDs(tooMany); err == nil { + t.Fatal("expected error for more than 200 product IDs") + } +} + +func TestProductsStoreSubmit_AllSkippedExitsZero(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"object":"submit_products_to_store_response","submitted_count":0,"results":[`+ + `{"object":"submit_product_to_store_result","product_id":"prod_abc","status":"skipped","submission_id":null,"message":"not ready to submit"}]}`) + })) + t.Cleanup(server.Close) + + out, _, err := runStoreLifecycleCommand(t, server.URL, "", + "products", "store", "submit", "prod_abc", "--yes", "--json", "--no-input") + if err != nil { + t.Fatalf("a fully-skipped response must exit 0, got %v", err) + } + if !strings.Contains(out, `"submitted_count": 0`) { + t.Fatalf("output missing submitted_count 0: %s", out) + } +} + type staticStoreStatePlanService struct{ plan *api.StoreStatePlan } func (s staticStoreStatePlanService) Get(context.Context, string, string) (*api.StoreStatePlan, error) { From 532b61ac197a17a42b10bba9e3a167520fc1a911 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Wed, 19 Aug 2026 10:36:00 -0500 Subject: [PATCH 3/4] docs: explain the first-product-needs-app-version rule on submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first In-App Purchase or subscription for an app can't be submitted on its own — App Store Connect requires it to be reviewed with a new app version, so submit returns it skipped until the app has an approved product. Document that in the command help and the command surface so it reads as expected, not a bug. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/command-surface.md | 2 +- internal/cli/products_store.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/command-surface.md b/docs/command-surface.md index 01219d75..2aafe2a7 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -197,7 +197,7 @@ rc products store sync [app-id] # human flow: input → rc products store plan [app-id] # persist desired state + diff on the backend; accepts --file rc products store show # inspect the exact persisted plan from any process rc products store apply # apply that same reviewed plan; requires confirmation/--yes -rc products store submit ... # start Apple review for App Store products (--store app_store); requires confirmation/--yes +rc products store submit ... # start Apple review for App Store products (--store app_store); requires confirmation/--yes; first IAP for an app must be reviewed with a new app version (skipped until one product is approved) rc products store discard # discard without applying; requires confirmation/--yes # Paywalls diff --git a/internal/cli/products_store.go b/internal/cli/products_store.go index f7445673..1c91082d 100644 --- a/internal/cli/products_store.go +++ b/internal/cli/products_store.go @@ -271,6 +271,12 @@ Connect; one that isn't ready comes back skipped with a reason instead of failing the whole run. App Store only — Apple is the sole store that accepts review submissions through this command. +The first In-App Purchase or subscription for an app cannot be submitted this +way: App Store Connect requires the first one to be reviewed with a new app +version. Add it on the app's version page in App Store Connect and submit that +version; this command works once the app has at least one approved product +(before then it returns the product as skipped, explaining this). + Reversibility: starts Apple review — manage the submission in App Store Connect. Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`, From 77c5935f09781e23aa592ff0d598cd64ad77dbdd Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 20 Aug 2026 13:30:42 -0500 Subject: [PATCH 4/4] fix(products): expose submit product-id args in agent schema The submit Use string was "...", but parseArgsFromUse only recognizes tokens fully enclosed in <> or [], so the trailing dots left the positional args invisible to `rc schema` / `rc commands --schemas` even though MinimumNArgs(1) requires them. Match the sibling attach/detach convention: " [product-id...]". Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/products_store.go | 2 +- internal/cli/schema_test.go | 57 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/internal/cli/products_store.go b/internal/cli/products_store.go index 1c91082d..985918fe 100644 --- a/internal/cli/products_store.go +++ b/internal/cli/products_store.go @@ -258,7 +258,7 @@ Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.` func newProductsStoreSubmitCmd() *cobra.Command { var store string cmd := &cobra.Command{ - Use: "submit ...", + Use: "submit [product-id...]", Short: "Submit App Store products for Apple review", Long: `Starts Apple review for the named App Store products. Applying a store-state plan pushes configuration to App Store Connect but never submits diff --git a/internal/cli/schema_test.go b/internal/cli/schema_test.go index f5b9da84..3b638533 100644 --- a/internal/cli/schema_test.go +++ b/internal/cli/schema_test.go @@ -124,6 +124,63 @@ func TestInferCapabilities_DriftGuard(t *testing.T) { walk(root) } +func TestParseArgsFromUse(t *testing.T) { + cases := []struct { + use string + want []map[string]any + }{ + {"show ", []map[string]any{{"name": "id", "required": true, "variadic": false}}}, + {"submit [product-id...]", []map[string]any{ + {"name": "product-id", "required": true, "variadic": false}, + {"name": "product-id", "required": false, "variadic": true}, + }}, + {"schema [command...]", []map[string]any{{"name": "command", "required": false, "variadic": true}}}, + {"list", []map[string]any{}}, + } + for _, tc := range cases { + got := parseArgsFromUse(tc.use) + if len(got) != len(tc.want) { + t.Errorf("%q: got %d args, want %d (%v)", tc.use, len(got), len(tc.want), got) + continue + } + for i := range got { + for k, v := range tc.want[i] { + if got[i][k] != v { + t.Errorf("%q arg %d: %s = %v, want %v", tc.use, i, k, got[i][k], v) + } + } + } + } +} + +// TestSchemaArgsMatchVariadicUse guards against variadic positionals that hide +// from the agent schema, e.g. "..." with the dots outside the brackets. +func TestSchemaArgsMatchVariadicUse(t *testing.T) { + root := NewRootCmd("test") + + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + if strings.Contains(c.Use, "...") { + args := parseArgsFromUse(c.Use) + variadic := false + for _, a := range args { + if v, _ := a["variadic"].(bool); v { + variadic = true + break + } + } + if !variadic { + t.Errorf("%q Use %q signals variadic args but schema exposes none %v", + commandPath(c), c.Use, args) + } + } + for _, sc := range c.Commands() { + walk(sc) + } + } + walk(root) +} + func hasRunnableDescendant(c *cobra.Command) bool { for _, sc := range c.Commands() { if isExperimental(sc) {