Skip to content
Open
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
65 changes: 64 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,26 @@ hookdeck login --interactive
Start a session to forward your events to an HTTP server.

```sh
hookdeck listen <port-or-URL> <source-alias?> <connection-query?> [--path?]
hookdeck listen <port-or-URL> <source-alias?> <connection-query?> [--path?] [--output?]
```

Hookdeck works by routing events received for a given `source` (i.e., Shopify, Github, etc.) to its defined `destination` by connecting them with a `connection` to a `destination`. The CLI allows you to receive events for any given connection and forward them to your localhost at the specified port or any valid URL.

Each `source` is assigned an Event URL, which you can use to receive events. When starting with a fresh account, the CLI will prompt you to create your first source. Each CLI process can listen to one source at a time.

#### Interactive Keyboard Shortcuts

While the listen command is running, you can use the following keyboard shortcuts:

- `↑` / `↓` - Navigate between events (select different events)
- `r` - Retry the selected event
- `o` - Open the selected event in the Hookdeck dashboard
- `d` - Show detailed request information for the selected event (headers, body, etc.)
- `q` - Quit the application
- `Ctrl+C` - Also quits the application

The selected event is indicated by a `>` character at the beginning of the line. All actions (retry, open, details) work on the currently selected event, not just the latest one. These shortcuts are displayed in the status line at the bottom of the terminal.

Contrary to ngrok, **Hookdeck does not allow to append a path to your event URL**. Instead, the routing is done within Hookdeck configuration. This means you will also be prompted to specify your `destination` path, and you can have as many as you want per `source`.

> The `port-or-URL` param is mandatory, events will be forwarded to http://localhost:$PORT/$DESTINATION_PATH when inputing a valid port or your provided URL.
Expand Down Expand Up @@ -205,6 +218,56 @@ Orders Service forwarding to /events/shopify/orders

```

#### Controlling output verbosity

The `--output` flag controls how events are displayed. This is useful for reducing resource usage in high-throughput scenarios or when running in the background.

**Available modes:**

- `interactive` (default) - Full interactive UI with event history, navigation, and keyboard shortcuts
- `compact` - Simple one-line logs for all events without interactive features
- `quiet` - Only displays fatal connection errors (network failures, timeouts), not HTTP errors

All modes display connection information at startup and a connection status message.

**Examples:**

```sh
# Default - full interactive UI with keyboard shortcuts
$ hookdeck listen 3000 shopify

# Simple logging mode - prints all events as one-line logs
$ hookdeck listen 3000 shopify --output compact

# Quiet mode - only shows fatal connection errors
$ hookdeck listen 3000 shopify --output quiet
```

**Compact mode output:**
```
Listening on
shopify
└─ Forwards to → http://localhost:3000

Connected. Waiting for events...

2025-10-08 15:56:53 [200] POST http://localhost:3000 (45ms) → https://...
2025-10-08 15:56:54 [422] POST http://localhost:3000 (12ms) → https://...
```

**Quiet mode output:**
```
Listening on
shopify
└─ Forwards to → http://localhost:3000

Connected. Waiting for events...

