Skip to content

Commit fe2763b

Browse files
committed
feat: add openboot delete command
Delete configs from openboot.dev by slug. Prompts for confirmation unless --force is used. Handles 200/204/401/404 status codes with clear error messages.
1 parent 2ea1638 commit fe2763b

File tree

2 files changed

+209
-0
lines changed

2 files changed

+209
-0
lines changed

internal/cli/delete.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
neturl "net/url"
8+
"os"
9+
"time"
10+
11+
"github.com/openbootdotdev/openboot/internal/auth"
12+
"github.com/openbootdotdev/openboot/internal/httputil"
13+
"github.com/openbootdotdev/openboot/internal/ui"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
var deleteCmd = &cobra.Command{
18+
Use: "delete <slug>",
19+
Short: "Delete a config from openboot.dev",
20+
Long: `Delete a config from your openboot.dev account by its slug.`,
21+
Example: ` # Delete a config (with confirmation prompt)
22+
openboot delete my-config
23+
24+
# Delete without confirmation (for scripting)
25+
openboot delete my-config --force`,
26+
Args: cobra.ExactArgs(1),
27+
RunE: func(cmd *cobra.Command, args []string) error {
28+
force, _ := cmd.Flags().GetBool("force")
29+
return runDelete(args[0], force)
30+
},
31+
}
32+
33+
func init() {
34+
deleteCmd.Flags().BoolP("force", "f", false, "skip confirmation prompt")
35+
rootCmd.AddCommand(deleteCmd)
36+
}
37+
38+
func runDelete(slug string, force bool) error {
39+
apiBase := auth.GetAPIBase()
40+
41+
if !auth.IsAuthenticated() {
42+
fmt.Fprintln(os.Stderr)
43+
ui.Info("You need to log in to delete configs.")
44+
fmt.Fprintln(os.Stderr)
45+
if _, err := auth.LoginInteractive(apiBase); err != nil {
46+
return fmt.Errorf("authentication failed: %w", err)
47+
}
48+
}
49+
50+
stored, err := auth.LoadToken()
51+
if err != nil {
52+
return fmt.Errorf("load auth token: %w", err)
53+
}
54+
if stored == nil {
55+
return fmt.Errorf("no valid auth token found — please log in again")
56+
}
57+
58+
if !force {
59+
confirmed, err := ui.Confirm(
60+
fmt.Sprintf("Delete config '%s'? This cannot be undone", slug), false)
61+
if err != nil {
62+
return fmt.Errorf("confirm: %w", err)
63+
}
64+
if !confirmed {
65+
ui.Info("Delete cancelled.")
66+
return nil
67+
}
68+
}
69+
70+
url := fmt.Sprintf("%s/api/configs/%s", apiBase, neturl.PathEscape(slug))
71+
72+
req, err := http.NewRequest(http.MethodDelete, url, nil)
73+
if err != nil {
74+
return fmt.Errorf("create request: %w", err)
75+
}
76+
req.Header.Set("Authorization", "Bearer "+stored.Token)
77+
78+
client := &http.Client{Timeout: 30 * time.Second}
79+
resp, err := httputil.Do(client, req)
80+
if err != nil {
81+
return fmt.Errorf("delete request: %w", err)
82+
}
83+
defer resp.Body.Close()
84+
85+
switch resp.StatusCode {
86+
case http.StatusOK, http.StatusNoContent:
87+
fmt.Fprintln(os.Stderr)
88+
ui.Success(fmt.Sprintf("Config '%s' deleted.", slug))
89+
fmt.Fprintln(os.Stderr)
90+
return nil
91+
case http.StatusUnauthorized:
92+
return fmt.Errorf("not authorized — please log in again with 'openboot login'")
93+
case http.StatusNotFound:
94+
return fmt.Errorf("config '%s' not found", slug)
95+
default:
96+
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
97+
if readErr != nil {
98+
return fmt.Errorf("delete failed (status %d): read response: %w", resp.StatusCode, readErr)
99+
}
100+
return fmt.Errorf("delete failed (status %d): %s", resp.StatusCode, string(body))
101+
}
102+
}

