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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ ui/.env
.env.local
.env


# CLI build artifacts
cli/dist/
57 changes: 57 additions & 0 deletions cli/.goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# goreleaser config for the hookdrop CLI.
# Released on v* tags via .github/workflows/release.yml (workdir: cli).
# The backend is deployed separately by scripts/deploy.sh and never tagged.
version: 2

project_name: hookdrop

builds:
- main: .
binary: hookdrop
env:
- CGO_ENABLED=0
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}

archives:
- formats: [tar.gz]
name_template: "hookdrop_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]

checksum:
name_template: checksums.txt

changelog:
filters:
exclude:
- "^docs:"
- "^test:"

# goreleaser deprecates `brews` in favor of macOS-only `homebrew_casks`;
# we keep the formula so `brew install` also works on Homebrew-for-Linux.
brews:
- name: hookdrop
repository:
owner: EOEboh
name: homebrew-hookdrop
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
directory: Formula
homepage: https://hookdrop.app
description: Stream and forward hookdrop webhooks from your terminal
test: |
system "#{bin}/hookdrop --version"
72 changes: 72 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# hookdrop CLI

Stream webhooks captured by [hookdrop](https://hookdrop.app) into your
terminal, and forward each one to a local server the hosted backend can't
reach.

## Install

```sh
# Homebrew (macOS / Linux)
brew install EOEboh/hookdrop/hookdrop

# curl
curl -fsSL https://raw.githubusercontent.com/EOEboh/hookdrop/main/scripts/install.sh | sh
```

Windows binaries are on [GitHub Releases](https://github.com/EOEboh/hookdrop/releases).
(Note: replacing `hookdrop.exe` while a `listen` session is running will fail
on Windows — stop it first. On macOS/Linux, upgrading while running is fine;
the active session keeps the old binary until it exits.)

## Use

```sh
hookdrop login # browser-based; --token or --no-browser for headless
hookdrop whoami # account, plan, limits
hookdrop endpoints # list your named endpoints
hookdrop listen --endpoint my-slug # stream webhooks live
hookdrop listen --endpoint my-slug \
--forward http://localhost:3000/webhook # …and re-send each one locally
hookdrop logout
```

Forwarded requests carry `X-Hookdrop-Forwarded: true` and
`X-Hookdrop-Original-Id` headers; hop-by-hop headers (`Host`,
`Content-Length`, …) are regenerated, everything else — including provider
signature headers — is preserved byte-for-byte.

## Config

Stored at the OS config dir (`~/.config/hookdrop/config.json` on Linux,
`~/Library/Application Support/hookdrop/config.json` on macOS,
`%AppData%\hookdrop\config.json` on Windows), file mode 0600.

Environment overrides: `HOOKDROP_TOKEN`, `HOOKDROP_API_URL`,
`HOOKDROP_FRONTEND_URL`. `NO_COLOR` disables ANSI colors.

## Development

```sh
cd cli
CGO_ENABLED=0 go build -o hookdrop .
HOOKDROP_API_URL=http://localhost:8080 ./hookdrop login
```

Releases are run locally with goreleaser (no GitHub Actions required).
Tag the release, then run goreleaser from `cli/` with two free personal
access tokens in the environment:

```sh
git tag v0.1.0
git push origin v0.1.0

cd cli
export GITHUB_TOKEN=<PAT with Contents:write on EOEboh/hookdrop>
export HOMEBREW_TAP_GITHUB_TOKEN=<fine-grained PAT on EOEboh/homebrew-hookdrop>
go run github.com/goreleaser/goreleaser/v2@latest release --clean
```

This publishes the GitHub Release for darwin/linux (amd64+arm64) and
windows/amd64, uploads `checksums.txt`, and updates the Homebrew tap
`EOEboh/homebrew-hookdrop`. `--snapshot --skip=publish` does a dry run.
42 changes: 42 additions & 0 deletions cli/cmd/endpoints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmd

import (
"fmt"
"os"
"strings"
"text/tabwriter"

"github.com/spf13/cobra"
)

var endpointsCmd = &cobra.Command{
Use: "endpoints",
Short: "List your named endpoints (use a slug with 'hookdrop listen --endpoint')",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
client, cfg, err := loadClient()
if err != nil {
return err
}
endpoints, err := client.Endpoints(cmd.Context())
if err != nil {
return friendlyAuthErr(err)
}

if len(endpoints) == 0 {
fmt.Printf("No named endpoints yet. Create one at %s\n", strings.TrimRight(cfg.FrontendURL, "/"))
return nil
}

w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "SLUG\tNAME\tINBOX URL")
for _, ep := range endpoints {
fmt.Fprintf(w, "%s\t%s\t%s/i/%s\n", ep.Slug, ep.Name, strings.TrimRight(cfg.APIURL, "/"), ep.Slug)
}
return w.Flush()
},
}

