diff --git a/cmd/root/root.go b/cmd/root/root.go index cd8cc5e..8523711 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -29,6 +29,7 @@ import ( "github.com/NodeOps-app/createos-cli/cmd/users" versioncmd "github.com/NodeOps-app/createos-cli/cmd/version" "github.com/NodeOps-app/createos-cli/cmd/vms" + "github.com/NodeOps-app/createos-cli/cmd/webhooks" "github.com/NodeOps-app/createos-cli/internal/api" "github.com/NodeOps-app/createos-cli/internal/config" "github.com/NodeOps-app/createos-cli/internal/intro" @@ -181,6 +182,7 @@ func NewApp() *cli.App { fmt.Println(" status Show project health and deployment status") fmt.Println(" templates Browse and scaffold from project templates") fmt.Println(" vms Manage VM terminal instances") + fmt.Println(" webhooks Manage webhook endpoints") fmt.Println(" whoami Show the currently authenticated user") } else { fmt.Println(" login Authenticate with CreateOS") @@ -219,6 +221,7 @@ func NewApp() *cli.App { upgrade.NewUpgradeCommand(), users.NewUsersCommand(), vms.NewVMsCommand(), + webhooks.NewWebhooksCommand(), auth.NewWhoamiCommand(), versioncmd.NewVersionCommand(), }, diff --git a/cmd/webhooks/create.go b/cmd/webhooks/create.go new file mode 100644 index 0000000..90eb459 --- /dev/null +++ b/cmd/webhooks/create.go @@ -0,0 +1,110 @@ +package webhooks + +import ( + "fmt" + + atomickeys "atomicgo.dev/keyboard/keys" + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/output" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newWebhooksCreateCommand() *cli.Command { + return &cli.Command{ + Name: "create", + Usage: "Create a new webhook endpoint", + Description: `Create a webhook endpoint that receives event notifications via HTTP POST. + +Examples: + # Subscribe to all events: + createos webhooks create --url https://example.com/webhook + + # Subscribe to specific events: + createos webhooks create --url https://example.com/webhook \ + --event sandbox.create --event sandbox.destroy + + # Interactive mode (TTY only): + createos webhooks create`, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "url", Usage: "HTTPS URL to receive webhook events"}, + &cli.StringSliceFlag{Name: "event", Usage: "Event to subscribe to (repeatable, omit for all events)"}, + }, + Action: func(c *cli.Context) error { + client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + webhookURL := c.String("url") + events := c.StringSlice("event") + + if !terminal.IsInteractive() { + if webhookURL == "" { + return fmt.Errorf("please provide a URL with --url") + } + } else { + if webhookURL == "" { + var err error + webhookURL, err = pterm.DefaultInteractiveTextInput. + WithDefaultText("Webhook URL"). + Show() + if err != nil { + return fmt.Errorf("could not read URL: %w", err) + } + } + if len(events) == 0 { + actions, err := client.ListWebhookActions() + if err != nil { + return fmt.Errorf("could not load available events: %w", err) + } + + fmt.Println("Select events to subscribe to (leave empty for all events):") + selected, selErr := pterm.DefaultInteractiveMultiselect. + WithOptions(actions). + WithDefaultText("Events"). + WithFilter(false). + WithKeySelect(atomickeys.Space). + WithKeyConfirm(atomickeys.Enter). + WithCheckmark(&pterm.Checkmark{Checked: "x", Unchecked: " "}). + Show() + if selErr != nil { + return fmt.Errorf("could not read selection: %w", selErr) + } + events = selected + } + } + + if webhookURL == "" { + return fmt.Errorf("URL cannot be empty") + } + + req := api.CreateWebhookEndpointRequest{ + URL: webhookURL, + Events: events, + } + + result, err := client.CreateWebhookEndpoint(req) + if err != nil { + return err + } + + if output.IsJSON(c) { + output.Render(c, result, func() {}) + return nil + } + + pterm.Success.Printf("Webhook endpoint created. ID: %s\n", result.ID) + fmt.Println() + label := pterm.NewStyle(pterm.FgCyan) + fmt.Printf("%s %s\n", label.Sprint("Secret:"), result.Secret) + fmt.Println() + pterm.Println(pterm.Gray(" Save this secret — it won't be shown again.")) + pterm.Println(pterm.Gray(" Use it to verify webhook signatures (X-Webhook-Signature header).")) + + return nil + }, + } +} diff --git a/cmd/webhooks/delete.go b/cmd/webhooks/delete.go new file mode 100644 index 0000000..fefb988 --- /dev/null +++ b/cmd/webhooks/delete.go @@ -0,0 +1,58 @@ +package webhooks + +import ( + "fmt" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newWebhooksDeleteCommand() *cli.Command { + return &cli.Command{ + Name: "delete", + Usage: "Delete a webhook endpoint", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "endpoint", Usage: "Webhook endpoint ID"}, + &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "Skip confirmation prompt"}, + }, + Action: func(c *cli.Context) error { + client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + endpointID, err := resolveEndpoint(c, client) + if err != nil { + return err + } + + if !c.Bool("force") { + if !terminal.IsInteractive() { + return fmt.Errorf("confirmation required — use --force to delete without a prompt") + } + + confirm, confirmErr := pterm.DefaultInteractiveConfirm. + WithDefaultText(fmt.Sprintf("Are you sure you want to delete webhook endpoint %q?", endpointID)). + WithDefaultValue(false). + Show() + if confirmErr != nil { + return fmt.Errorf("could not read confirmation: %w", confirmErr) + } + if !confirm { + fmt.Println("Cancelled. Your webhook endpoint was not deleted.") + return nil + } + } + + if err := client.DeleteWebhookEndpoint(endpointID); err != nil { + return err + } + + pterm.Success.Println("Webhook endpoint deleted.") + return nil + }, + } +} diff --git a/cmd/webhooks/get.go b/cmd/webhooks/get.go new file mode 100644 index 0000000..8021c27 --- /dev/null +++ b/cmd/webhooks/get.go @@ -0,0 +1,89 @@ +package webhooks + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/output" +) + +func newWebhooksGetCommand() *cli.Command { + return &cli.Command{ + Name: "get", + Usage: "Show details for a webhook endpoint", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "endpoint", Usage: "Webhook endpoint ID"}, + }, + Action: func(c *cli.Context) error { + client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + endpointID, err := resolveEndpoint(c, client) + if err != nil { + return err + } + + result, err := client.GetWebhookEndpoint(endpointID) + if err != nil { + return err + } + + output.Render(c, result, func() { + ep := result.Endpoint + label := pterm.NewStyle(pterm.FgCyan) + + fmt.Printf("%s %s\n", label.Sprint("ID:"), ep.ID) + fmt.Printf("%s %s\n", label.Sprint("URL:"), ep.URL) + fmt.Printf("%s %s\n", label.Sprint("Status:"), statusIcon(ep.Active)) + events := "* (all events)" + if len(ep.Events) > 0 { + events = strings.Join(ep.Events, ", ") + } + fmt.Printf("%s %s\n", label.Sprint("Events:"), events) + fmt.Printf("%s %d\n", label.Sprint("Failures:"), ep.FailureCount) + fmt.Printf("%s %s\n", label.Sprint("Created:"), ep.CreatedAt.Format("2006-01-02 15:04:05 UTC")) + fmt.Println() + + if len(result.Deliveries) == 0 { + fmt.Println("No recent deliveries.") + return + } + + fmt.Println("Recent Deliveries:") + tableData := pterm.TableData{ + {"ID", "Event", "Status", "Attempts", "Created"}, + } + for _, d := range result.Deliveries { + tableData = append(tableData, []string{ + d.ID, + d.EventAction, + deliveryStatusIcon(d.Status), + fmt.Sprintf("%d", d.Attempts), + d.CreatedAt.Format("2006-01-02 15:04"), + }) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + fmt.Println() + }) + return nil + }, + } +} + +func deliveryStatusIcon(status string) string { + switch status { + case "delivered": + return pterm.Green("delivered") + case "failed": + return pterm.Red("failed") + default: + return pterm.Yellow("pending") + } +} diff --git a/cmd/webhooks/helpers.go b/cmd/webhooks/helpers.go new file mode 100644 index 0000000..6e8bc05 --- /dev/null +++ b/cmd/webhooks/helpers.go @@ -0,0 +1,68 @@ +package webhooks + +import ( + "fmt" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// resolveEndpoint resolves a webhook endpoint ID from the --endpoint flag, +// the first positional arg, or an interactive picker (TTY only). +func resolveEndpoint(c *cli.Context, client *api.APIClient) (string, error) { + if id := c.String("endpoint"); id != "" { + return id, nil + } + if c.Args().Len() > 0 { + return c.Args().First(), nil + } + + if !terminal.IsInteractive() { + return "", fmt.Errorf( + "please provide a webhook endpoint ID\n\n Example:\n createos webhooks %s --endpoint \n\n To see your endpoints, run:\n createos webhooks list", + c.Command.Name, + ) + } + + return pickEndpoint(client) +} + +// pickEndpoint interactively selects a webhook endpoint from the user's list. +func pickEndpoint(client *api.APIClient) (string, error) { + endpoints, err := client.ListWebhookEndpoints() + if err != nil { + return "", err + } + if len(endpoints) == 0 { + return "", fmt.Errorf("you don't have any webhook endpoints yet\n\n Create one with:\n createos webhooks create") + } + if len(endpoints) == 1 { + return endpoints[0].ID, nil + } + + options := make([]string, len(endpoints)) + for i, ep := range endpoints { + status := "active" + if !ep.Active { + status = "suspended" + } + options[i] = fmt.Sprintf("%s %s (%s)", ep.URL, ep.ID[:8], status) + } + + selected, err := pterm.DefaultInteractiveSelect. + WithOptions(options). + WithDefaultText("Select a webhook endpoint"). + Show() + if err != nil { + return "", fmt.Errorf("could not read selection: %w", err) + } + for i, opt := range options { + if opt == selected { + return endpoints[i].ID, nil + } + } + return "", fmt.Errorf("no endpoint selected") +} diff --git a/cmd/webhooks/list.go b/cmd/webhooks/list.go new file mode 100644 index 0000000..09c950f --- /dev/null +++ b/cmd/webhooks/list.go @@ -0,0 +1,68 @@ +package webhooks + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/output" +) + +func newWebhooksListCommand() *cli.Command { + return &cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List all webhook endpoints", + Action: func(c *cli.Context) error { + client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + endpoints, err := client.ListWebhookEndpoints() + if err != nil { + return err + } + + output.Render(c, endpoints, func() { + if len(endpoints) == 0 { + fmt.Println("You don't have any webhook endpoints yet.") + fmt.Println() + fmt.Println(" Create one with:") + fmt.Println(" createos webhooks create") + return + } + + tableData := pterm.TableData{ + {"ID", "URL", "Events", "Status", "Failures"}, + } + for _, ep := range endpoints { + status := statusIcon(ep.Active) + events := "*" + if len(ep.Events) > 0 { + events = strings.Join(ep.Events, ", ") + if len(events) > 40 { + events = events[:37] + "..." + } + } + tableData = append(tableData, []string{ + ep.ID, ep.URL, events, status, fmt.Sprintf("%d", ep.FailureCount), + }) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + fmt.Println() + }) + return nil + }, + } +} + +func statusIcon(active bool) string { + if active { + return pterm.Green("active") + } + return pterm.Red("suspended") +} diff --git a/cmd/webhooks/resume.go b/cmd/webhooks/resume.go new file mode 100644 index 0000000..854c3c3 --- /dev/null +++ b/cmd/webhooks/resume.go @@ -0,0 +1,38 @@ +package webhooks + +import ( + "fmt" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +func newWebhooksResumeCommand() *cli.Command { + return &cli.Command{ + Name: "resume", + Usage: "Resume a suspended webhook endpoint", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "endpoint", Usage: "Webhook endpoint ID"}, + }, + Action: func(c *cli.Context) error { + client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + endpointID, err := resolveEndpoint(c, client) + if err != nil { + return err + } + + if err := client.ResumeWebhookEndpoint(endpointID); err != nil { + return err + } + + pterm.Success.Println("Webhook endpoint resumed.") + return nil + }, + } +} diff --git a/cmd/webhooks/suspend.go b/cmd/webhooks/suspend.go new file mode 100644 index 0000000..8e57582 --- /dev/null +++ b/cmd/webhooks/suspend.go @@ -0,0 +1,38 @@ +package webhooks + +import ( + "fmt" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +func newWebhooksSuspendCommand() *cli.Command { + return &cli.Command{ + Name: "suspend", + Usage: "Suspend a webhook endpoint", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "endpoint", Usage: "Webhook endpoint ID"}, + }, + Action: func(c *cli.Context) error { + client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + endpointID, err := resolveEndpoint(c, client) + if err != nil { + return err + } + + if err := client.SuspendWebhookEndpoint(endpointID); err != nil { + return err + } + + pterm.Success.Println("Webhook endpoint suspended.") + return nil + }, + } +} diff --git a/cmd/webhooks/webhooks.go b/cmd/webhooks/webhooks.go new file mode 100644 index 0000000..5b2052a --- /dev/null +++ b/cmd/webhooks/webhooks.go @@ -0,0 +1,19 @@ +package webhooks + +import "github.com/urfave/cli/v2" + +// NewWebhooksCommand returns the top-level "webhooks" command with its subcommands. +func NewWebhooksCommand() *cli.Command { + return &cli.Command{ + Name: "webhooks", + Usage: "Manage webhook endpoints", + Subcommands: []*cli.Command{ + newWebhooksListCommand(), + newWebhooksGetCommand(), + newWebhooksCreateCommand(), + newWebhooksDeleteCommand(), + newWebhooksSuspendCommand(), + newWebhooksResumeCommand(), + }, + } +} diff --git a/internal/api/webhooks.go b/internal/api/webhooks.go new file mode 100644 index 0000000..3d19651 --- /dev/null +++ b/internal/api/webhooks.go @@ -0,0 +1,153 @@ +package api + +import ( + "encoding/json" + "time" +) + +// WebhookEndpoint represents a user-created webhook endpoint. +type WebhookEndpoint struct { + ID string `json:"id"` + UserID string `json:"userId"` + URL string `json:"url"` + Events []string `json:"events"` + Active bool `json:"active"` + FailureCount int `json:"failureCount"` + CreatedAt time.Time `json:"createdAt"` +} + +// WebhookDelivery represents a delivery attempt for a webhook. +type WebhookDelivery struct { + ID string `json:"id"` + EndpointID string `json:"endpointId"` + AuditLogID string `json:"auditLogId"` + EventAction string `json:"eventAction"` + Payload json.RawMessage `json:"payload"` + Status string `json:"status"` + Attempts int `json:"attempts"` + LastError *string `json:"lastError"` + NextAttemptAt time.Time `json:"nextAttemptAt"` + CreatedAt time.Time `json:"createdAt"` + DeliveredAt *time.Time `json:"deliveredAt"` +} + +// CreateWebhookEndpointRequest is the request body for creating a webhook endpoint. +type CreateWebhookEndpointRequest struct { + URL string `json:"url"` + Events []string `json:"events"` +} + +// CreateWebhookEndpointResponse is returned after creating a webhook endpoint. +type CreateWebhookEndpointResponse struct { + ID string `json:"id"` + URL string `json:"url"` + Secret string `json:"secret"` + Events []string `json:"events"` + Active bool `json:"active"` +} + +// GetWebhookEndpointResponse wraps an endpoint with its recent deliveries. +type GetWebhookEndpointResponse struct { + Endpoint WebhookEndpoint `json:"endpoint"` + Deliveries []WebhookDelivery `json:"deliveries"` +} + +// ListWebhookEndpoints returns all webhook endpoints for the authenticated user. +func (c *APIClient) ListWebhookEndpoints() ([]WebhookEndpoint, error) { + var result Response[[]WebhookEndpoint] + resp, err := c.Client.R(). + SetResult(&result). + Get("/v1/webhook-endpoints") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return result.Data, nil +} + +// GetWebhookEndpoint returns a single webhook endpoint with its recent deliveries. +func (c *APIClient) GetWebhookEndpoint(id string) (*GetWebhookEndpointResponse, error) { + var result Response[GetWebhookEndpointResponse] + resp, err := c.Client.R(). + SetResult(&result). + Get("/v1/webhook-endpoints/" + id) + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &result.Data, nil +} + +// CreateWebhookEndpoint creates a new webhook endpoint and returns the response including the signing secret. +func (c *APIClient) CreateWebhookEndpoint(req CreateWebhookEndpointRequest) (*CreateWebhookEndpointResponse, error) { + var result Response[CreateWebhookEndpointResponse] + resp, err := c.Client.R(). + SetResult(&result). + SetBody(req). + Post("/v1/webhook-endpoints") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &result.Data, nil +} + +// DeleteWebhookEndpoint deletes a webhook endpoint by ID. +func (c *APIClient) DeleteWebhookEndpoint(id string) error { + resp, err := c.Client.R(). + Delete("/v1/webhook-endpoints/" + id) + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// SuspendWebhookEndpoint suspends a webhook endpoint. +func (c *APIClient) SuspendWebhookEndpoint(id string) error { + resp, err := c.Client.R(). + Post("/v1/webhook-endpoints/" + id + "/suspend") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// ResumeWebhookEndpoint resumes a suspended webhook endpoint. +func (c *APIClient) ResumeWebhookEndpoint(id string) error { + resp, err := c.Client.R(). + Post("/v1/webhook-endpoints/" + id + "/resume") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// ListWebhookActions returns all supported webhook event action names. +func (c *APIClient) ListWebhookActions() ([]string, error) { + var result Response[[]string] + resp, err := c.Client.R(). + SetResult(&result). + Get("/v1/webhook-endpoints/actions") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return result.Data, nil +}