From dad18426b8126b264cf185e07a9dc3e2fc887e16 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Wed, 19 Aug 2026 09:51:57 -0500 Subject: [PATCH 1/6] 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 d2aab9a9..5922624d 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -177,6 +177,7 @@ rc products store plan [app-id] # persist desired state rc products store list # list persisted store plans 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 rc products store screenshot # upload an App Review screenshot to App Store Connect for a product diff --git a/docs/specs/cli-coverage.yaml b/docs/specs/cli-coverage.yaml index 166b91bc..ab0629fa 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 62a8d760..f0a6518b 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 ab8439b0..3e2fa6b6 100644 --- a/internal/api/paths_gen.go +++ b/internal/api/paths_gen.go @@ -278,6 +278,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 c49c2dcb..80272b10 100644 --- a/internal/api/store_state_direct.go +++ b/internal/api/store_state_direct.go @@ -92,6 +92,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 0ec9f15e..d98c4309 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -27265,6 +27265,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" @@ -43795,6 +43810,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 @@ -45474,6 +45503,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 90436fce..023d8a35 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(), @@ -274,6 +275,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 5d6ae197..faf47e9c 100644 --- a/internal/cli/products_store_lifecycle_test.go +++ b/internal/cli/products_store_lifecycle_test.go @@ -191,6 +191,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 610ce045c0e8907dc51ddc106f69401e00bd1562 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Wed, 19 Aug 2026 10:05:47 -0500 Subject: [PATCH 2/6] 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 80272b10..f9af5bfd 100644 --- a/internal/api/store_state_direct.go +++ b/internal/api/store_state_direct.go @@ -101,6 +101,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 023d8a35..efd3677a 100644 --- a/internal/cli/products_store.go +++ b/internal/cli/products_store.go @@ -359,11 +359,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 faf47e9c..2796c7ef 100644 --- a/internal/cli/products_store_lifecycle_test.go +++ b/internal/cli/products_store_lifecycle_test.go @@ -262,6 +262,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 1c2d088e413def1288ab85bda9ab430b77ac4775 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Wed, 19 Aug 2026 10:36:00 -0500 Subject: [PATCH 3/6] 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 5922624d..71ba38b1 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -177,7 +177,7 @@ rc products store plan [app-id] # persist desired state rc products store list # list persisted store plans 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 rc products store screenshot # upload an App Review screenshot to App Store Connect for a product diff --git a/internal/cli/products_store.go b/internal/cli/products_store.go index efd3677a..b1d37ebf 100644 --- a/internal/cli/products_store.go +++ b/internal/cli/products_store.go @@ -291,6 +291,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 2966d8e8807020c412733d86154591fa20f07ed7 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 20 Aug 2026 13:30:42 -0500 Subject: [PATCH 4/6] 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 b1d37ebf..6d923e98 100644 --- a/internal/cli/products_store.go +++ b/internal/cli/products_store.go @@ -278,7 +278,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 0b4a77ce..4439d4db 100644 --- a/internal/cli/schema_test.go +++ b/internal/cli/schema_test.go @@ -181,6 +181,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) { From a9933c4c2a4676da361a935c143ccf22f68b5c70 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Mon, 28 Sep 2026 18:31:13 -0500 Subject: [PATCH 5/6] fix(products): clarify Apple review submission and outcomes --- docs/command-surface.md | 2 +- docs/previews/products-store-submit.svg | 31 ++++++++++++++++ internal/api/store_state_direct.go | 4 +- internal/cli/products_store.go | 37 +++++++++++++------ internal/cli/products_store_lifecycle_test.go | 8 ++-- internal/cli/snapshot_test.go | 3 ++ .../snapshots/products-store-submit.golden | 16 ++++++++ 7 files changed, 83 insertions(+), 18 deletions(-) create mode 100644 docs/previews/products-store-submit.svg create mode 100644 internal/cli/testdata/snapshots/products-store-submit.golden diff --git a/docs/command-surface.md b/docs/command-surface.md index 71ba38b1..b2001f0b 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -177,7 +177,7 @@ rc products store plan [app-id] # persist desired state rc products store list # list persisted store plans 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; first IAP for an app must be reviewed with a new app version (skipped until one product is approved) +rc products store submit ... # start Apple review for App Store products (--store app_store); requires confirmation/--yes; first product of each IAP type must be reviewed with a new app version rc products store discard # discard without applying; requires confirmation/--yes rc products store screenshot # upload an App Review screenshot to App Store Connect for a product diff --git a/docs/previews/products-store-submit.svg b/docs/previews/products-store-submit.svg new file mode 100644 index 00000000..9dba6c6f --- /dev/null +++ b/docs/previews/products-store-submit.svg @@ -0,0 +1,31 @@ + + + + + +$ rc products store submit prod_snap1 prod_snap2 --yes --no-input --project-id proj_snap --api-key sk_snap + +▍ Submit products for Apple review +  Send these App Store products to Apple for review. + +  Project                     proj_snap +  Product 1                   prod_snap1 +  Product 2                   prod_snap2 + +▍ Plan +· 1. Submit 2 products for Apple review +· Submitted 1 of 2 products for review +  1 product was not submitted. Check each reason in DETAIL; resolve it before retrying. +PRODUCT     STATUS     DETAIL                           +prod_snap1  submitted  sub_snap1                        +prod_snap2  skipped    App Store metadata is incomplete + + + diff --git a/internal/api/store_state_direct.go b/internal/api/store_state_direct.go index f9af5bfd..9d76e770 100644 --- a/internal/api/store_state_direct.go +++ b/internal/api/store_state_direct.go @@ -112,9 +112,7 @@ type SubmitProductToStoreResult struct { 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. +// SubmitToStore starts store review for the given products. 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} diff --git a/internal/cli/products_store.go b/internal/cli/products_store.go index 6d923e98..02fc9e1b 100644 --- a/internal/cli/products_store.go +++ b/internal/cli/products_store.go @@ -282,8 +282,8 @@ func newProductsStoreSubmitCmd() *cobra.Command { 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. +anything for review. Apple must review and approve products after submission +before they can be purchased. 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 @@ -291,11 +291,13 @@ 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 +The first product of each In-App Purchase type must 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). +version. Later products of that type can be submitted separately after the +first is approved and the app has an approved version. + +The result lists each product as submitted or skipped with a reason. If every +product is skipped, the command exits nonzero; partial submissions exit zero. Reversibility: starts Apple review — manage the submission in App Store Connect. @@ -320,7 +322,14 @@ Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.` if err != nil { return err } - if err := confirmOrAbort(rt, fmt.Sprintf("Submit %d App Store product(s) for Apple review?", len(productIDs)), + rt.Out.Title("Submit products for Apple review") + rt.Out.Lead("Send these App Store products to Apple for review.") + rt.Out.Field("Project", projectID) + for i, id := range productIDs { + rt.Out.Field(fmt.Sprintf("Product %d", i+1), id) + } + rt.Out.Plan([]string{fmt.Sprintf("Submit %s for Apple review", pluralize(len(productIDs), "product"))}) + if err := confirmOrAbort(rt, fmt.Sprintf("Submit %s for Apple review?", pluralize(len(productIDs), "App Store product")), "nothing was submitted"); err != nil { return err } @@ -335,8 +344,6 @@ Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.` 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 { @@ -364,7 +371,7 @@ 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))) + rt.Out.Info(fmt.Sprintf("Submitted %d of %s for review", resp.SubmittedCount, pluralize(len(resp.Results), "product"))) if err := rt.Out.RenderTable(output.Table{ Columns: []string{"PRODUCT", "STATUS", "DETAIL"}, Rows: rows, @@ -374,7 +381,15 @@ func renderStoreSubmitResult(rt *Runtime, resp *api.SubmitProductsToStoreRespons } 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)) + verb := "were" + if skipped == 1 { + verb = "was" + } + rt.Out.Hint(fmt.Sprintf("%s %s not submitted. Check each reason in DETAIL; resolve it before retrying.", pluralize(skipped, "product"), verb)) + } + if resp.SubmittedCount == 0 { + rt.Out.Warn("No products were submitted for review") + return &SilentExitError{Code: 1} } return nil } diff --git a/internal/cli/products_store_lifecycle_test.go b/internal/cli/products_store_lifecycle_test.go index 2796c7ef..88a0dc45 100644 --- a/internal/cli/products_store_lifecycle_test.go +++ b/internal/cli/products_store_lifecycle_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -282,7 +283,7 @@ func TestCleanSubmitProductIDs(t *testing.T) { } } -func TestProductsStoreSubmit_AllSkippedExitsZero(t *testing.T) { +func TestProductsStoreSubmit_AllSkippedExitsNonzero(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":[`+ @@ -292,8 +293,9 @@ func TestProductsStoreSubmit_AllSkippedExitsZero(t *testing.T) { 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) + var exit *SilentExitError + if !errors.As(err, &exit) || exit.Code != 1 { + t.Fatalf("a fully-skipped response must exit 1, got %v", err) } if !strings.Contains(out, `"submitted_count": 0`) { t.Fatalf("output missing submitted_count 0: %s", out) diff --git a/internal/cli/snapshot_test.go b/internal/cli/snapshot_test.go index 72acca9c..9a112d15 100644 --- a/internal/cli/snapshot_test.go +++ b/internal/cli/snapshot_test.go @@ -69,6 +69,8 @@ func snapshotServer(t *testing.T) *httptest.Server { io.WriteString(w, `{"object":"experiment","id":"exp_snap","display_name":"Price — paywall","status":"draft","created_at":1784297950368,"updated_at":1784297950368,"total_running_time_seconds":1989824,"enrollment_percentage":50,"offering_a":{"id":"ofrng_control","display_name":"Control","paywall_id":"pw_control"},"offering_b":{"id":"ofrng_treatment","display_name":"Treatment","paywall_id":"pw_treatment"},"offering_c":{"id":"ofrng_extra"},"targeting_conditions":[{"field":"platform","operator":"in","value":["ios"]}],"placements":{"placement_offerings":[{"placement_identifier":"onboarding","offering_a":{"id":"ofrng_control"},"offering_b":{"id":"ofrng_treatment"}}]},"experiment_type":"paywall_design","primary_metric":"initial_conversion_rate","secondary_metrics":["realized_ltv_per_customer","trial_conversion_rate"],"enrollment_mode":"only_new","experiment_duration_settings":{"chance_to_win_percentage":95},"conflicts":[{"id":"exp_other"}]}`) case strings.HasSuffix(r.URL.Path, "/experiments/exp_snap/results"): io.WriteString(w, `{"object":"experiment_results","currency":"USD","sections":[{"object":"experiment_results_section","section":"Conversion","metrics":[{"name":"trial_conversion_rate","unit":"%"}],"segments":[{"object":"experiment_results_segment","id":"total","display_name":"Total","is_total":true}],"values":[{"object":"experiment_results_value","metric":0,"segment":0,"variant":"Control","value":12.5,"change":null,"credible_interval":null,"lift_credible_interval":null},{"object":"experiment_results_value","metric":0,"segment":0,"variant":"Treatment","value":15.0,"change":20,"credible_interval":null,"lift_credible_interval":null}]}],"statistics":[{"object":"experiment_results_statistic","metric_name":"trial_conversion_rate","variants":[{"object":"experiment_results_variant_statistic","name":"Control","chance_to_win":null,"chance_to_win_status":"unavailable","credible_interval_lower":0.1,"credible_interval_upper":0.15,"credible_interval_status":"ok","lift_credible_interval_lower":null,"lift_credible_interval_upper":null,"lift_credible_interval_status":"unavailable"},{"object":"experiment_results_variant_statistic","name":"Treatment","chance_to_win":0.98,"chance_to_win_status":"ok","credible_interval_lower":0.11,"credible_interval_upper":0.18,"credible_interval_status":"ok","lift_credible_interval_lower":-5,"lift_credible_interval_upper":22,"lift_credible_interval_status":"ok"}]}],"predicted_ltv":null}`) + case r.URL.Path == "/projects/proj_snap/products/actions/submit_to_store": + io.WriteString(w, `{"object":"submit_products_to_store_response","submitted_count":1,"results":[{"object":"submit_product_to_store_result","product_id":"prod_snap1","status":"submitted","submission_id":"sub_snap1","message":null},{"object":"submit_product_to_store_result","product_id":"prod_snap2","status":"skipped","submission_id":null,"message":"App Store metadata is incomplete"}]}`) case strings.HasSuffix(r.URL.Path, "/offerings/ofrng_snap"): io.WriteString(w, `{"object":"offering","id":"ofrng_snap","lookup_key":"default","display_name":"Default","is_current":true,"created_at":1784297950368,"project_id":"proj_snap"}`) case strings.HasSuffix(r.URL.Path, "/apps/app_snap1"): @@ -114,6 +116,7 @@ func TestOutputSnapshots(t *testing.T) { {"apps-list-all-projects", []string{"apps", "list", "--all-projects", "--bundle-id", "com.example.moodly", "--no-input", "--api-key", "sk_snap"}}, {"error-not-found", []string{"offerings", "show", "ofrng_missing", "--no-input", "--project-id", "proj_snap", "--api-key", "sk_snap"}}, {"apps-apple-setup", []string{"apps", "apple", "setup", "app_snap1", "--no-input", "--project-id", "proj_snap", "--api-key", "sk_snap"}}, + {"products-store-submit", []string{"products", "store", "submit", "prod_snap1", "prod_snap2", "--yes", "--no-input", "--project-id", "proj_snap", "--api-key", "sk_snap"}}, } for _, sc := range scenarios { diff --git a/internal/cli/testdata/snapshots/products-store-submit.golden b/internal/cli/testdata/snapshots/products-store-submit.golden new file mode 100644 index 00000000..b7dcfc8d --- /dev/null +++ b/internal/cli/testdata/snapshots/products-store-submit.golden @@ -0,0 +1,16 @@ +$ rc products store submit prod_snap1 prod_snap2 --yes --no-input --project-id proj_snap --api-key sk_snap + +▍ Submit products for Apple review + Send these App Store products to Apple for review. + + Project proj_snap + Product 1 prod_snap1 + Product 2 prod_snap2 + +▍ Plan +· 1. Submit 2 products for Apple review +· Submitted 1 of 2 products for review + 1 product was not submitted. Check each reason in DETAIL; resolve it before retrying. +PRODUCT STATUS DETAIL +prod_snap1 submitted sub_snap1 +prod_snap2 skipped App Store metadata is incomplete From ca0425404e93230aa8787944f5f268ce5218f4d2 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Fri, 16 Oct 2026 10:14:35 -0500 Subject: [PATCH 6/6] test(products): verify store submission consent and API outcomes --- docs/previews/products-store-submit.svg | 8 +-- internal/api/store_submit_test.go | 61 +++++++++++++++++++ internal/cli/products_store_lifecycle_test.go | 23 +++++++ .../snapshots/products-store-submit.golden | 2 +- 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 internal/api/store_submit_test.go diff --git a/docs/previews/products-store-submit.svg b/docs/previews/products-store-submit.svg index 9dba6c6f..9d66bfa4 100644 --- a/docs/previews/products-store-submit.svg +++ b/docs/previews/products-store-submit.svg @@ -22,10 +22,10 @@ ▍ Plan · 1. Submit 2 products for Apple review · Submitted 1 of 2 products for review -  1 product was not submitted. Check each reason in DETAIL; resolve it before retrying. -PRODUCT     STATUS     DETAIL                           -prod_snap1  submitted  sub_snap1                        -prod_snap2  skipped    App Store metadata is incomplete +PRODUCT     STATUS     DETAIL                           +prod_snap1  submitted  sub_snap1                        +prod_snap2  skipped    App Store metadata is incomplete +  1 product was not submitted. Check each reason in DETAIL; resolve it before retrying. diff --git a/internal/api/store_submit_test.go b/internal/api/store_submit_test.go new file mode 100644 index 00000000..b78423bf --- /dev/null +++ b/internal/api/store_submit_test.go @@ -0,0 +1,61 @@ +package api_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/revenuecat/cli/internal/api" +) + +func TestStoreSubmitSendsProductIDsAndPreservesOutcomes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/projects/proj/products/actions/submit_to_store" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var body struct { + Store string `json:"store"` + ProductIDs []string `json:"product_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body.Store != "app_store" || !reflect.DeepEqual(body.ProductIDs, []string{"prod1", "prod2"}) { + t.Errorf("unexpected body: %+v", body) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"submitted_count":1,"results":[{"product_id":"prod1","status":"submitted","submission_id":"sub1","message":null},{"product_id":"prod2","status":"skipped","submission_id":null,"message":"metadata incomplete"}]}`) + })) + t.Cleanup(server.Close) + client := api.NewClient(api.Options{APIKey: "sk_test", BaseURL: server.URL}) + result, err := client.StoreState.SubmitToStore(context.Background(), "proj", "app_store", []string{"prod1", "prod2"}) + if err != nil { + t.Fatal(err) + } + if result.SubmittedCount != 1 || len(result.Results) != 2 { + t.Fatalf("unexpected result: %+v", result) + } + if result.Results[0].SubmissionID == nil || *result.Results[0].SubmissionID != "sub1" || result.Results[1].Message == nil || *result.Results[1].Message != "metadata incomplete" { + t.Fatalf("lost submission details: %+v", result.Results) + } +} + +func TestStoreSubmitReturnsTypedAPIError(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":"products must belong to the same app"}`) + })) + t.Cleanup(server.Close) + client := api.NewClient(api.Options{APIKey: "sk_test", BaseURL: server.URL}) + _, err := client.StoreState.SubmitToStore(context.Background(), "proj", "app_store", []string{"prod1", "prod2"}) + var apiErr *api.APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusBadRequest || apiErr.Type != "parameter_error" { + t.Fatalf("expected typed submission error, got %v", err) + } +} diff --git a/internal/cli/products_store_lifecycle_test.go b/internal/cli/products_store_lifecycle_test.go index 88a0dc45..1c068a41 100644 --- a/internal/cli/products_store_lifecycle_test.go +++ b/internal/cli/products_store_lifecycle_test.go @@ -229,6 +229,29 @@ func TestProductsStoreSubmit_ReportsPerProductOutcomes(t *testing.T) { } } +func TestProductsStoreSubmit_RequiresConsentBeforeSubmission(t *testing.T) { + for _, mode := range [][]string{nil, {"--no-input"}, {"--json", "--no-input"}} { + t.Run(strings.Join(mode, " "), func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"submitted_count":1,"results":[{"product_id":"prod_abc","status":"submitted"}]}`) + })) + t.Cleanup(server.Close) + args := append([]string{"products", "store", "submit", "prod_abc"}, mode...) + _, _, err := runStoreLifecycleCommand(t, server.URL, "", args...) + if err == nil || !strings.Contains(err.Error(), "pass --yes") || requests != 0 { + t.Fatalf("want consent error before submission: err=%v requests=%d", err, requests) + } + _, _, err = runStoreLifecycleCommand(t, server.URL, "", append(args, "--yes")...) + if err != nil || requests != 1 { + t.Fatalf("want one approved submission: err=%v requests=%d", err, requests) + } + }) + } +} + 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) diff --git a/internal/cli/testdata/snapshots/products-store-submit.golden b/internal/cli/testdata/snapshots/products-store-submit.golden index b7dcfc8d..3a42ca3f 100644 --- a/internal/cli/testdata/snapshots/products-store-submit.golden +++ b/internal/cli/testdata/snapshots/products-store-submit.golden @@ -10,7 +10,7 @@ $ rc products store submit prod_snap1 prod_snap2 --yes --no-input --project-id p ▍ Plan · 1. Submit 2 products for Apple review · Submitted 1 of 2 products for review - 1 product was not submitted. Check each reason in DETAIL; resolve it before retrying. PRODUCT STATUS DETAIL prod_snap1 submitted sub_snap1 prod_snap2 skipped App Store metadata is incomplete + 1 product was not submitted. Check each reason in DETAIL; resolve it before retrying.