internal/cli/delete_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package cli
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestDeleteCmd_CommandStructure(t *testing.T) {
12+
assert.Equal(t, "delete <slug>", deleteCmd.Use)
13+
assert.NotEmpty(t, deleteCmd.Short)
14+
assert.NotEmpty(t, deleteCmd.Long)
15+
assert.NotEmpty(t, deleteCmd.Example)
16+
assert.NotNil(t, deleteCmd.RunE)
17+
18+
flag := deleteCmd.Flags().Lookup("force")
19+
assert.NotNil(t, flag, "should have --force flag")
20+
assert.Equal(t, "f", flag.Shorthand)
21+
assert.Equal(t, "false", flag.DefValue)
22+
}
23+
24+
func TestDeleteCmd_RequiresSlugArg(t *testing.T) {
25+
err := deleteCmd.Args(deleteCmd, []string{})
26+
assert.Error(t, err, "should require exactly one argument")
27+
28+
err = deleteCmd.Args(deleteCmd, []string{"my-slug"})
29+
assert.NoError(t, err, "should accept exactly one argument")
30+
31+
err = deleteCmd.Args(deleteCmd, []string{"slug1", "slug2"})
32+
assert.Error(t, err, "should reject more than one argument")
33+
}
34+
35+
func TestRunDelete_NotAuthenticated(t *testing.T) {
36+
setupTestAuth(t, false)
37+
38+
t.Setenv("OPENBOOT_API_URL", "http://localhost:9999")
39+
40+
err := runDelete("test-slug", true)
41+
42+
assert.Error(t, err)
43+
assert.Contains(t, err.Error(), "authentication failed")
44+
}
45+
46+
func TestRunDelete_Success(t *testing.T) {
47+
setupTestAuth(t, true)
48+
49+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+
assert.Equal(t, http.MethodDelete, r.Method)
51+
assert.Equal(t, "/api/configs/my-config", r.URL.Path)
52+
assert.Contains(t, r.Header.Get("Authorization"), "Bearer ")
53+
w.WriteHeader(http.StatusOK)
54+
}))
55+
defer server.Close()
56+
57+
t.Setenv("OPENBOOT_API_URL", server.URL)
58+
59+
err := runDelete("my-config", true)
60+
assert.NoError(t, err)
61+
}
62+
63+
func TestRunDelete_NotFound(t *testing.T) {
64+
setupTestAuth(t, true)
65+
66+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67+
w.WriteHeader(http.StatusNotFound)
68+
}))
69+
defer server.Close()
70+
71+
t.Setenv("OPENBOOT_API_URL", server.URL)
72+
73+
err := runDelete("nonexistent", true)
74+
assert.Error(t, err)
75+
assert.Contains(t, err.Error(), "not found")
76+
}
77+
78+
func TestRunDelete_Unauthorized(t *testing.T) {
79+
setupTestAuth(t, true)
80+
81+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82+
w.WriteHeader(http.StatusUnauthorized)
83+
}))
84+
defer server.Close()
85+
86+
t.Setenv("OPENBOOT_API_URL", server.URL)
87+
88+
err := runDelete("my-config", true)
89+
assert.Error(t, err)
90+
assert.Contains(t, err.Error(), "not authorized")
91+
}
92+
93+
func TestRunDelete_ServerError(t *testing.T) {
94+
setupTestAuth(t, true)
95+
96+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97+
w.WriteHeader(http.StatusInternalServerError)
98+
w.Write([]byte("internal server error"))
99+
}))
100+
defer server.Close()
101+
102+
t.Setenv("OPENBOOT_API_URL", server.URL)
103+
104+
err := runDelete("my-config", true)
105+
assert.Error(t, err)
106+
assert.Contains(t, err.Error(), "delete failed (status 500)")
107+
}

0 commit comments

Comments
 (0)