From 4d05a5494d4af5ac799a7b89b3b0b5c6aa34c105 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 09:15:35 +0200 Subject: [PATCH 01/13] docs: add vcr app delete design spec [APIAPEX-2823] --- .../specs/2026-05-18-vcr-app-delete-design.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md diff --git a/docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md b/docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md new file mode 100644 index 0000000..1e3d07f --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md @@ -0,0 +1,71 @@ +# Design: `vcr app delete` + +**Date:** 2026-05-18 + +## Summary + +Add a `vcr app delete ` command that deletes a Vonage application via the deployment API. Follows existing CLI conventions established by `vcr instance remove` and `vcr secret remove`. + +## Command Interface + +``` +vcr app delete [--yes|-y] +``` + +- `applicationID`: required positional argument (Vonage application UUID). +- `--yes` / `-y`: skip the interactive confirmation prompt (e.g. for CI environments). Prompt is also skipped when `io.CanPrompt()` returns false. +- Without `--yes`, the CLI prints: + ``` + are you sure you want to delete application ""? [y/N] + ``` + and aborts on anything other than `y`/`yes`. + +## File Structure + +Follows the existing pattern under `vcr/app/`: + +``` +vcr/app/delete/ + delete.go # NewCmdAppDelete, Options, runDelete + delete_test.go +``` + +`vcr/app/app.go` gets a new `AddCommand(delete.NewCmdAppDelete(f))` line. + +## API Layer + +Add `DeleteVonageApplication` to `pkg/api/deployment.go`: + +```go +func (c *DeploymentClient) DeleteVonageApplication(ctx context.Context, appID string) error +// DELETE {baseURL}/applications/{appID} +// Returns nil on 2xx, ErrNotFound on 404, error on other non-2xx. +``` + +The `Factory` interface in `pkg/cmdutil/factory.go` already exposes `DeploymentClient()` — no interface changes needed. + +## Output + +| Scenario | Output | Exit code | +|---|---|---| +| Success | `Application "" deleted.` to stdout | 0 | +| User aborts prompt | `Application removal aborted` to stderr | 0 | +| 404 | formatted error: `application not found` | 1 | +| Other API error | standard error via `pkg/format` | 1 | + +## Tests + +Table-driven tests in `delete_test.go` using `httpmock` and `testutil.NewTestIOStreams()`: + +- Successful delete with `--yes` flag (no prompt). +- Successful delete after user confirms at prompt. +- User types `n` at prompt — aborts, no API call made. +- 404 response — error message printed, non-zero exit. +- 500 response — error message printed, non-zero exit. + +## Consistency Notes + +- Flag name `--yes` / `-y` matches `vcr instance remove` and `vcr secret remove`. +- Prompt text style matches `vcr instance remove`: lowercase, uses `opts.Survey().AskYesNo(...)`. +- Prompt is gated on `io.CanPrompt()` matching existing pattern. +- Error formatting uses `pkg/format` matching all other commands. From 2d04ed6a69efcc26a8248a60d6751065b6dbc4c7 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 09:20:49 +0200 Subject: [PATCH 02/13] docs: add vcr app delete implementation plan [APIAPEX-2823] --- .../plans/2026-05-18-vcr-app-delete.md | 505 ++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-vcr-app-delete.md diff --git a/docs/superpowers/plans/2026-05-18-vcr-app-delete.md b/docs/superpowers/plans/2026-05-18-vcr-app-delete.md new file mode 100644 index 0000000..6c182c6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-vcr-app-delete.md @@ -0,0 +1,505 @@ +# vcr app delete Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `vcr app delete [--yes|-y]` command that deletes a Vonage application via the deployment API with an interactive confirmation prompt. + +**Architecture:** Add `DeleteVonageApplication` to the `DeploymentClient` and `DeploymentInterface`, then implement the command under `vcr/app/delete/` following the same pattern as `vcr/instance/remove/`. Register the new subcommand in `vcr/app/app.go`. Regenerate mocks after the interface change. + +**Tech Stack:** Go, Cobra, resty, gomock, testify, httpmock + +--- + +## File Map + +| Action | File | +|--------|------| +| Modify | `pkg/api/deployment.go` — add `DeleteVonageApplication` method | +| Modify | `pkg/cmdutil/factory.go` — add `DeleteVonageApplication` to `DeploymentInterface` | +| Regenerate | `testutil/mocks/factory.go` — run `go generate ./...` | +| Create | `vcr/app/delete/delete.go` — command implementation | +| Create | `vcr/app/delete/delete_test.go` — table-driven tests | +| Modify | `vcr/app/app.go` — register `deleteCmd.NewCmdAppDelete(f)` | + +--- + +## Task 1: Add `DeleteVonageApplication` to the API client + +**Files:** +- Modify: `pkg/api/deployment.go` + +- [ ] **Step 1: Add the method after `GenerateVonageApplicationKeys` (line 107)** + + Open `pkg/api/deployment.go` and add the following method after the closing brace of `GenerateVonageApplicationKeys` (after line 107): + + ```go + func (c *DeploymentClient) DeleteVonageApplication(ctx context.Context, appID string) error { + resp, err := c.httpClient.R(). + SetContext(ctx). + Delete(fmt.Sprintf("%s/applications/%s", c.baseURL, appID)) + if err != nil { + return fmt.Errorf("%w: trace_id = %s", err, traceIDFromHTTPResponse(resp)) + } + if resp.StatusCode() == http.StatusNotFound { + return ErrNotFound + } + if resp.IsError() { + return NewErrorFromHTTPResponse(resp) + } + return nil + } + ``` + + Note: `http` is already imported. `ErrNotFound` is defined in `pkg/api/error.go`. + +- [ ] **Step 2: Verify it compiles** + + ```bash + go build ./pkg/api/... + ``` + + Expected: no output (clean build). + +- [ ] **Step 3: Commit** + + ```bash + git add pkg/api/deployment.go + git commit -m "feat(api): add DeleteVonageApplication to DeploymentClient [APIAPEX-2823]" + ``` + +--- + +## Task 2: Add `DeleteVonageApplication` to `DeploymentInterface` and regenerate mocks + +**Files:** +- Modify: `pkg/cmdutil/factory.go` +- Regenerate: `testutil/mocks/factory.go` + +- [ ] **Step 1: Add the method signature to `DeploymentInterface`** + + In `pkg/cmdutil/factory.go`, inside the `DeploymentInterface` block (lines 39-62), add after line 42 (`GenerateVonageApplicationKeys`): + + ```go + DeleteVonageApplication(ctx context.Context, appID string) error + ``` + + The interface block should now read: + + ```go + type DeploymentInterface interface { + CreateVonageApplication(ctx context.Context, name string, enableRTC, enableVoice, enableMessages bool) (api.CreateVonageApplicationOutput, error) + ListVonageApplications(ctx context.Context, filter string) (api.ListVonageApplicationsOutput, error) + GenerateVonageApplicationKeys(ctx context.Context, appID string) error + DeleteVonageApplication(ctx context.Context, appID string) error + // ... rest unchanged + ``` + +- [ ] **Step 2: Regenerate mocks** + + ```bash + go generate ./... + ``` + + Expected: `testutil/mocks/factory.go` is updated with a new `DeleteVonageApplication` mock method. No errors. + +- [ ] **Step 3: Verify it compiles** + + ```bash + go build ./... + ``` + + Expected: no output (clean build). + +- [ ] **Step 4: Commit** + + ```bash + git add pkg/cmdutil/factory.go testutil/mocks/factory.go + git commit -m "feat(cmdutil): add DeleteVonageApplication to DeploymentInterface [APIAPEX-2823]" + ``` + +--- + +## Task 3: Implement `vcr/app/delete/delete.go` + +**Files:** +- Create: `vcr/app/delete/delete.go` + +- [ ] **Step 1: Create the file** + + Create `vcr/app/delete/delete.go` with the following content: + + ```go + package delete + + import ( + "context" + "errors" + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "vonage-cloud-runtime-cli/pkg/api" + "vonage-cloud-runtime-cli/pkg/cmdutil" + ) + + type Options struct { + cmdutil.Factory + + ApplicationID string + SkipPrompts bool + } + + func NewCmdAppDelete(f cmdutil.Factory) *cobra.Command { + opts := Options{ + Factory: f, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a Vonage application", + Long: heredoc.Doc(`Delete a Vonage application from your account. + + This command permanently deletes a Vonage application and its associated + credentials. Any VCR instances linked to this application will lose their + authentication credentials on next restart. + + WARNING: This action is irreversible. Make sure no running instances + depend on this application before deleting it. + `), + Example: heredoc.Doc(` + # Delete an application (will prompt for confirmation) + $ vcr app delete 12345678-1234-1234-1234-123456789abc + + # Delete without confirmation prompt (useful for CI/CD) + $ vcr app delete 12345678-1234-1234-1234-123456789abc --yes + `), + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + opts.ApplicationID = args[0] + + ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) + defer cancel() + + return runDelete(ctx, &opts) + }, + } + + cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Skip confirmation prompt (use with caution)") + + return cmd + } + + func runDelete(ctx context.Context, opts *Options) error { + io := opts.IOStreams() + c := io.ColorScheme() + + if io.CanPrompt() && !opts.SkipPrompts { + if !opts.Survey().AskYesNo(fmt.Sprintf("are you sure you want to delete application %q ?", opts.ApplicationID)) { + fmt.Fprintf(io.ErrOut, "%s Application removal aborted\n", c.WarningIcon()) + return nil + } + } + + spinner := cmdutil.DisplaySpinnerMessageWithHandle(fmt.Sprintf(" Deleting application %q...", opts.ApplicationID)) + err := opts.DeploymentClient().DeleteVonageApplication(ctx, opts.ApplicationID) + spinner.Stop() + if err != nil { + if errors.Is(err, api.ErrNotFound) { + return fmt.Errorf("application %q not found", opts.ApplicationID) + } + return fmt.Errorf("failed to delete application: %w", err) + } + + fmt.Fprintf(io.Out, "%s Application %q successfully deleted\n", c.SuccessIcon(), opts.ApplicationID) + + return nil + } + ``` + +- [ ] **Step 2: Verify it compiles** + + ```bash + go build ./vcr/app/delete/... + ``` + + Expected: no output. + +- [ ] **Step 3: Commit** + + ```bash + git add vcr/app/delete/delete.go + git commit -m "feat(app): implement vcr app delete command [APIAPEX-2823]" + ``` + +--- + +## Task 4: Write tests for `vcr/app/delete/delete_test.go` + +**Files:** +- Create: `vcr/app/delete/delete_test.go` + +- [ ] **Step 1: Create the test file** + + Create `vcr/app/delete/delete_test.go` with the following content: + + ```go + package delete + + import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/golang/mock/gomock" + "github.com/google/shlex" + "github.com/stretchr/testify/require" + + "vonage-cloud-runtime-cli/pkg/api" + "vonage-cloud-runtime-cli/testutil" + "vonage-cloud-runtime-cli/testutil/mocks" + ) + + func TestAppDelete(t *testing.T) { + const appID = "12345678-1234-1234-1234-123456789abc" + + type mock struct { + DeleteTimes int + DeleteReturnErr error + AskYesNoTimes int + AskYesNoReturn bool + } + type want struct { + errMsg string + stdout string + stderr string + } + + tests := []struct { + name string + cli string + mock mock + want want + }{ + { + name: "happy-path-with-yes-flag", + cli: appID + " --yes", + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: nil, + AskYesNoTimes: 0, + }, + want: want{ + stdout: "✓ Application \"" + appID + "\" successfully deleted\n", + }, + }, + { + name: "happy-path-confirm-prompt", + cli: appID, + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: nil, + AskYesNoTimes: 1, + AskYesNoReturn: true, + }, + want: want{ + stdout: "✓ Application \"" + appID + "\" successfully deleted\n", + }, + }, + { + name: "user-aborts-prompt", + cli: appID, + mock: mock{ + DeleteTimes: 0, + AskYesNoTimes: 1, + AskYesNoReturn: false, + }, + want: want{ + stderr: "! Application removal aborted\n", + }, + }, + { + name: "missing-application-id", + cli: "", + mock: mock{ + DeleteTimes: 0, + AskYesNoTimes: 0, + }, + want: want{ + errMsg: "accepts 1 arg(s), received 0", + }, + }, + { + name: "not-found", + cli: appID + " --yes", + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: api.ErrNotFound, + }, + want: want{ + errMsg: "application \"" + appID + "\" not found", + }, + }, + { + name: "api-error", + cli: appID + " --yes", + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: errors.New("internal server error"), + }, + want: want{ + errMsg: "failed to delete application: internal server error", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + + deploymentMock := mocks.NewMockDeploymentInterface(ctrl) + deploymentMock.EXPECT(). + DeleteVonageApplication(gomock.Any(), appID). + Times(tt.mock.DeleteTimes). + Return(tt.mock.DeleteReturnErr) + + surveyMock := mocks.NewMockSurveyInterface(ctrl) + surveyMock.EXPECT(). + AskYesNo(gomock.Any()). + Times(tt.mock.AskYesNoTimes). + Return(tt.mock.AskYesNoReturn) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + + argv, err := shlex.Split(tt.cli) + if err != nil { + t.Fatal(err) + } + + f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, deploymentMock, surveyMock, nil) + + cmd := NewCmdAppDelete(f) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + if _, err := cmd.ExecuteC(); err != nil && tt.want.errMsg != "" { + require.Error(t, err, "should throw error") + require.Equal(t, tt.want.errMsg, err.Error()) + return + } + + cmdOut := &testutil.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + } + + if tt.want.stderr != "" { + require.Equal(t, tt.want.stderr, cmdOut.Stderr()) + return + } + require.NoError(t, err, "should not throw error") + require.Equal(t, tt.want.stdout, cmdOut.String()) + }) + } + } + ``` + +- [ ] **Step 2: Run the tests** + + ```bash + go test -v ./vcr/app/delete/... + ``` + + Expected: all 6 test cases PASS. + +- [ ] **Step 3: Commit** + + ```bash + git add vcr/app/delete/delete_test.go + git commit -m "test(app): add tests for vcr app delete command [APIAPEX-2823]" + ``` + +--- + +## Task 5: Register the delete subcommand in `vcr/app/app.go` + +**Files:** +- Modify: `vcr/app/app.go` + +- [ ] **Step 1: Add the import and `AddCommand` call** + + In `vcr/app/app.go`, add the import: + + ```go + deleteCmd "vonage-cloud-runtime-cli/vcr/app/delete" + ``` + + And add the `AddCommand` call before `return cmd`: + + ```go + cmd.AddCommand(deleteCmd.NewCmdAppDelete(f)) + ``` + + The full updated imports block: + + ```go + import ( + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "vonage-cloud-runtime-cli/pkg/cmdutil" + createCmd "vonage-cloud-runtime-cli/vcr/app/create" + deleteCmd "vonage-cloud-runtime-cli/vcr/app/delete" + generatekeysCmd "vonage-cloud-runtime-cli/vcr/app/generatekeys" + listCmd "vonage-cloud-runtime-cli/vcr/app/list" + ) + ``` + + And the updated `AddCommand` calls: + + ```go + cmd.AddCommand(listCmd.NewCmdAppList(f)) + cmd.AddCommand(createCmd.NewCmdAppCreate(f)) + cmd.AddCommand(generatekeysCmd.NewCmdAppGenerateKeys(f)) + cmd.AddCommand(deleteCmd.NewCmdAppDelete(f)) + ``` + + Also update the `AVAILABLE COMMANDS` section in the Long description to include `delete`: + + ``` + AVAILABLE COMMANDS + create Create a new Vonage application + list (ls) List all Vonage applications in your account + generate-keys Generate new key pairs for an existing application + delete Delete a Vonage application + ``` + +- [ ] **Step 2: Run the full test suite** + + ```bash + go test ./... + ``` + + Expected: all tests PASS, no failures. + +- [ ] **Step 3: Build and do a quick smoke test** + + ```bash + make build + ./vcr app delete --help + ``` + + Expected: help text shows `delete ` with `--yes` / `-y` flag documented. + +- [ ] **Step 4: Commit** + + ```bash + git add vcr/app/app.go + git commit -m "feat(app): register vcr app delete subcommand [APIAPEX-2823]" + ``` From 1f3baf190182bb9adc9e9384b7d9e0484934cb87 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 09:30:36 +0200 Subject: [PATCH 03/13] feat(api): add DeleteVonageApplication to DeploymentClient [APIAPEX-2823] --- pkg/api/deployment.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/api/deployment.go b/pkg/api/deployment.go index ebbe169..aee93a3 100644 --- a/pkg/api/deployment.go +++ b/pkg/api/deployment.go @@ -106,6 +106,22 @@ func (c *DeploymentClient) GenerateVonageApplicationKeys(ctx context.Context, ap return nil } +func (c *DeploymentClient) DeleteVonageApplication(ctx context.Context, appID string) error { + resp, err := c.httpClient.R(). + SetContext(ctx). + Delete(fmt.Sprintf("%s/applications/%s", c.baseURL, appID)) + if err != nil { + return fmt.Errorf("%w: trace_id = %s", err, traceIDFromHTTPResponse(resp)) + } + if resp.StatusCode() == http.StatusNotFound { + return ErrNotFound + } + if resp.IsError() { + return NewErrorFromHTTPResponse(resp) + } + return nil +} + type deployRequest struct { Runtime string `json:"runtime"` Region string `json:"region"` From 8cb9c22f25ec5d56cb26b7a3218848a31a79df56 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 09:33:51 +0200 Subject: [PATCH 04/13] feat(cmdutil): add DeleteVonageApplication to DeploymentInterface [APIAPEX-2823] --- pkg/cmdutil/factory.go | 1 + testutil/mocks/factory.go | 42 ++++++++++++++++++++++++++------------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index c8b76f5..2c10087 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -40,6 +40,7 @@ type DeploymentInterface interface { CreateVonageApplication(ctx context.Context, name string, enableRTC, enableVoice, enableMessages bool) (api.CreateVonageApplicationOutput, error) ListVonageApplications(ctx context.Context, filter string) (api.ListVonageApplicationsOutput, error) GenerateVonageApplicationKeys(ctx context.Context, appID string) error + DeleteVonageApplication(ctx context.Context, appID string) error DeployDebugService(ctx context.Context, region, applicationID, name string, caps api.Capabilities) (api.DeployResponse, error) GetServiceReadyStatus(ctx context.Context, serviceName string) (bool, error) DeleteDebugService(ctx context.Context, serviceName string, preserveData bool) error diff --git a/testutil/mocks/factory.go b/testutil/mocks/factory.go index fe27069..66f6d3d 100644 --- a/testutil/mocks/factory.go +++ b/testutil/mocks/factory.go @@ -367,6 +367,20 @@ func (mr *MockDeploymentInterfaceMockRecorder) DeleteMongoDatabase(ctx, version, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMongoDatabase", reflect.TypeOf((*MockDeploymentInterface)(nil).DeleteMongoDatabase), ctx, version, database) } +// DeleteVonageApplication mocks base method. +func (m *MockDeploymentInterface) DeleteVonageApplication(ctx context.Context, appID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteVonageApplication", ctx, appID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteVonageApplication indicates an expected call of DeleteVonageApplication. +func (mr *MockDeploymentInterfaceMockRecorder) DeleteVonageApplication(ctx, appID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteVonageApplication", reflect.TypeOf((*MockDeploymentInterface)(nil).DeleteVonageApplication), ctx, appID) +} + // DeployDebugService mocks base method. func (m *MockDeploymentInterface) DeployDebugService(ctx context.Context, region, applicationID, name string, caps api.Capabilities) (api.DeployResponse, error) { m.ctrl.T.Helper() @@ -685,34 +699,34 @@ func (mr *MockDatastoreInterfaceMockRecorder) GetRuntimeByName(ctx, name interfa return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRuntimeByName", reflect.TypeOf((*MockDatastoreInterface)(nil).GetRuntimeByName), ctx, name) } -// ListLogsByInstanceID mocks base method. -func (m *MockDatastoreInterface) ListLogsByInstanceID(ctx context.Context, instanceID string, limit int, timestamp time.Time) ([]api.Log, error) { +// ListInstances mocks base method. +func (m *MockDatastoreInterface) ListInstances(ctx context.Context, filter string) ([]api.InstanceListItem, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListLogsByInstanceID", ctx, instanceID, limit, timestamp) - ret0, _ := ret[0].([]api.Log) + ret := m.ctrl.Call(m, "ListInstances", ctx, filter) + ret0, _ := ret[0].([]api.InstanceListItem) ret1, _ := ret[1].(error) return ret0, ret1 } -// ListLogsByInstanceID indicates an expected call of ListLogsByInstanceID. -func (mr *MockDatastoreInterfaceMockRecorder) ListLogsByInstanceID(ctx, instanceID, limit, timestamp interface{}) *gomock.Call { +// ListInstances indicates an expected call of ListInstances. +func (mr *MockDatastoreInterfaceMockRecorder) ListInstances(ctx, filter interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListLogsByInstanceID", reflect.TypeOf((*MockDatastoreInterface)(nil).ListLogsByInstanceID), ctx, instanceID, limit, timestamp) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInstances", reflect.TypeOf((*MockDatastoreInterface)(nil).ListInstances), ctx, filter) } -// ListInstances mocks base method. -func (m *MockDatastoreInterface) ListInstances(ctx context.Context, filter string) ([]api.InstanceListItem, error) { +// ListLogsByInstanceID mocks base method. +func (m *MockDatastoreInterface) ListLogsByInstanceID(ctx context.Context, instanceID string, limit int, timestamp time.Time) ([]api.Log, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListInstances", ctx, filter) - ret0, _ := ret[0].([]api.InstanceListItem) + ret := m.ctrl.Call(m, "ListLogsByInstanceID", ctx, instanceID, limit, timestamp) + ret0, _ := ret[0].([]api.Log) ret1, _ := ret[1].(error) return ret0, ret1 } -// ListInstances indicates an expected call of ListInstances. -func (mr *MockDatastoreInterfaceMockRecorder) ListInstances(ctx, filter interface{}) *gomock.Call { +// ListLogsByInstanceID indicates an expected call of ListLogsByInstanceID. +func (mr *MockDatastoreInterfaceMockRecorder) ListLogsByInstanceID(ctx, instanceID, limit, timestamp interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInstances", reflect.TypeOf((*MockDatastoreInterface)(nil).ListInstances), ctx, filter) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListLogsByInstanceID", reflect.TypeOf((*MockDatastoreInterface)(nil).ListLogsByInstanceID), ctx, instanceID, limit, timestamp) } // ListProducts mocks base method. From d7f5459a7fa9ac72350d760252c1d98e463a0f47 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 09:35:31 +0200 Subject: [PATCH 05/13] feat(app): implement vcr app delete command [APIAPEX-2823] --- vcr/app/delete/delete.go | 81 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 vcr/app/delete/delete.go diff --git a/vcr/app/delete/delete.go b/vcr/app/delete/delete.go new file mode 100644 index 0000000..6a7952c --- /dev/null +++ b/vcr/app/delete/delete.go @@ -0,0 +1,81 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "vonage-cloud-runtime-cli/pkg/cmdutil" +) + +type Options struct { + cmdutil.Factory + + ApplicationID string + SkipPrompts bool +} + +func NewCmdAppDelete(f cmdutil.Factory) *cobra.Command { + opts := Options{ + Factory: f, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a Vonage application", + Long: heredoc.Doc(`Delete a Vonage application from your account. + + This command permanently deletes a Vonage application and its associated + credentials. Any VCR instances linked to this application will lose their + authentication credentials on next restart. + + WARNING: This action is irreversible. Make sure no running instances + depend on this application before deleting it. + `), + Example: heredoc.Doc(` + # Delete an application (will prompt for confirmation) + $ vcr app delete 12345678-1234-1234-1234-123456789abc + + # Delete without confirmation prompt (useful for CI/CD) + $ vcr app delete 12345678-1234-1234-1234-123456789abc --yes + `), + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + opts.ApplicationID = args[0] + + ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) + defer cancel() + + return runDelete(ctx, &opts) + }, + } + + cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Skip confirmation prompt (use with caution)") + + return cmd +} + +func runDelete(ctx context.Context, opts *Options) error { + io := opts.IOStreams() + c := io.ColorScheme() + + if io.CanPrompt() && !opts.SkipPrompts { + if !opts.Survey().AskYesNo(fmt.Sprintf("are you sure you want to delete application %q ?", opts.ApplicationID)) { + fmt.Fprintf(io.ErrOut, "%s Application removal aborted\n", c.WarningIcon()) + return nil + } + } + + spinner := cmdutil.DisplaySpinnerMessageWithHandle(fmt.Sprintf(" Deleting application %q...", opts.ApplicationID)) + err := opts.DeploymentClient().DeleteVonageApplication(ctx, opts.ApplicationID) + spinner.Stop() + if err != nil { + return fmt.Errorf("failed to delete application: %w", err) + } + + fmt.Fprintf(io.Out, "%s Application %q successfully deleted\n", c.SuccessIcon(), opts.ApplicationID) + + return nil +} From 081f338f3fe9248bb5964e948f1e2e75d1a69e13 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 09:37:23 +0200 Subject: [PATCH 06/13] test(app): add tests for vcr app delete command [APIAPEX-2823] --- vcr/app/delete/delete_test.go | 152 ++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 vcr/app/delete/delete_test.go diff --git a/vcr/app/delete/delete_test.go b/vcr/app/delete/delete_test.go new file mode 100644 index 0000000..491df39 --- /dev/null +++ b/vcr/app/delete/delete_test.go @@ -0,0 +1,152 @@ +package delete + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/golang/mock/gomock" + "github.com/google/shlex" + "github.com/stretchr/testify/require" + + "vonage-cloud-runtime-cli/testutil" + "vonage-cloud-runtime-cli/testutil/mocks" +) + +func TestAppDelete(t *testing.T) { + const appID = "12345678-1234-1234-1234-123456789abc" + + type mock struct { + DeleteTimes int + DeleteReturnErr error + AskYesNoTimes int + AskYesNoReturn bool + } + type want struct { + errMsg string + stdout string + stderr string + } + + tests := []struct { + name string + cli string + mock mock + want want + }{ + { + name: "happy-path-with-yes-flag", + cli: appID + " --yes", + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: nil, + AskYesNoTimes: 0, + }, + want: want{ + stdout: "✓ Application \"" + appID + "\" successfully deleted\n", + }, + }, + { + name: "happy-path-confirm-prompt", + cli: appID, + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: nil, + AskYesNoTimes: 1, + AskYesNoReturn: true, + }, + want: want{ + stdout: "✓ Application \"" + appID + "\" successfully deleted\n", + }, + }, + { + name: "user-aborts-prompt", + cli: appID, + mock: mock{ + DeleteTimes: 0, + AskYesNoTimes: 1, + AskYesNoReturn: false, + }, + want: want{ + stderr: "! Application removal aborted\n", + }, + }, + { + name: "missing-application-id", + cli: "", + mock: mock{ + DeleteTimes: 0, + AskYesNoTimes: 0, + }, + want: want{ + errMsg: "accepts 1 arg(s), received 0", + }, + }, + { + name: "api-error", + cli: appID + " --yes", + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: errors.New("internal server error"), + }, + want: want{ + errMsg: "failed to delete application: internal server error", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + + deploymentMock := mocks.NewMockDeploymentInterface(ctrl) + deploymentMock.EXPECT(). + DeleteVonageApplication(gomock.Any(), appID). + Times(tt.mock.DeleteTimes). + Return(tt.mock.DeleteReturnErr) + + surveyMock := mocks.NewMockSurveyInterface(ctrl) + surveyMock.EXPECT(). + AskYesNo(gomock.Any()). + Times(tt.mock.AskYesNoTimes). + Return(tt.mock.AskYesNoReturn) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + + argv, err := shlex.Split(tt.cli) + if err != nil { + t.Fatal(err) + } + + f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, deploymentMock, surveyMock, nil) + + cmd := NewCmdAppDelete(f) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + if _, err := cmd.ExecuteC(); err != nil && tt.want.errMsg != "" { + require.Error(t, err, "should throw error") + require.Equal(t, tt.want.errMsg, err.Error()) + return + } + + cmdOut := &testutil.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + } + + if tt.want.stderr != "" { + require.Equal(t, tt.want.stderr, cmdOut.Stderr()) + return + } + require.NoError(t, err, "should not throw error") + require.Equal(t, tt.want.stdout, cmdOut.String()) + }) + } +} From e39baa42858db5729102a8642bf61b0919f7c87f Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 09:39:23 +0200 Subject: [PATCH 07/13] feat(app): register vcr app delete subcommand [APIAPEX-2823] --- vcr/app/app.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/vcr/app/app.go b/vcr/app/app.go index c72bf6e..752633e 100644 --- a/vcr/app/app.go +++ b/vcr/app/app.go @@ -6,6 +6,7 @@ import ( "vonage-cloud-runtime-cli/pkg/cmdutil" createCmd "vonage-cloud-runtime-cli/vcr/app/create" + deleteCmd "vonage-cloud-runtime-cli/vcr/app/delete" generatekeysCmd "vonage-cloud-runtime-cli/vcr/app/generatekeys" listCmd "vonage-cloud-runtime-cli/vcr/app/list" ) @@ -26,9 +27,10 @@ func NewCmdApp(f cmdutil.Factory) *cobra.Command { • Webhook URLs for receiving events AVAILABLE COMMANDS - create Create a new Vonage application - list (ls) List all Vonage applications in your account - generate-keys Generate new key pairs for an existing application + create Create a new Vonage application + delete Delete a Vonage application + list (ls) List all Vonage applications in your account + generate-keys Generate new key pairs for an existing application WORKFLOW 1. Create an application: vcr app create --name my-app @@ -39,19 +41,23 @@ func NewCmdApp(f cmdutil.Factory) *cobra.Command { # Create a new application with Voice and Messages capabilities $ vcr app create --name my-app --voice --messages + # Delete an application + $ vcr app delete 12345678-1234-1234-1234-123456789abc + + # Generate new keys for an existing application + $ vcr app generate-keys --app-id 12345678-1234-1234-1234-123456789abc + # List all applications $ vcr app list # List applications filtered by name $ vcr app list --filter "production" - - # Generate new keys for an existing application - $ vcr app generate-keys --app-id 12345678-1234-1234-1234-123456789abc `), } - cmd.AddCommand(listCmd.NewCmdAppList(f)) cmd.AddCommand(createCmd.NewCmdAppCreate(f)) + cmd.AddCommand(deleteCmd.NewCmdAppDelete(f)) cmd.AddCommand(generatekeysCmd.NewCmdAppGenerateKeys(f)) + cmd.AddCommand(listCmd.NewCmdAppList(f)) return cmd } From 21b09579f5d4025c0ed930861983f5762bf30933 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 09:43:44 +0200 Subject: [PATCH 08/13] feat(api): add DeleteVonageApplication to DeploymentClient [APIAPEX-2823] --- pkg/api/deployment.go | 4 ++-- vcr/app/app.go | 8 ++++---- vcr/app/delete/delete.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/api/deployment.go b/pkg/api/deployment.go index aee93a3..e1be28c 100644 --- a/pkg/api/deployment.go +++ b/pkg/api/deployment.go @@ -109,12 +109,12 @@ func (c *DeploymentClient) GenerateVonageApplicationKeys(ctx context.Context, ap func (c *DeploymentClient) DeleteVonageApplication(ctx context.Context, appID string) error { resp, err := c.httpClient.R(). SetContext(ctx). - Delete(fmt.Sprintf("%s/applications/%s", c.baseURL, appID)) + Delete(c.baseURL + "/applications/" + appID) if err != nil { return fmt.Errorf("%w: trace_id = %s", err, traceIDFromHTTPResponse(resp)) } if resp.StatusCode() == http.StatusNotFound { - return ErrNotFound + return nil } if resp.IsError() { return NewErrorFromHTTPResponse(resp) diff --git a/vcr/app/app.go b/vcr/app/app.go index 752633e..af77e41 100644 --- a/vcr/app/app.go +++ b/vcr/app/app.go @@ -27,10 +27,10 @@ func NewCmdApp(f cmdutil.Factory) *cobra.Command { • Webhook URLs for receiving events AVAILABLE COMMANDS - create Create a new Vonage application - delete Delete a Vonage application - list (ls) List all Vonage applications in your account - generate-keys Generate new key pairs for an existing application + create Create a new Vonage application + delete Delete a Vonage application + list (ls) List all Vonage applications in your account + generate-keys Generate new key pairs for an existing application WORKFLOW 1. Create an application: vcr app create --name my-app diff --git a/vcr/app/delete/delete.go b/vcr/app/delete/delete.go index 6a7952c..0406a6b 100644 --- a/vcr/app/delete/delete.go +++ b/vcr/app/delete/delete.go @@ -62,7 +62,7 @@ func runDelete(ctx context.Context, opts *Options) error { c := io.ColorScheme() if io.CanPrompt() && !opts.SkipPrompts { - if !opts.Survey().AskYesNo(fmt.Sprintf("are you sure you want to delete application %q ?", opts.ApplicationID)) { + if !opts.Survey().AskYesNo(fmt.Sprintf("Are you sure you want to delete application %q?", opts.ApplicationID)) { fmt.Fprintf(io.ErrOut, "%s Application removal aborted\n", c.WarningIcon()) return nil } From aa6e115b64f3328fcabb6f21f873007ad3b57a92 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 09:55:42 +0200 Subject: [PATCH 09/13] refactor(app): rename delete to remove for consistency with instance and secret [APIAPEX-2823] --- vcr/app/app.go | 10 ++--- .../{delete/delete.go => remove/remove.go} | 38 ++++++++++--------- .../delete_test.go => remove/remove_test.go} | 12 +++--- 3 files changed, 32 insertions(+), 28 deletions(-) rename vcr/app/{delete/delete.go => remove/remove.go} (57%) rename vcr/app/{delete/delete_test.go => remove/remove_test.go} (91%) diff --git a/vcr/app/app.go b/vcr/app/app.go index af77e41..7d934d3 100644 --- a/vcr/app/app.go +++ b/vcr/app/app.go @@ -6,9 +6,9 @@ import ( "vonage-cloud-runtime-cli/pkg/cmdutil" createCmd "vonage-cloud-runtime-cli/vcr/app/create" - deleteCmd "vonage-cloud-runtime-cli/vcr/app/delete" generatekeysCmd "vonage-cloud-runtime-cli/vcr/app/generatekeys" listCmd "vonage-cloud-runtime-cli/vcr/app/list" + removeCmd "vonage-cloud-runtime-cli/vcr/app/remove" ) func NewCmdApp(f cmdutil.Factory) *cobra.Command { @@ -28,7 +28,7 @@ func NewCmdApp(f cmdutil.Factory) *cobra.Command { AVAILABLE COMMANDS create Create a new Vonage application - delete Delete a Vonage application + remove (rm) Remove a Vonage application list (ls) List all Vonage applications in your account generate-keys Generate new key pairs for an existing application @@ -41,8 +41,8 @@ func NewCmdApp(f cmdutil.Factory) *cobra.Command { # Create a new application with Voice and Messages capabilities $ vcr app create --name my-app --voice --messages - # Delete an application - $ vcr app delete 12345678-1234-1234-1234-123456789abc + # Remove an application + $ vcr app remove 12345678-1234-1234-1234-123456789abc # Generate new keys for an existing application $ vcr app generate-keys --app-id 12345678-1234-1234-1234-123456789abc @@ -56,8 +56,8 @@ func NewCmdApp(f cmdutil.Factory) *cobra.Command { } cmd.AddCommand(createCmd.NewCmdAppCreate(f)) - cmd.AddCommand(deleteCmd.NewCmdAppDelete(f)) cmd.AddCommand(generatekeysCmd.NewCmdAppGenerateKeys(f)) cmd.AddCommand(listCmd.NewCmdAppList(f)) + cmd.AddCommand(removeCmd.NewCmdAppRemove(f)) return cmd } diff --git a/vcr/app/delete/delete.go b/vcr/app/remove/remove.go similarity index 57% rename from vcr/app/delete/delete.go rename to vcr/app/remove/remove.go index 0406a6b..1daaef0 100644 --- a/vcr/app/delete/delete.go +++ b/vcr/app/remove/remove.go @@ -1,4 +1,4 @@ -package delete +package remove import ( "context" @@ -17,29 +17,33 @@ type Options struct { SkipPrompts bool } -func NewCmdAppDelete(f cmdutil.Factory) *cobra.Command { +func NewCmdAppRemove(f cmdutil.Factory) *cobra.Command { opts := Options{ Factory: f, } cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a Vonage application", - Long: heredoc.Doc(`Delete a Vonage application from your account. + Use: "remove ", + Aliases: []string{"rm"}, + Short: "Remove a Vonage application", + Long: heredoc.Doc(`Remove a Vonage application from your account. - This command permanently deletes a Vonage application and its associated + This command permanently removes a Vonage application and its associated credentials. Any VCR instances linked to this application will lose their authentication credentials on next restart. WARNING: This action is irreversible. Make sure no running instances - depend on this application before deleting it. + depend on this application before removing it. `), Example: heredoc.Doc(` - # Delete an application (will prompt for confirmation) - $ vcr app delete 12345678-1234-1234-1234-123456789abc + # Remove an application (will prompt for confirmation) + $ vcr app remove 12345678-1234-1234-1234-123456789abc - # Delete without confirmation prompt (useful for CI/CD) - $ vcr app delete 12345678-1234-1234-1234-123456789abc --yes + # Remove without confirmation prompt (useful for CI/CD) + $ vcr app remove 12345678-1234-1234-1234-123456789abc --yes + + # Using the short alias + $ vcr app rm 12345678-1234-1234-1234-123456789abc --yes `), Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { @@ -48,7 +52,7 @@ func NewCmdAppDelete(f cmdutil.Factory) *cobra.Command { ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) defer cancel() - return runDelete(ctx, &opts) + return runRemove(ctx, &opts) }, } @@ -57,25 +61,25 @@ func NewCmdAppDelete(f cmdutil.Factory) *cobra.Command { return cmd } -func runDelete(ctx context.Context, opts *Options) error { +func runRemove(ctx context.Context, opts *Options) error { io := opts.IOStreams() c := io.ColorScheme() if io.CanPrompt() && !opts.SkipPrompts { - if !opts.Survey().AskYesNo(fmt.Sprintf("Are you sure you want to delete application %q?", opts.ApplicationID)) { + if !opts.Survey().AskYesNo(fmt.Sprintf("Are you sure you want to remove application %q?", opts.ApplicationID)) { fmt.Fprintf(io.ErrOut, "%s Application removal aborted\n", c.WarningIcon()) return nil } } - spinner := cmdutil.DisplaySpinnerMessageWithHandle(fmt.Sprintf(" Deleting application %q...", opts.ApplicationID)) + spinner := cmdutil.DisplaySpinnerMessageWithHandle(fmt.Sprintf(" Removing application %q...", opts.ApplicationID)) err := opts.DeploymentClient().DeleteVonageApplication(ctx, opts.ApplicationID) spinner.Stop() if err != nil { - return fmt.Errorf("failed to delete application: %w", err) + return fmt.Errorf("failed to remove application: %w", err) } - fmt.Fprintf(io.Out, "%s Application %q successfully deleted\n", c.SuccessIcon(), opts.ApplicationID) + fmt.Fprintf(io.Out, "%s Application %q successfully removed\n", c.SuccessIcon(), opts.ApplicationID) return nil } diff --git a/vcr/app/delete/delete_test.go b/vcr/app/remove/remove_test.go similarity index 91% rename from vcr/app/delete/delete_test.go rename to vcr/app/remove/remove_test.go index 491df39..61c9eab 100644 --- a/vcr/app/delete/delete_test.go +++ b/vcr/app/remove/remove_test.go @@ -1,4 +1,4 @@ -package delete +package remove import ( "bytes" @@ -15,7 +15,7 @@ import ( "vonage-cloud-runtime-cli/testutil/mocks" ) -func TestAppDelete(t *testing.T) { +func TestAppRemove(t *testing.T) { const appID = "12345678-1234-1234-1234-123456789abc" type mock struct { @@ -45,7 +45,7 @@ func TestAppDelete(t *testing.T) { AskYesNoTimes: 0, }, want: want{ - stdout: "✓ Application \"" + appID + "\" successfully deleted\n", + stdout: "✓ Application \"" + appID + "\" successfully removed\n", }, }, { @@ -58,7 +58,7 @@ func TestAppDelete(t *testing.T) { AskYesNoReturn: true, }, want: want{ - stdout: "✓ Application \"" + appID + "\" successfully deleted\n", + stdout: "✓ Application \"" + appID + "\" successfully removed\n", }, }, { @@ -92,7 +92,7 @@ func TestAppDelete(t *testing.T) { DeleteReturnErr: errors.New("internal server error"), }, want: want{ - errMsg: "failed to delete application: internal server error", + errMsg: "failed to remove application: internal server error", }, }, } @@ -124,7 +124,7 @@ func TestAppDelete(t *testing.T) { f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, deploymentMock, surveyMock, nil) - cmd := NewCmdAppDelete(f) + cmd := NewCmdAppRemove(f) cmd.SetArgs(argv) cmd.SetIn(&bytes.Buffer{}) cmd.SetOut(io.Discard) From b38fcc4e0b823c0c43f6473c1d1a9be3a02eb27e Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 09:58:24 +0200 Subject: [PATCH 10/13] docs: update spec and plan to reflect remove rename [APIAPEX-2823] --- .../plans/2026-05-18-vcr-app-delete.md | 381 +++--------------- .../specs/2026-05-18-vcr-app-delete-design.md | 47 ++- 2 files changed, 88 insertions(+), 340 deletions(-) diff --git a/docs/superpowers/plans/2026-05-18-vcr-app-delete.md b/docs/superpowers/plans/2026-05-18-vcr-app-delete.md index 6c182c6..359f1a1 100644 --- a/docs/superpowers/plans/2026-05-18-vcr-app-delete.md +++ b/docs/superpowers/plans/2026-05-18-vcr-app-delete.md @@ -1,12 +1,12 @@ -# vcr app delete Implementation Plan +# vcr app remove Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add `vcr app delete [--yes|-y]` command that deletes a Vonage application via the deployment API with an interactive confirmation prompt. +**Goal:** Add `vcr app remove [--yes|-y]` command that removes a Vonage application via the deployment API with an interactive confirmation prompt. -**Architecture:** Add `DeleteVonageApplication` to the `DeploymentClient` and `DeploymentInterface`, then implement the command under `vcr/app/delete/` following the same pattern as `vcr/instance/remove/`. Register the new subcommand in `vcr/app/app.go`. Regenerate mocks after the interface change. +**Architecture:** Add `DeleteVonageApplication` to the `DeploymentClient` and `DeploymentInterface`, then implement the command under `vcr/app/remove/` following the same pattern as `vcr/instance/remove/`. Register the new subcommand in `vcr/app/app.go`. Regenerate mocks after the interface change. -**Tech Stack:** Go, Cobra, resty, gomock, testify, httpmock +**Tech Stack:** Go, Cobra, resty, gomock, testify --- @@ -17,9 +17,9 @@ | Modify | `pkg/api/deployment.go` — add `DeleteVonageApplication` method | | Modify | `pkg/cmdutil/factory.go` — add `DeleteVonageApplication` to `DeploymentInterface` | | Regenerate | `testutil/mocks/factory.go` — run `go generate ./...` | -| Create | `vcr/app/delete/delete.go` — command implementation | -| Create | `vcr/app/delete/delete_test.go` — table-driven tests | -| Modify | `vcr/app/app.go` — register `deleteCmd.NewCmdAppDelete(f)` | +| Create | `vcr/app/remove/remove.go` — command implementation | +| Create | `vcr/app/remove/remove_test.go` — table-driven tests | +| Modify | `vcr/app/app.go` — register `removeCmd.NewCmdAppRemove(f)` | --- @@ -28,20 +28,18 @@ **Files:** - Modify: `pkg/api/deployment.go` -- [ ] **Step 1: Add the method after `GenerateVonageApplicationKeys` (line 107)** - - Open `pkg/api/deployment.go` and add the following method after the closing brace of `GenerateVonageApplicationKeys` (after line 107): +- [x] **Step 1: Add the method after `GenerateVonageApplicationKeys`** ```go func (c *DeploymentClient) DeleteVonageApplication(ctx context.Context, appID string) error { resp, err := c.httpClient.R(). SetContext(ctx). - Delete(fmt.Sprintf("%s/applications/%s", c.baseURL, appID)) + Delete(c.baseURL + "/applications/" + appID) if err != nil { return fmt.Errorf("%w: trace_id = %s", err, traceIDFromHTTPResponse(resp)) } if resp.StatusCode() == http.StatusNotFound { - return ErrNotFound + return nil } if resp.IsError() { return NewErrorFromHTTPResponse(resp) @@ -50,20 +48,17 @@ } ``` - Note: `http` is already imported. `ErrNotFound` is defined in `pkg/api/error.go`. + Note: returns `nil` on 404 (idempotent). Uses string concatenation consistent with adjacent methods. -- [ ] **Step 2: Verify it compiles** +- [x] **Step 2: Verify it compiles** ```bash go build ./pkg/api/... ``` - Expected: no output (clean build). - -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash - git add pkg/api/deployment.go git commit -m "feat(api): add DeleteVonageApplication to DeploymentClient [APIAPEX-2823]" ``` @@ -75,71 +70,49 @@ - Modify: `pkg/cmdutil/factory.go` - Regenerate: `testutil/mocks/factory.go` -- [ ] **Step 1: Add the method signature to `DeploymentInterface`** - - In `pkg/cmdutil/factory.go`, inside the `DeploymentInterface` block (lines 39-62), add after line 42 (`GenerateVonageApplicationKeys`): +- [x] **Step 1: Add the method signature to `DeploymentInterface`** after `GenerateVonageApplicationKeys`: ```go DeleteVonageApplication(ctx context.Context, appID string) error ``` - The interface block should now read: - - ```go - type DeploymentInterface interface { - CreateVonageApplication(ctx context.Context, name string, enableRTC, enableVoice, enableMessages bool) (api.CreateVonageApplicationOutput, error) - ListVonageApplications(ctx context.Context, filter string) (api.ListVonageApplicationsOutput, error) - GenerateVonageApplicationKeys(ctx context.Context, appID string) error - DeleteVonageApplication(ctx context.Context, appID string) error - // ... rest unchanged - ``` - -- [ ] **Step 2: Regenerate mocks** +- [x] **Step 2: Regenerate mocks** ```bash go generate ./... ``` - Expected: `testutil/mocks/factory.go` is updated with a new `DeleteVonageApplication` mock method. No errors. - -- [ ] **Step 3: Verify it compiles** +- [x] **Step 3: Verify it compiles** ```bash go build ./... ``` - Expected: no output (clean build). - -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash - git add pkg/cmdutil/factory.go testutil/mocks/factory.go git commit -m "feat(cmdutil): add DeleteVonageApplication to DeploymentInterface [APIAPEX-2823]" ``` --- -## Task 3: Implement `vcr/app/delete/delete.go` +## Task 3: Implement `vcr/app/remove/remove.go` **Files:** -- Create: `vcr/app/delete/delete.go` - -- [ ] **Step 1: Create the file** +- Create: `vcr/app/remove/remove.go` - Create `vcr/app/delete/delete.go` with the following content: +- [x] **Step 1: Create the file** ```go - package delete + package remove import ( "context" - "errors" "fmt" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" - "vonage-cloud-runtime-cli/pkg/api" "vonage-cloud-runtime-cli/pkg/cmdutil" ) @@ -150,356 +123,128 @@ SkipPrompts bool } - func NewCmdAppDelete(f cmdutil.Factory) *cobra.Command { - opts := Options{ - Factory: f, - } + func NewCmdAppRemove(f cmdutil.Factory) *cobra.Command { + opts := Options{Factory: f} cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a Vonage application", - Long: heredoc.Doc(`Delete a Vonage application from your account. - - This command permanently deletes a Vonage application and its associated - credentials. Any VCR instances linked to this application will lose their - authentication credentials on next restart. - - WARNING: This action is irreversible. Make sure no running instances - depend on this application before deleting it. - `), - Example: heredoc.Doc(` - # Delete an application (will prompt for confirmation) - $ vcr app delete 12345678-1234-1234-1234-123456789abc - - # Delete without confirmation prompt (useful for CI/CD) - $ vcr app delete 12345678-1234-1234-1234-123456789abc --yes - `), + Use: "remove ", + Aliases: []string{"rm"}, + Short: "Remove a Vonage application", + // ... Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { opts.ApplicationID = args[0] - ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) defer cancel() - - return runDelete(ctx, &opts) + return runRemove(ctx, &opts) }, } cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Skip confirmation prompt (use with caution)") - return cmd } - func runDelete(ctx context.Context, opts *Options) error { + func runRemove(ctx context.Context, opts *Options) error { io := opts.IOStreams() c := io.ColorScheme() if io.CanPrompt() && !opts.SkipPrompts { - if !opts.Survey().AskYesNo(fmt.Sprintf("are you sure you want to delete application %q ?", opts.ApplicationID)) { + if !opts.Survey().AskYesNo(fmt.Sprintf("Are you sure you want to remove application %q?", opts.ApplicationID)) { fmt.Fprintf(io.ErrOut, "%s Application removal aborted\n", c.WarningIcon()) return nil } } - spinner := cmdutil.DisplaySpinnerMessageWithHandle(fmt.Sprintf(" Deleting application %q...", opts.ApplicationID)) + spinner := cmdutil.DisplaySpinnerMessageWithHandle(fmt.Sprintf(" Removing application %q...", opts.ApplicationID)) err := opts.DeploymentClient().DeleteVonageApplication(ctx, opts.ApplicationID) spinner.Stop() if err != nil { - if errors.Is(err, api.ErrNotFound) { - return fmt.Errorf("application %q not found", opts.ApplicationID) - } - return fmt.Errorf("failed to delete application: %w", err) + return fmt.Errorf("failed to remove application: %w", err) } - fmt.Fprintf(io.Out, "%s Application %q successfully deleted\n", c.SuccessIcon(), opts.ApplicationID) - + fmt.Fprintf(io.Out, "%s Application %q successfully removed\n", c.SuccessIcon(), opts.ApplicationID) return nil } ``` -- [ ] **Step 2: Verify it compiles** +- [x] **Step 2: Verify it compiles** ```bash - go build ./vcr/app/delete/... + go build ./vcr/app/remove/... ``` - Expected: no output. - -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash - git add vcr/app/delete/delete.go - git commit -m "feat(app): implement vcr app delete command [APIAPEX-2823]" + git commit -m "feat(app): implement vcr app remove command [APIAPEX-2823]" ``` --- -## Task 4: Write tests for `vcr/app/delete/delete_test.go` +## Task 4: Write tests for `vcr/app/remove/remove_test.go` **Files:** -- Create: `vcr/app/delete/delete_test.go` - -- [ ] **Step 1: Create the test file** - - Create `vcr/app/delete/delete_test.go` with the following content: - - ```go - package delete - - import ( - "bytes" - "errors" - "io" - "testing" - - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/golang/mock/gomock" - "github.com/google/shlex" - "github.com/stretchr/testify/require" - - "vonage-cloud-runtime-cli/pkg/api" - "vonage-cloud-runtime-cli/testutil" - "vonage-cloud-runtime-cli/testutil/mocks" - ) +- Create: `vcr/app/remove/remove_test.go` - func TestAppDelete(t *testing.T) { - const appID = "12345678-1234-1234-1234-123456789abc" +- [x] **Step 1: Create the file with 5 test cases** - type mock struct { - DeleteTimes int - DeleteReturnErr error - AskYesNoTimes int - AskYesNoReturn bool - } - type want struct { - errMsg string - stdout string - stderr string - } - - tests := []struct { - name string - cli string - mock mock - want want - }{ - { - name: "happy-path-with-yes-flag", - cli: appID + " --yes", - mock: mock{ - DeleteTimes: 1, - DeleteReturnErr: nil, - AskYesNoTimes: 0, - }, - want: want{ - stdout: "✓ Application \"" + appID + "\" successfully deleted\n", - }, - }, - { - name: "happy-path-confirm-prompt", - cli: appID, - mock: mock{ - DeleteTimes: 1, - DeleteReturnErr: nil, - AskYesNoTimes: 1, - AskYesNoReturn: true, - }, - want: want{ - stdout: "✓ Application \"" + appID + "\" successfully deleted\n", - }, - }, - { - name: "user-aborts-prompt", - cli: appID, - mock: mock{ - DeleteTimes: 0, - AskYesNoTimes: 1, - AskYesNoReturn: false, - }, - want: want{ - stderr: "! Application removal aborted\n", - }, - }, - { - name: "missing-application-id", - cli: "", - mock: mock{ - DeleteTimes: 0, - AskYesNoTimes: 0, - }, - want: want{ - errMsg: "accepts 1 arg(s), received 0", - }, - }, - { - name: "not-found", - cli: appID + " --yes", - mock: mock{ - DeleteTimes: 1, - DeleteReturnErr: api.ErrNotFound, - }, - want: want{ - errMsg: "application \"" + appID + "\" not found", - }, - }, - { - name: "api-error", - cli: appID + " --yes", - mock: mock{ - DeleteTimes: 1, - DeleteReturnErr: errors.New("internal server error"), - }, - want: want{ - errMsg: "failed to delete application: internal server error", - }, - }, - } + | Test case | DeleteTimes | AskYesNoTimes | Expected | + |---|---|---|---| + | `happy-path-with-yes-flag` | 1 | 0 | stdout: success message | + | `happy-path-confirm-prompt` | 1 | 1 (returns true) | stdout: success message | + | `user-aborts-prompt` | 0 | 1 (returns false) | stderr: abort message | + | `missing-application-id` | 0 | 0 | errMsg: cobra arg error | + | `api-error` | 1 | 0 | errMsg: wrapped error | - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - - deploymentMock := mocks.NewMockDeploymentInterface(ctrl) - deploymentMock.EXPECT(). - DeleteVonageApplication(gomock.Any(), appID). - Times(tt.mock.DeleteTimes). - Return(tt.mock.DeleteReturnErr) - - surveyMock := mocks.NewMockSurveyInterface(ctrl) - surveyMock.EXPECT(). - AskYesNo(gomock.Any()). - Times(tt.mock.AskYesNoTimes). - Return(tt.mock.AskYesNoReturn) - - ios, _, stdout, stderr := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - - argv, err := shlex.Split(tt.cli) - if err != nil { - t.Fatal(err) - } - - f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, deploymentMock, surveyMock, nil) - - cmd := NewCmdAppDelete(f) - cmd.SetArgs(argv) - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - - if _, err := cmd.ExecuteC(); err != nil && tt.want.errMsg != "" { - require.Error(t, err, "should throw error") - require.Equal(t, tt.want.errMsg, err.Error()) - return - } - - cmdOut := &testutil.CmdOut{ - OutBuf: stdout, - ErrBuf: stderr, - } - - if tt.want.stderr != "" { - require.Equal(t, tt.want.stderr, cmdOut.Stderr()) - return - } - require.NoError(t, err, "should not throw error") - require.Equal(t, tt.want.stdout, cmdOut.String()) - }) - } - } - ``` + Note: no `not-found` case — `DeleteVonageApplication` returns `nil` on 404. -- [ ] **Step 2: Run the tests** +- [x] **Step 2: Run the tests** ```bash - go test -v ./vcr/app/delete/... + go test -v ./vcr/app/remove/... ``` - Expected: all 6 test cases PASS. + Expected: all 5 PASS. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash - git add vcr/app/delete/delete_test.go - git commit -m "test(app): add tests for vcr app delete command [APIAPEX-2823]" + git commit -m "test(app): add tests for vcr app remove command [APIAPEX-2823]" ``` --- -## Task 5: Register the delete subcommand in `vcr/app/app.go` +## Task 5: Register the remove subcommand in `vcr/app/app.go` **Files:** - Modify: `vcr/app/app.go` -- [ ] **Step 1: Add the import and `AddCommand` call** - - In `vcr/app/app.go`, add the import: +- [x] **Step 1: Add import and `AddCommand` call** ```go - deleteCmd "vonage-cloud-runtime-cli/vcr/app/delete" + removeCmd "vonage-cloud-runtime-cli/vcr/app/remove" ``` - And add the `AddCommand` call before `return cmd`: - ```go - cmd.AddCommand(deleteCmd.NewCmdAppDelete(f)) + cmd.AddCommand(removeCmd.NewCmdAppRemove(f)) ``` - The full updated imports block: + Update `AVAILABLE COMMANDS` in Long description to include `remove (rm)`. - ```go - import ( - "github.com/MakeNowJust/heredoc" - "github.com/spf13/cobra" - - "vonage-cloud-runtime-cli/pkg/cmdutil" - createCmd "vonage-cloud-runtime-cli/vcr/app/create" - deleteCmd "vonage-cloud-runtime-cli/vcr/app/delete" - generatekeysCmd "vonage-cloud-runtime-cli/vcr/app/generatekeys" - listCmd "vonage-cloud-runtime-cli/vcr/app/list" - ) - ``` - - And the updated `AddCommand` calls: - - ```go - cmd.AddCommand(listCmd.NewCmdAppList(f)) - cmd.AddCommand(createCmd.NewCmdAppCreate(f)) - cmd.AddCommand(generatekeysCmd.NewCmdAppGenerateKeys(f)) - cmd.AddCommand(deleteCmd.NewCmdAppDelete(f)) - ``` - - Also update the `AVAILABLE COMMANDS` section in the Long description to include `delete`: - - ``` - AVAILABLE COMMANDS - create Create a new Vonage application - list (ls) List all Vonage applications in your account - generate-keys Generate new key pairs for an existing application - delete Delete a Vonage application - ``` - -- [ ] **Step 2: Run the full test suite** +- [x] **Step 2: Run the full test suite** ```bash go test ./... ``` - Expected: all tests PASS, no failures. - -- [ ] **Step 3: Build and do a quick smoke test** +- [x] **Step 3: Smoke test** ```bash - make build - ./vcr app delete --help + make build && ./vcr app remove --help ``` - Expected: help text shows `delete ` with `--yes` / `-y` flag documented. - -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash - git add vcr/app/app.go - git commit -m "feat(app): register vcr app delete subcommand [APIAPEX-2823]" + git commit -m "feat(app): register vcr app remove subcommand [APIAPEX-2823]" ``` diff --git a/docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md b/docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md index 1e3d07f..99e7bee 100644 --- a/docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md +++ b/docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md @@ -1,22 +1,23 @@ -# Design: `vcr app delete` +# Design: `vcr app remove` **Date:** 2026-05-18 ## Summary -Add a `vcr app delete ` command that deletes a Vonage application via the deployment API. Follows existing CLI conventions established by `vcr instance remove` and `vcr secret remove`. +Add a `vcr app remove ` command that removes a Vonage application via the deployment API. Follows existing CLI conventions established by `vcr instance remove` and `vcr secret remove`. ## Command Interface ``` -vcr app delete [--yes|-y] +vcr app remove [--yes|-y] ``` - `applicationID`: required positional argument (Vonage application UUID). - `--yes` / `-y`: skip the interactive confirmation prompt (e.g. for CI environments). Prompt is also skipped when `io.CanPrompt()` returns false. +- `rm` alias available (consistent with `vcr instance rm`, `vcr secret rm`). - Without `--yes`, the CLI prints: ``` - are you sure you want to delete application ""? [y/N] + Are you sure you want to remove application ""? ``` and aborts on anything other than `y`/`yes`. @@ -25,12 +26,12 @@ vcr app delete [--yes|-y] Follows the existing pattern under `vcr/app/`: ``` -vcr/app/delete/ - delete.go # NewCmdAppDelete, Options, runDelete - delete_test.go +vcr/app/remove/ + remove.go # NewCmdAppRemove, Options, runRemove + remove_test.go ``` -`vcr/app/app.go` gets a new `AddCommand(delete.NewCmdAppDelete(f))` line. +`vcr/app/app.go` gets a new `AddCommand(removeCmd.NewCmdAppRemove(f))` line. ## API Layer @@ -39,33 +40,35 @@ Add `DeleteVonageApplication` to `pkg/api/deployment.go`: ```go func (c *DeploymentClient) DeleteVonageApplication(ctx context.Context, appID string) error // DELETE {baseURL}/applications/{appID} -// Returns nil on 2xx, ErrNotFound on 404, error on other non-2xx. +// Returns nil on 2xx and 404 (idempotent), error on other non-2xx. ``` -The `Factory` interface in `pkg/cmdutil/factory.go` already exposes `DeploymentClient()` — no interface changes needed. +The `Factory` interface in `pkg/cmdutil/factory.go` exposes `DeploymentClient()` — `DeleteVonageApplication` is added to `DeploymentInterface` and mocks regenerated. ## Output | Scenario | Output | Exit code | |---|---|---| -| Success | `Application "" deleted.` to stdout | 0 | -| User aborts prompt | `Application removal aborted` to stderr | 0 | -| 404 | formatted error: `application not found` | 1 | -| Other API error | standard error via `pkg/format` | 1 | +| Success | `✓ Application "" successfully removed` to stdout | 0 | +| User aborts prompt | `! Application removal aborted` to stderr | 0 | +| Other API error | `failed to remove application: ` to stderr | 1 | + +Note: 404 is treated as success (idempotent delete) — no error surfaced to the user. ## Tests -Table-driven tests in `delete_test.go` using `httpmock` and `testutil.NewTestIOStreams()`: +Table-driven tests in `remove_test.go` using `gomock` and `testutil.NewTestIOStreams()`: -- Successful delete with `--yes` flag (no prompt). -- Successful delete after user confirms at prompt. +- Successful remove with `--yes` flag (no prompt). +- Successful remove after user confirms at prompt. - User types `n` at prompt — aborts, no API call made. -- 404 response — error message printed, non-zero exit. -- 500 response — error message printed, non-zero exit. +- Missing application ID argument — cobra returns error. +- API returns error — command returns wrapped error. ## Consistency Notes +- Command name `remove` / alias `rm` matches `vcr instance remove` and `vcr secret remove`. - Flag name `--yes` / `-y` matches `vcr instance remove` and `vcr secret remove`. -- Prompt text style matches `vcr instance remove`: lowercase, uses `opts.Survey().AskYesNo(...)`. -- Prompt is gated on `io.CanPrompt()` matching existing pattern. -- Error formatting uses `pkg/format` matching all other commands. +- Prompt text capitalised: `"Are you sure you want to remove application %q?"`. +- Prompt gated on `io.CanPrompt()` matching existing pattern. +- API method named `DeleteVonageApplication` (HTTP verb) while CLI command is `remove` (user-facing verb). From 23c587734642b622c89359a44aabd9717e9a39b8 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 10:02:31 +0200 Subject: [PATCH 11/13] fix: doc tab --- vcr/app/app.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vcr/app/app.go b/vcr/app/app.go index 7d934d3..2c7d411 100644 --- a/vcr/app/app.go +++ b/vcr/app/app.go @@ -41,8 +41,8 @@ func NewCmdApp(f cmdutil.Factory) *cobra.Command { # Create a new application with Voice and Messages capabilities $ vcr app create --name my-app --voice --messages - # Remove an application - $ vcr app remove 12345678-1234-1234-1234-123456789abc + # Remove an application + $ vcr app remove 12345678-1234-1234-1234-123456789abc # Generate new keys for an existing application $ vcr app generate-keys --app-id 12345678-1234-1234-1234-123456789abc From 63ff24bec31bf6206e4b9452bd67addf7b6bd2cf Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 10:06:18 +0200 Subject: [PATCH 12/13] chore: ignore docs/superpowers agent working files [APIAPEX-2823] --- .gitignore | 3 + .../plans/2026-05-18-vcr-app-delete.md | 250 ------------------ .../specs/2026-05-18-vcr-app-delete-design.md | 74 ------ 3 files changed, 3 insertions(+), 324 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-18-vcr-app-delete.md delete mode 100644 docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md diff --git a/.gitignore b/.gitignore index 7957e2b..348b3e0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ lefthook-local.yml # Node modules (for commitlint) node_modules/ + +# Agent working files +docs/superpowers/ diff --git a/docs/superpowers/plans/2026-05-18-vcr-app-delete.md b/docs/superpowers/plans/2026-05-18-vcr-app-delete.md deleted file mode 100644 index 359f1a1..0000000 --- a/docs/superpowers/plans/2026-05-18-vcr-app-delete.md +++ /dev/null @@ -1,250 +0,0 @@ -# vcr app remove Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `vcr app remove [--yes|-y]` command that removes a Vonage application via the deployment API with an interactive confirmation prompt. - -**Architecture:** Add `DeleteVonageApplication` to the `DeploymentClient` and `DeploymentInterface`, then implement the command under `vcr/app/remove/` following the same pattern as `vcr/instance/remove/`. Register the new subcommand in `vcr/app/app.go`. Regenerate mocks after the interface change. - -**Tech Stack:** Go, Cobra, resty, gomock, testify - ---- - -## File Map - -| Action | File | -|--------|------| -| Modify | `pkg/api/deployment.go` — add `DeleteVonageApplication` method | -| Modify | `pkg/cmdutil/factory.go` — add `DeleteVonageApplication` to `DeploymentInterface` | -| Regenerate | `testutil/mocks/factory.go` — run `go generate ./...` | -| Create | `vcr/app/remove/remove.go` — command implementation | -| Create | `vcr/app/remove/remove_test.go` — table-driven tests | -| Modify | `vcr/app/app.go` — register `removeCmd.NewCmdAppRemove(f)` | - ---- - -## Task 1: Add `DeleteVonageApplication` to the API client - -**Files:** -- Modify: `pkg/api/deployment.go` - -- [x] **Step 1: Add the method after `GenerateVonageApplicationKeys`** - - ```go - func (c *DeploymentClient) DeleteVonageApplication(ctx context.Context, appID string) error { - resp, err := c.httpClient.R(). - SetContext(ctx). - Delete(c.baseURL + "/applications/" + appID) - if err != nil { - return fmt.Errorf("%w: trace_id = %s", err, traceIDFromHTTPResponse(resp)) - } - if resp.StatusCode() == http.StatusNotFound { - return nil - } - if resp.IsError() { - return NewErrorFromHTTPResponse(resp) - } - return nil - } - ``` - - Note: returns `nil` on 404 (idempotent). Uses string concatenation consistent with adjacent methods. - -- [x] **Step 2: Verify it compiles** - - ```bash - go build ./pkg/api/... - ``` - -- [x] **Step 3: Commit** - - ```bash - git commit -m "feat(api): add DeleteVonageApplication to DeploymentClient [APIAPEX-2823]" - ``` - ---- - -## Task 2: Add `DeleteVonageApplication` to `DeploymentInterface` and regenerate mocks - -**Files:** -- Modify: `pkg/cmdutil/factory.go` -- Regenerate: `testutil/mocks/factory.go` - -- [x] **Step 1: Add the method signature to `DeploymentInterface`** after `GenerateVonageApplicationKeys`: - - ```go - DeleteVonageApplication(ctx context.Context, appID string) error - ``` - -- [x] **Step 2: Regenerate mocks** - - ```bash - go generate ./... - ``` - -- [x] **Step 3: Verify it compiles** - - ```bash - go build ./... - ``` - -- [x] **Step 4: Commit** - - ```bash - git commit -m "feat(cmdutil): add DeleteVonageApplication to DeploymentInterface [APIAPEX-2823]" - ``` - ---- - -## Task 3: Implement `vcr/app/remove/remove.go` - -**Files:** -- Create: `vcr/app/remove/remove.go` - -- [x] **Step 1: Create the file** - - ```go - package remove - - import ( - "context" - "fmt" - - "github.com/MakeNowJust/heredoc" - "github.com/spf13/cobra" - - "vonage-cloud-runtime-cli/pkg/cmdutil" - ) - - type Options struct { - cmdutil.Factory - - ApplicationID string - SkipPrompts bool - } - - func NewCmdAppRemove(f cmdutil.Factory) *cobra.Command { - opts := Options{Factory: f} - - cmd := &cobra.Command{ - Use: "remove ", - Aliases: []string{"rm"}, - Short: "Remove a Vonage application", - // ... - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - opts.ApplicationID = args[0] - ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) - defer cancel() - return runRemove(ctx, &opts) - }, - } - - cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Skip confirmation prompt (use with caution)") - return cmd - } - - func runRemove(ctx context.Context, opts *Options) error { - io := opts.IOStreams() - c := io.ColorScheme() - - if io.CanPrompt() && !opts.SkipPrompts { - if !opts.Survey().AskYesNo(fmt.Sprintf("Are you sure you want to remove application %q?", opts.ApplicationID)) { - fmt.Fprintf(io.ErrOut, "%s Application removal aborted\n", c.WarningIcon()) - return nil - } - } - - spinner := cmdutil.DisplaySpinnerMessageWithHandle(fmt.Sprintf(" Removing application %q...", opts.ApplicationID)) - err := opts.DeploymentClient().DeleteVonageApplication(ctx, opts.ApplicationID) - spinner.Stop() - if err != nil { - return fmt.Errorf("failed to remove application: %w", err) - } - - fmt.Fprintf(io.Out, "%s Application %q successfully removed\n", c.SuccessIcon(), opts.ApplicationID) - return nil - } - ``` - -- [x] **Step 2: Verify it compiles** - - ```bash - go build ./vcr/app/remove/... - ``` - -- [x] **Step 3: Commit** - - ```bash - git commit -m "feat(app): implement vcr app remove command [APIAPEX-2823]" - ``` - ---- - -## Task 4: Write tests for `vcr/app/remove/remove_test.go` - -**Files:** -- Create: `vcr/app/remove/remove_test.go` - -- [x] **Step 1: Create the file with 5 test cases** - - | Test case | DeleteTimes | AskYesNoTimes | Expected | - |---|---|---|---| - | `happy-path-with-yes-flag` | 1 | 0 | stdout: success message | - | `happy-path-confirm-prompt` | 1 | 1 (returns true) | stdout: success message | - | `user-aborts-prompt` | 0 | 1 (returns false) | stderr: abort message | - | `missing-application-id` | 0 | 0 | errMsg: cobra arg error | - | `api-error` | 1 | 0 | errMsg: wrapped error | - - Note: no `not-found` case — `DeleteVonageApplication` returns `nil` on 404. - -- [x] **Step 2: Run the tests** - - ```bash - go test -v ./vcr/app/remove/... - ``` - - Expected: all 5 PASS. - -- [x] **Step 3: Commit** - - ```bash - git commit -m "test(app): add tests for vcr app remove command [APIAPEX-2823]" - ``` - ---- - -## Task 5: Register the remove subcommand in `vcr/app/app.go` - -**Files:** -- Modify: `vcr/app/app.go` - -- [x] **Step 1: Add import and `AddCommand` call** - - ```go - removeCmd "vonage-cloud-runtime-cli/vcr/app/remove" - ``` - - ```go - cmd.AddCommand(removeCmd.NewCmdAppRemove(f)) - ``` - - Update `AVAILABLE COMMANDS` in Long description to include `remove (rm)`. - -- [x] **Step 2: Run the full test suite** - - ```bash - go test ./... - ``` - -- [x] **Step 3: Smoke test** - - ```bash - make build && ./vcr app remove --help - ``` - -- [x] **Step 4: Commit** - - ```bash - git commit -m "feat(app): register vcr app remove subcommand [APIAPEX-2823]" - ``` diff --git a/docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md b/docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md deleted file mode 100644 index 99e7bee..0000000 --- a/docs/superpowers/specs/2026-05-18-vcr-app-delete-design.md +++ /dev/null @@ -1,74 +0,0 @@ -# Design: `vcr app remove` - -**Date:** 2026-05-18 - -## Summary - -Add a `vcr app remove ` command that removes a Vonage application via the deployment API. Follows existing CLI conventions established by `vcr instance remove` and `vcr secret remove`. - -## Command Interface - -``` -vcr app remove [--yes|-y] -``` - -- `applicationID`: required positional argument (Vonage application UUID). -- `--yes` / `-y`: skip the interactive confirmation prompt (e.g. for CI environments). Prompt is also skipped when `io.CanPrompt()` returns false. -- `rm` alias available (consistent with `vcr instance rm`, `vcr secret rm`). -- Without `--yes`, the CLI prints: - ``` - Are you sure you want to remove application ""? - ``` - and aborts on anything other than `y`/`yes`. - -## File Structure - -Follows the existing pattern under `vcr/app/`: - -``` -vcr/app/remove/ - remove.go # NewCmdAppRemove, Options, runRemove - remove_test.go -``` - -`vcr/app/app.go` gets a new `AddCommand(removeCmd.NewCmdAppRemove(f))` line. - -## API Layer - -Add `DeleteVonageApplication` to `pkg/api/deployment.go`: - -```go -func (c *DeploymentClient) DeleteVonageApplication(ctx context.Context, appID string) error -// DELETE {baseURL}/applications/{appID} -// Returns nil on 2xx and 404 (idempotent), error on other non-2xx. -``` - -The `Factory` interface in `pkg/cmdutil/factory.go` exposes `DeploymentClient()` — `DeleteVonageApplication` is added to `DeploymentInterface` and mocks regenerated. - -## Output - -| Scenario | Output | Exit code | -|---|---|---| -| Success | `✓ Application "" successfully removed` to stdout | 0 | -| User aborts prompt | `! Application removal aborted` to stderr | 0 | -| Other API error | `failed to remove application: ` to stderr | 1 | - -Note: 404 is treated as success (idempotent delete) — no error surfaced to the user. - -## Tests - -Table-driven tests in `remove_test.go` using `gomock` and `testutil.NewTestIOStreams()`: - -- Successful remove with `--yes` flag (no prompt). -- Successful remove after user confirms at prompt. -- User types `n` at prompt — aborts, no API call made. -- Missing application ID argument — cobra returns error. -- API returns error — command returns wrapped error. - -## Consistency Notes - -- Command name `remove` / alias `rm` matches `vcr instance remove` and `vcr secret remove`. -- Flag name `--yes` / `-y` matches `vcr instance remove` and `vcr secret remove`. -- Prompt text capitalised: `"Are you sure you want to remove application %q?"`. -- Prompt gated on `io.CanPrompt()` matching existing pattern. -- API method named `DeleteVonageApplication` (HTTP verb) while CLI command is `remove` (user-facing verb). From 516f208d4e1d2d9cf535d114d3d72a0a3dd51f34 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 18 May 2026 10:10:39 +0200 Subject: [PATCH 13/13] fix(app/remove): address Copilot review comments [APIAPEX-2823] - Return ErrNotFound on 404 in DeleteVonageApplication (was silently nil) - Handle ErrNotFound in runRemove with descriptive user-facing error - Use url.PathEscape for application ID in URL construction - Add TestDeleteVonageApplication with 204/404/500 cases - Fix err shadowing in remove_test.go (ExecuteC result was discarded) - Only register mock expectations when Times > 0 --- pkg/api/deployment.go | 5 ++- pkg/api/deployment_test.go | 74 +++++++++++++++++++++++++++++++++++ vcr/app/remove/remove.go | 5 +++ vcr/app/remove/remove_test.go | 41 +++++++++++++------ 4 files changed, 111 insertions(+), 14 deletions(-) diff --git a/pkg/api/deployment.go b/pkg/api/deployment.go index e1be28c..fe19616 100644 --- a/pkg/api/deployment.go +++ b/pkg/api/deployment.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "net/http" + "net/url" "regexp" "strings" @@ -109,12 +110,12 @@ func (c *DeploymentClient) GenerateVonageApplicationKeys(ctx context.Context, ap func (c *DeploymentClient) DeleteVonageApplication(ctx context.Context, appID string) error { resp, err := c.httpClient.R(). SetContext(ctx). - Delete(c.baseURL + "/applications/" + appID) + Delete(c.baseURL + "/applications/" + url.PathEscape(appID)) if err != nil { return fmt.Errorf("%w: trace_id = %s", err, traceIDFromHTTPResponse(resp)) } if resp.StatusCode() == http.StatusNotFound { - return nil + return ErrNotFound } if resp.IsError() { return NewErrorFromHTTPResponse(resp) diff --git a/pkg/api/deployment_test.go b/pkg/api/deployment_test.go index a6d8467..29ac8f9 100644 --- a/pkg/api/deployment_test.go +++ b/pkg/api/deployment_test.go @@ -239,6 +239,80 @@ func TestGenerateVonageApplicationKeys(t *testing.T) { } } +func TestDeleteVonageApplication(t *testing.T) { + client := resty.New() + httpmock.ActivateNonDefault(client.GetClient()) + defer httpmock.DeactivateAndReset() + + type mock struct { + mockResponse string + status int + } + + type want struct { + err error + } + + tests := []struct { + name string + mock mock + want want + }{ + { + name: "204-happy-path", + mock: mock{ + mockResponse: "", + status: http.StatusNoContent, + }, + want: want{ + err: nil, + }, + }, + { + name: "404-not-found", + mock: mock{ + mockResponse: "", + status: http.StatusNotFound, + }, + want: want{ + err: ErrNotFound, + }, + }, + { + name: "500-error", + mock: mock{ + mockResponse: `{"error": {"code": 1001, "message": "internal server error", "traceId": "n/a", "containerLogs": ""}}`, + status: http.StatusInternalServerError, + }, + want: want{ + err: errors.New("API Error Encountered: ( HTTP status: 500 Error code: 1001 Detailed message: internal server error Trace ID: n/a )"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + httpmock.RegisterResponder("DELETE", "https://example.com/v0.3/applications/application-id", + func(_ *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(tt.mock.status, tt.mock.mockResponse) + resp.Header.Set("Content-Type", "application/json") + return resp, nil + }) + + deploymentClient := NewDeploymentClient("https://example.com", "v0.3", client, nil) + + err := deploymentClient.DeleteVonageApplication(t.Context(), "application-id") + if tt.want.err != nil { + require.EqualError(t, err, tt.want.err.Error()) + httpmock.Reset() + return + } + require.NoError(t, err) + httpmock.Reset() + }) + } +} + func TestDeployDebugService(t *testing.T) { client := resty.New() httpmock.ActivateNonDefault(client.GetClient()) diff --git a/vcr/app/remove/remove.go b/vcr/app/remove/remove.go index 1daaef0..fc2543f 100644 --- a/vcr/app/remove/remove.go +++ b/vcr/app/remove/remove.go @@ -2,11 +2,13 @@ package remove import ( "context" + "errors" "fmt" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" + "vonage-cloud-runtime-cli/pkg/api" "vonage-cloud-runtime-cli/pkg/cmdutil" ) @@ -76,6 +78,9 @@ func runRemove(ctx context.Context, opts *Options) error { err := opts.DeploymentClient().DeleteVonageApplication(ctx, opts.ApplicationID) spinner.Stop() if err != nil { + if errors.Is(err, api.ErrNotFound) { + return fmt.Errorf("application %q could not be found or may have already been deleted", opts.ApplicationID) + } return fmt.Errorf("failed to remove application: %w", err) } diff --git a/vcr/app/remove/remove_test.go b/vcr/app/remove/remove_test.go index 61c9eab..c7a411a 100644 --- a/vcr/app/remove/remove_test.go +++ b/vcr/app/remove/remove_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/shlex" "github.com/stretchr/testify/require" + "vonage-cloud-runtime-cli/pkg/api" "vonage-cloud-runtime-cli/testutil" "vonage-cloud-runtime-cli/testutil/mocks" ) @@ -84,6 +85,17 @@ func TestAppRemove(t *testing.T) { errMsg: "accepts 1 arg(s), received 0", }, }, + { + name: "not-found", + cli: appID + " --yes", + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: api.ErrNotFound, + }, + want: want{ + errMsg: "application \"" + appID + "\" could not be found or may have already been deleted", + }, + }, { name: "api-error", cli: appID + " --yes", @@ -102,16 +114,20 @@ func TestAppRemove(t *testing.T) { ctrl := gomock.NewController(t) deploymentMock := mocks.NewMockDeploymentInterface(ctrl) - deploymentMock.EXPECT(). - DeleteVonageApplication(gomock.Any(), appID). - Times(tt.mock.DeleteTimes). - Return(tt.mock.DeleteReturnErr) + if tt.mock.DeleteTimes > 0 { + deploymentMock.EXPECT(). + DeleteVonageApplication(gomock.Any(), appID). + Times(tt.mock.DeleteTimes). + Return(tt.mock.DeleteReturnErr) + } surveyMock := mocks.NewMockSurveyInterface(ctrl) - surveyMock.EXPECT(). - AskYesNo(gomock.Any()). - Times(tt.mock.AskYesNoTimes). - Return(tt.mock.AskYesNoReturn) + if tt.mock.AskYesNoTimes > 0 { + surveyMock.EXPECT(). + AskYesNo(gomock.Any()). + Times(tt.mock.AskYesNoTimes). + Return(tt.mock.AskYesNoReturn) + } ios, _, stdout, stderr := iostreams.Test() ios.SetStdinTTY(true) @@ -130,9 +146,10 @@ func TestAppRemove(t *testing.T) { cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - if _, err := cmd.ExecuteC(); err != nil && tt.want.errMsg != "" { - require.Error(t, err, "should throw error") - require.Equal(t, tt.want.errMsg, err.Error()) + _, cmdErr := cmd.ExecuteC() + if cmdErr != nil && tt.want.errMsg != "" { + require.Error(t, cmdErr, "should throw error") + require.Equal(t, tt.want.errMsg, cmdErr.Error()) return } @@ -145,7 +162,7 @@ func TestAppRemove(t *testing.T) { require.Equal(t, tt.want.stderr, cmdOut.Stderr()) return } - require.NoError(t, err, "should not throw error") + require.NoError(t, cmdErr, "should not throw error") require.Equal(t, tt.want.stdout, cmdOut.String()) }) }