Skip to content

Commit 920b142

Browse files
feat: webhook and authn via ENV (#56)
* feat: webhook and authn via ENV * chore: fix lint
1 parent 8e3f009 commit 920b142

10 files changed

Lines changed: 644 additions & 0 deletions

File tree

cmd/root/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/NodeOps-app/createos-cli/cmd/users"
3030
versioncmd "github.com/NodeOps-app/createos-cli/cmd/version"
3131
"github.com/NodeOps-app/createos-cli/cmd/vms"
32+
"github.com/NodeOps-app/createos-cli/cmd/webhooks"
3233
"github.com/NodeOps-app/createos-cli/internal/api"
3334
"github.com/NodeOps-app/createos-cli/internal/config"
3435
"github.com/NodeOps-app/createos-cli/internal/intro"
@@ -181,6 +182,7 @@ func NewApp() *cli.App {
181182
fmt.Println(" status Show project health and deployment status")
182183
fmt.Println(" templates Browse and scaffold from project templates")
183184
fmt.Println(" vms Manage VM terminal instances")
185+
fmt.Println(" webhooks Manage webhook endpoints")
184186
fmt.Println(" whoami Show the currently authenticated user")
185187
} else {
186188
fmt.Println(" login Authenticate with CreateOS")
@@ -219,6 +221,7 @@ func NewApp() *cli.App {
219221
upgrade.NewUpgradeCommand(),
220222
users.NewUsersCommand(),
221223
vms.NewVMsCommand(),
224+
webhooks.NewWebhooksCommand(),
222225
auth.NewWhoamiCommand(),
223226
versioncmd.NewVersionCommand(),
224227
},

cmd/webhooks/create.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package webhooks
2+
3+
import (
4+
"fmt"
5+
6+
atomickeys "atomicgo.dev/keyboard/keys"
7+
"github.com/pterm/pterm"
8+
"github.com/urfave/cli/v2"
9+
10+
"github.com/NodeOps-app/createos-cli/internal/api"
11+
"github.com/NodeOps-app/createos-cli/internal/output"
12+
"github.com/NodeOps-app/createos-cli/internal/terminal"
13+
)
14+
15+
func newWebhooksCreateCommand() *cli.Command {
16+
return &cli.Command{
17+
Name: "create",
18+
Usage: "Create a new webhook endpoint",
19+
Description: `Create a webhook endpoint that receives event notifications via HTTP POST.
20+
21+
Examples:
22+
# Subscribe to all events:
23+
createos webhooks create --url https://example.com/webhook
24+
25+
# Subscribe to specific events:
26+
createos webhooks create --url https://example.com/webhook \
27+
--event sandbox.create --event sandbox.destroy
28+
29+
# Interactive mode (TTY only):
30+
createos webhooks create`,
31+
Flags: []cli.Flag{
32+
&cli.StringFlag{Name: "url", Usage: "HTTPS URL to receive webhook events"},
33+
&cli.StringSliceFlag{Name: "event", Usage: "Event to subscribe to (repeatable, omit for all events)"},
34+
},
35+
Action: func(c *cli.Context) error {
36+
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
37+
if !ok {
38+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
39+
}
40+
41+
webhookURL := c.String("url")
42+
events := c.StringSlice("event")
43+
44+
if !terminal.IsInteractive() {
45+
if webhookURL == "" {
46+
return fmt.Errorf("please provide a URL with --url")
47+
}
48+
} else {
49+
if webhookURL == "" {
50+
var err error
51+
webhookURL, err = pterm.DefaultInteractiveTextInput.
52+
WithDefaultText("Webhook URL").
53+
Show()
54+
if err != nil {
55+
return fmt.Errorf("could not read URL: %w", err)
56+
}
57+
}
58+
if len(events) == 0 {
59+
actions, err := client.ListWebhookActions()
60+
if err != nil {
61+
return fmt.Errorf("could not load available events: %w", err)
62+
}
63+
64+
fmt.Println("Select events to subscribe to (leave empty for all events):")
65+
selected, selErr := pterm.DefaultInteractiveMultiselect.
66+
WithOptions(actions).
67+
WithDefaultText("Events").
68+
WithFilter(false).
69+
WithKeySelect(atomickeys.Space).
70+
WithKeyConfirm(atomickeys.Enter).
71+
WithCheckmark(&pterm.Checkmark{Checked: "x", Unchecked: " "}).
72+
Show()
73+
if selErr != nil {
74+
return fmt.Errorf("could not read selection: %w", selErr)
75+
}
76+
events = selected
77+
}
78+
}
79+
80+
if webhookURL == "" {
81+
return fmt.Errorf("URL cannot be empty")
82+
}
83+
84+
req := api.CreateWebhookEndpointRequest{
85+
URL: webhookURL,
86+
Events: events,
87+
}
88+
89+
result, err := client.CreateWebhookEndpoint(req)
90+
if err != nil {
91+
return err
92+
}
93+
94+
if output.IsJSON(c) {
95+
output.Render(c, result, func() {})
96+
return nil
97+
}
98+
99+
pterm.Success.Printf("Webhook endpoint created. ID: %s\n", result.ID)
100+
fmt.Println()
101+
label := pterm.NewStyle(pterm.FgCyan)
102+
fmt.Printf("%s %s\n", label.Sprint("Secret:"), result.Secret)
103+
fmt.Println()
104+
pterm.Println(pterm.Gray(" Save this secret — it won't be shown again."))
105+
pterm.Println(pterm.Gray(" Use it to verify webhook signatures (X-Webhook-Signature header)."))
106+
107+
return nil
108+
},
109+
}
110+
}

cmd/webhooks/delete.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package webhooks
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/pterm/pterm"
7+
"github.com/urfave/cli/v2"
8+
9+
"github.com/NodeOps-app/createos-cli/internal/api"
10+
"github.com/NodeOps-app/createos-cli/internal/terminal"
11+
)
12+
13+
func newWebhooksDeleteCommand() *cli.Command {
14+
return &cli.Command{
15+
Name: "delete",
16+
Usage: "Delete a webhook endpoint",
17+
Flags: []cli.Flag{
18+
&cli.StringFlag{Name: "endpoint", Usage: "Webhook endpoint ID"},
19+
&cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "Skip confirmation prompt"},
20+
},
21+
Action: func(c *cli.Context) error {
22+
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
23+
if !ok {
24+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
25+
}
26+
27+
endpointID, err := resolveEndpoint(c, client)
28+
if err != nil {
29+
return err
30+
}
31+
32+
if !c.Bool("force") {
33+
if !terminal.IsInteractive() {
34+
return fmt.Errorf("confirmation required — use --force to delete without a prompt")
35+
}
36+
37+
confirm, confirmErr := pterm.DefaultInteractiveConfirm.
38+
WithDefaultText(fmt.Sprintf("Are you sure you want to delete webhook endpoint %q?", endpointID)).
39+
WithDefaultValue(false).
40+
Show()
41+
if confirmErr != nil {
42+
return fmt.Errorf("could not read confirmation: %w", confirmErr)
43+
}
44+
if !confirm {
45+
fmt.Println("Cancelled. Your webhook endpoint was not deleted.")
46+
return nil
47+
}
48+
}
49+
50+
if err := client.DeleteWebhookEndpoint(endpointID); err != nil {
51+
return err
52+
}
53+
54+
pterm.Success.Println("Webhook endpoint deleted.")
55+
return nil
56+
},
57+
}
58+
}

