feat(control-plane): confirm destructive org/project/repo delete · Entire
feat(control-plane): confirm destructive org/project/repo delete
8e63b7b·
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
cmd/entire/cli
Mcorecmd.go+89
Mcorecmd_test.go+40
Morg.go+10/-12
Mproject.go+10/-12
Mrepo.go+8/-11
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" }
27 unmodified lines
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
4 unmodified lines
"context" "errors" "fmt" "strings" "testing"
// 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) {
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
93 unmodified lines
}
func newOrgDeleteCmd() *cobra.Command {
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
}
Mcmd/entire/cli/org.go+10/-12
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
182 unmodified lines
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
}
// parseProjectOwnerType maps the --owner-type flag to the generated enum,
Mcmd/entire/cli/project.go+10/-12
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
182 unmodified lines
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
}
Mcmd/entire/cli/repo.go+8/-11
182 unmodified lines
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
182 unmodified lines