feat(control-plane): confirm destructive org/project/repo delete · Entire

feat(control-plane): confirm destructive org/project/repo delete

966aa98→main·toothbrush·3w ago·5 files·+157 added/-35 removed

org/project/repo delete called the destructive API immediately after resolving the ref — a name typo that resolves to a real resource (or a wrong-but-valid ULID) deleted silently. Gate each behind a confirmation prompt (the existing trail-delete pattern) with a --force/-f bypass and --yes/-y alias; non-interactive runs refuse without --force rather than deleting unprompted. Shared body extracted to runControlPlaneDelete.

Addresses trail #642 finding.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

Sessions

5201fa5dc67cView transcript

Changes

5

7 unmodified lines

8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
27 unmodified lines

47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
79  
80  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100  
101  
102  
103  
104  
105  
106  
107  
108  
109  
110  
111  
112  
113  
114  
115  
116  
117  
118  
119  
120  
121  
122  
123  
124  
125  
126  
127  
128  
129  
130  
131  
132  
133  
134  
135  
136  
137  
138  
139

7 unmodified lines

"io"
    "strings"

"charm.land/huh/v2"
    "charm.land/lipgloss/v2"
    "github.com/spf13/cobra"

"github.com/entireio/cli/cmd/entire/cli/auth"
    "github.com/entireio/cli/cmd/entire/cli/interactive"
    "github.com/entireio/cli/internal/coreapi"
}

return err == nil && v
}

// addForceFlag registers the standard confirmation bypass on a destructive
// control-plane command: --force/-f, with --yes/-y as an alias. Either skips
// the prompt. Read the combined value with forceRequested.
func addForceFlag(cmd *cobra.Command) {
    cmd.Flags().BoolP("force", "f", false, "Skip the confirmation prompt")
    cmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt (alias for --force)")
}

// forceRequested reports whether the delete should skip its confirmation
// prompt, i.e. --force or its --yes alias was set.
func forceRequested(cmd *cobra.Command) bool {
    force, ferr := cmd.Flags().GetBool("force")
    yes, yerr := cmd.Flags().GetBool("yes")
    return (ferr == nil && force) || (yerr == nil && yes)
}

// runControlPlaneDelete is the shared body of the destructive `delete` verbs
// (org/project/repo). It resolves the target ref to a ULID, gates on a
// confirmation prompt (bypassed by --force/--yes), deletes, and reports the
// resolved identifier. noun names the resource ("org"); ref is the user's
// original argument, shown alongside the resolved ULID. resolve and del isolate
// the per-resource API calls.
func runControlPlaneDelete(
    cmd *cobra.Command,
    noun, ref string,
    resolve func(context.Context, *coreapi.Client) (string, error),
    del func(context.Context, *coreapi.Client, string) error,
) error {
    force := forceRequested(cmd)
    return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error {
        id, err := resolve(ctx, c)
        if err != nil {
            return err
        }
        label := noun + " " + resolvedRefLabel(ref, id)
        proceed, err := confirmControlPlaneDeletion(ctx, cmd.OutOrStdout(), label, force, interactive.CanPromptInteractively())
        if err != nil {
            return err
        }
        if !proceed {
            return nil
        }
        if err := del(ctx, c, id); err != nil {
            return err
        }
        cmd.Printf("Deleted %s\n", label)
        return nil
    })
}

// confirmControlPlaneDeletion gates a destructive control-plane delete. With
// force it proceeds silently. Otherwise it requires an interactive terminal:
// with none it refuses (returns an error) rather than deleting unprompted; with
// one it shows a confirmation form. canPrompt is passed in (not queried) so the
// decision is unit-testable without a TTY. label is the human description of
// the target, e.g. `org acme (01J…)`.
func confirmControlPlaneDeletion(ctx context.Context, w io.Writer, label string, force, canPrompt bool) (bool, error) {
    if force {
        return true, nil
    }
    if !canPrompt {
        return false, fmt.Errorf("refusing to delete %s without confirmation; pass --force", label)
    }
    // huh opens the TTY during form startup regardless of context state, so
    // guard explicitly to honor an already-cancelled command context.
    if ctx.Err() != nil {
        return false, nil //nolint:nilerr // cancelled context is a clean skip, not an error
    }
    confirmed := false
    form := NewAccessibleForm(
        huh.NewGroup(huh.NewConfirm().Title(fmt.Sprintf("Delete %s?", label)).Value(&confirmed)),
    )
    if err := form.RunWithContext(ctx); err != nil {
        // A user abort (Esc) or context cancel (Ctrl+C) is a clean cancel, not
        // an error — mirror confirmTrailDeletion.
        if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, context.Canceled) {
            return false, nil
        }
        return false, fmt.Errorf("deletion prompt: %w", err)
    }
    if !confirmed {
        fmt.Fprintln(w, "Deletion cancelled.")
        return false, nil
    }
    return true, nil
}

// runCoreList fetches a slice via fn and renders it as an aligned table
// (default) or the raw wire JSON (--json). headers names the columns; row
// maps one item to its cells in the same order. The human view keeps the
``