cmd/webhooks/get.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package webhooks
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/pterm/pterm"
8+
"github.com/urfave/cli/v2"
9+
10+
"github.com/NodeOps-app/createos-cli/internal/api"
11+
"github.com/NodeOps-app/createos-cli/internal/output"
12+
)
13+
14+
func newWebhooksGetCommand() *cli.Command {
15+
return &cli.Command{
16+
Name: "get",
17+
Usage: "Show details for a webhook endpoint",
18+
ArgsUsage: "<endpoint-id>",
19+
Flags: []cli.Flag{
20+
&cli.StringFlag{Name: "endpoint", Usage: "Webhook endpoint ID"},
21+
},
22+
Action: func(c *cli.Context) error {
23+
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
24+
if !ok {
25+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
26+
}
27+
28+
endpointID, err := resolveEndpoint(c, client)
29+
if err != nil {
30+
return err
31+
}
32+
33+
result, err := client.GetWebhookEndpoint(endpointID)
34+
if err != nil {
35+
return err
36+
}
37+
38+
output.Render(c, result, func() {
39+
ep := result.Endpoint
40+
label := pterm.NewStyle(pterm.FgCyan)
41+
42+
fmt.Printf("%s %s\n", label.Sprint("ID:"), ep.ID)
43+
fmt.Printf("%s %s\n", label.Sprint("URL:"), ep.URL)
44+
fmt.Printf("%s %s\n", label.Sprint("Status:"), statusIcon(ep.Active))
45+
events := "* (all events)"
46+
if len(ep.Events) > 0 {
47+
events = strings.Join(ep.Events, ", ")
48+
}
49+
fmt.Printf("%s %s\n", label.Sprint("Events:"), events)
50+
fmt.Printf("%s %d\n", label.Sprint("Failures:"), ep.FailureCount)
51+
fmt.Printf("%s %s\n", label.Sprint("Created:"), ep.CreatedAt.Format("2006-01-02 15:04:05 UTC"))
52+
fmt.Println()
53+
54+
if len(result.Deliveries) == 0 {
55+
fmt.Println("No recent deliveries.")
56+
return
57+
}
58+
59+
fmt.Println("Recent Deliveries:")
60+
tableData := pterm.TableData{
61+
{"ID", "Event", "Status", "Attempts", "Created"},
62+
}
63+
for _, d := range result.Deliveries {
64+
tableData = append(tableData, []string{
65+
d.ID,
66+
d.EventAction,
67+
deliveryStatusIcon(d.Status),
68+
fmt.Sprintf("%d", d.Attempts),
69+
d.CreatedAt.Format("2006-01-02 15:04"),
70+
})
71+
}
72+
_ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck
73+
fmt.Println()
74+
})
75+
return nil
76+
},
77+
}
78+
}
79+
80+
func deliveryStatusIcon(status string) string {
81+
switch status {
82+
case "delivered":
83+
return pterm.Green("delivered")
84+
case "failed":
85+
return pterm.Red("failed")
86+
default:
87+
return pterm.Yellow("pending")
88+
}
89+
}