2025-10-08 15:56:53 [ERROR] Failed to POST: connection refused
```

> Note: In `quiet` mode, only fatal errors are shown (connection failures, network unreachable, timeouts). HTTP error responses (4xx, 5xx) are not displayed as they are valid HTTP responses.

#### Viewing and interacting with your events

Event logs for your CLI can be found at [https://dashboard.hookdeck.com/cli/events](https://dashboard.hookdeck.com/cli/events?ref=github-hookdeck-cli). Events can be replayed or saved at any time.
Expand Down
8 changes: 8 additions & 0 deletions pkg/ansi/ansi.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"io"
"os"
"regexp"
"runtime"
"time"

Expand All @@ -13,6 +14,8 @@ import (
"golang.org/x/term"
)

var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)

var darkTerminalStyle = &pretty.Style{
Key: [2]string{"\x1B[34m", "\x1B[0m"},
String: [2]string{"\x1B[30m", "\x1B[0m"},
Expand Down Expand Up @@ -46,6 +49,11 @@ func Bold(text string) string {
return color.Sprintf(color.Bold(text))
}

// StripANSI removes all ANSI escape sequences from a string
func StripANSI(text string) string {
return ansiRegex.ReplaceAllString(text, "")
}

// Color returns an aurora.Aurora instance with colors enabled or disabled
// depending on whether the writer supports colors.
func Color(w io.Writer) aurora.Aurora {
Expand Down
7 changes: 5 additions & 2 deletions pkg/cmd/ci.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package cmd

import (
"log"
"fmt"
"os"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -35,7 +35,10 @@ func newCICmd() *ciCmd {
func (lc *ciCmd) runCICmd(cmd *cobra.Command, args []string) error {
err := validators.APIKey(lc.apiKey)
if err != nil {
log.Fatal(err)
if err == validators.ErrAPIKeyNotConfigured {
return fmt.Errorf("Provide a project API key using the --api-key flag. Example: hookdeck ci --api-key YOUR_KEY")
}
return err
}
return login.CILogin(&Config, lc.apiKey, lc.name)
}
24 changes: 19 additions & 5 deletions pkg/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ import (
)

type listenCmd struct {
cmd *cobra.Command
noWSS bool
path string
cmd *cobra.Command
noWSS bool
path string
output string
}

// Map --cli-path to --path
Expand Down Expand Up @@ -96,6 +97,8 @@ Destination CLI path will be "/". To set the CLI path, use the "--path" flag.`,

lc.cmd.Flags().StringVar(&lc.path, "path", "", "Sets the path to which events are forwarded e.g., /webhooks or /api/stripe")

lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (only fatal errors)")

// --cli-path is an alias for
lc.cmd.Flags().SetNormalizeFunc(normalizeCliPathFlag)

Expand Down Expand Up @@ -145,6 +148,16 @@ func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error {
connectionQuery = args[2]
}

// Validate output flag
validOutputModes := map[string]bool{
"interactive": true,
"compact": true,
"quiet": true,
}
if !validOutputModes[lc.output] {
return errors.New("invalid --output mode. Must be: interactive, compact, or quiet")
}

_, err_port := strconv.ParseInt(args[0], 10, 64)
var url *url.URL
if err_port != nil {
Expand All @@ -162,7 +175,8 @@ func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error {
}

return listen.Listen(url, sourceQuery, connectionQuery, listen.Flags{
NoWSS: lc.noWSS,
Path: lc.path,
NoWSS: lc.noWSS,
Path: lc.path,
Output: lc.output,
}, &Config)
}
2 changes: 2 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ func (c *Config) constructConfig() {
c.Profile.ProjectId = stringCoalesce(c.Profile.ProjectId, c.viper.GetString(c.Profile.getConfigField("project_id")), c.viper.GetString("project_id"), c.viper.GetString(c.Profile.getConfigField("workspace_id")), c.viper.GetString(c.Profile.getConfigField("team_id")), c.viper.GetString("workspace_id"), "")

c.Profile.ProjectMode = stringCoalesce(c.Profile.ProjectMode, c.viper.GetString(c.Profile.getConfigField("project_mode")), c.viper.GetString("project_mode"), c.viper.GetString(c.Profile.getConfigField("workspace_mode")), c.viper.GetString(c.Profile.getConfigField("team_mode")), c.viper.GetString("workspace_mode"), "")

c.Profile.GuestURL = stringCoalesce(c.Profile.GuestURL, c.viper.GetString(c.Profile.getConfigField("guest_url")), c.viper.GetString("guest_url"), "")
}

// getConfigPath returns the path for the config file.
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type Profile struct {
APIKey string
ProjectId string
ProjectMode string
GuestURL string // URL to create permanent account for guest users

Config *Config
}
Expand All @@ -22,6 +23,7 @@ func (p *Profile) SaveProfile() error {
p.Config.viper.Set(p.getConfigField("api_key"), p.APIKey)
p.Config.viper.Set(p.getConfigField("project_id"), p.ProjectId)
p.Config.viper.Set(p.getConfigField("project_mode"), p.ProjectMode)
p.Config.viper.Set(p.getConfigField("guest_url"), p.GuestURL)
return p.Config.writeConfig()
}