func init() {
rootCmd.AddCommand(endpointsCmd)
}
155 changes: 155 additions & 0 deletions cli/cmd/listen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package cmd

import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/EOEboh/hookdrop/cli/internal/api"
"github.com/EOEboh/hookdrop/cli/internal/forward"
"github.com/EOEboh/hookdrop/cli/internal/output"
"github.com/EOEboh/hookdrop/cli/internal/sseclient"
"github.com/spf13/cobra"
)

const (
reconnectBaseDelay = time.Second
reconnectMaxDelay = 30 * time.Second
// a connection that survives this long resets the backoff
stableConnection = time.Minute
)

var (
listenEndpoint string
listenForward string
)

var listenCmd = &cobra.Command{
Use: "listen --endpoint <slug-or-session-id>",
Short: "Stream webhooks live into your terminal",
Long: `Streams every webhook hitting your hookdrop endpoint into the terminal,
one line per event. With --forward, each webhook is also re-sent to a local
URL — the same way the web UI's replay works — so your local dev server
receives traffic the hosted backend can't deliver directly.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if listenEndpoint == "" {
return errors.New("--endpoint is required. Run 'hookdrop endpoints' to list yours")
}
if listenForward != "" {
u, err := url.Parse(listenForward)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("--forward must be an http(s) URL, got %q", listenForward)
}
}

client, cfg, err := loadClient()
if err != nil {
return err
}

ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
defer stop()

return runListen(ctx, cfg.APIURL, client.Token)
},
}

func init() {
listenCmd.Flags().StringVar(&listenEndpoint, "endpoint", "", "named endpoint slug or temporary session ID")
listenCmd.Flags().StringVar(&listenForward, "forward", "", "forward each webhook to this local URL (e.g. http://localhost:3000/webhook)")
rootCmd.AddCommand(listenCmd)
}

func runListen(ctx context.Context, apiURL, token string) error {
printer := output.NewPrinter()
defer printer.Close()

forwarding := listenForward != ""
var fwd *forward.Forwarder
if forwarding {
fwd = forward.New(listenForward, func(res forward.Result) {
printer.Line(printer.ForwardResult(res))
})
fwd.Start(ctx)
}

events := make(chan sseclient.Event, 256)

// Renderer: the only consumer of the events channel
go func() {
for ev := range events {
switch ev.Name {
case "connected":
inbox := strings.TrimRight(apiURL, "/") + "/i/" + listenEndpoint
msg := "Listening on " + inbox
if forwarding {
msg += " → forwarding to " + listenForward
}
printer.Line(printer.Status(msg))
case "request":
var req api.CapturedRequest
if err := json.Unmarshal(ev.Data, &req); err != nil {
printer.Line(printer.Status("skipped an event the CLI couldn't parse: " + err.Error()))
continue
}
printer.Line(printer.Event(&req, forwarding))
if forwarding && !fwd.Enqueue(&req) {
printer.Line(printer.Status("⚠ forward queue full — dropped " + output.ShortID(req.ID) + " (still visible in the dashboard)"))
}
}
}
}()
defer close(events)

// Connection loop: reconnect forever on transient failures, stop cleanly
// on auth/ownership errors or ctrl-c.
sse := sseclient.New(apiURL, token)
delay := reconnectBaseDelay
attempt := 0

for {
connectedAt := time.Now()
err := sse.Stream(ctx, listenEndpoint, events)

switch {
case ctx.Err() != nil:
printer.Line(printer.Status("stopped"))
return nil
case errors.Is(err, api.ErrUnauthorized):
return errors.New("your session is no longer valid — the token may have been revoked. Run 'hookdrop login' again")
case errors.Is(err, api.ErrNotFound):
return fmt.Errorf("endpoint %q not found on your account (it may have expired if it was a temporary session). Run 'hookdrop endpoints' to see yours", listenEndpoint)
case errors.Is(err, api.ErrPaymentRequired):
return friendlyAuthErr(err)
}

if time.Since(connectedAt) > stableConnection {
delay = reconnectBaseDelay
attempt = 0
}
attempt++
jittered := delay + time.Duration(rand.Int63n(int64(delay/2+1)))
printer.Line(printer.Status(fmt.Sprintf("⟳ connection lost (%v) — reconnecting in %s (attempt %d)", err, jittered.Round(time.Second), attempt)))

select {
case <-time.After(jittered):
case <-ctx.Done():
printer.Line(printer.Status("stopped"))
return nil
}

delay *= 2
if delay > reconnectMaxDelay {
delay = reconnectMaxDelay
}
}
}
Loading