Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions internal/cli/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions internal/cli/confirm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
Comment thread
cursor[bot] marked this conversation as resolved.
}

// 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
Expand Down
132 changes: 132 additions & 0 deletions internal/cli/destructive_guard_test.go
Original file line number Diff line number Diff line change
@@ -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"},
}
Comment thread
cursor[bot] marked this conversation as resolved.
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)
}
})
}
}
9 changes: 7 additions & 2 deletions internal/cli/entitlements.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions internal/cli/offerings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions internal/cli/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 32 additions & 16 deletions internal/cli/paywalls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.")
Expand All @@ -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.
Expand Down
81 changes: 26 additions & 55 deletions internal/cli/paywalls_delete_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading