Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -219,6 +221,7 @@ func NewApp() *cli.App {
upgrade.NewUpgradeCommand(),
users.NewUsersCommand(),
vms.NewVMsCommand(),
webhooks.NewWebhooksCommand(),
auth.NewWhoamiCommand(),
versioncmd.NewVersionCommand(),
},
Expand Down
110 changes: 110 additions & 0 deletions cmd/webhooks/create.go
Original file line number Diff line number Diff line change
@@ -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
},
}
}
58 changes: 58 additions & 0 deletions cmd/webhooks/delete.go
Original file line number Diff line number Diff line change
@@ -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
},
}
}
89 changes: 89 additions & 0 deletions cmd/webhooks/get.go
Original file line number Diff line number Diff line change
@@ -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: "<endpoint-id>",
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")
}
}
68 changes: 68 additions & 0 deletions cmd/webhooks/helpers.go
Original file line number Diff line number Diff line change
@@ -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 <endpoint-id>\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")
}
Loading