```Mcmd/entire/cli/corecmd.go+89
4 unmodified lines

5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

// TestConfirmControlPlaneDeletion covers the non-TTY decision paths of the // destructive-delete gate. The interactive form path needs a real terminal and // is left to manual/e2e coverage. func TestConfirmControlPlaneDeletion(t *testing.T) { t.Parallel()

// --force proceeds without prompting (no TTY needed). var buf bytes.Buffer proceed, err := confirmControlPlaneDeletion(t.Context(), &buf, "org acme (01J)", true, false) if err != nil || !proceed { t.Fatalf("force: got (proceed=%v, err=%v), want (true, nil)", proceed, err) }

// Non-interactive without --force must refuse, not delete unprompted. buf.Reset() proceed, err = confirmControlPlaneDeletion(t.Context(), &buf, "org acme (01J)", false, false) if err == nil { t.Fatalf("non-interactive without --force: expected error, got nil (proceed=%v)", proceed) } if proceed { t.Fatal("non-interactive without --force: must not proceed") } if !strings.Contains(err.Error(), "--force") { t.Fatalf("error should mention --force, got: %v", err) } if !strings.Contains(err.Error(), "org acme") { t.Fatalf("error should name the target, got: %v", err) }

// An already-cancelled context is a clean cancel: no prompt, no error. ctx, cancel := context.WithCancel(context.Background()) cancel() buf.Reset() proceed, err = confirmControlPlaneDeletion(ctx, &buf, "org acme (01J)", false, true) if err != nil || proceed { t.Fatalf("cancelled ctx: got (proceed=%v, err=%v), want (false, nil)", proceed, err) } }

// TestFetchAllPages walks a multi-page source, stops on the empty cursor, // and errors rather than looping when the server fails to advance. func TestFetchAllPages(t *testing.T) {


```Mcmd/entire/cli/corecmd_test.go+40
93 unmodified lines

94
95
96
97
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
102
103
104
105
106
107
108
109
110
111
112
113
    return &cobra.Command{
    cmd := &cobra.Command{
        Use:   "delete <org>",
        Short: "Delete an organization by name or ULID",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error {
                orgID, err := resolveOrgRef(ctx, c, args[0])
                if err != nil {
                    return err
                }
                if err := c.DeleteOrg(ctx, coreapi.DeleteOrgParams{OrgId: orgID}); err != nil {
                    return err
                }
                cmd.Printf("Deleted org %s\n", resolvedRefLabel(args[0], orgID))
                return nil
            })
            return runControlPlaneDelete(cmd, "org", args[0],
                func(ctx context.Context, c *coreapi.Client) (string, error) {
                    return resolveOrgRef(ctx, c, args[0])
                },
                func(ctx context.Context, c *coreapi.Client, id string) error {
                    return c.DeleteOrg(ctx, coreapi.DeleteOrgParams{OrgId: id})
                })
        },
    }
    addForceFlag(cmd)
    return cmd
}

172 unmodified lines

173
174
175
176
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
181
182
183
184
185
186
187
188
189
190
191
192
193
194
    return &cobra.Command{
    cmd := &cobra.Command{
        Use:   "delete <project>",
        Short: "Delete a project by name or ULID",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error {
                projID, err := resolveProjectRef(ctx, c, args[0])
                if err != nil {
                    return err
                }
                if err := c.DeleteProject(ctx, coreapi.DeleteProjectParams{ProjectId: projID}); err != nil {
                    return err
                }
                cmd.Printf("Deleted project %s\n", resolvedRefLabel(args[0], projID))
                return nil
            })
            return runControlPlaneDelete(cmd, "project", args[0],
                func(ctx context.Context, c *coreapi.Client) (string, error) {
                    return resolveProjectRef(ctx, c, args[0])
                },
                func(ctx context.Context, c *coreapi.Client, id string) error {
                    return c.DeleteProject(ctx, coreapi.DeleteProjectParams{ProjectId: id})
                })
        },
    }
    addForceFlag(cmd)
    return cmd
}

182 unmodified lines

183
184
185
186
187
188
189
190
191
192
193
194
195
196
186
187
188
189
190
191
192
193
194
195
196
197
198
199
    return &cobra.Command{
    cmd := &cobra.Command{
        Use:   "delete <repo>",
        Short: "Delete a repository by name or ULID",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error {
                repoID, err := resolveRepoRef(ctx, c, args[0], project)
                if err != nil {
                    return err
                }
                if err := c.DeleteRepo(ctx, coreapi.DeleteRepoParams{RepoId: repoID}); err != nil {
                    return err
                }
                cmd.Printf("Deleted repo %s\n", resolvedRefLabel(args[0], repoID))
                return nil
            })
            return runControlPlaneDelete(cmd, "repo", args[0],
                func(ctx context.Context, c *coreapi.Client) (string, error) {
                    return resolveRepoRef(ctx, c, args[0], project)
                },
                func(ctx context.Context, c *coreapi.Client, id string) error {
                    return c.DeleteRepo(ctx, coreapi.DeleteRepoParams{RepoId: id})
                })
        },
    }
    bindRepoProjectFlag(cmd, &project)
    addForceFlag(cmd)
    return cmd
}

172 unmodified lines

173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199