diff --git a/internal/cli/apps.go b/internal/cli/apps.go index 999ecb72..7ccf1e0d 100644 --- a/internal/cli/apps.go +++ b/internal/cli/apps.go @@ -351,11 +351,16 @@ but no longer associated with this app. Reversibility: irreversible. -Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`, - Example: ` rc apps delete app_old --yes`, +Interactive-only: run it yourself in a terminal. It is unavailable under +--json or --no-input so automation can't delete apps. --yes skips the +confirmation prompt once you're in a terminal.`, + Example: ` rc apps delete app_old`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { rt := RuntimeFrom(cmd.Context()) + if err := requireInteractive(rt, cmd.CommandPath()); err != nil { + return err + } projectID, err := requireProject(rt) if err != nil { return err diff --git a/internal/cli/confirm.go b/internal/cli/confirm.go index a47e1fc2..41559d0e 100644 --- a/internal/cli/confirm.go +++ b/internal/cli/confirm.go @@ -6,6 +6,21 @@ import ( "github.com/revenuecat/cli/internal/tui" ) +// requireInteractive gates the most destructive, irreversible commands to a +// person at a real terminal. Unlike confirmOrAbort, --yes cannot bypass it and +// there is no non-interactive path: --json, --no-input, and non-TTY sessions +// are all refused. This keeps automation from firing deletes that are hard or +// impossible to undo — those stay a deliberate, human-only action. +func requireInteractive(rt *Runtime, action string) error { + if rt.CanPrompt() { + return nil + } + return WithHint( + fmt.Errorf("%s is interactive-only: it needs a real terminal and can't run with --json or --no-input", action), + "Run it yourself in an interactive terminal. It is intentionally unavailable to automation because it is irreversible.", + ) +} + // confirmOrAbort is the one way a command asks consent before acting. It owns // the full contract in one place — --yes skips it, --no-input without --yes // fails it, declining aborts with a uniform error — so no command can diff --git a/internal/cli/destructive_guard_test.go b/internal/cli/destructive_guard_test.go new file mode 100644 index 00000000..e1e91799 --- /dev/null +++ b/internal/cli/destructive_guard_test.go @@ -0,0 +1,132 @@ +package cli_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/revenuecat/cli/internal/config" +) + +// Every command that removes or mutates real state must refuse to act under +// --no-input unless --yes is passed, and --yes must let it through. This drives +// the real commands; a new destructive command is covered by adding it here. +// +// The fake server allows read-only GET preflight (some commands fetch state to +// decide whether extra confirmation is needed) but fails the test on any write, +// proving nothing is destroyed before consent. +func TestDestructiveCommands_RefuseUnderNoInputWithoutYes(t *testing.T) { + // The most destructive, irreversible commands are interactive-only and are + // covered separately below; --yes bypasses confirmation for everything here. + commands := [][]string{ + {"products", "push", "prod_x"}, + {"paywalls", "publish", "pw_x"}, + {"paywalls", "unpublish", "pw_x"}, + {"webhooks", "delete", "wh_x"}, + {"purchases", "refund", "txn_x"}, + {"subscriptions", "cancel", "sub_x"}, + {"subscriptions", "refund", "sub_x"}, + {"products", "store", "discard", "plan_x"}, + {"customer", "revoke", "cust_x", "ent_x"}, + {"customer", "grant", "cust_x", "ent_x", "--duration", "monthly"}, + {"customer", "transfer", "cust_x", "--to", "cust_y"}, + } + for _, args := range commands { + t.Run(strings.Join(args, " "), func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("RC_CONFIG_DIR", configDir) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("%s %s: state-changing call reached the network before confirmation", r.Method, r.URL.Path) + http.Error(w, "unexpected write before confirmation", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(server.Close) + if err := config.Save("", &config.Config{APIKey: "sk_test", ProjectID: "proj_test", BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + + runArgs := append(append([]string{}, args...), "--no-input") + _, _, err := runCmdInConfigDir(t, configDir, runArgs...) + if err == nil { + t.Fatal("want refusal under --no-input without --yes") + } + if !strings.Contains(err.Error(), "pass --yes") { + t.Fatalf("want the confirmation gate error, got: %v", err) + } + }) + } +} + +func TestDestructiveCommand_YesBypassesConfirmation(t *testing.T) { + configDir := t.TempDir() + t.Setenv("RC_CONFIG_DIR", configDir) + var deleted bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.HasSuffix(r.URL.Path, "/webhooks/wh_x"): + deleted = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + case r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + default: + http.Error(w, "unexpected request", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + if err := config.Save("", &config.Config{APIKey: "sk_test", ProjectID: "proj_test", BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + + _, errb, err := runCmdInConfigDir(t, configDir, "webhooks", "delete", "wh_x", "--yes", "--no-input") + if err != nil { + t.Fatalf("--yes should let the delete proceed: %v\nstderr: %s", err, errb) + } + if !deleted { + t.Fatal("--yes did not let the command reach the delete call") + } +} + +// The irreversible, customer-facing deletes are interactive-only: --yes does NOT +// buy a way through, and --json/--no-input are refused outright so automation +// can't fire them. A new human-only command is covered by adding it here. +func TestInteractiveOnlyCommands_RefuseEvenWithYes(t *testing.T) { + commands := [][]string{ + {"apps", "delete", "app_x"}, + {"paywalls", "delete", "pw_x"}, + {"offerings", "delete", "ofrng_x"}, + {"entitlements", "delete", "ent_x"}, + {"products", "delete", "prod_x"}, + {"packages", "delete", "pkg_x"}, + } + for _, args := range commands { + t.Run(strings.Join(args, " "), func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("RC_CONFIG_DIR", configDir) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("%s %s: interactive-only command reached the network", r.Method, r.URL.Path) + http.Error(w, "must not touch the network", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + if err := config.Save("", &config.Config{APIKey: "sk_test", ProjectID: "proj_test", BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + + // --yes present on purpose: it must NOT bypass the interactive gate. + runArgs := append(append([]string{}, args...), "--yes", "--no-input") + _, _, err := runCmdInConfigDir(t, configDir, runArgs...) + if err == nil { + t.Fatal("want refusal: interactive-only even with --yes") + } + if !strings.Contains(err.Error(), "interactive-only") { + t.Fatalf("want the interactive-only gate error, got: %v", err) + } + }) + } +} diff --git a/internal/cli/entitlements.go b/internal/cli/entitlements.go index db23c02c..8268c8ae 100644 --- a/internal/cli/entitlements.go +++ b/internal/cli/entitlements.go @@ -398,11 +398,16 @@ Reversibility: irreversible. If you only need to hide it from current Offerings, prefer ` + "`rc entitlements archive`" + ` which can be undone with ` + "`rc entitlements restore`" + `. -Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`, - Example: ` rc entitlements delete entl_pro --yes`, +Interactive-only: run it yourself in a terminal. It is unavailable under +--json or --no-input so automation can't delete entitlements. --yes skips the +confirmation prompt once you're in a terminal.`, + Example: ` rc entitlements delete entl_pro`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { rt := RuntimeFrom(cmd.Context()) + if err := requireInteractive(rt, cmd.CommandPath()); err != nil { + return err + } projectID, err := requireProject(rt) if err != nil { return err diff --git a/internal/cli/offerings.go b/internal/cli/offerings.go index f7dcda4e..31bc2061 100644 --- a/internal/cli/offerings.go +++ b/internal/cli/offerings.go @@ -549,11 +549,16 @@ terminal to pick from a list. Reversibility: irreversible. Prefer ` + "`rc offerings archive`" + ` for reversible removal. -Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`, - Example: ` rc offerings delete ofrng_default --yes`, +Interactive-only: run it yourself in a terminal. It is unavailable under +--json or --no-input so automation can't delete offerings. --yes skips the +confirmation prompt once you're in a terminal.`, + Example: ` rc offerings delete ofrng_default`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { rt := RuntimeFrom(cmd.Context()) + if err := requireInteractive(rt, cmd.CommandPath()); err != nil { + return err + } projectID, err := requireProject(rt) if err != nil { return err diff --git a/internal/cli/packages.go b/internal/cli/packages.go index 361ab108..2b00c665 100644 --- a/internal/cli/packages.go +++ b/internal/cli/packages.go @@ -266,11 +266,16 @@ to pick from a list. Reversibility: irreversible. -Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`, - Example: ` rc packages delete pkg_x --yes`, +Interactive-only: run it yourself in a terminal. It is unavailable under +--json or --no-input so automation can't delete packages. --yes skips the +confirmation prompt once you're in a terminal.`, + Example: ` rc packages delete pkg_x`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { rt := RuntimeFrom(cmd.Context()) + if err := requireInteractive(rt, cmd.CommandPath()); err != nil { + return err + } projectID, err := requireProject(rt) if err != nil { return err diff --git a/internal/cli/paywalls.go b/internal/cli/paywalls.go index e6c1cd1b..c4f9bd8a 100644 --- a/internal/cli/paywalls.go +++ b/internal/cli/paywalls.go @@ -427,12 +427,17 @@ unless --force is passed — --yes alone is not enough. It may be serving customers or be someone else's in-progress work; get explicit consent from the user first. -Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`, - Example: ` rc paywalls delete pw_old --yes - rc paywalls delete pw_attached --force --yes`, +Interactive-only: run it yourself in a terminal. It is unavailable under +--json or --no-input so automation can't delete paywalls. --yes skips the +confirmation prompt once you're in a terminal.`, + Example: ` rc paywalls delete pw_old + rc paywalls delete pw_attached --force`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { rt := RuntimeFrom(cmd.Context()) + if err := requireInteractive(rt, cmd.CommandPath()); err != nil { + return err + } projectID, err := requireProject(rt) if err != nil { return err @@ -451,19 +456,8 @@ Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.` if err != nil { return err } - if !force { - switch { - case paywall.PublishedAt != nil: - return WithHint( - fmt.Errorf("paywall %s is published — customers may be seeing it", pickedID), - "Deletion is irreversible. Unpublish it first (rc paywalls unpublish "+pickedID+"), then detach it (rc paywalls detach "+pickedID+") if you only need to free the offering, or re-run with --force after the user explicitly confirms this paywall should be destroyed.", - ) - case paywall.OfferingID != "": - return WithHint( - fmt.Errorf("paywall %s is attached to offering %s and may be someone's in-progress work", pickedID, paywall.OfferingID), - "Deletion is irreversible. Re-run with --force only after the user explicitly confirms this paywall should be destroyed. To free the offering instead, detach this paywall (rc paywalls detach "+pickedID+") — it stays as a standalone draft.", - ) - } + if err := checkPaywallDeletable(paywall, pickedID, force); err != nil { + return err } if paywall.PublishedAt != nil { rt.Out.Warn("This paywall is published — customers may be seeing it.") @@ -485,6 +479,28 @@ Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.` return cmd } +// checkPaywallDeletable refuses to delete a published or attached paywall unless +// --force is passed: those may be serving customers or be someone's in-progress +// work, so deletion needs an explicit override on top of confirmation. +func checkPaywallDeletable(paywall *api.Paywall, id string, force bool) error { + if force { + return nil + } + switch { + case paywall.PublishedAt != nil: + return WithHint( + fmt.Errorf("paywall %s is published — customers may be seeing it", id), + "Deletion is irreversible. Unpublish it first (rc paywalls unpublish "+id+"), then detach it (rc paywalls detach "+id+") if you only need to free the offering, or re-run with --force after the user explicitly confirms this paywall should be destroyed.", + ) + case paywall.OfferingID != "": + return WithHint( + fmt.Errorf("paywall %s is attached to offering %s and may be someone's in-progress work", id, paywall.OfferingID), + "Deletion is irreversible. Re-run with --force only after the user explicitly confirms this paywall should be destroyed. To free the offering instead, detach this paywall (rc paywalls detach "+id+") — it stays as a standalone draft.", + ) + } + return nil +} + // wrapPaywallActionGateError explains the beta gate on the paywall // publish/unpublish v2 actions: they 404 with a bare "Resource not found" // for projects without beta API access. diff --git a/internal/cli/paywalls_delete_test.go b/internal/cli/paywalls_delete_test.go index ccf995be..6c6f8c22 100644 --- a/internal/cli/paywalls_delete_test.go +++ b/internal/cli/paywalls_delete_test.go @@ -1,64 +1,35 @@ package cli import ( - "bytes" - "context" - "io" - "net/http" - "net/http/httptest" - "strings" "testing" -) - -func TestPaywallsDeleteRequiresForceForAttachedOrPublished(t *testing.T) { - paywalls := map[string]string{ - "pw_attached": `{"object":"paywall","id":"pw_attached","name":"Hero","offering_id":"ofrng_x","created_at":1700000000000,"published_at":null}`, - "pw_published": `{"object":"paywall","id":"pw_published","name":"Live","offering_id":"ofrng_live","created_at":1700000000000,"published_at":1700000000000}`, - "pw_standalone": `{"object":"paywall","id":"pw_standalone","created_at":1700000000000,"published_at":null}`, - } - var deletedPaths []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - id := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] - switch r.Method { - case http.MethodGet: - io.WriteString(w, paywalls[id]) - case http.MethodDelete: - deletedPaths = append(deletedPaths, r.URL.Path) - io.WriteString(w, `{}`) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - t.Setenv("RC_CONFIG_DIR", t.TempDir()) - t.Setenv("RC_BASE_URL", server.URL) - - del := func(args ...string) error { - root := NewRootCmd("test") - root.SetOut(io.Discard) - root.SetErr(&bytes.Buffer{}) - root.SetArgs(append([]string{"paywalls", "delete"}, append(args, "--yes", "--api-key", "sk_test", "--project-id", "proj")...)) - return root.ExecuteContext(context.Background()) - } - for _, id := range []string{"pw_attached", "pw_published"} { - if err := del(id); err == nil { - t.Fatalf("%s should require --force", id) - } - } - if len(deletedPaths) != 0 { - t.Fatalf("refusals must not issue DELETE, got %v", deletedPaths) - } + "github.com/revenuecat/cli/internal/api" +) - if err := del("pw_standalone"); err != nil { - t.Fatalf("standalone draft should delete without --force: %v", err) - } - if err := del("pw_attached", "--force"); err != nil { - t.Fatalf("--force should delete attached paywall: %v", err) +// paywalls delete is interactive-only (its refusal to run under automation is +// covered by the destructive-command guard), so the force-check is exercised +// directly here: a published or attached paywall needs --force, a standalone +// draft does not. +func TestCheckPaywallDeletable(t *testing.T) { + published := api.Millis(1700000000000) + tests := []struct { + name string + paywall *api.Paywall + force bool + wantErr bool + }{ + {name: "standalone draft deletes", paywall: &api.Paywall{ID: "pw_standalone"}}, + {name: "published needs force", paywall: &api.Paywall{ID: "pw_pub", PublishedAt: &published}, wantErr: true}, + {name: "attached needs force", paywall: &api.Paywall{ID: "pw_att", OfferingID: "ofrng_x"}, wantErr: true}, + {name: "published with force deletes", paywall: &api.Paywall{ID: "pw_pub", PublishedAt: &published}, force: true}, + {name: "attached with force deletes", paywall: &api.Paywall{ID: "pw_att", OfferingID: "ofrng_x"}, force: true}, } - want := []string{"/projects/proj/paywalls/pw_standalone", "/projects/proj/paywalls/pw_attached"} - if len(deletedPaths) != 2 || deletedPaths[0] != want[0] || deletedPaths[1] != want[1] { - t.Fatalf("DELETE paths = %v, want %v", deletedPaths, want) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkPaywallDeletable(tt.paywall, tt.paywall.ID, tt.force) + if (err != nil) != tt.wantErr { + t.Fatalf("checkPaywallDeletable err = %v, wantErr %v", err, tt.wantErr) + } + }) } } diff --git a/internal/cli/products.go b/internal/cli/products.go index 8ba86514..286271b2 100644 --- a/internal/cli/products.go +++ b/internal/cli/products.go @@ -669,11 +669,16 @@ to pick from a list. Reversibility: irreversible. Prefer ` + "`rc products archive`" + ` for reversible removal. -Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`, - Example: ` rc products delete prod_x --yes`, +Interactive-only: run it yourself in a terminal. It is unavailable under +--json or --no-input so automation can't delete products. --yes skips the +confirmation prompt once you're in a terminal.`, + Example: ` rc products delete prod_x`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { rt := RuntimeFrom(cmd.Context()) + if err := requireInteractive(rt, cmd.CommandPath()); err != nil { + return err + } projectID, err := requireProject(rt) if err != nil { return err