diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..02d675f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,429 @@ +--- +applyTo: "**/*.go" +--- + +# Copilot Instructions for Vonage Cloud Runtime CLI + +## Priority Guidelines + +When generating code for this repository: + +1. **Factory Pattern First**: ALWAYS use the `cmdutil.Factory` interface for dependency injection - never instantiate API clients directly +2. **Go 1.24 Only**: Use only language features available in Go 1.24.0 - this is our version +3. **Error Wrapping**: Always wrap errors with context using `fmt.Errorf("context: %w", err)` +4. **Context Management**: Create deadline context from `opts.Deadline()` in command RunE functions +5. **Testing Required**: All code changes must include unit tests +6. **Consistent Code Style**: Match existing patterns exactly - scan similar files before generating new code + +## Technology Stack + +**Go Version**: 1.24.0 + +**Core Dependencies**: +- `github.com/spf13/cobra` v1.9.1 - CLI framework +- `github.com/go-resty/resty/v2` v2.16.5 - HTTP client +- `github.com/cli/cli/v2` v2.36.0 - IO streams and colors +- `github.com/AlecAivazis/survey/v2` v2.3.7 - Interactive prompts +- `github.com/golang/mock` v1.6.0 - Mocking (use mockgen) +- `gopkg.in/ini.v1` v1.67.0 - Config parsing +- `github.com/stretchr/testify` v1.10.0 - Test assertions + +## Naming Conventions + +### Files +- Lowercase with underscores: `create.go`, `create_test.go` +- Test files: Always `*_test.go` in same package +- Platform-specific: `command_gen_syscall_notwin.go`, `command_gen_syscall_win.go` + +### Packages +- Lowercase, single-word: `api`, `config`, `cmdutil`, `format` +- Grouped by domain: `vcr/app/`, `vcr/deploy/`, `vcr/secret/` + +### Types and Variables +- **Exported types**: `PascalCase` (e.g., `DeploymentClient`, `Options`) +- **Unexported types**: `camelCase` (e.g., `listRequest`, `createResponse`) +- **Interfaces**: End with `Interface` (e.g., `DeploymentInterface`, `AssetInterface`) +- **Functions**: Exported = `PascalCase`, Unexported = `camelCase` +- **Command factories**: `NewCmd` (e.g., `NewCmdDeploy`, `NewCmdAppCreate`) +- **Run functions**: `run` (e.g., `runDeploy`, `runCreate`) +- **Constants**: `PascalCase` (e.g., `DefaultTimeout`, `DefaultRegion`) + +## Command Structure Pattern + +Every CLI command MUST follow this exact structure: + +```go +package commandname + +import ( + "context" + "fmt" + "github.com/Vonage/vonage-cloud-runtime-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// Options holds the command-specific configuration +type Options struct { + cmdutil.Factory // ALWAYS embed the Factory interface + + // Command-specific fields matching flags + Name string + SkipPrompts bool +} + +// NewCmdCommandName creates the command +func NewCmdCommandName(f cmdutil.Factory) *cobra.Command { + opts := Options{Factory: f} + + cmd := &cobra.Command{ + Use: "commandname", + Short: "Brief description", + RunE: func(_ *cobra.Command, _ []string) error { + // ALWAYS create context with deadline from Factory + ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) + defer cancel() + return runCommandName(ctx, &opts) + }, + } + + // Define flags + cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Resource name") + + return cmd +} + +// runCommandName executes the command logic +func runCommandName(ctx context.Context, opts *Options) error { + io := opts.IOStreams() + c := io.ColorScheme() + + // Get clients from Factory (NEVER instantiate directly) + deployClient := opts.DeploymentClient() + + // Use spinners for long operations + spinner := cmdutil.DisplaySpinnerMessageWithHandle("Processing...") + result, err := deployClient.SomeOperation(ctx, opts.Name) + spinner.Stop() + + if err != nil { + return fmt.Errorf("operation failed: %w", err) + } + + // Use color scheme for output + fmt.Fprintf(io.Out, "%s Operation successful\n", c.SuccessIcon()) + fmt.Fprintf(io.Out, "%s ID: %s\n", c.Blue(cmdutil.InfoIcon), result.ID) + + return nil +} +``` + +## Testing Pattern + +**All code changes require unit tests.** Table-driven tests are a common pattern in this codebase but not mandatory. Write tests that are clear and maintainable. + +**Table-driven test example** (commonly used for commands with multiple scenarios): + +```go +package commandname + +import ( + "testing" + "github.com/Vonage/vonage-cloud-runtime-cli/testutil" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func TestCommandName(t *testing.T) { + type mock struct { + OperationTimes int + OperationReturnData interface{} + OperationReturnErr error + } + + type want struct { + errMsg string + stdout string + } + + tests := []struct { + name string + cli string + mock mock + want want + }{ + { + name: "happy-path", + cli: "--name=Test", + mock: mock{ + OperationTimes: 1, + OperationReturnData: expectedData, + }, + want: want{ + stdout: "Operation successful", + }, + }, + { + name: "error-case", + cli: "--name=Test", + mock: mock{ + OperationTimes: 1, + OperationReturnErr: errors.New("operation failed"), + }, + want: want{ + errMsg: "operation failed", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ios, _, stdout, _ := testutil.NewTestIOStreams() + mockClient := mocks.NewMockDeploymentInterface(ctrl) + + if tt.mock.OperationTimes > 0 { + mockClient.EXPECT(). + SomeOperation(gomock.Any(), gomock.Any()). + Return(tt.mock.OperationReturnData, tt.mock.OperationReturnErr). + Times(tt.mock.OperationTimes) + } + + f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, mockClient, nil, nil) + + cmd := NewCmdCommandName(f) + cmd.SetArgs(strings.Split(tt.cli, " ")) + cmd.SetOut(ios.Out) + cmd.SetErr(ios.ErrOut) + + err := cmd.Execute() + + if tt.want.errMsg != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.want.errMsg) + } else { + assert.NoError(t, err) + } + + if tt.want.stdout != "" { + assert.Contains(t, stdout.String(), tt.want.stdout) + } + }) + } +} +``` + +**Simple unit test example** (for straightforward functions): + +```go +func TestParseConfig(t *testing.T) { + cfg, err := ParseConfig("testdata/config.yaml") + assert.NoError(t, err) + assert.Equal(t, "expected-value", cfg.Field) +} +``` + +## Error Handling Pattern + +Use our custom error types and ALWAYS wrap with context: + +```go +// For API errors - wrap with context +if err != nil { + return fmt.Errorf("failed to create application: %w", err) +} + +// Check for specific error types +if errors.Is(err, cmdutil.ErrCancel) { + return nil // User cancelled +} + +// Type assertions for API errors +var apiErr api.Error +if errors.As(err, &apiErr) { + // Access TraceID, ContainerLogs, etc. + fmt.Fprintf(io.ErrOut, "Trace ID: %s\n", apiErr.TraceID) +} + +// Mutual exclusivity checking +if err := cmdutil.MutuallyExclusive("cannot use both flags", flagA != "", flagB != ""); err != nil { + return err +} +``` + +## API Client Pattern + +When creating new API clients, follow this exact structure: + +```go +package api + +import ( + "context" + "github.com/go-resty/resty/v2" +) + +// Client struct with baseURL and injected httpClient +type ResourceClient struct { + baseURL string + httpClient *resty.Client +} + +// Constructor receives URL and client (NEVER create client here) +func NewResourceClient(baseURL string, httpClient *resty.Client) *ResourceClient { + return &ResourceClient{ + baseURL: baseURL, + httpClient: httpClient, + } +} + +// Request/response types (unexported for internal API types) +type createRequest struct { + Name string `json:"name"` +} + +type createResponse struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Methods with context, check IsError() before processing +func (c *ResourceClient) CreateResource(ctx context.Context, name string) (*createResponse, error) { + resp, err := c.httpClient.R(). + SetContext(ctx). + SetResult(&createResponse{}). + SetBody(createRequest{Name: name}). + Post(c.baseURL + "/create") + + if err != nil { + return nil, err + } + + if resp.IsError() { + return nil, NewErrorFromHTTPResponse(resp) + } + + result := resp.Result().(*createResponse) + return result, nil +} +``` + +## Configuration Management + +Follow this precedence order (highest to lowest): +1. Command-line flags (highest priority) +2. Deployment manifest (`vcr.yaml`) +3. Home config file (`~/.vcr-cli`) (lowest priority) + +Access config through Factory methods: +- `opts.APIKey()` - Get API key (resolved from flags/config) +- `opts.Region()` - Get region +- `opts.Deadline()` - Get deadline for context + +## Output Formatting + +Use IOStreams ColorScheme for consistent output: + +```go +io := opts.IOStreams() +c := io.ColorScheme() + +// Success messages +fmt.Fprintf(io.Out, "%s Operation completed\n", c.SuccessIcon()) // ✓ + +// Info messages +fmt.Fprintf(io.Out, "%s ID: %s\n", c.Blue(cmdutil.InfoIcon), id) // ℹ + +// Warnings +fmt.Fprintf(io.Out, "%s Warning: xyz\n", c.WarningIcon()) // ⚠ + +// Errors (to stderr) +fmt.Fprintf(io.ErrOut, "%s Operation failed\n", c.FailureIcon()) // ✗ + +// Spinners for long operations +spinner := cmdutil.DisplaySpinnerMessageWithHandle("Processing...") +// ... do work ... +spinner.Stop() +``` + +## Project Structure + +``` +. +├── main.go # Entry point, error formatting +├── Makefile # build, test, run targets +├── pkg/ +│ ├── api/ # API clients (Asset, Deployment, Release, Datastore) +│ ├── cmdutil/ # Factory, error types, utilities +│ ├── config/ # Config/manifest parsing (INI, YAML) +│ └── format/ # Output formatting, tables +├── vcr/ +│ ├── root/ # Root command, global flags +│ ├── app/ # Application commands (create, list, generatekeys) +│ ├── deploy/ # Deployment command +│ ├── configure/ # Configuration command +│ ├── secret/ # Secret management +│ ├── mongo/ # MongoDB operations +│ └── instance/ # Instance management +├── testutil/ # Test helpers, mock factory +│ └── mocks/ # Generated mocks (mockgen) +└── tests/integration/ # Integration tests with Docker +``` + +## Code Generation + +Use `//go:generate` directives for mock generation: + +```go +//go:generate mockgen -source=factory.go -destination=../../testutil/mocks/mock_factory.go -package=mocks +``` + +Run with: `go generate ./...` + +## Common Patterns to Avoid + +❌ **DON'T** instantiate API clients directly: +```go +client := api.NewDeploymentClient(url, httpClient) // WRONG +``` + +✅ **DO** use Factory interface: +```go +client := opts.DeploymentClient() // CORRECT +``` + +❌ **DON'T** ignore context: +```go +result, err := client.Operation(name) // WRONG - missing context +``` + +✅ **DO** pass context from Factory deadline: +```go +ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) +defer cancel() +result, err := client.Operation(ctx, name) // CORRECT +``` + +❌ **DON'T** create code without tests: +```go +// New feature with no test file // WRONG +``` + +✅ **DO** include unit tests for all changes: +```go +// create.go + create_test.go // CORRECT +``` + +## Additional Notes + +- **All changes require tests** - No exceptions +- **Use heredoc for multi-line strings**: `heredoc.Doc(`...`)` +- **Platform-specific code**: Use build tags and separate files (`_win.go`, `_notwin.go`) +- **Linting**: Code must pass `golangci-lint` (see `.golangci.yml`) +- **Dependencies**: Update via `make deps`, never commit `vendor/` + +--- + +For examples, see existing commands in `vcr/app/create/`, `vcr/deploy/`, etc. +For more details, see `README.md`, `PLAN.md`, and command docs in `docs/`. diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 41c8700..8fc44f7 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -20,14 +20,14 @@ jobs: - uses: actions/setup-go@v5 with: go-version: '1.24.0' - cache: false + cache: true - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v8 with: # Require: The version of golangci-lint to use. # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. - version: v1.64.8 + version: v2.6.2 # Optional: working directory, useful for monorepos # working-directory: somedir diff --git a/.golangci.yml b/.golangci.yml index 9aadee4..249265a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,92 +1,70 @@ -linters-settings: - gofmt: - simplify: true - goimports: - local-prefixes: github.com/Vonage/vonage-cloud-runtime-cli - gocyclo: - min-complexity: 20 - cyclop: - max-complexity: 20 +version: "2" run: timeout: 10m issues-exit-code: 1 tests: true - skip-dirs: - - vendor - - .git - - node_modules linters: - enable-all: true - disable: - - gochecknoglobals - - gochecknoinits - - funlen - - wsl - - lll - - exhaustivestruct - - wrapcheck - - exhaustruct - - forbidigo - - godot - - gofumpt - - depguard - - goerr113 - - ifshort - - interfacebloat - - ireturn - - paralleltest - - testpackage - - varnamelen - - tagliatelle - - nlreturn - - noctx - - gomnd - - forcetypeassert - - whitespace - - gci - - scopelint - - godox - - gosec - - nestif - - mirror - - thelper - - usestdlibvars - - nosnakecase - - contextcheck - - maligned - - unconvert - - durationcheck - - usestdlibvars - - thelper - - predeclared - - maintidx - - unparam - - perfsprint - - gosmopolitan + enable: + - errcheck + - govet + - staticcheck + - unused + - ineffassign + - misspell + - goconst + - gocyclo + - cyclop + - errname + - errorlint + - gocritic + - goprintffuncname + - nilerr + - rowserrcheck + - sqlclosecheck + settings: + gocyclo: + min-complexity: 25 + cyclop: + max-complexity: 25 + + exclusions: + rules: + - text: "Error return value of `.*Close` is not checked" + linters: + - errcheck + - text: "Error return value of `fmt.Fprint` is not checked" + linters: + - errcheck + - text: "Error return value of `fmt.Fprintf` is not checked" + linters: + - errcheck + - text: "Error return value of `fmt.Fprintln` is not checked" + linters: + - errcheck + - text: "don't use an underscore in package name" + linters: + - golint + - text: "package comment should be of the form" + linters: + - golint + - path: _test\.go + linters: + - funlen + - goconst + - dupl + - gocognit + - testifylint + - cyclop + - gocyclo + - errcheck + - path: tests/integration + linters: + - funlen + - goconst + - dupl + - cyclop + - gocyclo + -issues: - exclude-rules: - - text: "don't use an underscore in package name" - linters: - - golint - - text: "package comment should be of the form" - linters: - - golint - - path: (.+)_test.go - linters: - - funlen - - goconst - - dupl - - exhaustivestruct - - gocognit - - structcheck - - testifylint - - path: tests/integration - linters: - - funlen - - goconst - - dupl - - exhaustivestruct - - structcheck \ No newline at end of file diff --git a/docs/vcr_deploy.md b/docs/vcr_deploy.md index deff1b6..f749968 100644 --- a/docs/vcr_deploy.md +++ b/docs/vcr_deploy.md @@ -26,11 +26,13 @@ instance: entrypoint: - node - index.js - path-access: - "/api/public": "public" - "/api/admin": "private" - "/v1/users/*/profile": "public" - "/v1/internal/**": "private" + security: + access: private + override: + - path: "/api/public" + access: public + - path: "/v1/users/*/profile" + access: public debug: name: debug application-id: 0dcbb945-cf09-4756-808a-e1873228f802 @@ -46,15 +48,19 @@ Flags can be used to override the mandatory fields, ie project name, instance na The project will be created if it does not already exist. -#### Path Access Configuration +#### Security Configuration -The `path-access` configuration allows you to control access to specific paths in your application: +The `security` configuration allows you to control access to your application and specific paths: - **public**: Allows public access to reach those paths - **private**: Returns forbidden for those paths **Default Behavior:** -If no `path-access` field is specified in your manifest, all endpoints will default to public access. +If no `security` field is specified in your manifest, all endpoints will default to public access. + +**Configuration Structure:** +- `access`: Sets the default access level for all paths (either "public" or "private") +- `override`: Array of path-specific access overrides **Wildcard Support:** - Use `*` to match a single path segment: `/v1/users/*/settings` @@ -62,11 +68,23 @@ If no `path-access` field is specified in your manifest, all endpoints will defa **Examples:** ```yaml -path-access: - "/api/health": "public" # Public health check endpoint - "/api/admin": "private" # Private admin interface - "/v1/users/*/profile": "public" # Public user profiles (wildcard) - "/v1/internal/**": "private" # All internal APIs (recursive wildcard) +# Example 1: Default public with specific private paths +security: + access: public + override: + - path: "/api/admin" + access: private + - path: "/v1/internal/**" + access: private + +# Example 2: Default private with specific public paths +security: + access: private + override: + - path: "/api/health" + access: public + - path: "/v1/users/*/profile" + access: public ``` diff --git a/main_test.go b/main_test.go index 9d139d9..24b5820 100644 --- a/main_test.go +++ b/main_test.go @@ -57,7 +57,7 @@ func Test_printError(t *testing.T) { }, want: want{ stdout: "\n\nA new release of vcr is available: 0.0.1 → 1.0.1\nTo upgrade, run: vcr upgrade\n", - stderr: "X Error Encountered: http error\n\nℹ Details:\n - HTTP Status : 404\n - Error Code : 3001\n - Message : Not Found\n - Trace ID : 1234\n\nℹ App logs captured before failure:\ncontainer logs\n\nPlease refer to the documentation or contact support for further assistance.\n", + stderr: "X Error Encountered: http error\n\nℹ Details:\n - HTTP Status : 404\n - Message : Not Found\n - Trace ID : 1234\n\nℹ App logs captured before failure:\ncontainer logs\n\nPlease refer to the documentation or contact support for further assistance.\n", }, }, { diff --git a/pkg/api/deployment.go b/pkg/api/deployment.go index 64bda06..0981b51 100644 --- a/pkg/api/deployment.go +++ b/pkg/api/deployment.go @@ -240,16 +240,16 @@ func (c *DeploymentClient) CreateProject(ctx context.Context, projectName string } type DeployInstanceArgs struct { - PackageID string `json:"packageId"` - ProjectID string `json:"projectId"` - APIApplicationID string `json:"apiApplicationId"` - InstanceName string `json:"instanceName"` - Region string `json:"region"` - Environment []config.Env `json:"environment"` - Domains []string `json:"domains"` - MinScale int `json:"minScale"` - MaxScale int `json:"maxScale"` - PathAccess map[string]string `json:"pathAccess,omitempty"` + PackageID string `json:"packageId"` + ProjectID string `json:"projectId"` + APIApplicationID string `json:"apiApplicationId"` + InstanceName string `json:"instanceName"` + Region string `json:"region"` + Environment []config.Env `json:"environment"` + Domains []string `json:"domains"` + MinScale int `json:"minScale"` + MaxScale int `json:"maxScale"` + Security *config.Security `json:"security,omitempty"` } type DeployInstanceResponse struct { diff --git a/pkg/api/deployment_test.go b/pkg/api/deployment_test.go index 14c4831..b2f9635 100644 --- a/pkg/api/deployment_test.go +++ b/pkg/api/deployment_test.go @@ -787,7 +787,7 @@ func TestDeployInstance(t *testing.T) { } } -func TestDeployInstanceWithPathAccess(t *testing.T) { +func TestDeployInstanceWithSecurity(t *testing.T) { client := resty.New() httpmock.ActivateNonDefault(client.GetClient()) defer httpmock.DeactivateAndReset() @@ -813,10 +813,13 @@ func TestDeployInstanceWithPathAccess(t *testing.T) { APIApplicationID: "test-app-id", InstanceName: "test-instance", Region: "test-region", - PathAccess: map[string]string{ - "/api/v1": "read", - "/admin": "write", - "/public": "read-write", + Security: &config.Security{ + Access: "private", + Override: []config.PathAccess{ + {Path: "/api/v1", Access: "public"}, + {Path: "/admin", Access: "private"}, + {Path: "/public", Access: "public"}, + }, }, } @@ -828,24 +831,29 @@ func TestDeployInstanceWithPathAccess(t *testing.T) { require.Equal(t, "test-deployment-id", output.DeploymentID) require.Equal(t, []string{"https://test.example.com"}, output.HostURLs) - // Validate that the request body contains the PathAccess field + // Validate that the request body contains the Security field require.NotEmpty(t, capturedRequestBody, "Request body should not be empty") - // Parse the captured request body to verify PathAccess is included + // Parse the captured request body to verify Security is included var requestPayload map[string]interface{} err = json.Unmarshal(capturedRequestBody, &requestPayload) require.NoError(t, err, "Should be able to parse request body as JSON") - // Check that pathAccess field is present and correct - pathAccess, exists := requestPayload["pathAccess"] - require.True(t, exists, "pathAccess field should be present in request body") + // Check that security field is present and correct + security, exists := requestPayload["security"] + require.True(t, exists, "security field should be present in request body") + + securityMap, ok := security.(map[string]interface{}) + require.True(t, ok, "security should be a map") + + require.Equal(t, "private", securityMap["access"]) - pathAccessMap, ok := pathAccess.(map[string]interface{}) - require.True(t, ok, "pathAccess should be a map") + overrides, exists := securityMap["override"] + require.True(t, exists, "override field should be present") - require.Equal(t, "read", pathAccessMap["/api/v1"]) - require.Equal(t, "write", pathAccessMap["/admin"]) - require.Equal(t, "read-write", pathAccessMap["/public"]) + overridesArray, ok := overrides.([]interface{}) + require.True(t, ok, "override should be an array") + require.Len(t, overridesArray, 3) httpmock.Reset() } diff --git a/pkg/config/manifest.go b/pkg/config/manifest.go index b692670..049e44e 100644 --- a/pkg/config/manifest.go +++ b/pkg/config/manifest.go @@ -44,18 +44,28 @@ type Scaling struct { MaxScale int `yaml:"max-scale,omitempty"` } +type Security struct { + Access string `json:"access" yaml:"access"` + Override []PathAccess `json:"override,omitempty" yaml:"override,omitempty"` +} + +type PathAccess struct { + Path string `json:"path" yaml:"path"` + Access string `json:"access" yaml:"access"` +} + type Instance struct { - Name string `yaml:"name"` - Runtime string `yaml:"runtime,omitempty"` - Region string `yaml:"region,omitempty"` - ApplicationID string `yaml:"application-id,omitempty"` - Environment []Env `yaml:"environment,omitempty"` - Capabilities []string `yaml:"capabilities,omitempty"` - Entrypoint []string `yaml:"entrypoint,omitempty"` - Domains []string `yaml:"domains,omitempty"` - BuildScript string `yaml:"build-script,omitempty"` - Scaling Scaling `yaml:"scaling,omitempty"` - PathAccess map[string]string `yaml:"path-access,omitempty"` + Name string `yaml:"name"` + Runtime string `yaml:"runtime,omitempty"` + Region string `yaml:"region,omitempty"` + ApplicationID string `yaml:"application-id,omitempty"` + Environment []Env `yaml:"environment,omitempty"` + Capabilities []string `yaml:"capabilities,omitempty"` + Entrypoint []string `yaml:"entrypoint,omitempty"` + Domains []string `yaml:"domains,omitempty"` + BuildScript string `yaml:"build-script,omitempty"` + Scaling Scaling `yaml:"scaling,omitempty"` + Security *Security `yaml:"security,omitempty"` } type Debug struct { diff --git a/pkg/format/format.go b/pkg/format/format.go index 9944e25..db5be80 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -225,10 +225,9 @@ func PrintAPIError(out *iostreams.IOStreams, err error, httpErr *api.Error) stri if err != nil { mainErrMsg = err.Error() } - var mainIssue, httpStatus, errorCode, detailedMessage, traceID, containerLogs string + var mainIssue, httpStatus, detailedMessage, traceID, containerLogs string mainIssue = fmt.Sprintf("%s Details:", c.Red(cmdutil.InfoIcon)) httpStatus = fmt.Sprintf("- HTTP Status : %s", strconv.Itoa(httpErr.HTTPStatusCode)) - errorCode = fmt.Sprintf("- Error Code : %s", strconv.Itoa(httpErr.ServerCode)) detailedMessage = fmt.Sprintf("- Message : %s", httpErr.Message) traceID = fmt.Sprintf("- Trace ID : %s", httpErr.TraceID) containerLogs = fmt.Sprintf("%s App logs captured before failure:\n%s", c.Red(cmdutil.InfoIcon), httpErr.ContainerLogs) @@ -239,9 +238,6 @@ func PrintAPIError(out *iostreams.IOStreams, err error, httpErr *api.Error) stri if httpErr.HTTPStatusCode != 0 { sb.WriteString(fmt.Sprintf(" %s\n", httpStatus)) } - if httpErr.ServerCode != 0 { - sb.WriteString(fmt.Sprintf(" %s\n", errorCode)) - } if httpErr.Message != "" { sb.WriteString(fmt.Sprintf(" %s\n", detailedMessage)) } diff --git a/vcr/app/app.go b/vcr/app/app.go index 3f9ab40..c72bf6e 100644 --- a/vcr/app/app.go +++ b/vcr/app/app.go @@ -13,9 +13,41 @@ import ( func NewCmdApp(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "app ", - Short: "Use app commands to manage Vonage applications", - Long: heredoc.Doc(`Use app commands to create, list and generate the key pairs of Vonage applications - `), + Short: "Manage Vonage applications for VCR deployments", + Long: heredoc.Doc(`Manage Vonage applications for VCR deployments. + + Vonage applications are containers that hold your communication settings and + capabilities. Each VCR deployment must be linked to a Vonage application. + + WHAT IS A VONAGE APPLICATION? + A Vonage application provides: + • Authentication credentials (API keys and private keys) + • Enabled capabilities (Voice, Messages, RTC) + • 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 + + WORKFLOW + 1. Create an application: vcr app create --name my-app + 2. Use the application ID in your vcr.yml manifest + 3. Deploy your VCR application with: vcr deploy + `), + Example: heredoc.Doc(` + # Create a new application with Voice and Messages capabilities + $ vcr app create --name my-app --voice --messages + + # 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)) diff --git a/vcr/app/create/create.go b/vcr/app/create/create.go index df49808..d80079c 100644 --- a/vcr/app/create/create.go +++ b/vcr/app/create/create.go @@ -28,13 +28,38 @@ func NewCmdAppCreate(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "create", - Short: "Create a Vonage application", + Short: "Create a new Vonage application", + Long: heredoc.Doc(`Create a new Vonage application for use with VCR. + + This command creates a Vonage application with the specified capabilities enabled. + The application ID returned can be used in your vcr.yml manifest file to link + your VCR deployment with the Vonage platform. + + CAPABILITIES + Applications can have one or more capabilities enabled: + • Voice (-v, --voice) - Enable Voice API for phone calls + • Messages (-m, --messages) - Enable Messages API for SMS, WhatsApp, etc. + • RTC (-r, --rtc) - Enable Real-Time Communication for in-app voice/video + + NOTE: If no capabilities are specified, the application is created without any + enabled capabilities. You can enable capabilities later via the Vonage Dashboard. + `), Example: heredoc.Doc(` - $ vcr app create --name App - ✓ Application created - ℹ id: 1 - ℹ name: App - `), + # Create a basic application + $ vcr app create --name my-app + ✓ Application created + ℹ id: 12345678-1234-1234-1234-123456789abc + ℹ name: my-app + + # Create an application with Voice capability enabled + $ vcr app create --name voice-app --voice + + # Create an application with multiple capabilities + $ vcr app create --name full-app --voice --messages --rtc + + # Create without confirmation prompt + $ vcr app create --name my-app --yes + `), Args: cobra.MaximumNArgs(0), RunE: func(_ *cobra.Command, _ []string) error { ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) @@ -44,11 +69,11 @@ func NewCmdAppCreate(f cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Name of the application") - cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Skip prompts") - cmd.Flags().BoolVarP(&opts.EnableRTC, "rtc", "r", false, "Enable or disable RTC") - cmd.Flags().BoolVarP(&opts.EnableVoice, "voice", "v", false, "Enable or disable voice") - cmd.Flags().BoolVarP(&opts.EnableMessages, "messages", "m", false, "Enable or disable messages") + cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Name of the application (required)") + cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Skip confirmation prompts") + cmd.Flags().BoolVarP(&opts.EnableRTC, "rtc", "r", false, "Enable RTC (Real-Time Communication) capability") + cmd.Flags().BoolVarP(&opts.EnableVoice, "voice", "v", false, "Enable Voice API capability") + cmd.Flags().BoolVarP(&opts.EnableMessages, "messages", "m", false, "Enable Messages API capability") _ = cmd.MarkFlagRequired("name") diff --git a/vcr/app/generatekeys/generatekeys.go b/vcr/app/generatekeys/generatekeys.go index 49b4998..436f942 100644 --- a/vcr/app/generatekeys/generatekeys.go +++ b/vcr/app/generatekeys/generatekeys.go @@ -22,17 +22,32 @@ func NewCmdAppGenerateKeys(f cmdutil.Factory) *cobra.Command { } cmd := &cobra.Command{ - Use: "generate-keys [--app-id]", - Short: "Generate Vonage application keys", - Long: heredoc.Doc(`Generate a new set of keys for the Vonage application. + Use: "generate-keys --app-id ", + Short: "Generate new key pairs for a Vonage application", + Long: heredoc.Doc(`Generate new public/private key pairs for a Vonage application. - This will regenerate the public/private key pair for the Vonage application to operate with the VCR platform. - If you created an app without using CLI and want to use it with VCR, generate new keys for it with this command, - so that the VCR platform has access to the credentials. + This command regenerates the authentication keys for a Vonage application, + allowing the VCR platform to access the application's credentials. + + WHEN TO USE THIS COMMAND + • You created an application via the Vonage Dashboard (not the CLI) + • You need to rotate your application's keys for security + • You're troubleshooting authentication issues with VCR + + WARNING: Regenerating keys will invalidate any existing private keys for this + application. Any services using the old keys will need to be updated. + + FINDING YOUR APPLICATION ID + Use 'vcr app list' to see all your applications and their IDs. `), Args: cobra.MaximumNArgs(0), Example: heredoc.Doc(` + # Generate new keys for an application $ vcr app generate-keys --app-id 42066b10-c4ae-48a0-addd-feb2bd615a67 + ✓ Application "42066b10-c4ae-48a0-addd-feb2bd615a67" configured with newly generated keys + + # Using the short flag + $ vcr app generate-keys -i 42066b10-c4ae-48a0-addd-feb2bd615a67 `), RunE: func(_ *cobra.Command, _ []string) error { ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) @@ -42,7 +57,7 @@ func NewCmdAppGenerateKeys(f cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.AppID, "app-id", "i", "", "Id of the application") + cmd.Flags().StringVarP(&opts.AppID, "app-id", "i", "", "The UUID of the Vonage application (required)") _ = cmd.MarkFlagRequired("app-id") return cmd } diff --git a/vcr/app/list/list.go b/vcr/app/list/list.go index e8505ec..baa8440 100644 --- a/vcr/app/list/list.go +++ b/vcr/app/list/list.go @@ -24,13 +24,40 @@ func NewCmdAppList(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List Vonage applications", + Short: "List all Vonage applications in your account", + Long: heredoc.Doc(`List all Vonage applications associated with your account. + + This command displays a table of all Vonage applications, showing their IDs + and names. Use the application ID in your vcr.yml manifest file to link + your VCR deployment to a specific application. + + Use the --filter flag to search for applications by name. The filter performs + a case-insensitive substring match. + `), Example: heredoc.Doc(` - $ vcr app list - ID Name - 1 App One - 2 App Two - `), + # List all applications + $ vcr app list + +--------------------------------------+----------------+ + | ID | NAME | + +--------------------------------------+----------------+ + | 12345678-1234-1234-1234-123456789abc | my-voice-app | + | 87654321-4321-4321-4321-cba987654321 | my-sms-app | + +--------------------------------------+----------------+ + + # List applications using the short alias + $ vcr app ls + + # Filter applications by name + $ vcr app list --filter "voice" + +--------------------------------------+----------------+ + | ID | NAME | + +--------------------------------------+----------------+ + | 12345678-1234-1234-1234-123456789abc | my-voice-app | + +--------------------------------------+----------------+ + + # Filter with partial match + $ vcr app list -f "prod" + `), Aliases: []string{"ls"}, Args: cobra.MaximumNArgs(0), @@ -42,7 +69,7 @@ func NewCmdAppList(f cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.Filter, "filter", "f", "", "Filter applications by name substring") + cmd.Flags().StringVarP(&opts.Filter, "filter", "f", "", "Filter applications by name (case-insensitive substring match)") return cmd } diff --git a/vcr/configure/configure.go b/vcr/configure/configure.go index b997a09..170fad1 100644 --- a/vcr/configure/configure.go +++ b/vcr/configure/configure.go @@ -24,16 +24,39 @@ func NewCmdConfigure(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "configure", - Short: "Configure VCR CLI", - Long: heredoc.Doc(`This command configures the VCR CLI. - - Configure your VCR CLI, you need provide Vonage API key and secret, if success Configure will create a configuration file (default is $HOME/.vcr-cli). - - The VCR CLI will not work unless it has been configured. + Short: "Configure VCR CLI with your Vonage API credentials", + Long: heredoc.Doc(`Configure the VCR CLI with your Vonage API credentials. + + This interactive command sets up the VCR CLI by prompting you for: + • Vonage API Key - Found in your Vonage API Dashboard + • Vonage API Secret - Found in your Vonage API Dashboard + • Default Region - The Vonage Cloud Runtime region for deployments + + On successful configuration, a configuration file is created at $HOME/.vcr-cli + (or the path specified by --config-file). + + PREREQUISITES + You need a Vonage API account to use VCR. Get your API credentials from: + https://dashboard.nexmo.com/settings + + CONFIGURATION FILE + The configuration file stores your credentials and preferences. You can have + multiple configuration files for different accounts/environments by using + the --config-file flag with other commands. + + NOTE: The VCR CLI requires configuration before any other commands will work. + Run this command first after installing the CLI. `), Example: heredoc.Doc(` + # Configure the CLI interactively $ vcr configure - ✓ New configuration file written to $HOME/.vcr-cli + ? Enter your Vonage api key: abc123 + ? Enter your Vonage api secret: ******** + ? Select your Vonage region: aws.euw1 - AWS Europe (Ireland) + ✓ New configuration file written to /Users/you/.vcr-cli + + # Use a custom configuration file path + $ vcr configure --config-file ~/.vcr-cli-staging `), Args: cobra.MaximumNArgs(0), RunE: func(_ *cobra.Command, _ []string) error { diff --git a/vcr/debug/debug.go b/vcr/debug/debug.go index e032154..3b7d24d 100644 --- a/vcr/debug/debug.go +++ b/vcr/debug/debug.go @@ -56,39 +56,80 @@ func NewCmdDebug(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "debug [path_to_project]", - Short: `Run the application code locally in debug mode.`, - Long: heredoc.Doc(`Run the application in debug mode. - - This command will allow your client code to be executed locally. A remote debug server will be started to act as a proxy for client app requests. - - The proxied requests will be displayed in the terminal to help you debug the application. - - You can also use a debugger tool by attaching the tool to port 9229 on the local nodejs process. An example for VS code debugger launch configuration is shown below.: - - { - "update": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "attach", - "name": "Attach VCR Debugger", - "port": 9229, - "restart": true, - "localRoot": "${workspaceFolder}", - } - ] - } + Short: "Run your application locally in debug mode with live VCR integration", + Long: heredoc.Doc(`Run your application locally in debug mode with live VCR integration. + + Debug mode allows you to run your application code locally while connected to the + VCR platform. A remote debug server acts as a proxy, forwarding requests from Vonage + services (webhooks, events) to your local machine. + + HOW IT WORKS + 1. A debug proxy server is deployed to VCR + 2. Your local application starts and connects to the proxy + 3. Vonage webhooks/events are forwarded to your local machine + 4. You can set breakpoints and debug your code in real-time + + REQUIREMENTS + • A vcr.yml manifest with a debug.entrypoint defined + • A Vonage application linked to your project + • The debug.application-id can differ from instance.application-id + + VS CODE DEBUGGER INTEGRATION + To attach VS Code debugger, add this configuration to .vscode/launch.json: + + { + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "attach", + "name": "Attach VCR Debugger", + "port": 9229, + "restart": true, + "localRoot": "${workspaceFolder}" + } + ] + } + + ENVIRONMENT VARIABLES + Environment variables from debug.environment (or instance.environment as fallback) + in your manifest are loaded. For secrets, export them locally before running debug. + + CLEANUP + Press Ctrl+C to stop debug mode. The remote debug server is automatically removed + unless --preserve-data is specified. `), Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` - # Run app in debug mode, here we point to the current directory. - $ vcr debug . - # If no arguments are provided, the code directory is assumed to be the current directory. + # Start debug mode in the current directory $ vcr debug - # By providing a name, we can generate a deterministic service name for the debugger proxy when it is started. - # The name is formatted like so: 'neru-{accountId}-debug-{name}' - # If no name is provided, it will be randomly generated, eg: 'neru-0dcbb945-debug-bf642' - $ vcr debug --name debugger" + + # Start debug mode in a specific directory + $ vcr debug ./my-project + + # Use a custom name for the debug proxy (makes URL deterministic) + $ vcr debug --name my-debugger + + # Use a specific Vonage application for debugging + $ vcr debug --app-id 12345678-1234-1234-1234-123456789abc + + # Change the local application port (default: 3000) + $ vcr debug --app-port 8080 + + # Change the debugger proxy port (default: 3001) + $ vcr debug --debugger-port 4000 + + # Preserve data after debug session ends + $ vcr debug --preserve-data + + # Skip confirmation prompts + $ vcr debug --yes + + # Enable verbose logging for troubleshooting + $ vcr debug --verbose + + # Use a specific manifest file + $ vcr debug --filename ./custom-vcr.yml `), RunE: func(_ *cobra.Command, args []string) error { ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) @@ -107,15 +148,15 @@ func NewCmdDebug(f cmdutil.Factory) *cobra.Command { } // flags - cmd.Flags().StringVarP(&opts.AppID, "app-id", "i", "", "Application id") - cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Set the name of the debugger proxy") - cmd.Flags().StringVarP(&opts.Runtime, "runtime", "r", "", "Select runtime for debugger") - cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Enable verbose logging") - cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Skip prompts") - cmd.Flags().IntVarP(&opts.AppPort, "app-port", "a", defaultAppPort, "Application port") - cmd.Flags().IntVarP(&opts.DebuggerPort, "debugger-port", "d", defaultDebuggerPort, "Debugger CLI server port") - cmd.Flags().BoolVarP(&opts.PreserveData, "preserve-data", "", false, "Preserve data generated by debug application after the debug session ends") - cmd.Flags().StringVarP(&opts.ManifestFile, "filename", "f", "", "File contains the VCR manifest to apply") + cmd.Flags().StringVarP(&opts.AppID, "app-id", "i", "", "Vonage application ID for debugging (overrides manifest)") + cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Name for the debug proxy server (creates deterministic URL)") + cmd.Flags().StringVarP(&opts.Runtime, "runtime", "r", "", "Runtime environment (overrides manifest, e.g., nodejs18)") + cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Enable verbose logging for troubleshooting") + cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Skip confirmation prompts") + cmd.Flags().IntVarP(&opts.AppPort, "app-port", "a", defaultAppPort, "Local port your application listens on (default: 3000)") + cmd.Flags().IntVarP(&opts.DebuggerPort, "debugger-port", "d", defaultDebuggerPort, "Local port for debugger proxy server (default: 3001)") + cmd.Flags().BoolVarP(&opts.PreserveData, "preserve-data", "", false, "Keep debug session data after stopping (useful for debugging state issues)") + cmd.Flags().StringVarP(&opts.ManifestFile, "filename", "f", "", "Path to VCR manifest file (default: vcr.yml in project directory)") return cmd } diff --git a/vcr/deploy/deploy.go b/vcr/deploy/deploy.go index 9b7a744..54b6608 100644 --- a/vcr/deploy/deploy.go +++ b/vcr/deploy/deploy.go @@ -52,51 +52,139 @@ func NewCmdDeploy(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "deploy [path_to_code]", - Short: `Deploy a VCR application`, - Long: heredoc.Doc(`Deploy a VCR application. - - This command will package up the local client app code and deploy it to the VCR platform. - - A deployment manifest should be provided so that the CLI knows how to deploy your application. An example manifest would look like: - - project: - name: booking-app - instance: - name: dev - runtime: nodejs18 - region: aws.euw1 - application-id: 0dcbb945-cf09-4756-808a-e1873228f802 - environment: - - name: VONAGE_NUMBER - value: "12012010601" - capabilities: - - messages-v1 - - rtc - entrypoint: - - node - - index.js - debug: - name: debug - application-id: 0dcbb945-cf09-4756-808a-e1873228f802 - environment: - - name: VONAGE_NUMBER - value: "12012010601" - entrypoint: - - node - - index.js - - By default, the CLI will look for a deployment manifest in the root of the code directory under the name 'vcr.yml'. - Flags can be used to override the mandatory fields, ie project name, instance name, runtime, region and application ID. - - The project will be created if it does not already exist. + Short: "Deploy your application to Vonage Cloud Runtime", + Long: heredoc.Doc(`Deploy your application to Vonage Cloud Runtime. + + This command packages your application code and deploys it to the VCR platform. + The deployment process includes: + 1. Creating/retrieving your project + 2. Compressing and uploading your source code + 3. Building your application in the cloud + 4. Deploying to the specified region + + MANIFEST FILE (vcr.yml) + A manifest file defines your deployment configuration. The CLI looks for + vcr.yml, vcr.yaml, neru.yml, or neru.yaml in your project directory. + + Example manifest: + + project: + name: booking-app # Project name (lowercase, alphanumeric, hyphens) + + instance: + name: dev # Instance name (e.g., dev, staging, prod) + runtime: nodejs22 # Runtime: nodejs22, nodejs18, python3, etc. + region: aws.euw1 # Region: aws.euw1, aws.use1, etc. + application-id: # Vonage application UUID + entrypoint: # Command to start your application + - node + - index.js + environment: # Environment variables + - name: VONAGE_NUMBER + value: "12012010601" + - name: API_KEY + secret: MY_SECRET # Reference a VCR secret that is created using the 'vcr secret create' command + capabilities: # Vonage API capabilities + - messages-v1 + - voice + - rtc + build-script: ./build.sh # Optional build script + domains: # Custom domains (optional) + - api.example.com + security: # Endpoint security + access: private # Required: [private, public] + override: + - path: "/api/public" + access: public # Override for specific paths + - path: "/api/users/*/settings" + access: public + + debug: + name: debug # Debug instance name + application-id: # Separate app for debugging (optional) + entrypoint: + - node + - --inspect + - index.js + + APPLICATION REQUIREMENTS + Your application must meet these requirements to deploy successfully: + + 1. HTTP Server on VCR_PORT + Your app MUST listen for HTTP requests on the port specified by the + VCR_PORT environment variable (automatically injected by VCR). + + Example (Node.js): + const port = process.env.VCR_PORT || 8080; + app.listen(port, () => console.log('Server running on port ' + port)); + + Example (Python): + port = int(os.environ.get('VCR_PORT', 8080)) + app.run(host='0.0.0.0', port=port) + + 2. Health Check Endpoint + Your app MUST expose a health check endpoint at GET /_/health that + returns HTTP 200. VCR uses this to verify your app started correctly. + + Example (Node.js/Express): + app.get('/_/health', (req, res) => res.status(200).send('OK')); + + Example (Python/Flask): + @app.route('/_/health') + def health(): return 'OK', 200 + + IGNORING FILES + Create a .vcrignore file to exclude files from deployment (similar to .gitignore). + Common exclusions: node_modules/, .git/, *.log, .env + + CAPABILITIES + • messages-v1 - Messages API (SMS, WhatsApp, Viber, etc.) + • voice - Voice API (phone calls, IVR) + • rtc - Real-Time Communication (in-app voice/video) + + SECURITY ACCESS LEVELS + • private - Requires authentication (default) + • public - No authentication required + + TROUBLESHOOTING + "credential not found" error after deployment: + This usually means the Vonage application keys need to be regenerated + for VCR access. Run: + $ vcr app generate-keys --app-id + + Application fails to start: + • Verify your app listens on VCR_PORT (not a hardcoded port) + • Ensure GET /_/health returns HTTP 200 + • Check logs with: vcr instance log -p -n `), Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` - # Deploy code in current app directory. - $ vcr deploy . - - # If no arguments are provided, the code directory is assumed to be the current directory. + # Deploy from the current directory $ vcr deploy + + # Deploy from a specific directory + $ vcr deploy ./my-project + + # Override the project name + $ vcr deploy --project-name my-project + + # Override the instance name + $ vcr deploy --instance-name production + + # Override the runtime + $ vcr deploy --runtime nodejs20 + + # Override the Vonage application ID + $ vcr deploy --app-id 12345678-1234-1234-1234-123456789abc + + # Deploy a pre-compressed tarball + $ vcr deploy --tgz ./my-app.tar.gz + + # Use a custom manifest file + $ vcr deploy --filename ./custom-manifest.yml + + # Override capabilities + $ vcr deploy --capabilities "messages-v1,voice" `), RunE: func(_ *cobra.Command, args []string) error { ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) @@ -114,13 +202,13 @@ func NewCmdDeploy(f cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.ProjectName, "project-name", "p", "", "Project name") - cmd.Flags().StringVarP(&opts.Runtime, "runtime", "r", "", "Set the runtime of the application") - cmd.Flags().StringVarP(&opts.AppID, "app-id", "i", "", "Set the id of the Vonage application you wish to link the VCR application to") - cmd.Flags().StringVarP(&opts.InstanceName, "instance-name", "n", "", "Instance name") - cmd.Flags().StringVarP(&opts.Capabilities, "capabilities", "c", "", "Provide the comma separated capabilities required for your application. eg: \"messaging,voice\"") - cmd.Flags().StringVarP(&opts.TgzFile, "tgz", "z", "", "Provide the path to the tar.gz code you wish to deploy. Code need to be compressed from root directory and include library") - cmd.Flags().StringVarP(&opts.ManifestFile, "filename", "f", "", "File contains the VCR manifest to apply") + cmd.Flags().StringVarP(&opts.ProjectName, "project-name", "p", "", "Project name (overrides manifest value)") + cmd.Flags().StringVarP(&opts.Runtime, "runtime", "r", "", "Runtime environment, e.g., nodejs18, nodejs20, python3 (overrides manifest)") + cmd.Flags().StringVarP(&opts.AppID, "app-id", "i", "", "Vonage application UUID to link with this deployment (overrides manifest)") + cmd.Flags().StringVarP(&opts.InstanceName, "instance-name", "n", "", "Instance name, e.g., dev, staging, prod (overrides manifest)") + cmd.Flags().StringVarP(&opts.Capabilities, "capabilities", "c", "", "Comma-separated capabilities: messages-v1,voice,rtc (overrides manifest)") + cmd.Flags().StringVarP(&opts.TgzFile, "tgz", "z", "", "Path to pre-compressed tar.gz file to deploy (skips local compression)") + cmd.Flags().StringVarP(&opts.ManifestFile, "filename", "f", "", "Path to manifest file (default: vcr.yml in project directory)") return cmd } @@ -456,7 +544,7 @@ func Deploy(ctx context.Context, opts *Options, createPkgResp api.CreatePackageR Domains: opts.manifest.Instance.Domains, MinScale: opts.manifest.Instance.Scaling.MinScale, MaxScale: opts.manifest.Instance.Scaling.MaxScale, - PathAccess: opts.manifest.Instance.PathAccess, + Security: opts.manifest.Instance.Security, } deploymentResponse, err := opts.DeploymentClient().DeployInstance(ctx, deployInstanceArgs) spinner.Stop() diff --git a/vcr/deploy/deploy_test.go b/vcr/deploy/deploy_test.go index ee12994..dd8775f 100644 --- a/vcr/deploy/deploy_test.go +++ b/vcr/deploy/deploy_test.go @@ -173,8 +173,8 @@ func TestDeploy(t *testing.T) { }, }, { - name: "happy-path-with-path-access", - cli: "testdata/ -f testdata/vcr-with-pathaccess.yaml", + name: "happy-path-with-security", + cli: "testdata/ -f testdata/vcr-with-security.yaml", mock: mock{ DeployAPIKey: testutil.DefaultAPIKey, @@ -216,10 +216,13 @@ func TestDeploy(t *testing.T) { Domains: nil, MinScale: 0, MaxScale: 0, - PathAccess: map[string]string{ - "/api/v1": "read", - "/admin": "write", - "/public": "read-write", + Security: &config.Security{ + Access: "private", + Override: []config.PathAccess{ + {Path: "/api/v1", Access: "public"}, + {Path: "/admin", Access: "private"}, + {Path: "/public", Access: "public"}, + }, }, }, DeployDeployInstanceTimes: 1, diff --git a/vcr/deploy/testdata/vcr-with-pathaccess.yaml b/vcr/deploy/testdata/vcr-with-security.yaml similarity index 59% rename from vcr/deploy/testdata/vcr-with-pathaccess.yaml rename to vcr/deploy/testdata/vcr-with-security.yaml index ba50a71..df0e1d9 100644 --- a/vcr/deploy/testdata/vcr-with-pathaccess.yaml +++ b/vcr/deploy/testdata/vcr-with-security.yaml @@ -13,9 +13,14 @@ instance: entrypoint: - node - index.js - path-access: - "/api/v1": "read" - "/admin": "write" - "/public": "read-write" + security: + access: private + override: + - path: "/api/v1" + access: public + - path: "/admin" + access: private + - path: "/public" + access: public debug: application-id: id diff --git a/vcr/init/init.go b/vcr/init/init.go index 0b5f632..6d33ace 100644 --- a/vcr/init/init.go +++ b/vcr/init/init.go @@ -42,21 +42,51 @@ func NewCmdInit(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "init [path_to_project]", Aliases: []string{"i"}, - Short: "Initialise a new code template", - Long: heredoc.Doc(` - This command will initialise a new VCR code template. + Short: "Initialize a new VCR project from a template", + Long: heredoc.Doc(`Initialize a new VCR project from a template. + + This interactive command creates a new VCR project by guiding you through: + • Project name - A unique identifier for your project + • Instance name - The deployment instance name (e.g., dev, staging, prod) + • Runtime - The programming language runtime (e.g., nodejs18, python3) + • Region - The Vonage Cloud Runtime region for deployment + • Application ID - The Vonage application to link (for deployment) + • Debug Application - The Vonage application for debug mode + • Template - A starter template for your chosen runtime + + OUTPUT + A vcr.yml manifest file is created with your configuration. This file defines + how your application is built and deployed to the VCR platform. + + TEMPLATES + Templates provide starter code for common use cases including: + • Starter projects (basic setup) + • Voice applications + • Messaging applications + • Real-time communication apps + + PROJECT NAME REQUIREMENTS + • Must contain only lowercase letters, numbers, and hyphens + • Must start and end with an alphanumeric character `), Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` - # Create a new directory for your project. - $ mkdir my-app - $ cd my-app - - # Initialise the project + # Initialize a project in the current directory $ vcr init - - # Initialise the project in a specific directory - $ vcr init my-app + ? Enter your project name: my-project + ? Enter your Instance name: dev + ? Select a runtime: nodejs18 + ? Select a region: aws.euw1 - AWS Europe (Ireland) + ? Select your Vonage application ID for deployment: my-app (abc123...) + ? Select your Vonage application ID for debug: my-debug-app (def456...) + ? Select a product template: Starter Project - Node.js + ✓ vcr.yml created + + # Initialize in a new directory (creates directory if it doesn't exist) + $ vcr init my-new-project + + # Initialize using the short alias + $ vcr i my-project `), RunE: func(_ *cobra.Command, args []string) error { ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) diff --git a/vcr/instance/instance.go b/vcr/instance/instance.go index a9e4b36..acda43f 100644 --- a/vcr/instance/instance.go +++ b/vcr/instance/instance.go @@ -1,6 +1,7 @@ package instance import ( + "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" "vonage-cloud-runtime-cli/pkg/cmdutil" @@ -11,8 +12,41 @@ import ( func NewCmdInstance(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "instance ", - Short: "Used for instance management", - Long: "Used for instance management", + Short: "Manage deployed VCR instances", + Long: heredoc.Doc(`Manage deployed VCR instances. + + Instances are running deployments of your VCR applications. Each deployment + creates an instance that can be monitored and managed using these commands. + + WHAT IS AN INSTANCE? + An instance is a deployed version of your application running on VCR. + Each instance has: + • A unique instance ID + • A service name (URL endpoint) + • A project name and instance name (from your manifest) + + AVAILABLE COMMANDS + log (logs) View real-time logs from a running instance + remove (rm) Delete an instance and free its resources + + IDENTIFYING INSTANCES + Instances can be identified by either: + • Instance ID: A unique UUID assigned during deployment + • Project + Instance name: The combination from your vcr.yml manifest + `), + Example: heredoc.Doc(` + # View logs for an instance by project and instance name + $ vcr instance log --project-name my-app --instance-name dev + + # View logs for an instance by ID + $ vcr instance log --id 12345678-1234-1234-1234-123456789abc + + # Remove an instance + $ vcr instance remove --project-name my-app --instance-name dev + + # Remove an instance by ID with automatic confirmation + $ vcr instance rm --id 12345678-1234-1234-1234-123456789abc --yes + `), } cmd.AddCommand(remove.NewCmdInstanceRemove(f)) diff --git a/vcr/instance/log/log.go b/vcr/instance/log/log.go index 109681e..0b70a32 100644 --- a/vcr/instance/log/log.go +++ b/vcr/instance/log/log.go @@ -60,17 +60,61 @@ func NewCmdInstanceLog(f cmdutil.Factory) *cobra.Command { } cmd := &cobra.Command{ - Use: "log --project-name --instance-name ", - Aliases: []string{""}, - Short: `This command will output the log of an instance.`, - Args: cobra.MaximumNArgs(0), + Use: "log", + Aliases: []string{"logs"}, + Short: "Stream real-time logs from a deployed VCR instance", + Long: heredoc.Doc(`Stream real-time logs from a deployed VCR instance. + + This command connects to a running instance and streams its logs in real-time + to your terminal. Logs are continuously fetched until you press Ctrl+C. + + IDENTIFYING THE INSTANCE + You can identify the instance using either: + • --id: The unique instance UUID + • --project-name + --instance-name: The combination from your manifest + + LOG LEVELS + Filter logs by severity level (shows specified level and above): + • trace - Most verbose, includes all logs + • debug - Debug information and above + • info - Informational messages and above + • warn - Warnings and above + • error - Errors and above + • fatal - Only fatal errors + + SOURCE TYPES + Filter logs by their source: + • application - Logs from your application code + • provider - Logs from VCR platform services + + OUTPUT FORMAT + Each log line shows: [timestamp] [source_type] message + Example: 2024-01-15T10:30:00Z [application] Server started on port 3000 + `), + Args: cobra.MaximumNArgs(0), Example: heredoc.Doc(` - # Output instance log by instance id: - $ vcr instance log --id + # Stream logs by project and instance name + $ vcr instance log --project-name my-app --instance-name dev + 2024-01-15T10:30:00Z [application] Server started on port 3000 + 2024-01-15T10:30:01Z [application] Connected to database + ^C + Interrupt received, stopping... - # Output instance log by project and instance name: - $ vcr instance log --project-name --instance-name - `), + # Stream logs by instance ID + $ vcr instance log --id 12345678-1234-1234-1234-123456789abc + + # Filter to show only errors and above + $ vcr instance log -p my-app -n dev --log-level error + + # Show only application logs (exclude provider logs) + $ vcr instance log -p my-app -n dev --source-type application + + # Increase history to last 500 log entries + $ vcr instance log -p my-app -n dev --history 500 + + # Combine filters + $ vcr instance log -p my-app -n dev -l warn -s application + `), RunE: func(_ *cobra.Command, _ []string) error { ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) defer cancel() @@ -79,12 +123,12 @@ func NewCmdInstanceLog(f cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.InstanceID, "id", "i", "", "Instance ID") - cmd.Flags().IntVarP(&opts.Limit, "history", "", DefaultHistoryLimit, "Prints the last N number of records") - cmd.Flags().StringVarP(&opts.ProjectName, "project-name", "p", "", "Project name (must be used with instance-name flag)") - cmd.Flags().StringVarP(&opts.InstanceName, "instance-name", "n", "", "Instance name (must be used with project-name flag)") - cmd.Flags().StringVarP(&opts.LogLevel, "log-level", "l", "", "Filter for log level, e.g.trace, debug, info, warn, error, fatal") - cmd.Flags().StringVarP(&opts.SourceType, "source-type", "s", "", "Filter for source type e.g. application, provider") + cmd.Flags().StringVarP(&opts.InstanceID, "id", "i", "", "Instance UUID (alternative to project-name + instance-name)") + cmd.Flags().IntVarP(&opts.Limit, "history", "", DefaultHistoryLimit, "Number of historical log entries to fetch initially (default: 300)") + cmd.Flags().StringVarP(&opts.ProjectName, "project-name", "p", "", "Project name (requires --instance-name)") + cmd.Flags().StringVarP(&opts.InstanceName, "instance-name", "n", "", "Instance name (requires --project-name)") + cmd.Flags().StringVarP(&opts.LogLevel, "log-level", "l", "", "Minimum log level: trace, debug, info, warn, error, fatal") + cmd.Flags().StringVarP(&opts.SourceType, "source-type", "s", "", "Filter by source: application, provider") return cmd } diff --git a/vcr/instance/remove/remove.go b/vcr/instance/remove/remove.go index c89d6f0..b0dab74 100644 --- a/vcr/instance/remove/remove.go +++ b/vcr/instance/remove/remove.go @@ -28,16 +28,39 @@ func NewCmdInstanceRemove(f cmdutil.Factory) *cobra.Command { } cmd := &cobra.Command{ - Use: "remove --project-name --instance-name ", + Use: "remove", Aliases: []string{"rm"}, - Short: `This command will remove an instance.`, - Args: cobra.MaximumNArgs(0), + Short: "Remove a deployed VCR instance", + Long: heredoc.Doc(`Remove a deployed VCR instance. + + This command permanently deletes an instance from the VCR platform, stopping + the running application and freeing all associated resources. + + IDENTIFYING THE INSTANCE + You can identify the instance to remove using either: + • --id: The unique instance UUID (from deployment output) + • --project-name + --instance-name: The combination from your manifest + + WARNING: This action is irreversible. All data associated with the instance + will be permanently deleted. You will be prompted for confirmation unless + --yes is specified. + `), + Args: cobra.MaximumNArgs(0), Example: heredoc.Doc(` - # Remove by project and instance name: - $ vcr instance rm --project-name --instance-name - - # Remove by instance id: - $ vcr instance rm --id `), + # Remove by project and instance name + $ vcr instance remove --project-name my-app --instance-name dev + ? Are you sure you want to remove instance with id="abc123" and service_name="my-service"? Yes + ✓ Instance "abc123" successfully removed + + # Remove using the short alias + $ vcr instance rm -p my-app -n dev + + # Remove by instance ID + $ vcr instance remove --id 12345678-1234-1234-1234-123456789abc + + # Skip confirmation prompt (useful for CI/CD) + $ vcr instance rm --project-name my-app --instance-name dev --yes + `), RunE: func(_ *cobra.Command, _ []string) error { ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) defer cancel() @@ -46,10 +69,10 @@ func NewCmdInstanceRemove(f cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.InstanceID, "id", "i", "", "Instance id") - cmd.Flags().StringVarP(&opts.ProjectName, "project-name", "p", "", "Project name (must be used with instance-name flag)") - cmd.Flags().StringVarP(&opts.InstanceName, "instance-name", "n", "", "Instance name (must be used with project-name flag)") - cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Automatically confirm removal and skip prompt") + cmd.Flags().StringVarP(&opts.InstanceID, "id", "i", "", "Instance UUID (alternative to project-name + instance-name)") + cmd.Flags().StringVarP(&opts.ProjectName, "project-name", "p", "", "Project name (requires --instance-name)") + cmd.Flags().StringVarP(&opts.InstanceName, "instance-name", "n", "", "Instance name (requires --project-name)") + cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Skip confirmation prompt (use with caution)") return cmd } diff --git a/vcr/mongo/create/create.go b/vcr/mongo/create/create.go deleted file mode 100644 index 72fdc74..0000000 --- a/vcr/mongo/create/create.go +++ /dev/null @@ -1,69 +0,0 @@ -package create - -import ( - "context" - "fmt" - "vonage-cloud-runtime-cli/pkg/cmdutil" - - "github.com/MakeNowJust/heredoc" - "github.com/spf13/cobra" -) - -type Options struct { - cmdutil.Factory - - Version string -} - -func NewCmdMongoCreate(f cmdutil.Factory) *cobra.Command { - - opts := Options{ - Factory: f, - } - - cmd := &cobra.Command{ - Use: "create", - Short: "Create a database and user credentials", - Example: heredoc.Doc(`$ vcr mongo create`), - Args: cobra.MaximumNArgs(0), - RunE: func(_ *cobra.Command, _ []string) error { - ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) - defer cancel() - - return runCreate(ctx, &opts) - }, - } - - cmd.Flags().StringVarP(&opts.Version, "version", "v", "v0.1", "API version (default is v0.1)") - - return cmd -} - -func runCreate(ctx context.Context, opts *Options) error { - io := opts.IOStreams() - c := opts.IOStreams().ColorScheme() - - spinner := cmdutil.DisplaySpinnerMessageWithHandle(" Creating database") - result, err := opts.DeploymentClient().CreateMongoDatabase(ctx, opts.Version) - spinner.Stop() - if err != nil { - return fmt.Errorf("failed to create database: %w", err) - } - fmt.Fprintf(io.Out, heredoc.Doc(` - %s Database created - %s username: %s - %s password: %s - %s database: %s - %s connectionString: %s - `), - c.SuccessIcon(), - c.Blue(cmdutil.InfoIcon), - result.Username, - c.Blue(cmdutil.InfoIcon), - result.Password, - c.Blue(cmdutil.InfoIcon), - result.Database, - c.Blue(cmdutil.InfoIcon), - result.ConnectionString) - return nil -} diff --git a/vcr/mongo/create/create_test.go b/vcr/mongo/create/create_test.go deleted file mode 100644 index e3898d0..0000000 --- a/vcr/mongo/create/create_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package create - -import ( - "bytes" - "errors" - "io" - "testing" - "vonage-cloud-runtime-cli/pkg/api" - "vonage-cloud-runtime-cli/testutil" - "vonage-cloud-runtime-cli/testutil/mocks" - - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/golang/mock/gomock" - "github.com/google/shlex" - "github.com/stretchr/testify/require" -) - -func TestMongoCreate(t *testing.T) { - type mock struct { - CreateTimes int - CreateReturnResponse api.MongoInfoResponse - CreateReturnErr error - CreateVersion string - } - type want struct { - errMsg string - stdout string - } - - tests := []struct { - name string - cli string - mock mock - want want - }{ - { - name: "happy-path", - cli: "", - mock: mock{ - CreateVersion: "v0.1", - CreateTimes: 1, - CreateReturnResponse: api.MongoInfoResponse{ - Username: "test", - Password: "test", - Database: "TestDB", - ConnectionString: "mongodb://test:test@localhost:27017/TestDB", - }, - CreateReturnErr: nil, - }, - want: want{ - stdout: heredoc.Doc(` - ✓ Database created - ℹ username: test - ℹ password: test - ℹ database: TestDB - ℹ connectionString: mongodb://test:test@localhost:27017/TestDB - `), - }, - }, - { - name: "create-api-error", - cli: "", - mock: mock{ - CreateVersion: "v0.1", - CreateTimes: 1, - CreateReturnResponse: api.MongoInfoResponse{}, - CreateReturnErr: errors.New("api error"), - }, - want: want{ - errMsg: "failed to create database: api error", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - ctrl := gomock.NewController(t) - - deploymentMock := mocks.NewMockDeploymentInterface(ctrl) - deploymentMock.EXPECT(). - CreateMongoDatabase(gomock.Any(), tt.mock.CreateVersion). - Times(tt.mock.CreateTimes). - Return(tt.mock.CreateReturnResponse, tt.mock.CreateReturnErr) - - ios, _, stdout, stderr := iostreams.Test() - - argv, err := shlex.Split(tt.cli) - if err != nil { - t.Fatal(err) - } - - f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, deploymentMock, nil, nil) - - cmd := NewCmdMongoCreate(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, - } - - require.NoError(t, err, "should not throw error") - require.Equal(t, tt.want.stdout, cmdOut.String()) - - }) - } -} diff --git a/vcr/mongo/delete/delete.go b/vcr/mongo/delete/delete.go deleted file mode 100644 index d5e7dd7..0000000 --- a/vcr/mongo/delete/delete.go +++ /dev/null @@ -1,59 +0,0 @@ -package delete - -import ( - "context" - "fmt" - - "vonage-cloud-runtime-cli/pkg/cmdutil" - - "github.com/MakeNowJust/heredoc" - "github.com/spf13/cobra" -) - -type Options struct { - cmdutil.Factory - - Version string - Database string -} - -func NewCmdMongoDelete(f cmdutil.Factory) *cobra.Command { - - opts := Options{ - Factory: f, - } - - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete database and corresponding user", - Example: heredoc.Doc(`$ vcr mongo delete --database `), - Args: cobra.MaximumNArgs(0), - RunE: func(_ *cobra.Command, _ []string) error { - ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) - defer cancel() - - return runInfo(ctx, &opts) - }, - } - - cmd.Flags().StringVarP(&opts.Database, "database", "d", "", "Database name") - cmd.Flags().StringVarP(&opts.Version, "version", "v", "v0.1", "API version (default is v0.1)") - - _ = cmd.MarkFlagRequired("database") - - return cmd -} - -func runInfo(ctx context.Context, opts *Options) error { - io := opts.IOStreams() - c := opts.IOStreams().ColorScheme() - - spinner := cmdutil.DisplaySpinnerMessageWithHandle(" Deleting database") - err := opts.DeploymentClient().DeleteMongoDatabase(ctx, opts.Version, opts.Database) - spinner.Stop() - if err != nil { - return fmt.Errorf("failed to delete database: %w", err) - } - fmt.Fprintf(io.Out, heredoc.Doc(`%s Database deleted`), c.SuccessIcon()) - return nil -} diff --git a/vcr/mongo/delete/delete_test.go b/vcr/mongo/delete/delete_test.go deleted file mode 100644 index c04efce..0000000 --- a/vcr/mongo/delete/delete_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package delete - -import ( - "bytes" - "errors" - "io" - "testing" - "vonage-cloud-runtime-cli/testutil" - "vonage-cloud-runtime-cli/testutil/mocks" - - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/golang/mock/gomock" - "github.com/google/shlex" - "github.com/stretchr/testify/require" -) - -func TestMongoDelete(t *testing.T) { - type mock struct { - DeleteTimes int - DeleteReturnErr error - DeleteDatabase string - DeleteVersion string - } - type want struct { - errMsg string - stdout string - } - - tests := []struct { - name string - cli string - mock mock - want want - }{ - { - name: "happy-path", - cli: "--database=TestDB", - mock: mock{ - DeleteVersion: "v0.1", - DeleteDatabase: "TestDB", - DeleteTimes: 1, - DeleteReturnErr: nil, - }, - want: want{ - stdout: heredoc.Doc(`✓ Database deleted`), - }, - }, - { - name: "delete-api-error", - cli: "--database=TestDB", - mock: mock{ - DeleteVersion: "v0.1", - DeleteDatabase: "TestDB", - DeleteTimes: 1, - DeleteReturnErr: errors.New("api error"), - }, - want: want{ - errMsg: "failed to delete database: api error", - }, - }, - { - name: "missing-database", - cli: "", - mock: mock{ - DeleteTimes: 0, - DeleteReturnErr: nil, - }, - want: want{ - errMsg: "required flag(s) \"database\" not set", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - ctrl := gomock.NewController(t) - - deploymentMock := mocks.NewMockDeploymentInterface(ctrl) - deploymentMock.EXPECT(). - DeleteMongoDatabase(gomock.Any(), tt.mock.DeleteVersion, tt.mock.DeleteDatabase). - Times(tt.mock.DeleteTimes). - Return(tt.mock.DeleteReturnErr) - - ios, _, stdout, stderr := iostreams.Test() - - argv, err := shlex.Split(tt.cli) - if err != nil { - t.Fatal(err) - } - - f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, deploymentMock, nil, nil) - - cmd := NewCmdMongoDelete(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, - } - - require.NoError(t, err, "should not throw error") - require.Equal(t, tt.want.stdout, cmdOut.String()) - - }) - } -} diff --git a/vcr/mongo/info/info.go b/vcr/mongo/info/info.go deleted file mode 100644 index 0dd5aad..0000000 --- a/vcr/mongo/info/info.go +++ /dev/null @@ -1,74 +0,0 @@ -package info - -import ( - "context" - "fmt" - - "vonage-cloud-runtime-cli/pkg/cmdutil" - - "github.com/MakeNowJust/heredoc" - "github.com/spf13/cobra" -) - -type Options struct { - cmdutil.Factory - - Version string - Database string -} - -func NewCmdMongoInfo(f cmdutil.Factory) *cobra.Command { - - opts := Options{ - Factory: f, - } - - cmd := &cobra.Command{ - Use: "info", - Short: "Get database connection info", - Example: heredoc.Doc(`$ vcr mongo info --database `), - Args: cobra.MaximumNArgs(0), - RunE: func(_ *cobra.Command, _ []string) error { - ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) - defer cancel() - - return runInfo(ctx, &opts) - }, - } - - cmd.Flags().StringVarP(&opts.Database, "database", "d", "", "Database name") - cmd.Flags().StringVarP(&opts.Version, "version", "v", "v0.1", "API version (default is v0.1)") - - _ = cmd.MarkFlagRequired("database") - - return cmd -} - -func runInfo(ctx context.Context, opts *Options) error { - io := opts.IOStreams() - c := opts.IOStreams().ColorScheme() - - spinner := cmdutil.DisplaySpinnerMessageWithHandle(" Getting Database") - result, err := opts.DeploymentClient().GetMongoDatabase(ctx, opts.Version, opts.Database) - spinner.Stop() - if err != nil { - return fmt.Errorf("failed to get database info: %w", err) - } - fmt.Fprintf(io.Out, heredoc.Doc(` - %s Database info - %s username: %s - %s password: %s - %s database: %s - %s connectionString: %s - `), - c.SuccessIcon(), - c.Blue(cmdutil.InfoIcon), - result.Username, - c.Blue(cmdutil.InfoIcon), - result.Password, - c.Blue(cmdutil.InfoIcon), - result.Database, - c.Blue(cmdutil.InfoIcon), - result.ConnectionString) - return nil -} diff --git a/vcr/mongo/info/info_test.go b/vcr/mongo/info/info_test.go deleted file mode 100644 index 6f61512..0000000 --- a/vcr/mongo/info/info_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package info - -import ( - "bytes" - "errors" - "io" - "testing" - "vonage-cloud-runtime-cli/pkg/api" - "vonage-cloud-runtime-cli/testutil" - "vonage-cloud-runtime-cli/testutil/mocks" - - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/golang/mock/gomock" - "github.com/google/shlex" - "github.com/stretchr/testify/require" -) - -func TestMongoInfo(t *testing.T) { - type mock struct { - InfoTimes int - InfoReturnErr error - InfoDatabase string - InfoVersion string - InfoResponse api.MongoInfoResponse - } - type want struct { - errMsg string - stdout string - } - - tests := []struct { - name string - cli string - mock mock - want want - }{ - { - name: "happy-path", - cli: "--database=TestDB", - mock: mock{ - InfoVersion: "v0.1", - InfoDatabase: "TestDB", - InfoTimes: 1, - InfoReturnErr: nil, - InfoResponse: api.MongoInfoResponse{ - Username: "test", - Password: "test", - Database: "TestDB", - ConnectionString: "mongodb://test:test@localhost:27017/TestDB", - }, - }, - want: want{ - stdout: heredoc.Doc(` - ✓ Database info - ℹ username: test - ℹ password: test - ℹ database: TestDB - ℹ connectionString: mongodb://test:test@localhost:27017/TestDB - `), - }, - }, - { - name: "info-api-error", - cli: "--database=TestDB", - mock: mock{ - InfoVersion: "v0.1", - InfoDatabase: "TestDB", - InfoTimes: 1, - InfoReturnErr: errors.New("api error"), - InfoResponse: api.MongoInfoResponse{}, - }, - want: want{ - errMsg: "failed to get database info: api error", - }, - }, - { - name: "missing-database", - cli: "", - mock: mock{ - InfoTimes: 0, - InfoReturnErr: nil, - }, - want: want{ - errMsg: "required flag(s) \"database\" not set", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - ctrl := gomock.NewController(t) - - deploymentMock := mocks.NewMockDeploymentInterface(ctrl) - deploymentMock.EXPECT(). - GetMongoDatabase(gomock.Any(), tt.mock.InfoVersion, tt.mock.InfoDatabase). - Times(tt.mock.InfoTimes). - Return(tt.mock.InfoResponse, tt.mock.InfoReturnErr) - - ios, _, stdout, stderr := iostreams.Test() - - argv, err := shlex.Split(tt.cli) - if err != nil { - t.Fatal(err) - } - - f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, deploymentMock, nil, nil) - - cmd := NewCmdMongoInfo(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, - } - - require.NoError(t, err, "should not throw error") - require.Equal(t, tt.want.stdout, cmdOut.String()) - - }) - } -} diff --git a/vcr/mongo/list/list.go b/vcr/mongo/list/list.go deleted file mode 100644 index badf383..0000000 --- a/vcr/mongo/list/list.go +++ /dev/null @@ -1,69 +0,0 @@ -package list - -import ( - "context" - "fmt" - "vonage-cloud-runtime-cli/pkg/cmdutil" - - "github.com/MakeNowJust/heredoc" - "github.com/olekukonko/tablewriter" - "github.com/spf13/cobra" -) - -type Options struct { - cmdutil.Factory - - Version string -} - -func NewCmdMongoList(f cmdutil.Factory) *cobra.Command { - - opts := Options{ - Factory: f, - } - - cmd := &cobra.Command{ - Use: "list", - Short: "List databases", - Example: heredoc.Doc(`$ vcr mongo list`), - Args: cobra.MaximumNArgs(0), - RunE: func(_ *cobra.Command, _ []string) error { - ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) - defer cancel() - - return runList(ctx, &opts) - }, - } - - cmd.Flags().StringVarP(&opts.Version, "version", "v", "v0.1", "API version (default is v0.1)") - - return cmd -} - -func runList(ctx context.Context, opts *Options) error { - io := opts.IOStreams() - c := opts.IOStreams().ColorScheme() - - spinner := cmdutil.DisplaySpinnerMessageWithHandle(" Listing Databases") - result, err := opts.DeploymentClient().ListMongoDatabases(ctx, opts.Version) - spinner.Stop() - if err != nil { - return fmt.Errorf("failed to list databases: %w", err) - } - - if len(result) == 0 { - fmt.Fprintf(io.Out, "%s No databases found\n", c.WarningIcon()) - return nil - } - - table := tablewriter.NewWriter(io.Out) - table.SetHeader([]string{"Database"}) - - for _, db := range result { - table.Append([]string{db}) - } - - table.Render() - - return nil -} diff --git a/vcr/mongo/list/list_test.go b/vcr/mongo/list/list_test.go deleted file mode 100644 index c24d66d..0000000 --- a/vcr/mongo/list/list_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package list - -import ( - "bytes" - "errors" - "io" - "testing" - "vonage-cloud-runtime-cli/testutil" - "vonage-cloud-runtime-cli/testutil/mocks" - - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/golang/mock/gomock" - "github.com/google/shlex" - "github.com/stretchr/testify/require" -) - -func TestMongoList(t *testing.T) { - type mock struct { - ListTimes int - ListReturnResponse []string - ListReturnErr error - ListVersion string - } - type want struct { - errMsg string - stdout string - } - - tests := []struct { - name string - cli string - mock mock - want want - }{ - { - name: "happy-path", - cli: "", - mock: mock{ - ListVersion: "v0.1", - ListTimes: 1, - ListReturnResponse: []string{ - "TestDB1", - "TestDB2", - }, - ListReturnErr: nil, - }, - want: want{ - stdout: heredoc.Doc(` - +----------+ - | DATABASE | - +----------+ - | TestDB1 | - | TestDB2 | - +----------+ - `), - }, - }, - { - name: "create-api-error", - cli: "", - mock: mock{ - ListVersion: "v0.1", - ListTimes: 1, - ListReturnResponse: nil, - ListReturnErr: errors.New("api error"), - }, - want: want{ - errMsg: "failed to list databases: api error", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - ctrl := gomock.NewController(t) - - deploymentMock := mocks.NewMockDeploymentInterface(ctrl) - deploymentMock.EXPECT(). - ListMongoDatabases(gomock.Any(), tt.mock.ListVersion). - Times(tt.mock.ListTimes). - Return(tt.mock.ListReturnResponse, tt.mock.ListReturnErr) - - ios, _, stdout, stderr := iostreams.Test() - - argv, err := shlex.Split(tt.cli) - if err != nil { - t.Fatal(err) - } - - f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, deploymentMock, nil, nil) - - cmd := NewCmdMongoList(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, - } - - require.NoError(t, err, "should not throw error") - require.Equal(t, tt.want.stdout, cmdOut.String()) - - }) - } -} diff --git a/vcr/mongo/mongo.go b/vcr/mongo/mongo.go deleted file mode 100644 index b6a6a84..0000000 --- a/vcr/mongo/mongo.go +++ /dev/null @@ -1,24 +0,0 @@ -package mongo - -import ( - "vonage-cloud-runtime-cli/pkg/cmdutil" - createCmd "vonage-cloud-runtime-cli/vcr/mongo/create" - deleteCmd "vonage-cloud-runtime-cli/vcr/mongo/delete" - infoCmd "vonage-cloud-runtime-cli/vcr/mongo/info" - listCmd "vonage-cloud-runtime-cli/vcr/mongo/list" - - "github.com/spf13/cobra" -) - -func NewCmdMongo(f cmdutil.Factory) *cobra.Command { - cmd := &cobra.Command{ - Use: "mongo ", - Short: "Used for managing MongoDB databases", - } - - cmd.AddCommand(createCmd.NewCmdMongoCreate(f)) - cmd.AddCommand(listCmd.NewCmdMongoList(f)) - cmd.AddCommand(infoCmd.NewCmdMongoInfo(f)) - cmd.AddCommand(deleteCmd.NewCmdMongoDelete(f)) - return cmd -} diff --git a/vcr/root/root.go b/vcr/root/root.go index 979ce04..4289eb9 100644 --- a/vcr/root/root.go +++ b/vcr/root/root.go @@ -36,13 +36,60 @@ func NewCmdRoot(f cmdutil.Factory, version, buildDate, commit string, updateStre Short: "Streamline your Vonage Cloud Runtime development and management tasks with VCR", Long: heredoc.Doc(` VCR CLI is a powerful command-line interface designed to streamline - and simplify the development and management of applications on + and simplify the development and management of applications on the Vonage Cloud Runtime platform. + + Vonage Cloud Runtime (VCR) enables you to build, deploy, and run serverless + applications that integrate with Vonage communication APIs including Voice, + Messages, and RTC (Real-Time Communication). + + GETTING STARTED + 1. Configure the CLI with your Vonage API credentials: + $ vcr configure + + 2. Initialize a new project from a template: + $ vcr init my-project + + 3. Deploy your application: + $ vcr deploy + + CORE WORKFLOW + • vcr configure - Set up your Vonage API credentials and region + • vcr app - Create and manage Vonage applications + • vcr init - Initialize a project from a template + • vcr deploy - Deploy your application to VCR + • vcr debug - Run your application locally in debug mode + • vcr instance - Manage deployed instances (logs, removal) + • vcr secret - Manage secrets for your applications + • vcr upgrade - Update the VCR CLI to the latest version `), Example: heredoc.Doc(` - $ vcr app create -n my-app + # Configure the CLI with your Vonage credentials + $ vcr configure + + # Create a new Vonage application + $ vcr app create --name my-app + + # List all your Vonage applications $ vcr app list + + # Initialize a new project in the current directory $ vcr init + + # Initialize a new project in a specific directory + $ vcr init my-project + + # Deploy your application to VCR + $ vcr deploy + + # Run your application locally in debug mode + $ vcr debug + + # View logs for a deployed instance + $ vcr instance log --project-name my-project --instance-name dev + + # Create a secret for your application + $ vcr secret create --name MY_API_KEY --value "secret-value" `), Annotations: map[string]string{ "versionInfo": upgradeCmd.Format(version, buildDate, commit), diff --git a/vcr/secret/create/create.go b/vcr/secret/create/create.go index 70fa643..abd709b 100644 --- a/vcr/secret/create/create.go +++ b/vcr/secret/create/create.go @@ -28,15 +28,46 @@ func NewCmdSecretCreate(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "create", - Short: `Create a new secret`, - Long: heredoc.Doc(`Create a new secret. + Short: "Create a new secret", + Long: heredoc.Doc(`Create a new secret for use in VCR applications. - The secrets can be loaded into your deployed applications as environment variables. + Secrets are securely stored and can be referenced in your vcr.yml manifest + to inject sensitive values as environment variables in your deployed instances. + + SECRET VALUE INPUT + You can provide the secret value in two ways: + • --value: Pass the value directly (be careful with shell history) + • --filename: Read the value from a file (recommended for multi-line values) + + If neither is provided, you will be prompted to enter the value interactively. + + SECRET NAMING RULES + • Must be a valid environment variable name + • Alphanumeric characters and underscores only + • Cannot start with a number + • Case-sensitive (MY_SECRET and my_secret are different) + + NOTE: Secret names must be unique within your account. If a secret with the + same name already exists, this command will fail. Use 'vcr secret update' instead. `), Example: heredoc.Doc(` - $ vcr secret create --name --value - - $ vcr secret create --name --file + # Create a secret with a direct value + $ vcr secret create --name MY_API_KEY --value "sk-12345abcde" + ✓ Secret "MY_API_KEY" created + + # Create a secret from a file + $ vcr secret create --name SSL_CERT --filename ./server.crt + + # Create a secret with interactive value input (value not shown in history) + $ vcr secret create --name DATABASE_PASSWORD + ? Enter value for secret "DATABASE_PASSWORD": ******** + ✓ Secret "DATABASE_PASSWORD" created + + # Using short flags + $ vcr secret create -n WEBHOOK_SECRET -v "whsec_xyz123" + + # Using the 'add' alias + $ vcr secret add --name MY_TOKEN --value "token123" `), Args: cobra.MaximumNArgs(0), Aliases: []string{"add"}, @@ -49,9 +80,9 @@ func NewCmdSecretCreate(f cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "The name of the secret") - cmd.Flags().StringVarP(&opts.Value, "value", "v", "", "The value of the secret") - cmd.Flags().StringVarP(&opts.SecretFile, "filename", "f", "", "The path to the file containing the secret") + cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Secret name (must be valid env var name, required)") + cmd.Flags().StringVarP(&opts.Value, "value", "v", "", "Secret value (or use --filename for file input)") + cmd.Flags().StringVarP(&opts.SecretFile, "filename", "f", "", "Path to file containing the secret value") _ = cmd.MarkFlagRequired("name") diff --git a/vcr/secret/remove/remove.go b/vcr/secret/remove/remove.go index 078bf5e..4fc39bc 100644 --- a/vcr/secret/remove/remove.go +++ b/vcr/secret/remove/remove.go @@ -24,8 +24,29 @@ func NewCmdSecretRemove(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "remove", Short: "Remove a secret", + Long: heredoc.Doc(`Remove a secret from your VCR account. + + This command permanently deletes a secret. Any deployed instances that + reference this secret will fail to access the value on their next restart. + + WARNING: This action is irreversible. Make sure no running instances + depend on this secret before removing it. + + BEFORE REMOVING + 1. Check if any vcr.yml manifests reference this secret + 2. Update or redeploy affected instances first + 3. Then remove the secret + `), Example: heredoc.Doc(` - $ vcr secret remove -n + # Remove a secret by name + $ vcr secret remove --name MY_API_KEY + ✓ Secret "MY_API_KEY" successfully removed + + # Using the short flag + $ vcr secret remove -n DATABASE_PASSWORD + + # Using the 'rm' alias + $ vcr secret rm --name OLD_TOKEN `), Args: cobra.MaximumNArgs(0), Aliases: []string{"rm"}, @@ -38,7 +59,7 @@ func NewCmdSecretRemove(f cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "The name of the secret") + cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Name of the secret to remove (required)") _ = cmd.MarkFlagRequired("name") diff --git a/vcr/secret/secret.go b/vcr/secret/secret.go index 1d06136..fcbea7c 100644 --- a/vcr/secret/secret.go +++ b/vcr/secret/secret.go @@ -13,10 +13,49 @@ import ( func NewCmdSecret(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "secret ", - Short: "Manage VCR secrets", + Short: "Manage secrets for VCR applications", + Long: heredoc.Doc(`Manage secrets for VCR applications. + + Secrets allow you to securely store sensitive values like API keys, passwords, + and tokens that your VCR applications need at runtime. Secrets are: + • Encrypted at rest + • Injected as environment variables into your deployed instances + • Scoped to your Vonage account + + USING SECRETS IN YOUR APPLICATION + Reference secrets in your vcr.yml manifest: + + instance: + environment: + - name: MY_API_KEY + secret: MY_SECRET_NAME # References a secret + + The secret value is then available in your application as the + environment variable MY_API_KEY. + + AVAILABLE COMMANDS + create (add) Create a new secret + update Update an existing secret's value + remove (rm) Delete a secret + + SECRET NAMING + Secret names must be valid environment variable names: + • Alphanumeric characters and underscores only + • Cannot start with a number + • Case-sensitive + `), Example: heredoc.Doc(` - $ vcr secret create --name --value - $ vcr secret create --name --file + # Create a secret with a value + $ vcr secret create --name MY_API_KEY --value "sk-12345..." + + # Create a secret from a file (useful for certificates, multi-line values) + $ vcr secret create --name SSL_CERT --filename ./cert.pem + + # Update a secret's value + $ vcr secret update --name MY_API_KEY --value "sk-new-key..." + + # Remove a secret + $ vcr secret remove --name MY_API_KEY `), } diff --git a/vcr/secret/update/update.go b/vcr/secret/update/update.go index 58f9bfa..c4cda5e 100644 --- a/vcr/secret/update/update.go +++ b/vcr/secret/update/update.go @@ -28,11 +28,42 @@ func NewCmdSecretUpdate(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "update", - Short: "update a secret", + Short: "Update an existing secret's value", + Long: heredoc.Doc(`Update the value of an existing secret. + + This command changes the value of a secret that was previously created. + The new value will be available to instances on their next restart or + new deployment. + + SECRET VALUE INPUT + You can provide the new value in two ways: + • --value: Pass the value directly (be careful with shell history) + • --filename: Read the value from a file (recommended for multi-line values) + + If neither is provided, you will be prompted to enter the value interactively. + + NOTE: The secret must already exist. Use 'vcr secret create' to create a new secret. + + UPDATING RUNNING INSTANCES + Instances do not automatically pick up secret changes. You need to either: + • Redeploy the instance: vcr deploy + • Or restart the instance through the dashboard + `), Example: heredoc.Doc(` - $ vcr secret create --name my-secret --value my-value - - $ vcr secret update --name my-secret --value changed-value + # Update a secret's value directly + $ vcr secret update --name MY_API_KEY --value "sk-newkey12345" + ✓ Secret "MY_API_KEY" updated + + # Update a secret from a file + $ vcr secret update --name SSL_CERT --filename ./new-cert.pem + + # Update with interactive input (value hidden) + $ vcr secret update --name DATABASE_PASSWORD + ? Enter new value for secret "DATABASE_PASSWORD": ******** + ✓ Secret "DATABASE_PASSWORD" updated + + # Using short flags + $ vcr secret update -n WEBHOOK_SECRET -v "whsec_newvalue" `), Args: cobra.MaximumNArgs(0), @@ -44,9 +75,9 @@ func NewCmdSecretUpdate(f cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "The name of the secret") - cmd.Flags().StringVarP(&opts.Value, "value", "v", "", "The value of the secret") - cmd.Flags().StringVarP(&opts.SecretFile, "filename", "f", "", "The path to the file containing the secret") + cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Name of the secret to update (required)") + cmd.Flags().StringVarP(&opts.Value, "value", "v", "", "New value for the secret (or use --filename)") + cmd.Flags().StringVarP(&opts.SecretFile, "filename", "f", "", "Path to file containing the new secret value") _ = cmd.MarkFlagRequired("name") diff --git a/vcr/upgrade/upgrade.go b/vcr/upgrade/upgrade.go index 8ed0657..185de84 100644 --- a/vcr/upgrade/upgrade.go +++ b/vcr/upgrade/upgrade.go @@ -34,12 +34,52 @@ func NewCmdUpgrade(f cmdutil.Factory, version string) *cobra.Command { cmd := &cobra.Command{ Use: "upgrade", - Short: `Show and update VCR CLI version`, - Long: heredoc.Doc(`Show VCR CLI version. - - If current version is not the latest, the option to update will be provided. + Short: "Check for and install VCR CLI updates", + Long: heredoc.Doc(`Check for and install VCR CLI updates. + + This command displays the current VCR CLI version and checks if a newer + version is available. If an update is found, you'll be prompted to install it. + + VERSION CHECK + The command compares your installed version against the latest release + on GitHub. It shows: + • Current version: Your installed version + • Latest version: The newest available release + + UPDATE PROCESS + If a new version is available: + 1. You'll be prompted to confirm the update (unless --force is used) + 2. The new binary is downloaded from GitHub releases + 3. The current binary is replaced with the new one + 4. Success message confirms the update + + CUSTOM INSTALLATION PATH + If you installed the CLI in a custom location (not the default), use + --path to specify where the vcr binary is located. + + TROUBLESHOOTING + • If update fails due to permissions, try running with sudo + • On some systems, you may need to reinstall using brew or the installer `), Args: cobra.MaximumNArgs(0), + Example: heredoc.Doc(` + # Check current version and available updates + $ vcr upgrade + vcr-cli version 1.2.3 (commit:abc123, date:2024-01-15) + ✓ You are using the latest version of vcr-cli (1.2.3) + + # When an update is available + $ vcr upgrade + vcr-cli version 1.2.3 (commit:abc123, date:2024-01-15) + ? Are you sure you want to update to 1.3.0? Yes + ✓ Successfully updated to version 1.3.0 + + # Force update without prompt (useful for CI/CD) + $ vcr upgrade --force + + # Update CLI installed in a custom location + $ vcr upgrade --path /opt/vonage/bin + `), RunE: func(cmd *cobra.Command, _ []string) error { ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) defer cancel() @@ -56,8 +96,8 @@ func NewCmdUpgrade(f cmdutil.Factory, version string) *cobra.Command { }, } - cmd.Flags().BoolVarP(&opts.forceUpdate, "force", "f", false, "Force update and skip prompt if new update exists") - cmd.Flags().StringVarP(&opts.path, "path", "p", "", "Path to the VCR CLI installed directory") + cmd.Flags().BoolVarP(&opts.forceUpdate, "force", "f", false, "Skip confirmation prompt and update automatically") + cmd.Flags().StringVarP(&opts.path, "path", "p", "", "Custom path to VCR CLI installation directory") return cmd }