cmd/webhooks/helpers.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package webhooks
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/pterm/pterm"
7+
"github.com/urfave/cli/v2"
8+
9+
"github.com/NodeOps-app/createos-cli/internal/api"
10+
"github.com/NodeOps-app/createos-cli/internal/terminal"
11+
)
12+
13+
// resolveEndpoint resolves a webhook endpoint ID from the --endpoint flag,
14+
// the first positional arg, or an interactive picker (TTY only).
15+
func resolveEndpoint(c *cli.Context, client *api.APIClient) (string, error) {
16+
if id := c.String("endpoint"); id != "" {
17+
return id, nil
18+
}
19+
if c.Args().Len() > 0 {
20+
return c.Args().First(), nil
21+
}
22+
23+
if !terminal.IsInteractive() {
24+
return "", fmt.Errorf(
25+
"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",
26+
c.Command.Name,
27+
)
28+
}
29+
30+
return pickEndpoint(client)
31+
}
32+
33+
// pickEndpoint interactively selects a webhook endpoint from the user's list.
34+
func pickEndpoint(client *api.APIClient) (string, error) {
35+
endpoints, err := client.ListWebhookEndpoints()
36+
if err != nil {
37+
return "", err
38+
}
39+
if len(endpoints) == 0 {
40+
return "", fmt.Errorf("you don't have any webhook endpoints yet\n\n Create one with:\n createos webhooks create")
41+
}
42+
if len(endpoints) == 1 {
43+
return endpoints[0].ID, nil
44+
}
45+
46+
options := make([]string, len(endpoints))
47+
for i, ep := range endpoints {
48+
status := "active"
49+
if !ep.Active {
50+
status = "suspended"
51+
}
52+
options[i] = fmt.Sprintf("%s %s (%s)", ep.URL, ep.ID[:8], status)
53+
}
54+
55+
selected, err := pterm.DefaultInteractiveSelect.
56+
WithOptions(options).
57+
WithDefaultText("Select a webhook endpoint").
58+
Show()
59+
if err != nil {
60+
return "", fmt.Errorf("could not read selection: %w", err)
61+
}
62+
for i, opt := range options {
63+
if opt == selected {
64+
return endpoints[i].ID, nil
65+
}
66+
}
67+
return "", fmt.Errorf("no endpoint selected")
68+
}

0 commit comments

Comments
 (0)