Expand Down
15 changes: 9 additions & 6 deletions pkg/listen/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ func ensureConnections(client *hookdeckclient.Client, connections []*hookdecksdk
return connections, nil
}

// If a connection filter was specified and no match found, don't auto-create
if connectionFilterString != "" {
return connections, fmt.Errorf("no connection found matching filter \"%s\" for source \"%s\"", connectionFilterString, sources[0].Name)
}

log.Debug(fmt.Sprintf("No connection found. Creating a connection for Source \"%s\", Connection \"%s\", and path \"%s\"", sources[0].Name, connectionFilterString, path))

connectionDetails := struct {
Expand All @@ -85,19 +90,17 @@ func ensureConnections(client *hookdeckclient.Client, connections []*hookdecksdk
}{}

connectionDetails.DestinationName = fmt.Sprintf("%s-%s", "cli", sources[0].Name)

if len(connectionFilterString) == 0 {
connectionDetails.ConnectionName = fmt.Sprintf("%s_to_%s", sources[0].Name, connectionDetails.DestinationName)
} else {
connectionDetails.ConnectionName = connectionFilterString
}
connectionDetails.ConnectionName = connectionDetails.DestinationName // Use same name as destination

if len(path) == 0 {
connectionDetails.Path = "/"
} else {
connectionDetails.Path = path
}

// Print message to user about creating the connection
fmt.Printf("\nThere's no CLI destination connected to %s, creating one named %s\n", sources[0].Name, connectionDetails.DestinationName)

connection, err := client.Connection.Create(context.Background(), &hookdecksdk.ConnectionCreateRequest{
Name: hookdecksdk.OptionalOrNull(&connectionDetails.ConnectionName),
SourceId: hookdecksdk.OptionalOrNull(&sources[0].Id),
Expand Down
23 changes: 16 additions & 7 deletions pkg/listen/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"regexp"
"strings"

"github.com/hookdeck/hookdeck-cli/pkg/ansi"
"github.com/hookdeck/hookdeck-cli/pkg/config"
"github.com/hookdeck/hookdeck-cli/pkg/login"
"github.com/hookdeck/hookdeck-cli/pkg/proxy"
Expand All @@ -31,8 +32,9 @@ import (
)

type Flags struct {
NoWSS bool
Path string
NoWSS bool
Path string
Output string
}

// listenCmd represents the listen command
Expand Down Expand Up @@ -66,7 +68,11 @@ func Listen(URL *url.URL, sourceQuery string, connectionFilterString string, fla
if guestURL == "" {
return err
}
} else if config.Profile.GuestURL != "" && config.Profile.APIKey != "" {
// User is logged in with a guest account (has both GuestURL and APIKey)
guestURL = config.Profile.GuestURL
}
// If user has permanent account (APIKey but no GuestURL), guestURL remains empty

sdkClient := config.GetClient()

Expand Down Expand Up @@ -119,13 +125,15 @@ Specify a single destination to update the path. For example, pass a connection
// Start proxy
printListenMessage(config, isMultiSource)
fmt.Println()
printDashboardInformation(config, guestURL)
fmt.Println()
printSources(config, sources)
fmt.Println()
printConnections(config, connections)
printSourcesWithConnections(config, sources, connections, URL, guestURL)
fmt.Println()

// Only show "Events" header in interactive mode
if flags.Output == "" || flags.Output == "interactive" {
fmt.Printf("%s\n", ansi.Faint("Events"))
fmt.Println()
}

p := proxy.New(&proxy.Config{
DeviceName: config.DeviceName,
Key: config.Profile.APIKey,
Expand All @@ -139,6 +147,7 @@ Specify a single destination to update the path. For example, pass a connection
URL: URL,
Log: log.StandardLogger(),
Insecure: config.Insecure,
Output: flags.Output,
}, connections)

err = p.Run(context.Background())
Expand Down
Loading