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
2 changes: 1 addition & 1 deletion cli/.goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# goreleaser config for the hookdrop CLI.
# Released locally via scripts/release.sh (see RELEASING.md) — no CI.
# Released locally via scripts/release.sh (see RELEASING.md). No CI.
# The backend is deployed separately by scripts/deploy.sh and never tagged.
version: 2

Expand Down
8 changes: 4 additions & 4 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ hookdrop listen my-slug -f 3000 # stream webhooks + forward to localhost:30

## Install

### curl (recommended one line, no extra steps)
### curl (recommended, one line, no extra steps)

```sh
curl -fsSL https://raw.githubusercontent.com/EOEboh/hookdrop/main/scripts/install.sh | sh
Expand All @@ -41,7 +41,7 @@ fails with "Refusing to load formula … from untrusted tap".)

Download the archive from [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;
on Windows, so stop it first. On macOS/Linux, upgrading while running is fine;
the active session keeps the old binary until it exits.)

## Use
Expand Down Expand Up @@ -69,8 +69,8 @@ Shell completions are available via `hookdrop completion bash|zsh|fish|powershel

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.
`Content-Length`, …) are regenerated, everything else, including provider
signature headers, is preserved byte-for-byte.

## Config

Expand Down
10 changes: 5 additions & 5 deletions cli/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ var listenCmd = &cobra.Command{
Short: "Stream webhooks live into your terminal (and forward them locally)",
Long: `Streams every webhook hitting your hookdrop endpoint into the terminal,
one line per event. With -f/--forward, each webhook is also re-sent to a local
server — the way the web UI's replay works so your dev server receives traffic
server. The way the web UI's replay works, so your dev server receives traffic
the hosted backend can't deliver directly.

The endpoint is optional: with one named endpoint it's picked automatically,
Expand Down Expand Up @@ -169,7 +169,7 @@ func resolveEndpoint(ctx context.Context, client *api.Client, explicit, frontend
for _, e := range endpoints {
slugs = append(slugs, e.Slug)
}
return "", fmt.Errorf("multiple endpoints (%s) — specify one: hookdrop listen <slug>", strings.Join(slugs, ", "))
return "", fmt.Errorf("multiple endpoints (%s). Specify one: hookdrop listen <slug>", strings.Join(slugs, ", "))
}
return pickEndpoint(endpoints)
}
Expand Down Expand Up @@ -229,7 +229,7 @@ func runListen(ctx context.Context, apiURL, token, endpoint, forwardURL string)
}
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)"))
printer.Line(printer.Status("⚠ forward queue full. Dropped " + output.ShortID(req.ID) + " (still visible in the dashboard)"))
}
}
}
Expand All @@ -251,7 +251,7 @@ func runListen(ctx context.Context, apiURL, token, endpoint, forwardURL string)
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")
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", endpoint)
case errors.Is(err, api.ErrPaymentRequired):
Expand All @@ -264,7 +264,7 @@ func runListen(ctx context.Context, apiURL, token, endpoint, forwardURL string)
}
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)))
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):
Expand Down
6 changes: 3 additions & 3 deletions cli/cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ or --no-browser on headless machines.`,
if token == "" && !loginNoBrowser {
token, err = browserLogin(cmd.Context(), cfg.FrontendURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Browser login didn't complete (%v) — falling back to manual entry.\n", err)
fmt.Fprintf(os.Stderr, "Browser login didn't complete (%v). Falling back to manual entry.\n", err)
}
}
if token == "" {
Expand Down Expand Up @@ -110,7 +110,7 @@ func browserLogin(ctx context.Context, frontendURL string) (string, error) {

fmt.Printf("Opening your browser to authorize the CLI…\n %s\n", authURL)
if err := openBrowser(authURL); err != nil {
fmt.Fprintln(os.Stderr, "Couldn't open a browser automatically — open the URL above manually.")
fmt.Fprintln(os.Stderr, "Couldn't open a browser automatically. Open the URL above manually.")
}
fmt.Println("Waiting for authorization…")

Expand All @@ -135,7 +135,7 @@ func waitForCallback(ctx context.Context, listener net.Listener, state string, t
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<!doctype html><html><body style="font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0">
<div style="text-align: center"><h2>✓ You're logged in</h2><p>Return to your terminal — you can close this tab.</p></div>
<div style="text-align: center"><h2>✓ You're logged in</h2><p>Return to your terminal. You can close this tab.</p></div>
</body></html>`)
// Flush before signaling: the wait returns and closes the server as
// soon as it has the token, which would race the buffered response.
Expand Down
4 changes: 2 additions & 2 deletions cli/cmd/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,12 @@ func TestWaitForCallbackTimesOut(t *testing.T) {
t.Fatal("expected timeout error")
}
if elapsed := time.Since(start); elapsed < 150*time.Millisecond {
t.Fatalf("returned too early (%v) — timeout not honored", elapsed)
t.Fatalf("returned too early (%v). Timeout not honored", elapsed)
}
}

// Two listeners bound back-to-back get distinct OS-assigned ports and each
// delivers independently — demonstrating the port-0 design has no
// delivers independently. Demonstrating the port-0 design has no
// fixed-port collision even with concurrent logins.
func TestConcurrentCallbacksDistinctPorts(t *testing.T) {
l1, l2 := listen(t), listen(t)
Expand Down
2 changes: 1 addition & 1 deletion cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ var rootCmd = &cobra.Command{
Use: "hookdrop",
Short: "Stream and forward your hookdrop webhooks from the terminal",
Long: `hookdrop streams webhooks captured at hookdrop.app into your terminal and
forwards each one to a local server the hosted backend can't reach directly
forwards each one to a local server the hosted backend can't reach directly.
a local webhook forwarder for developing against real webhook traffic.`,
Example: ` hookdrop login # authenticate (opens your browser)
hookdrop listen my-slug -f 3000 # stream + forward to localhost:3000
Expand Down
2 changes: 1 addition & 1 deletion cli/internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package api
import "time"

// Wire types mirroring the backend's JSON responses. Source of truth:
// internal/models/request.go in the backend module — keep field names and
// internal/models/request.go in the backend module. Keep field names and
// JSON tags in sync (internal/ packages can't be imported across modules).

// CapturedRequest is one webhook as delivered over SSE and /requests.
Expand Down
8 changes: 4 additions & 4 deletions cli/internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Package config manages the CLI's local configuration file, which holds
// the API token — permissions are locked down and writes are atomic.
// the API token. Permissions are locked down and writes are atomic.
package config

import (
Expand Down Expand Up @@ -50,12 +50,12 @@ func Load() (*Config, error) {
data, err := os.ReadFile(path)
switch {
case errors.Is(err, os.ErrNotExist):
// fresh install — fall through to defaults
// fresh install. Fall through to defaults
case err != nil:
return nil, fmt.Errorf("read config %s: %w", path, err)
default:
if jsonErr := json.Unmarshal(data, cfg); jsonErr != nil {
return nil, fmt.Errorf("config file %s is corrupt (%v) — run 'hookdrop login' to recreate it", path, jsonErr)
return nil, fmt.Errorf("config file %s is corrupt (%v). Run 'hookdrop login' to recreate it", path, jsonErr)
}
warnLoosePermissions(path)
}
Expand Down Expand Up @@ -123,7 +123,7 @@ func warnLoosePermissions(path string) {
}
if info.Mode().Perm()&0o077 != 0 {
fmt.Fprintf(os.Stderr,
"warning: %s is readable by other users (mode %o) — consider: chmod 600 %s\n",
"warning: %s is readable by other users (mode %o). Consider: chmod 600 %s\n",
path, info.Mode().Perm(), path)
}
}
4 changes: 2 additions & 2 deletions cli/internal/forward/forwarder.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func (f *Forwarder) Start(ctx context.Context) {
}

// Enqueue adds a webhook to the delivery queue. Returns false when the
// queue is full — callers should surface the drop, never block the SSE
// queue is full. Callers should surface the drop, never block the SSE
// reader on a slow local server.
func (f *Forwarder) Enqueue(req *api.CapturedRequest) bool {
select {
Expand Down Expand Up @@ -115,7 +115,7 @@ func (f *Forwarder) deliver(ctx context.Context, original *api.CapturedRequest)

// shouldSkipHeader filters headers that break or are meaningless when
// forwarded. Parity port of shouldSkipHeader in the backend's
// internal/replay/engine.go — keep the two lists identical so behavior
// internal/replay/engine.go. Keep the two lists identical so behavior
// matches whether a request is replayed from the web UI or forwarded live.
func shouldSkipHeader(key string) bool {
skip := map[string]bool{
Expand Down
2 changes: 1 addition & 1 deletion cli/internal/forward/forwarder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func TestDeliverUnreachableTarget(t *testing.T) {

func TestEnqueueOverflowDropsInsteadOfBlocking(t *testing.T) {
f := New("http://127.0.0.1:1", func(Result) {})
// worker not started — queue just fills
// worker not started. Queue just fills
for i := 0; i < queueSize; i++ {
if !f.Enqueue(&api.CapturedRequest{}) {
t.Fatalf("enqueue %d should succeed", i)
Expand Down
4 changes: 2 additions & 2 deletions cli/internal/output/printer.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func (p *Printer) Event(req *api.CapturedRequest, showID bool) string {

// ForwardResult renders the delivery outcome, indented under its event:
//
// ↳ (4dbb48bd) 200 in 45ms
// ↳ (4dbb48bd) 200 in 45ms
func (p *Printer) ForwardResult(res forward.Result) string {
id := Colorize(p.Colors, Dim, "("+ShortID(res.Request.ID)+")")
if res.Err != nil {
Expand Down Expand Up @@ -105,7 +105,7 @@ func (p *Printer) Status(s string) string {
func (p *Printer) Ready(inboxURL, forwardURL string) string {
check := Colorize(p.Colors, Green, "✓")
var b strings.Builder
fmt.Fprintf(&b, "%s Ready — listening on %s\n", check, Colorize(p.Colors, Bold, inboxURL))
fmt.Fprintf(&b, "%s Ready. Listening on %s\n", check, Colorize(p.Colors, Bold, inboxURL))
if forwardURL != "" {
fmt.Fprintf(&b, " → forwarding to %s\n", Colorize(p.Colors, Bold, forwardURL))
}
Expand Down
8 changes: 4 additions & 4 deletions cli/internal/sseclient/client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package sseclient is a minimal Server-Sent Events client for the hookdrop
// /events stream. The format is three line types: "event:", "data:", and
// ":" comments (keepalives) — no library needed.
// ":" comments (keepalives). No library needed.
package sseclient

import (
Expand Down Expand Up @@ -41,7 +41,7 @@ func New(baseURL, token string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
// No overall client timeout — the stream is long-lived. Individual
// No overall client timeout. The stream is long-lived. Individual
// phases are bounded instead; mid-stream silence is the watchdog's job.
http: &http.Client{
Transport: &http.Transport{
Expand Down Expand Up @@ -111,7 +111,7 @@ func (c *Client) Stream(ctx context.Context, identifier string, events chan<- Ev
}
}
case line[0] == ':':
// comment/keepalive — watchdog already reset
// comment/keepalive. Watchdog already reset
case bytes.HasPrefix(line, []byte("event:")):
eventName = string(bytes.TrimSpace(line[len("event:"):]))
case bytes.HasPrefix(line, []byte("data:")):
Expand All @@ -123,7 +123,7 @@ func (c *Client) Stream(ctx context.Context, identifier string, events chan<- Ev
}

if watchdogFired.Load() {
return fmt.Errorf("no data for %s — connection stale", watchdogTimeout)
return fmt.Errorf("no data for %s. Connection stale", watchdogTimeout)
}
if ctx.Err() != nil {
return ctx.Err()
Expand Down
4 changes: 2 additions & 2 deletions internal/billing/entitlements.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ const PastDueGrace = 7 * 24 * time.Hour
// IsActive reports whether a subscription is usable right now.
//
// The period is authoritative, not the status. Paystack does not retry a
// failed subscription charge "when a payment attempt fails, it will not be
// attempted again" so a lapsed subscription simply stops producing events
// failed subscription charge: "when a payment attempt fails, it will not be
// attempted again", so a lapsed subscription simply stops producing events
// and sits at status "active" forever. Trusting the status alone handed those
// customers Pro indefinitely.
func IsActive(status string, periodEnd *time.Time) bool {
Expand Down
18 changes: 9 additions & 9 deletions internal/billing/lemonsqueezy.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func (p *LemonSqueezyProvider) CreateCheckout(

// Lemonsqueezy uses JSON:API format.
//
// store_id and variant_id are READ-ONLY response attributes — the only
// store_id and variant_id are READ-ONLY response attributes. The only
// attributes accepted on create are custom_price, product_options,
// checkout_options, checkout_data, preview, test_mode and expires_at.
// The store and variant are addressed through relationships.
Expand All @@ -142,7 +142,7 @@ func (p *LemonSqueezyProvider) CreateCheckout(
},

// The free trial is configured on the variant in the
// Lemonsqueezy dashboard — there is no per-checkout trial
// Lemonsqueezy dashboard. There is no per-checkout trial
// override. checkout_options.skip_trial would REMOVE it, so
// it is deliberately not set here.
"checkout_options": map[string]interface{}{
Expand Down Expand Up @@ -243,8 +243,8 @@ func (p *LemonSqueezyProvider) GetPortalURL(

url := result.Data.Attributes.URLs.CustomerPortal
if url == "" {
// Do not silently bounce the user back to the page they came from
// that hides a real failure behind a no-op redirect.
// Do not silently bounce the user back to the page they came from.
// That hides a real failure behind a no-op redirect.
return "", fmt.Errorf(
"lemonsqueezy customer %s returned no customer_portal URL", customerID)
}
Expand All @@ -256,8 +256,8 @@ func (p *LemonSqueezyProvider) GetPortalURL(
// Every other Lemonsqueezy event carries a DIFFERENT object shape:
// order_created sends an Order (no top-level variant_id, status "paid"),
// subscription_payment_* send a Subscription invoice. Parsing either as a
// subscription writes a garbage row — plan "free", status "paid" and an order
// ID in provider_sub_id over a paying customer.
// subscription writes a garbage row. Plan "free", status "paid" and an order
// ID in provider_sub_id, over a paying customer.
var lsSubscriptionEvents = map[string]bool{
"subscription_created": true,
"subscription_updated": true,
Expand Down Expand Up @@ -294,8 +294,8 @@ func (p *LemonSqueezyProvider) HandleWebhook(
// Parse the event envelope.
//
// Note there is deliberately no first_subscription_item here: it is null
// while the subscription is on trial, and it carries no interval field
// the interval is derived from variant_id instead.
// while the subscription is on trial, and it carries no interval field.
// The interval is derived from variant_id instead.
var envelope struct {
Meta struct {
EventName string `json:"event_name"`
Expand Down Expand Up @@ -329,7 +329,7 @@ func (p *LemonSqueezyProvider) HandleWebhook(
}

if !lsSubscriptionEvents[envelope.Meta.EventName] {
// Not a subscription event nothing to persist.
// Not a subscription event, so there is nothing to persist.
return nil, nil
}

Expand Down
12 changes: 6 additions & 6 deletions internal/billing/lemonsqueezy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func handle(t *testing.T, payload string) (*WebhookEvent, error) {
}

// subscriptionPayload mirrors the shape Lemonsqueezy actually sends for
// subscription events. Note first_subscription_item is null — that is what a
// subscription events. Note first_subscription_item is null. That is what a
// subscription on a free trial looks like.
const trialCreatedPayload = `{
"meta": {
Expand Down Expand Up @@ -100,7 +100,7 @@ func TestHandleWebhook_TrialSubscriptionCreated(t *testing.T) {
if ev.Status != "trialing" {
t.Errorf("Status = %q, want trialing", ev.Status)
}
// The customer ID must be the LS customer, not the order or subscription
// The customer ID must be the LS customer, not the order or subscription.
// GetPortalURL calls GET /customers/{id} with it.
if ev.CustomerID != "4210987" {
t.Errorf("CustomerID = %q, want 4210987 (customer_id, not order_id)", ev.CustomerID)
Expand All @@ -112,7 +112,7 @@ func TestHandleWebhook_TrialSubscriptionCreated(t *testing.T) {
t.Errorf("Interval = %q, want month", ev.Interval)
}
if ev.TrialEnd == 0 {
t.Error("TrialEnd = 0, want the parsed trial_ends_at — a nil trial_end renders 'Trial ends soon' forever")
t.Error("TrialEnd = 0, want the parsed trial_ends_at. A nil trial_end renders 'Trial ends soon' forever")
}
if want := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC).Unix(); ev.TrialEnd != want {
t.Errorf("TrialEnd = %d, want %d", ev.TrialEnd, want)
Expand Down Expand Up @@ -172,7 +172,7 @@ func TestHandleWebhook_IgnoresNonSubscriptionEvents(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if ev != nil {
t.Fatalf("expected nil event, got %+v — this would corrupt the subscriptions row", ev)
t.Fatalf("expected nil event, got %+v. This would corrupt the subscriptions row", ev)
}
})
}
Expand All @@ -193,7 +193,7 @@ func TestHandleWebhook_CancelKeepsAccessUntilEndsAt(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if ev.Type == "subscription.canceled" {
t.Error("subscription_cancelled mapped to subscription.canceled — that drops the customer to free during a grace period they paid for")
t.Error("subscription_cancelled mapped to subscription.canceled. That drops the customer to free during a grace period they paid for")
}
if ev.Type != "subscription.updated" {
t.Errorf("Type = %q, want subscription.updated", ev.Type)
Expand Down Expand Up @@ -229,7 +229,7 @@ func TestHandleWebhook_ExpiredDowngrades(t *testing.T) {
t.Errorf("Type = %q, want subscription.canceled", ev.Type)
}
if ev.Status != "canceled" {
t.Errorf("Status = %q, want canceled — an expired subscription reported as active keeps Pro forever", ev.Status)
t.Errorf("Status = %q, want canceled. An expired subscription reported as active keeps Pro forever", ev.Status)
}
}

Expand Down
Loading
Loading