diff --git a/README.md b/README.md index 376b0fd..1626444 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ hookdeck login ``` If you are in an environment without a browser (e.g., a TTY-only terminal), you can use the `--interactive` (or `-i`) flag to log in by pasting your API key: + ```sh hookdeck login --interactive ``` @@ -110,17 +111,43 @@ hookdeck login --interactive Start a session to forward your events to an HTTP server. ```sh -hookdeck listen [--path?] +hookdeck listen [--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. -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. +#### Interactive Mode + +The default interactive mode uses a full-screen TUI (Terminal User Interface) with an alternative screen buffer, meaning your terminal history is preserved when you exit. The interface includes: + +- **Connection Header**: Shows your sources, webhook URLs, and connection routing + - Auto-collapses when the first event arrives to save space + - Toggle with `i` to expand/collapse connection details +- **Event List**: Scrollable history of all received events (up to 1000 events) + - Auto-scrolls to show latest events as they arrive + - Manual navigation pauses auto-scrolling +- **Status Bar**: Shows event details and available keyboard shortcuts +- **Event Details View**: Full request/response inspection with headers and body + +#### Interactive Keyboard Shortcuts + +While in interactive mode, you can use the following keyboard shortcuts: + +- `↑` / `↓` or `k` / `j` - Navigate between events (select different events) +- `i` - Toggle connection information (expand/collapse connection details) +- `r` - Retry the selected event +- `o` - Open the selected event in the Hookdeck dashboard +- `d` - Show detailed request/response information for the selected event (press `d` or `ESC` to close) + - When details view is open: `↑` / `↓` scroll through content, `PgUp` / `PgDown` for page navigation +- `q` - Quit the application (terminal state is restored) +- `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 bar at the bottom of the screen. + #### Listen to all your connections for a given source The second param, `source-alias` is used to select a specific source to listen on. By default, the CLI will start listening on all eligible connections for that source. @@ -128,18 +155,24 @@ The second param, `source-alias` is used to select a specific source to listen o ```sh $ hookdeck listen 3000 shopify -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +ā—ā”€ā”€ HOOKDECK CLI ā”€ā”€ā— + +Listening on 1 source • 2 connections • [i] Collapse Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +ā”œā”€ Forwards to → http://localhost:3000/webhooks/shopify/inventory (Inventory Service) +└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (Orders Service) -Connections -Inventory Service forwarding to /webhooks/shopify/inventory -Orders Service forwarding to /webhooks/shopify/orders +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── -⣾ Getting ready... +2025-10-12 14:32:15 [200] POST http://localhost:3000/webhooks/shopify/orders (23ms) → https://dashboard.hookdeck.com/events/evt_... +> 2025-10-12 14:32:18 [200] POST http://localhost:3000/webhooks/shopify/inventory (45ms) → https://dashboard.hookdeck.com/events/evt_... +─────────────────────────────────────────────────────────────────────────────── +> āœ“ Last event succeeded with status 200 | [r] Retry • [o] Open in dashboard • [d] Show data ``` #### Listen to multiple sources @@ -149,20 +182,32 @@ Orders Service forwarding to /webhooks/shopify/orders ```sh $ hookdeck listen 3000 '*' -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +ā—ā”€ā”€ HOOKDECK CLI ā”€ā”€ā— + +Listening on 3 sources • 3 connections • [i] Collapse -Sources -šŸ”Œ stripe URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn01 -šŸ”Œ shopify URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn02 -šŸ”Œ twilio URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn03 +stripe +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn01 +└─ Forwards to → http://localhost:3000/webhooks/stripe (cli-stripe) -Connections -stripe -> cli-stripe forwarding to /webhooks/stripe -shopify -> cli-shopify forwarding to /webhooks/shopify -twilio -> cli-twilio forwarding to /webhooks/twilio +shopify +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn02 +└─ Forwards to → http://localhost:3000/webhooks/shopify (cli-shopify) -⣾ Getting ready... +twilio +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn03 +└─ Forwards to → http://localhost:3000/webhooks/twilio (cli-twilio) +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... + +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── + +2025-10-12 14:35:21 [200] POST http://localhost:3000/webhooks/stripe (12ms) → https://dashboard.hookdeck.com/events/evt_... +2025-10-12 14:35:44 [200] POST http://localhost:3000/webhooks/shopify (31ms) → https://dashboard.hookdeck.com/events/evt_... +> 2025-10-12 14:35:52 [200] POST http://localhost:3000/webhooks/twilio (18ms) → https://dashboard.hookdeck.com/events/evt_... + +─────────────────────────────────────────────────────────────────────────────── +> āœ“ Last event succeeded with status 200 | [r] Retry • [o] Open in dashboard • [d] Show data ``` #### Listen to a subset of connections @@ -172,17 +217,22 @@ The 3rd param, `connection-query` specifies which connection with a CLI destinat ```sh $ hookdeck listen 3000 shopify orders -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +ā—ā”€ā”€ HOOKDECK CLI ā”€ā”€ā— + +Listening on 1 source • 1 connection • [i] Collapse Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (Orders Service) -Connections -Orders Service forwarding to /webhooks/shopify/orders +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── -⣾ Getting ready... +> 2025-10-12 14:38:09 [200] POST http://localhost:3000/webhooks/shopify/orders (27ms) → https://dashboard.hookdeck.com/events/evt_... +─────────────────────────────────────────────────────────────────────────────── +> āœ“ Last event succeeded with status 200 | [r] Retry • [o] Open in dashboard • [d] Show data ``` #### Changing the path events are forwarded to @@ -192,19 +242,76 @@ The `--path` flag sets the path to which events are forwarded. ```sh $ hookdeck listen 3000 shopify orders --path /events/shopify/orders -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +ā—ā”€ā”€ HOOKDECK CLI ā”€ā”€ā— + +Listening on 1 source • 1 connection • [i] Collapse Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +└─ Forwards to → http://localhost:3000/events/shopify/orders (Orders Service) -Connections -Orders Service forwarding to /events/shopify/orders +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── -⣾ Getting ready... +> 2025-10-12 14:40:23 [200] POST http://localhost:3000/events/shopify/orders (19ms) → https://dashboard.hookdeck.com/events/evt_... +─────────────────────────────────────────────────────────────────────────────── +> āœ“ Last event succeeded with status 200 | [r] Retry • [o] Open in dashboard • [d] Show data ``` +#### 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-screen TUI with alternative screen buffer, event history, navigation, and keyboard shortcuts. Your terminal history is preserved and restored when you exit. +- `compact` - Simple one-line logs for all events without interactive features. Events are appended to your terminal history. +- `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. @@ -226,6 +333,7 @@ For local development scenarios, you can instruct the `listen` command to bypass **This is dangerous and should only be used in trusted local development environments for destinations you control.** Example of skipping SSL validation for an HTTPS destination: + ```sh hookdeck listen --insecure https:/// ``` @@ -256,17 +364,22 @@ Done! The Hookdeck CLI is configured in project MyProject $ hookdeck listen 3000 shopify orders -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +ā—ā”€ā”€ HOOKDECK CLI ā”€ā”€ā— + +Listening on 1 source • 1 connection • [i] Collapse Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (Orders Service) -Connections -Inventory Service forwarding to /webhooks/shopify/inventory +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── -⣾ Getting ready... +> 2025-10-12 14:42:55 [200] POST http://localhost:3000/webhooks/shopify/orders (34ms) → https://dashboard.hookdeck.com/events/evt_... +─────────────────────────────────────────────────────────────────────────────── +> āœ“ Last event succeeded with status 200 | [r] Retry • [o] Open in dashboard • [d] Show data ``` ### Manage active project @@ -296,38 +409,41 @@ hookdeck project use [ []] **Behavior:** -- **`hookdeck project use`** (no arguments): - An interactive prompt will guide you through selecting your organization and then the project within that organization. - ```sh - $ hookdeck project use - Use the arrow keys to navigate: ↓ ↑ → ← - ? Select Organization: - My Org - ā–ø Another Org - ... - ? Select Project (Another Org): - Project X - ā–ø Project Y - Selecting project Project Y - Successfully set active project to: [Another Org] Project Y - ``` - -- **`hookdeck project use `** (one argument): - Filters projects by the specified ``. - - If multiple projects exist under that organization, you'll be prompted to choose one. - - If only one project exists, it will be selected automatically. - ```sh - $ hookdeck project use "My Org" - # (If multiple projects, prompts to select. If one, auto-selects) - Successfully set active project to: [My Org] Default Project - ``` - -- **`hookdeck project use `** (two arguments): - Directly selects the project `` under the organization ``. - ```sh - $ hookdeck project use "My Corp" "API Staging" - Successfully set active project to: [My Corp] API Staging - ``` +- **`hookdeck project use`** (no arguments): + An interactive prompt will guide you through selecting your organization and then the project within that organization. + + ```sh + $ hookdeck project use + Use the arrow keys to navigate: ↓ ↑ → ← + ? Select Organization: + My Org + ā–ø Another Org + ... + ? Select Project (Another Org): + Project X + ā–ø Project Y + Selecting project Project Y + Successfully set active project to: [Another Org] Project Y + ``` + +- **`hookdeck project use `** (one argument): + Filters projects by the specified ``. + + - If multiple projects exist under that organization, you'll be prompted to choose one. + - If only one project exists, it will be selected automatically. + + ```sh + $ hookdeck project use "My Org" + # (If multiple projects, prompts to select. If one, auto-selects) + Successfully set active project to: [My Org] Default Project + ``` + +- **`hookdeck project use `** (two arguments): + Directly selects the project `` under the organization ``. + ```sh + $ hookdeck project use "My Corp" "API Staging" + Successfully set active project to: [My Corp] API Staging + ``` Upon successful selection, you will generally see a confirmation message like: `Successfully set active project to: [] ` @@ -340,9 +456,9 @@ The Hookdeck CLI uses configuration files to store the your keys, project settin The CLI will look for the configuration file in the following order: - 1. The `--config` flag, which allows you to specify a custom configuration file name and path per command. - 2. The local directory `.hookdeck/config.toml`. - 3. The default global configuration file location. +1. The `--config` flag, which allows you to specify a custom configuration file name and path per command. +2. The local directory `.hookdeck/config.toml`. +3. The default global configuration file location. ### Default configuration Location @@ -415,13 +531,13 @@ hookdeck listen 3030 webhooks -p prod The following flags can be used with any command: -* `--api-key`: Your API key to use for the command. -* `--color`: Turn on/off color output (on, off, auto). -* `--config`: Path to a specific configuration file. -* `--device-name`: A unique name for your device. -* `--insecure`: Allow invalid TLS certificates. -* `--log-level`: Set the logging level (debug, info, warn, error). -* `--profile` or `-p`: Use a specific configuration profile. +- `--api-key`: Your API key to use for the command. +- `--color`: Turn on/off color output (on, off, auto). +- `--config`: Path to a specific configuration file. +- `--device-name`: A unique name for your device. +- `--insecure`: Allow invalid TLS certificates. +- `--log-level`: Set the logging level (debug, info, warn, error). +- `--profile` or `-p`: Use a specific configuration profile. There are also some hidden flags that are mainly used for development and debugging: diff --git a/go.mod b/go.mod index 1fa1cb6..8180194 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/hookdeck/hookdeck-cli -go 1.18 +go 1.24.0 + +toolchain go1.24.8 require ( github.com/AlecAivazis/survey/v2 v2.3.7 @@ -19,12 +21,21 @@ require ( github.com/stretchr/testify v1.11.0 github.com/tidwall/pretty v1.2.1 github.com/x-cray/logrus-prefixed-formatter v0.5.2 - golang.org/x/sys v0.28.0 + golang.org/x/sys v0.36.0 golang.org/x/term v0.27.0 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.9.0 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/google/go-querystring v1.0.0 // indirect @@ -33,19 +44,27 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.3 // indirect github.com/mattn/go-colorable v0.1.7 // indirect - github.com/mattn/go-isatty v0.0.12 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/mapstructure v1.3.3 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/onsi/ginkgo v1.14.1 // indirect github.com/onsi/gomega v1.10.1 // indirect github.com/pelletier/go-toml v1.8.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/afero v1.4.0 // indirect github.com/spf13/cast v1.3.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect golang.org/x/text v0.4.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index f2d45d3..b8584e4 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= @@ -32,6 +34,20 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -47,6 +63,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= @@ -153,6 +171,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.3 h1:kJSsc6EXkBLgr3SphHk9w5mtjn0bjlR4JYEXKrJ45rQ= github.com/magiconair/properties v1.8.3/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= @@ -166,6 +186,12 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= @@ -184,6 +210,12 @@ github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8 github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -214,6 +246,9 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -262,6 +297,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1 github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= @@ -349,11 +386,15 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= diff --git a/pkg/ansi/ansi.go b/pkg/ansi/ansi.go index 15980a4..7b6a754 100644 --- a/pkg/ansi/ansi.go +++ b/pkg/ansi/ansi.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "regexp" "runtime" "time" @@ -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"}, @@ -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 { diff --git a/pkg/cmd/ci.go b/pkg/cmd/ci.go index 0839831..6bc0bb9 100644 --- a/pkg/cmd/ci.go +++ b/pkg/cmd/ci.go @@ -1,7 +1,7 @@ package cmd import ( - "log" + "fmt" "os" "github.com/spf13/cobra" @@ -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) } diff --git a/pkg/cmd/listen.go b/pkg/cmd/listen.go index ef45274..96f0c3f 100644 --- a/pkg/cmd/listen.go +++ b/pkg/cmd/listen.go @@ -32,6 +32,7 @@ type listenCmd struct { noWSS bool path string maxConnections int + output string } // Map --cli-path to --path @@ -98,6 +99,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().IntVar(&lc.maxConnections, "max-connections", 50, "Maximum concurrent connections to local endpoint (default: 50, increase for high-volume testing)") + 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) @@ -147,6 +150,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 { @@ -166,6 +179,7 @@ func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error { return listen.Listen(url, sourceQuery, connectionQuery, listen.Flags{ NoWSS: lc.noWSS, Path: lc.path, + Output: lc.output, MaxConnections: lc.maxConnections, }, &Config) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 106c142..8fff86c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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. diff --git a/pkg/config/profile.go b/pkg/config/profile.go index 487a34b..77c9142 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -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 } @@ -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() } diff --git a/pkg/listen/connection.go b/pkg/listen/connection.go index c459ca4..c213f1d 100644 --- a/pkg/listen/connection.go +++ b/pkg/listen/connection.go @@ -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 { @@ -85,12 +90,7 @@ 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 = "/" @@ -98,6 +98,9 @@ func ensureConnections(client *hookdeckclient.Client, connections []*hookdecksdk 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), diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index 3eabddd..181f134 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -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" @@ -34,6 +35,7 @@ type Flags struct { NoWSS bool Path string MaxConnections int + Output string } // listenCmd represents the listen command @@ -67,7 +69,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() @@ -118,16 +124,19 @@ 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) - fmt.Println() - - p := proxy.New(&proxy.Config{ + // For non-interactive modes, print connection info before starting + if flags.Output == "compact" || flags.Output == "quiet" { + printListenMessage(config, isMultiSource) + fmt.Println() + printSourcesWithConnections(config, sources, connections, URL, guestURL) + fmt.Println() + fmt.Printf("%s\n", ansi.Faint("Events")) + fmt.Println() + } + // For interactive mode, connection info will be shown in TUI + + // Use new TUI-based proxy + p := proxy.NewTUI(&proxy.Config{ DeviceName: config.DeviceName, Key: config.Profile.APIKey, ProjectID: config.Profile.ProjectId, @@ -140,8 +149,10 @@ Specify a single destination to update the path. For example, pass a connection URL: URL, Log: log.StandardLogger(), Insecure: config.Insecure, + Output: flags.Output, + GuestURL: guestURL, MaxConnections: flags.MaxConnections, - }, connections) + }, sources, connections) err = p.Run(context.Background()) if err != nil { diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 265c0b9..4664c3a 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -2,6 +2,8 @@ package listen import ( "fmt" + "net/url" + "strings" "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/config" @@ -17,35 +19,83 @@ func printListenMessage(config *config.Config, isMultiSource bool) { fmt.Println("Listening for events on Sources that have Connections with CLI Destinations") } -func printDashboardInformation(config *config.Config, guestURL string) { - fmt.Println(ansi.Bold("Dashboard")) +func printSourcesWithConnections(config *config.Config, sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection, targetURL *url.URL, guestURL string) { + // Group connections by source ID + sourceConnections := make(map[string][]*hookdecksdk.Connection) + for _, connection := range connections { + sourceID := connection.Source.Id + sourceConnections[sourceID] = append(sourceConnections[sourceID], connection) + } + + // Print the Sources title line + fmt.Printf("%s\n", ansi.Faint("Listening on")) + fmt.Println() + + // Print each source with its connections + for i, source := range sources { + // Print source name + fmt.Printf("%s\n", ansi.Bold(source.Name)) + + // Print connections for this source + if sourceConns, exists := sourceConnections[source.Id]; exists { + numConns := len(sourceConns) + + // Print webhook URL with vertical line only (no horizontal branch) + fmt.Printf("│ Requests to → %s\n", source.Url) + + // Print each connection + for j, connection := range sourceConns { + fullPath := targetURL.Scheme + "://" + targetURL.Host + *connection.Destination.CliPath + + // Get connection name from FullName (format: "source -> destination") + // Split on "->" and take the second part (destination) + connNameDisplay := "" + if connection.FullName != nil && *connection.FullName != "" { + parts := strings.Split(*connection.FullName, "->") + if len(parts) == 2 { + destinationName := strings.TrimSpace(parts[1]) + if destinationName != "" { + connNameDisplay = " " + ansi.Faint(fmt.Sprintf("(%s)", destinationName)) + } + } + } + + if j == numConns-1 { + // Last connection - use └─ + fmt.Printf("└─ Forwards to → %s%s\n", fullPath, connNameDisplay) + } else { + // Not last connection - use ā”œā”€ + fmt.Printf("ā”œā”€ Forwards to → %s%s\n", fullPath, connNameDisplay) + } + } + } else { + // No connections, just show webhook URL + fmt.Printf(" Request sents to → %s\n", source.Url) + } + + // Add spacing between sources (but not after the last one) + if i < len(sources)-1 { + fmt.Println() + } + } + + // Print dashboard hint + fmt.Println() if guestURL != "" { - fmt.Println("šŸ‘¤ Console URL: " + guestURL) - fmt.Println("Sign up in the Console to make your webhook URL permanent.") - fmt.Println() + fmt.Printf("šŸ’” Sign up to make your webhook URL permanent: %s\n", guestURL) } else { var url = config.DashboardBaseURL + var displayURL = config.DashboardBaseURL if config.Profile.ProjectId != "" { - url += "?team_id=" + config.Profile.ProjectId + url += "/events/cli?team_id=" + config.Profile.ProjectId + displayURL += "/events/cli" } if config.Profile.ProjectMode == "console" { url = config.ConsoleBaseURL + displayURL = config.ConsoleBaseURL } - fmt.Println("šŸ‘‰ Inspect and replay events: " + url) - } -} - -func printSources(config *config.Config, sources []*hookdecksdk.Source) { - fmt.Println(ansi.Bold("Sources")) - - for _, source := range sources { - fmt.Printf("šŸ”Œ %s URL: %s\n", source.Name, source.Url) - } -} - -func printConnections(config *config.Config, connections []*hookdecksdk.Connection) { - fmt.Println(ansi.Bold("Connections")) - for _, connection := range connections { - fmt.Println(*connection.FullName + " forwarding to " + *connection.Destination.CliPath) + // Create clickable link with OSC 8 hyperlink sequence + // Format: \033]8;;URL\033\\DISPLAY_TEXT\033]8;;\033\\ + fmt.Printf("šŸ’” View dashboard to inspect, retry & bookmark events: \033]8;;%s\033\\%s\033]8;;\033\\\n", url, displayURL) } } diff --git a/pkg/listen/source.go b/pkg/listen/source.go index 4e8797d..67b7814 100644 --- a/pkg/listen/source.go +++ b/pkg/listen/source.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "github.com/AlecAivazis/survey/v2" "github.com/hookdeck/hookdeck-cli/pkg/slug" @@ -59,6 +60,23 @@ func getSources(sdkClient *hookdeckclient.Client, sourceQuery []string) ([]*hook return validateSources(searchedSources) } + // Source not found, ask user if they want to create it + fmt.Printf("\nSource \"%s\" not found.\n", sourceQuery[0]) + + createConfirm := false + prompt := &survey.Confirm{ + Message: fmt.Sprintf("Do you want to create a new source named \"%s\"?", sourceQuery[0]), + } + err = survey.AskOne(prompt, &createConfirm) + if err != nil { + return []*hookdecksdk.Source{}, err + } + + if !createConfirm { + // User declined to create source, exit cleanly without error message + os.Exit(0) + } + // Create source with provided name source, err := createSource(sdkClient, &sourceQuery[0]) if err != nil { diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 53766de..d1c913f 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -98,6 +98,7 @@ func Login(config *config.Config, input io.Reader) error { config.Profile.APIKey = response.APIKey config.Profile.ProjectId = response.ProjectID config.Profile.ProjectMode = response.ProjectMode + config.Profile.GuestURL = "" // Clear guest URL when logging in with permanent account if err = config.Profile.SaveProfile(); err != nil { return err @@ -122,7 +123,7 @@ func GuestLogin(config *config.Config) (string, error) { BaseURL: parsedBaseURL, } - fmt.Println("🚩 Not connected with any account. Creating a guest account...") + fmt.Println("\n🚩 Not connected with any account. Creating a guest account...") guest_user, err := client.CreateGuestUser(hookdeck.CreateGuestUserInput{ DeviceName: config.DeviceName, @@ -144,6 +145,7 @@ func GuestLogin(config *config.Config) (string, error) { config.Profile.APIKey = response.APIKey config.Profile.ProjectId = response.ProjectID config.Profile.ProjectMode = response.ProjectMode + config.Profile.GuestURL = guest_user.Url if err = config.Profile.SaveProfile(); err != nil { return "", err diff --git a/pkg/login/interactive_login.go b/pkg/login/interactive_login.go index d5a53b5..3893e14 100644 --- a/pkg/login/interactive_login.go +++ b/pkg/login/interactive_login.go @@ -65,6 +65,7 @@ func InteractiveLogin(config *config.Config) error { config.Profile.APIKey = response.APIKey config.Profile.ProjectMode = response.ProjectMode config.Profile.ProjectId = response.ProjectID + config.Profile.GuestURL = "" // Clear guest URL when logging in with permanent account if err = config.Profile.SaveProfile(); err != nil { ansi.StopSpinner(s, "", os.Stdout) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index ade232d..9cfe899 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -28,10 +28,6 @@ import ( const timeLayout = "2006-01-02 15:04:05" -// -// Public types -// - // Config provides the configuration of a Proxy type Config struct { // DeviceName is the name of the device sent to Hookdeck to help identify the device @@ -45,12 +41,13 @@ type Config struct { DashboardBaseURL string ConsoleBaseURL string WSBaseURL string - // Indicates whether to print full JSON objects to stdout - PrintJSON bool - Log *log.Logger + Log *log.Logger // Force use of unencrypted ws:// protocol instead of wss:// NoWSS bool Insecure bool + // Output mode: interactive, compact, quiet + Output string + GuestURL string // MaxConnections allows tuning the maximum concurrent connections per host. // Default: 50 concurrent connections // This can be increased for high-volume testing scenarios where the local @@ -75,7 +72,6 @@ type Proxy struct { } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { - // Create a context that will be canceled when Ctrl+C is pressed ctx, cancel := context.WithCancel(ctx) interruptCh := make(chan os.Signal, 1) @@ -260,94 +256,90 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { "prefix": "proxy.Proxy.processAttempt", }).Debugf("Processing webhook event") - if p.cfg.PrintJSON { - fmt.Println(webhookEvent.Body.Request.DataString) - } else { - url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path + url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path - // Create request with context for timeout control - timeout := webhookEvent.Body.Request.Timeout - if timeout == 0 { - timeout = 1000 * 30 - } + // Create request with context for timeout control + timeout := webhookEvent.Body.Request.Timeout + if timeout == 0 { + timeout = 1000 * 30 + } - // Track active requests - atomic.AddInt32(&p.activeRequests, 1) - defer atomic.AddInt32(&p.activeRequests, -1) - - activeCount := atomic.LoadInt32(&p.activeRequests) - - // Calculate warning thresholds proportionally to max connections - maxConns := int32(p.transport.MaxConnsPerHost) - warningThreshold := int32(float64(maxConns) * 0.8) // Warn at 80% capacity - resetThreshold := int32(float64(maxConns) * 0.6) // Reset warning at 60% capacity - - // Warn when approaching connection limit - if activeCount > warningThreshold && !p.maxConnWarned { - p.maxConnWarned = true - color := ansi.Color(os.Stdout) - fmt.Printf("\n%s High connection load detected (%d active requests)\n", - color.Yellow("⚠ WARNING:"), activeCount) - fmt.Printf(" The CLI is limited to %d concurrent connections per host.\n", p.transport.MaxConnsPerHost) - fmt.Printf(" Consider reducing request rate or increasing connection limit.\n") - fmt.Printf(" Run with --max-connections=%d to increase the limit.\n\n", maxConns*2) - } else if activeCount < resetThreshold && p.maxConnWarned { - // Reset warning flag when load decreases - p.maxConnWarned = false - } + // Track active requests + atomic.AddInt32(&p.activeRequests, 1) + defer atomic.AddInt32(&p.activeRequests, -1) + + activeCount := atomic.LoadInt32(&p.activeRequests) + + // Calculate warning thresholds proportionally to max connections + maxConns := int32(p.transport.MaxConnsPerHost) + warningThreshold := int32(float64(maxConns) * 0.8) // Warn at 80% capacity + resetThreshold := int32(float64(maxConns) * 0.6) // Reset warning at 60% capacity + + // Warn when approaching connection limit + if activeCount > warningThreshold && !p.maxConnWarned { + p.maxConnWarned = true + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s High connection load detected (%d active requests)\n", + color.Yellow("⚠ WARNING:"), activeCount) + fmt.Printf(" The CLI is limited to %d concurrent connections per host.\n", p.transport.MaxConnsPerHost) + fmt.Printf(" Consider reducing request rate or increasing connection limit.\n") + fmt.Printf(" Run with --max-connections=%d to increase the limit.\n\n", maxConns*2) + } else if activeCount < resetThreshold && p.maxConnWarned { + // Reset warning flag when load decreases + p.maxConnWarned = false + } - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Millisecond) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Millisecond) + defer cancel() - req, err := http.NewRequestWithContext(ctx, webhookEvent.Body.Request.Method, url, nil) - if err != nil { - fmt.Printf("Error: %s\n", err) - return - } - x := make(map[string]json.RawMessage) - err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) - if err != nil { - fmt.Printf("Error: %s\n", err) - return - } + req, err := http.NewRequestWithContext(ctx, webhookEvent.Body.Request.Method, url, nil) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + x := make(map[string]json.RawMessage) + err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } - for key, value := range x { - unquoted_value, _ := strconv.Unquote(string(value)) - req.Header.Set(key, unquoted_value) - } + for key, value := range x { + unquoted_value, _ := strconv.Unquote(string(value)) + req.Header.Set(key, unquoted_value) + } - req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) - req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) + req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) + req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) - res, err := p.httpClient.Do(req) - if err != nil { - color := ansi.Color(os.Stdout) - localTime := time.Now().Format(timeLayout) + res, err := p.httpClient.Do(req) + if err != nil { + color := ansi.Color(os.Stdout) + localTime := time.Now().Format(timeLayout) - // Use the original error message - errStr := fmt.Sprintf("%s [%s] Failed to %s: %s", - color.Faint(localTime), - color.Red("ERROR"), - webhookEvent.Body.Request.Method, - err, - ) + // Use the original error message + errStr := fmt.Sprintf("%s [%s] Failed to %s: %s", + color.Faint(localTime), + color.Red("ERROR"), + webhookEvent.Body.Request.Method, + err, + ) - fmt.Println(errStr) - p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ - ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ - Event: "attempt_response", - Body: websocket.ErrorAttemptBody{ - AttemptId: webhookEvent.Body.AttemptId, - Error: true, - }, - }}) - } else { - // Process the response (this reads the entire body) - p.processEndpointResponse(webhookEvent, res) + fmt.Println(errStr) + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ + Event: "attempt_response", + Body: websocket.ErrorAttemptBody{ + AttemptId: webhookEvent.Body.AttemptId, + Error: true, + }, + }}) + } else { + // Process the response (this reads the entire body) + p.processEndpointResponse(webhookEvent, res) - // Close the body - connection can be reused since body was fully read - res.Body.Close() - } + // Close the body - connection can be reused since body was fully read + res.Body.Close() } } diff --git a/pkg/proxy/proxy_tui.go b/pkg/proxy/proxy_tui.go new file mode 100644 index 0000000..2648dfb --- /dev/null +++ b/pkg/proxy/proxy_tui.go @@ -0,0 +1,801 @@ +package proxy + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "math" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/briandowns/spinner" + tea "github.com/charmbracelet/bubbletea" + log "github.com/sirupsen/logrus" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/tui" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" + hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" +) + +// ProxyTUI is a Proxy that uses Bubble Tea for interactive mode +type ProxyTUI struct { + cfg *Config + connections []*hookdecksdk.Connection + webSocketClient *websocket.Client + connectionTimer *time.Timer + + // HTTP client with connection pooling + httpClient *http.Client + transport *http.Transport + activeRequests int32 // atomic counter + maxConnWarned bool // Track if we've warned about connection limit + + // Bubble Tea program + teaProgram *tea.Program + teaModel *tui.Model +} + +// NewTUI creates a new Proxy with Bubble Tea UI +func NewTUI(cfg *Config, sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection) *ProxyTUI { + if cfg.Log == nil { + cfg.Log = &log.Logger{Out: ioutil.Discard} + } + + // Default to interactive mode if not specified + if cfg.Output == "" { + cfg.Output = "interactive" + } + + // Default to 50 connections if not specified + maxConns := cfg.MaxConnections + if maxConns <= 0 { + maxConns = 50 + } + + // Create a shared HTTP transport with connection pooling + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.Insecure}, + // Connection pool settings - sensible defaults for typical usage + MaxIdleConns: 20, // Total idle connections across all hosts + MaxIdleConnsPerHost: 10, // Keep some idle connections for reuse + IdleConnTimeout: 30 * time.Second, // Clean up idle connections + DisableKeepAlives: false, + // Limit concurrent connections to prevent resource exhaustion + MaxConnsPerHost: maxConns, // User-configurable (default: 50) + ResponseHeaderTimeout: 60 * time.Second, + } + + p := &ProxyTUI{ + cfg: cfg, + connections: connections, + connectionTimer: time.NewTimer(0), + transport: tr, + httpClient: &http.Client{ + Transport: tr, + // Timeout is controlled per-request via context in processAttempt + }, + } + + // Only create Bubble Tea program for interactive mode + if cfg.Output == "interactive" { + tuiCfg := &tui.Config{ + DeviceName: cfg.DeviceName, + APIKey: cfg.Key, + APIBaseURL: cfg.APIBaseURL, + DashboardBaseURL: cfg.DashboardBaseURL, + ConsoleBaseURL: cfg.ConsoleBaseURL, + ProjectMode: cfg.ProjectMode, + ProjectID: cfg.ProjectID, + GuestURL: cfg.GuestURL, + TargetURL: cfg.URL, + Sources: sources, + Connections: connections, + } + model := tui.NewModel(tuiCfg) + p.teaModel = &model + // Use alt screen to keep terminal clean + p.teaProgram = tea.NewProgram(&model, tea.WithAltScreen()) + } + + return p +} + +// Run manages the connection to Hookdeck with Bubble Tea UI +func (p *ProxyTUI) Run(parentCtx context.Context) error { + const maxConnectAttempts = 10 + const maxReconnectAttempts = 10 + nAttempts := 0 + + hasConnectedOnce := false + canConnect := func() bool { + if hasConnectedOnce { + return nAttempts < maxReconnectAttempts + } + return nAttempts < maxConnectAttempts + } + + signalCtx := withSIGTERMCancel(parentCtx, func() { + log.WithFields(log.Fields{ + "prefix": "proxy.ProxyTUI.Run", + }).Debug("Ctrl+C received, cleaning up...") + }) + + // Create a channel to signal when TUI exits + tuiDoneCh := make(chan struct{}) + + // Start Bubble Tea program in interactive mode immediately + if p.cfg.Output == "interactive" && p.teaProgram != nil { + go func() { + if _, err := p.teaProgram.Run(); err != nil { + log.WithField("prefix", "proxy.ProxyTUI.Run"). + Errorf("Bubble Tea error: %v", err) + } + // Signal that TUI has exited (user pressed q or Ctrl+C) + close(tuiDoneCh) + }() + } + + // For non-interactive modes, show spinner + var s *spinner.Spinner + if p.cfg.Output != "interactive" { + s = ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out) + } + + // Send connecting message to TUI + if p.teaProgram != nil { + p.teaProgram.Send(tui.ConnectingMsg{}) + } + + session, err := p.createSession(signalCtx) + if err != nil { + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.teaProgram != nil { + p.teaProgram.Kill() + } + p.cfg.Log.Fatalf("Error while authenticating with Hookdeck: %v", err) + } + + if session.Id == "" { + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.teaProgram != nil { + p.teaProgram.Kill() + } + p.cfg.Log.Fatalf("Error while starting a new session") + } + + // Main connection loop + for canConnect() { + // Apply backoff delay + if nAttempts > 0 { + backoffMS := math.Min(100*math.Pow(2, float64(nAttempts-1)), 30000) + sleepDurationMS := int(backoffMS) + + log.WithField("prefix", "proxy.ProxyTUI.Run"). + Debugf("Connect backoff (%dms)", sleepDurationMS) + + p.connectionTimer.Stop() + p.connectionTimer.Reset(time.Duration(sleepDurationMS) * time.Millisecond) + + // For non-interactive modes, update spinner + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + if hasConnectedOnce { + s = ansi.StartNewSpinner("Connection lost, reconnecting...", p.cfg.Log.Out) + } else { + s = ansi.StartNewSpinner("Connecting...", p.cfg.Log.Out) + } + } + + // For interactive mode, send reconnecting message to TUI + if p.teaProgram != nil { + if hasConnectedOnce { + p.teaProgram.Send(tui.DisconnectedMsg{}) + } else { + p.teaProgram.Send(tui.ConnectingMsg{}) + } + } + + select { + case <-p.connectionTimer.C: + // Continue to retry + case <-signalCtx.Done(): + p.connectionTimer.Stop() + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.teaProgram != nil { + p.teaProgram.Kill() + } + return nil + case <-tuiDoneCh: + // TUI exited during backoff + p.connectionTimer.Stop() + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.webSocketClient != nil { + p.webSocketClient.Stop() + } + return nil + } + } + + p.webSocketClient = websocket.NewClient( + p.cfg.WSBaseURL, + session.Id, + p.cfg.Key, + p.cfg.ProjectID, + &websocket.Config{ + Log: p.cfg.Log, + NoWSS: p.cfg.NoWSS, + EventHandler: websocket.EventHandlerFunc(p.processAttempt), + }, + ) + + // Monitor websocket connection + go func() { + <-p.webSocketClient.Connected() + nAttempts = 0 + + // For non-interactive modes, stop spinner and show message + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + color := ansi.Color(os.Stdout) + fmt.Printf("%s\n\n", color.Faint("Connected. Waiting for events...")) + } + + // Send connected message to TUI + if p.teaProgram != nil { + p.teaProgram.Send(tui.ConnectedMsg{}) + } + + hasConnectedOnce = true + }() + + // Run websocket + go p.webSocketClient.Run(signalCtx) + nAttempts++ + + // Block until ctrl+c, TUI exits, or connection lost + select { + case <-signalCtx.Done(): + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.teaProgram != nil { + p.teaProgram.Kill() + } + return nil + case <-tuiDoneCh: + // TUI exited (user pressed q or Ctrl+C in TUI) + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.webSocketClient != nil { + p.webSocketClient.Stop() + } + return nil + case <-p.webSocketClient.NotifyExpired: + // Send disconnected message + if p.teaProgram != nil { + p.teaProgram.Send(tui.DisconnectedMsg{}) + } + + if !canConnect() { + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.teaProgram != nil { + p.teaProgram.Quit() + // Wait a moment for TUI to clean up properly + select { + case <-tuiDoneCh: + // TUI exited cleanly + case <-time.After(100 * time.Millisecond): + // Timeout, force kill + p.teaProgram.Kill() + } + } + + return fmt.Errorf("Could not establish connection. Terminating after %d attempts to connect", nAttempts) + } + } + } + + if p.webSocketClient != nil { + p.webSocketClient.Stop() + } + + if p.teaProgram != nil { + p.teaProgram.Kill() + } + + log.WithFields(log.Fields{ + "prefix": "proxy.ProxyTUI.Run", + }).Debug("Bye!") + + return nil +} + +func (p *ProxyTUI) createSession(ctx context.Context) (hookdeck.Session, error) { + var session hookdeck.Session + + parsedBaseURL, err := url.Parse(p.cfg.APIBaseURL) + if err != nil { + return session, err + } + + client := &hookdeck.Client{ + BaseURL: parsedBaseURL, + APIKey: p.cfg.Key, + ProjectID: p.cfg.ProjectID, + } + + var connectionIDs []string + for _, connection := range p.connections { + connectionIDs = append(connectionIDs, connection.Id) + } + + for i := 0; i <= 5; i++ { + session, err = client.CreateSession(hookdeck.CreateSessionInput{ + ConnectionIds: connectionIDs, + }) + + if err == nil { + return session, nil + } + + select { + case <-ctx.Done(): + return session, errors.New("canceled by context") + case <-time.After(1 * time.Second): + } + } + + return session, err +} + +func (p *ProxyTUI) processAttempt(msg websocket.IncomingMessage) { + if msg.Attempt == nil { + p.cfg.Log.Debug("WebSocket specified for Events received unexpected event") + return + } + + webhookEvent := msg.Attempt + eventID := webhookEvent.Body.EventID + + p.cfg.Log.WithFields(log.Fields{ + "prefix": "proxy.ProxyTUI.processAttempt", + }).Debugf("Processing webhook event") + + url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path + + // Create request with context for timeout control + timeout := webhookEvent.Body.Request.Timeout + if timeout == 0 { + timeout = 1000 * 30 + } + + // Track active requests + atomic.AddInt32(&p.activeRequests, 1) + defer atomic.AddInt32(&p.activeRequests, -1) + + activeCount := atomic.LoadInt32(&p.activeRequests) + + // Calculate warning thresholds proportionally to max connections + maxConns := int32(p.transport.MaxConnsPerHost) + warningThreshold := int32(float64(maxConns) * 0.8) // Warn at 80% capacity + resetThreshold := int32(float64(maxConns) * 0.6) // Reset warning at 60% capacity + + // Warn when approaching connection limit + if activeCount > warningThreshold && !p.maxConnWarned { + p.maxConnWarned = true + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s High connection load detected (%d active requests)\n", + color.Yellow("⚠ WARNING:"), activeCount) + fmt.Printf(" The CLI is limited to %d concurrent connections per host.\n", p.transport.MaxConnsPerHost) + fmt.Printf(" Consider reducing request rate or increasing connection limit.\n") + fmt.Printf(" Run with --max-connections=%d to increase the limit.\n\n", maxConns*2) + } else if activeCount < resetThreshold && p.maxConnWarned { + // Reset warning flag when load decreases + p.maxConnWarned = false + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Millisecond) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, webhookEvent.Body.Request.Method, url, nil) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + x := make(map[string]json.RawMessage) + err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + for key, value := range x { + unquoted_value, _ := strconv.Unquote(string(value)) + req.Header.Set(key, unquoted_value) + } + + req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) + req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) + + // Start 100ms timer and HTTP request concurrently + requestStartTime := time.Now() + eventTime := requestStartTime + + // Channel to receive HTTP response or error + type httpResult struct { + res *http.Response + err error + } + responseCh := make(chan httpResult, 1) + + // Make HTTP request in goroutine + go func() { + res, err := p.httpClient.Do(req) + responseCh <- httpResult{res: res, err: err} + }() + + // Wait for either 100ms to pass or HTTP response to arrive + timer := time.NewTimer(100 * time.Millisecond) + defer timer.Stop() + + var eventShown bool + var result httpResult + + select { + case result = <-responseCh: + // Response came back within 100ms - show final event immediately + timer.Stop() + if result.err != nil { + p.handleRequestError(eventID, webhookEvent, result.err) + } else { + p.processEndpointResponse(webhookEvent, result.res, requestStartTime) + } + return + + case <-timer.C: + // 100ms passed - show pending event + eventShown = true + p.showPendingEvent(eventID, webhookEvent, eventTime) + + // Wait for HTTP response to complete + result = <-responseCh + } + + // If we showed pending event, now update it with final result + if eventShown { + if result.err != nil { + p.updateEventWithError(eventID, webhookEvent, result.err, eventTime) + } else { + p.updateEventWithResponse(eventID, webhookEvent, result.res, requestStartTime, eventTime) + } + } +} + +func (p *ProxyTUI) showPendingEvent(eventID string, webhookEvent *websocket.Attempt, eventTime time.Time) { + color := ansi.Color(os.Stdout) + localTime := eventTime.Format(timeLayout) + + pendingStr := fmt.Sprintf("%s [%s] %s %s %s", + color.Faint(localTime), + color.Faint("..."), + webhookEvent.Body.Request.Method, + fmt.Sprintf("http://localhost%s", webhookEvent.Body.Path), + color.Faint("(Waiting for response)"), + ) + + // Send pending event to UI + event := tui.EventInfo{ + ID: eventID, + AttemptID: webhookEvent.Body.AttemptId, + Status: 0, + Success: false, + Time: eventTime, + Data: webhookEvent, + LogLine: pendingStr, + ResponseStatus: 0, + ResponseDuration: 0, + } + + switch p.cfg.Output { + case "interactive": + if p.teaProgram != nil { + p.teaProgram.Send(tui.NewEventMsg{Event: event}) + } + case "compact": + fmt.Println(pendingStr) + case "quiet": + // Don't show pending events in quiet mode + } +} + +func (p *ProxyTUI) updateEventWithError(eventID string, webhookEvent *websocket.Attempt, err error, eventTime time.Time) { + color := ansi.Color(os.Stdout) + localTime := eventTime.Format(timeLayout) + + errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", + color.Faint(localTime), + color.Red("ERROR").Bold(), + webhookEvent.Body.Request.Method, + err, + ) + + // Update event in UI + switch p.cfg.Output { + case "interactive": + if p.teaProgram != nil { + p.teaProgram.Send(tui.UpdateEventMsg{ + EventID: eventID, + Time: eventTime, + Status: 0, + Success: false, + LogLine: errStr, + ResponseStatus: 0, + ResponseHeaders: nil, + ResponseBody: "", + ResponseDuration: 0, + }) + } + case "compact": + fmt.Println(errStr) + case "quiet": + fmt.Println(errStr) + } + + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ + Event: "attempt_response", + Body: websocket.ErrorAttemptBody{ + AttemptId: webhookEvent.Body.AttemptId, + Error: true, + }, + }}) +} + +func (p *ProxyTUI) updateEventWithResponse(eventID string, webhookEvent *websocket.Attempt, resp *http.Response, requestStartTime time.Time, eventTime time.Time) { + localTime := eventTime.Format(timeLayout) + color := ansi.Color(os.Stdout) + + // Build display URL (without team_id for cleaner display) + var displayURL string + if p.cfg.ProjectMode == "console" { + displayURL = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID + } else { + displayURL = p.cfg.DashboardBaseURL + "/events/" + webhookEvent.Body.EventID + } + + responseDuration := eventTime.Sub(requestStartTime) + durationMs := responseDuration.Milliseconds() + + outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", + color.Faint(localTime), + ansi.ColorizeStatus(resp.StatusCode), + resp.Request.Method, + resp.Request.URL, + color.Faint(fmt.Sprintf("(%dms)", durationMs)), + color.Faint("→"), + color.Faint(displayURL), + ) + + eventStatus := resp.StatusCode + eventSuccess := resp.StatusCode >= 200 && resp.StatusCode < 300 + + buf, err := ioutil.ReadAll(resp.Body) + if err != nil { + errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n", + color.Faint(localTime), + color.Red("ERROR").Bold(), + err, + ) + log.Errorf(errStr) + resp.Body.Close() + return + } + + // Close the body - connection can be reused since body was fully read + resp.Body.Close() + + responseHeaders := make(map[string][]string) + for key, values := range resp.Header { + responseHeaders[key] = values + } + responseBody := string(buf) + + // Update event in UI + switch p.cfg.Output { + case "interactive": + if p.teaProgram != nil { + p.teaProgram.Send(tui.UpdateEventMsg{ + EventID: eventID, + Time: eventTime, + Status: eventStatus, + Success: eventSuccess, + LogLine: outputStr, + ResponseStatus: eventStatus, + ResponseHeaders: responseHeaders, + ResponseBody: responseBody, + ResponseDuration: responseDuration, + }) + } + case "compact": + fmt.Println(outputStr) + case "quiet": + // Only print fatal errors + if !eventSuccess && eventStatus == 0 { + fmt.Println(outputStr) + } + } + + if p.webSocketClient != nil { + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + AttemptResponse: &websocket.AttemptResponse{ + Event: "attempt_response", + Body: websocket.AttemptResponseBody{ + AttemptId: webhookEvent.Body.AttemptId, + CLIPath: webhookEvent.Body.Path, + Status: resp.StatusCode, + Data: string(buf), + }, + }}) + } +} + +func (p *ProxyTUI) handleRequestError(eventID string, webhookEvent *websocket.Attempt, err error) { + color := ansi.Color(os.Stdout) + localTime := time.Now().Format(timeLayout) + + errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", + color.Faint(localTime), + color.Red("ERROR").Bold(), + webhookEvent.Body.Request.Method, + err, + ) + + // Send event to UI + event := tui.EventInfo{ + ID: eventID, + AttemptID: webhookEvent.Body.AttemptId, + Status: 0, + Success: false, + Time: time.Now(), + Data: webhookEvent, + LogLine: errStr, + ResponseStatus: 0, + ResponseDuration: 0, + } + + switch p.cfg.Output { + case "interactive": + if p.teaProgram != nil { + p.teaProgram.Send(tui.NewEventMsg{Event: event}) + } + case "compact": + fmt.Println(errStr) + case "quiet": + fmt.Println(errStr) + } + + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ + Event: "attempt_response", + Body: websocket.ErrorAttemptBody{ + AttemptId: webhookEvent.Body.AttemptId, + Error: true, + }, + }}) +} + +func (p *ProxyTUI) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response, requestStartTime time.Time) { + eventTime := time.Now() + localTime := eventTime.Format(timeLayout) + color := ansi.Color(os.Stdout) + + // Build display URL (without team_id for cleaner display) + var displayURL string + if p.cfg.ProjectMode == "console" { + displayURL = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID + } else { + displayURL = p.cfg.DashboardBaseURL + "/events/" + webhookEvent.Body.EventID + } + + responseDuration := eventTime.Sub(requestStartTime) + durationMs := responseDuration.Milliseconds() + + outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", + color.Faint(localTime), + ansi.ColorizeStatus(resp.StatusCode), + resp.Request.Method, + resp.Request.URL, + color.Faint(fmt.Sprintf("(%dms)", durationMs)), + color.Faint("→"), + color.Faint(displayURL), + ) + + eventStatus := resp.StatusCode + eventSuccess := resp.StatusCode >= 200 && resp.StatusCode < 300 + eventID := webhookEvent.Body.EventID + + buf, err := ioutil.ReadAll(resp.Body) + if err != nil { + errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n", + color.Faint(localTime), + color.Red("ERROR").Bold(), + err, + ) + log.Errorf(errStr) + resp.Body.Close() + return + } + + // Close the body - connection can be reused since body was fully read + resp.Body.Close() + + responseHeaders := make(map[string][]string) + for key, values := range resp.Header { + responseHeaders[key] = values + } + responseBody := string(buf) + + // Send event to UI + event := tui.EventInfo{ + ID: eventID, + AttemptID: webhookEvent.Body.AttemptId, + Status: eventStatus, + Success: eventSuccess, + Time: eventTime, + Data: webhookEvent, + LogLine: outputStr, + ResponseStatus: eventStatus, + ResponseHeaders: responseHeaders, + ResponseBody: responseBody, + ResponseDuration: responseDuration, + } + + switch p.cfg.Output { + case "interactive": + if p.teaProgram != nil { + p.teaProgram.Send(tui.NewEventMsg{Event: event}) + } + case "compact": + fmt.Println(outputStr) + case "quiet": + // Only print fatal errors + if !eventSuccess && eventStatus == 0 { + fmt.Println(outputStr) + } + } + + if p.webSocketClient != nil { + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + AttemptResponse: &websocket.AttemptResponse{ + Event: "attempt_response", + Body: websocket.AttemptResponseBody{ + AttemptId: webhookEvent.Body.AttemptId, + CLIPath: webhookEvent.Body.Path, + Status: resp.StatusCode, + Data: string(buf), + }, + }}) + } +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go new file mode 100644 index 0000000..fef4018 --- /dev/null +++ b/pkg/tui/model.go @@ -0,0 +1,401 @@ +package tui + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" + + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +const ( + maxEvents = 1000 // Maximum events to keep in memory (all navigable) + timeLayout = "2006-01-02 15:04:05" // Time format for display +) + +// EventInfo represents a single event with all its data +type EventInfo struct { + ID string // Event ID from Hookdeck + AttemptID string // Attempt ID (unique per retry) + Status int + Success bool + Time time.Time + Data *websocket.Attempt + LogLine string + ResponseStatus int + ResponseHeaders map[string][]string + ResponseBody string + ResponseDuration time.Duration +} + +// Model is the Bubble Tea model for the interactive TUI +type Model struct { + // Configuration + cfg *Config + + // Event history + events []EventInfo + selectedIndex int + userNavigated bool // Track if user has manually navigated away from latest + + // UI state + ready bool + hasReceivedEvent bool + isConnected bool + waitingFrameToggle bool + width int + height int + viewport viewport.Model + viewportReady bool + headerHeight int // Height of the fixed header + + // Details view state + showingDetails bool + detailsViewport viewport.Model + detailsContent string + eventsTitleShown bool // Track if "Events" title has been displayed + + // Header state + headerCollapsed bool // Track if connection header is collapsed +} + +// Config holds configuration for the TUI +type Config struct { + DeviceName string + APIKey string + APIBaseURL string + DashboardBaseURL string + ConsoleBaseURL string + ProjectMode string + ProjectID string + GuestURL string + TargetURL *url.URL + Sources []*hookdecksdk.Source + Connections []*hookdecksdk.Connection +} + +// NewModel creates a new TUI model +func NewModel(cfg *Config) Model { + return Model{ + cfg: cfg, + events: make([]EventInfo, 0), + selectedIndex: -1, + ready: false, + isConnected: false, + } +} + +// Init initializes the model (required by Bubble Tea) +func (m Model) Init() tea.Cmd { + return tea.Batch( + tickWaitingAnimation(), + ) +} + +// AddEvent adds a new event to the history +func (m *Model) AddEvent(event EventInfo) { + // Check for duplicates (same ID and timestamp) + for i := len(m.events) - 1; i >= 0; i-- { + if m.events[i].ID == event.ID && m.events[i].Time.Equal(event.Time) { + return // Duplicate, skip + } + } + + // Record if user is on the current latest before adding new event + wasOnLatest := m.selectedIndex == len(m.events)-1 + + // Add event + m.events = append(m.events, event) + + // Trim to maxEvents if exceeded - old events just disappear + if len(m.events) > maxEvents { + removeCount := len(m.events) - maxEvents + m.events = m.events[removeCount:] + + // Adjust selected index + if m.selectedIndex >= 0 { + m.selectedIndex -= removeCount + if m.selectedIndex < 0 { + // Selected event was removed, select latest + m.selectedIndex = len(m.events) - 1 + m.userNavigated = false + } + } + } + + // If user was on the latest event when new event arrived, resume auto-tracking + if m.userNavigated && wasOnLatest { + m.userNavigated = false + } + + // Auto-select latest unless user has manually navigated + if !m.userNavigated { + m.selectedIndex = len(m.events) - 1 + // Note: viewport will be scrolled in View() after content is updated + } + + // Mark as having received first event and auto-collapse header + if !m.hasReceivedEvent { + m.hasReceivedEvent = true + m.headerCollapsed = true // Auto-collapse on first event + } +} + +// UpdateEvent updates an existing event by EventID + Time +func (m *Model) UpdateEvent(update UpdateEventMsg) { + // Find event by EventID + Time (unique identifier for retries) + for i := range m.events { + if m.events[i].ID == update.EventID && m.events[i].Time.Equal(update.Time) { + // Update event fields + m.events[i].Status = update.Status + m.events[i].Success = update.Success + m.events[i].LogLine = update.LogLine + m.events[i].ResponseStatus = update.ResponseStatus + m.events[i].ResponseHeaders = update.ResponseHeaders + m.events[i].ResponseBody = update.ResponseBody + m.events[i].ResponseDuration = update.ResponseDuration + return + } + } +} + +// Navigate moves selection up or down (all events are navigable) +func (m *Model) Navigate(direction int) bool { + if len(m.events) == 0 { + return false + } + + // Ensure selected index is valid + if m.selectedIndex < 0 || m.selectedIndex >= len(m.events) { + m.selectedIndex = len(m.events) - 1 + m.userNavigated = false + return false + } + + // Calculate new position + newIndex := m.selectedIndex + direction + + // Clamp to valid range + if newIndex < 0 { + newIndex = 0 + } else if newIndex >= len(m.events) { + newIndex = len(m.events) - 1 + } + + if newIndex != m.selectedIndex { + m.selectedIndex = newIndex + m.userNavigated = true + + // Don't reset userNavigated here to avoid jump when navigating to latest + // It will be reset in AddEvent() when a new event arrives while on latest + + // Auto-scroll viewport to keep selected event visible + m.scrollToSelectedEvent() + + return true + } + + return false +} + +// scrollToSelectedEvent scrolls the viewport to keep the selected event visible +func (m *Model) scrollToSelectedEvent() { + if !m.viewportReady || m.selectedIndex < 0 { + return + } + + // Each event is one line, selected event is at line m.selectedIndex + // Add 1 to account for the leading newline in renderEventHistory + lineNum := m.selectedIndex + 1 + + // Scroll to make this line visible + if lineNum < m.viewport.YOffset { + // Selected is above visible area, scroll up + m.viewport.YOffset = lineNum + } else if lineNum >= m.viewport.YOffset+m.viewport.Height { + // Selected is below visible area, scroll down + m.viewport.YOffset = lineNum - m.viewport.Height + 1 + } + + // Clamp offset + if m.viewport.YOffset < 0 { + m.viewport.YOffset = 0 + } +} + +// GetSelectedEvent returns the currently selected event +func (m *Model) GetSelectedEvent() *EventInfo { + if len(m.events) == 0 { + return nil + } + + if m.selectedIndex < 0 || m.selectedIndex >= len(m.events) { + m.selectedIndex = len(m.events) - 1 + m.userNavigated = false + } + + return &m.events[m.selectedIndex] +} + +// calculateHeaderHeight counts the number of lines in the header +func (m *Model) calculateHeaderHeight(header string) int { + return strings.Count(header, "\n") + 1 +} + +// buildDetailsContent builds the formatted details view for an event +func (m *Model) buildDetailsContent(event *EventInfo) string { + var content strings.Builder + + content.WriteString("═══════════════════════════════════════════════════════════════\n") + content.WriteString(" EVENT DETAILS\n") + content.WriteString("═══════════════════════════════════════════════════════════════\n\n") + + // Event metadata + content.WriteString(faintStyle.Render("Event ID: ") + event.ID + "\n") + content.WriteString(faintStyle.Render("Time: ") + event.Time.Format(timeLayout) + "\n") + if event.ResponseDuration > 0 { + content.WriteString(faintStyle.Render("Duration: ") + event.ResponseDuration.String() + "\n") + } + content.WriteString("\n") + + // Request section + if event.Data != nil { + content.WriteString("─────────────────────────────────────────────────────────────\n") + content.WriteString(" REQUEST\n") + content.WriteString("─────────────────────────────────────────────────────────────\n\n") + + content.WriteString(faintStyle.Render("Method: ") + event.Data.Body.Request.Method + "\n") + content.WriteString(faintStyle.Render("Path: ") + event.Data.Body.Path + "\n\n") + + // Request headers + content.WriteString("Headers:\n") + if len(event.Data.Body.Request.Headers) > 0 { + // Parse headers JSON + var headers map[string]string + if err := json.Unmarshal(event.Data.Body.Request.Headers, &headers); err == nil { + for key, value := range headers { + content.WriteString(" " + faintStyle.Render(key+":") + " " + value + "\n") + } + } else { + content.WriteString(" " + string(event.Data.Body.Request.Headers) + "\n") + } + } else { + content.WriteString(" " + faintStyle.Render("(none)") + "\n") + } + content.WriteString("\n") + + // Request body + content.WriteString("Body:\n") + if event.Data.Body.Request.DataString != "" { + // Try to pretty print JSON + prettyBody := m.prettyPrintJSON(event.Data.Body.Request.DataString) + content.WriteString(prettyBody + "\n") + } else { + content.WriteString(" " + faintStyle.Render("(empty)") + "\n") + } + content.WriteString("\n") + } + + // Response section + content.WriteString("─────────────────────────────────────────────────────────────\n") + content.WriteString(" RESPONSE\n") + content.WriteString("─────────────────────────────────────────────────────────────\n\n") + + if event.ResponseStatus > 0 { + content.WriteString(faintStyle.Render("Status: ") + fmt.Sprintf("%d", event.ResponseStatus) + "\n\n") + + // Response headers + content.WriteString("Headers:\n") + if len(event.ResponseHeaders) > 0 { + for key, values := range event.ResponseHeaders { + for _, value := range values { + content.WriteString(" " + faintStyle.Render(key+":") + " " + value + "\n") + } + } + } else { + content.WriteString(" " + faintStyle.Render("(none)") + "\n") + } + content.WriteString("\n") + + // Response body + content.WriteString("Body:\n") + if event.ResponseBody != "" { + // Try to pretty print JSON + prettyBody := m.prettyPrintJSON(event.ResponseBody) + content.WriteString(prettyBody + "\n") + } else { + content.WriteString(" " + faintStyle.Render("(empty)") + "\n") + } + } else { + content.WriteString(faintStyle.Render("(No response received yet)") + "\n") + } + + content.WriteString("\n") + content.WriteString("═══════════════════════════════════════════════════════════════\n") + content.WriteString("Press " + boldStyle.Render("[d]") + " or " + boldStyle.Render("[ESC]") + " to close • " + boldStyle.Render("[↑↓]") + " to scroll\n") + content.WriteString("═══════════════════════════════════════════════════════════════\n") + + return content.String() +} + +// prettyPrintJSON attempts to pretty print JSON, returns original if not valid JSON +func (m *Model) prettyPrintJSON(input string) string { + var obj interface{} + if err := json.Unmarshal([]byte(input), &obj); err != nil { + // Not valid JSON, return original + return input + } + + // Pretty print with 2-space indentation + pretty, err := json.MarshalIndent(obj, "", " ") + if err != nil { + // Fallback to original + return input + } + + return string(pretty) +} + +// Messages for Bubble Tea + +// NewEventMsg is sent when a new webhook event arrives +type NewEventMsg struct { + Event EventInfo +} + +// UpdateEventMsg is sent when an existing event gets a response +type UpdateEventMsg struct { + EventID string // Event ID + Time time.Time // Time when event was received (unique with EventID) + Status int + Success bool + LogLine string + ResponseStatus int + ResponseHeaders map[string][]string + ResponseBody string + ResponseDuration time.Duration +} + +// ConnectingMsg is sent when starting to connect +type ConnectingMsg struct{} + +// ConnectedMsg is sent when websocket connects +type ConnectedMsg struct{} + +// DisconnectedMsg is sent when websocket disconnects +type DisconnectedMsg struct{} + +// TickWaitingMsg is sent to animate waiting indicator +type TickWaitingMsg struct{} + +func tickWaitingAnimation() tea.Cmd { + return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { + return TickWaitingMsg{} + }) +} diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go new file mode 100644 index 0000000..a62207e --- /dev/null +++ b/pkg/tui/styles.go @@ -0,0 +1,84 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +var ( + // Color definitions matching current implementation + colorGreen = lipgloss.Color("2") // Green for success + colorRed = lipgloss.Color("1") // Red for errors + colorYellow = lipgloss.Color("3") // Yellow for warnings + colorFaint = lipgloss.Color("240") // Faint gray + colorPurple = lipgloss.Color("5") // Purple for brand accent + colorCyan = lipgloss.Color("6") // Cyan for brand accent + + // Base styles + faintStyle = lipgloss.NewStyle(). + Foreground(colorFaint) + + boldStyle = lipgloss.NewStyle(). + Bold(true) + + greenStyle = lipgloss.NewStyle(). + Foreground(colorGreen) + + redStyle = lipgloss.NewStyle(). + Foreground(colorRed). + Bold(true) + + yellowStyle = lipgloss.NewStyle(). + Foreground(colorYellow) + + // Brand styles + brandStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("4")). // Blue + Bold(true) + + brandAccentStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("4")) // Blue + + // Component styles + selectionIndicatorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("7")) // White/default + + sectionTitleStyle = faintStyle.Copy() + + statusBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("7")) + + waitingDotStyle = greenStyle.Copy() + + connectingDotStyle = yellowStyle.Copy() + + dividerStyle = lipgloss.NewStyle(). + Foreground(colorFaint) + + // Status code color styles + successStatusStyle = lipgloss.NewStyle(). + Foreground(colorGreen) + + errorStatusStyle = lipgloss.NewStyle(). + Foreground(colorRed) + + warningStatusStyle = lipgloss.NewStyle(). + Foreground(colorYellow) +) + +// ColorizeStatus returns a styled status code string +func ColorizeStatus(status int) string { + statusStr := fmt.Sprintf("%d", status) + + switch { + case status >= 200 && status < 300: + return successStatusStyle.Render(statusStr) + case status >= 400: + return errorStatusStyle.Render(statusStr) + case status >= 300: + return warningStatusStyle.Render(statusStr) + default: + return statusStr + } +} diff --git a/pkg/tui/update.go b/pkg/tui/update.go new file mode 100644 index 0000000..9dc8687 --- /dev/null +++ b/pkg/tui/update.go @@ -0,0 +1,262 @@ +package tui + +import ( + "context" + "fmt" + "net/url" + "os/exec" + "runtime" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// Update handles all events in the Bubble Tea event loop +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.KeyMsg: + return m.handleKeyPress(msg) + + case tea.MouseMsg: + // Ignore all mouse events (including scroll) + // Navigation should only work with arrow keys + return m, nil + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + if !m.viewportReady { + // Initialize viewport on first window size message + // Reserve space for header (will be calculated dynamically) and status bar (3 lines) + m.viewport = viewport.New(msg.Width, msg.Height-15) // Initial estimate + m.viewportReady = true + m.ready = true + } else { + // Update viewport dimensions + m.viewport.Width = msg.Width + // Height will be set properly in the View function + } + return m, nil + + case NewEventMsg: + m.AddEvent(msg.Event) + return m, nil + + case UpdateEventMsg: + m.UpdateEvent(msg) + return m, nil + + case ConnectingMsg: + m.isConnected = false + return m, nil + + case ConnectedMsg: + m.isConnected = true + return m, nil + + case DisconnectedMsg: + m.isConnected = false + return m, nil + + case TickWaitingMsg: + // Toggle waiting animation + if !m.hasReceivedEvent { + m.waitingFrameToggle = !m.waitingFrameToggle + return m, tickWaitingAnimation() + } + return m, nil + + case retryResultMsg: + // Retry completed, could show notification if needed + return m, nil + + case openBrowserResultMsg: + // Browser opened, could show notification if needed + return m, nil + } + + return m, nil +} + +// handleKeyPress processes keyboard input +func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Always allow quit and header toggle + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "i", "I": + // Toggle header collapsed/expanded + m.headerCollapsed = !m.headerCollapsed + return m, nil + } + + // Disable other shortcuts until connected and first event received + if !m.isConnected || !m.hasReceivedEvent { + return m, nil + } + + // Handle navigation and actions + switch msg.String() { + case "up", "k": + if m.showingDetails { + // Scroll details view up + m.detailsViewport.LineUp(1) + return m, nil + } + if m.Navigate(-1) { + return m, nil + } + + case "down", "j": + if m.showingDetails { + // Scroll details view down + m.detailsViewport.LineDown(1) + return m, nil + } + if m.Navigate(1) { + return m, nil + } + + case "pgup": + if m.showingDetails { + m.detailsViewport.ViewUp() + return m, nil + } + + case "pgdown": + if m.showingDetails { + m.detailsViewport.ViewDown() + return m, nil + } + + case "r", "R": + // Retry selected event + return m, m.retrySelectedEvent() + + case "o", "O": + // Open event in browser + return m, m.openSelectedEventInBrowser() + + case "d", "D": + // Toggle event details view + if m.showingDetails { + // Close details view + m.showingDetails = false + } else { + // Open details view + selectedEvent := m.GetSelectedEvent() + if selectedEvent != nil { + m.detailsContent = m.buildDetailsContent(selectedEvent) + m.showingDetails = true + + // Initialize details viewport if not already done + m.detailsViewport = viewport.New(m.width, m.height) + m.detailsViewport.SetContent(m.detailsContent) + m.detailsViewport.GotoTop() + } + } + return m, nil + + case "esc": + // Close details view + if m.showingDetails { + m.showingDetails = false + return m, nil + } + } + + return m, nil +} + +// retrySelectedEvent retries the currently selected event +func (m Model) retrySelectedEvent() tea.Cmd { + selectedEvent := m.GetSelectedEvent() + if selectedEvent == nil || selectedEvent.ID == "" { + return nil + } + + eventID := selectedEvent.ID + apiKey := m.cfg.APIKey + apiBaseURL := m.cfg.APIBaseURL + projectID := m.cfg.ProjectID + + return func() tea.Msg { + // Create HTTP client + parsedBaseURL, err := url.Parse(apiBaseURL) + if err != nil { + return retryResultMsg{err: err} + } + + client := &hookdeck.Client{ + BaseURL: parsedBaseURL, + APIKey: apiKey, + ProjectID: projectID, + } + + // Make retry request + retryURL := fmt.Sprintf("/events/%s/retry", eventID) + resp, err := client.Post(context.Background(), retryURL, []byte("{}"), nil) + if err != nil { + return retryResultMsg{err: err} + } + defer resp.Body.Close() + + return retryResultMsg{success: true} + } +} + +// openSelectedEventInBrowser opens the event in the dashboard +func (m Model) openSelectedEventInBrowser() tea.Cmd { + selectedEvent := m.GetSelectedEvent() + if selectedEvent == nil || selectedEvent.ID == "" { + return nil + } + + return func() tea.Msg { + // Build event URL with team_id query parameter + var eventURL string + if m.cfg.ProjectMode == "console" { + eventURL = m.cfg.ConsoleBaseURL + "/?event_id=" + selectedEvent.ID + "&team_id=" + m.cfg.ProjectID + } else { + eventURL = m.cfg.DashboardBaseURL + "/events/" + selectedEvent.ID + "?team_id=" + m.cfg.ProjectID + } + + // Open in browser + err := openBrowser(eventURL) + return openBrowserResultMsg{err: err} + } +} + +// openBrowser opens a URL in the default browser (cross-platform) +func openBrowser(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + case "darwin": + cmd = "open" + args = []string{url} + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + args = []string{url} + } + + return exec.Command(cmd, args...).Start() +} + +// Result messages + +type retryResultMsg struct { + success bool + err error +} + +type openBrowserResultMsg struct { + err error +} diff --git a/pkg/tui/view.go b/pkg/tui/view.go new file mode 100644 index 0000000..e706b33 --- /dev/null +++ b/pkg/tui/view.go @@ -0,0 +1,443 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// View renders the TUI with fixed header and scrollable event list +func (m Model) View() string { + if !m.ready || !m.viewportReady { + return "" + } + + // If showing details, render full-screen details view + if m.showingDetails { + return m.detailsViewport.View() + } + + // Build fixed header (connection info + events title + divider) + var header strings.Builder + header.WriteString(m.renderConnectionInfo()) + header.WriteString("\n") + + // Add events title with divider + eventsTitle := "Events • [↑↓] Navigate " + titleLen := len(eventsTitle) + remainingWidth := m.width - titleLen + if remainingWidth < 0 { + remainingWidth = 0 + } + dividerLine := strings.Repeat("─", remainingWidth) + header.WriteString(faintStyle.Render(eventsTitle + dividerLine)) + header.WriteString("\n") + + headerStr := header.String() + headerHeight := m.calculateHeaderHeight(headerStr) + + // Build scrollable content for viewport + var content strings.Builder + + // If not connected yet, show connecting status + if !m.isConnected { + content.WriteString("\n") + content.WriteString(m.renderConnectingStatus()) + content.WriteString("\n") + } else if !m.hasReceivedEvent { + // If no events received yet, show waiting animation + content.WriteString("\n") + content.WriteString(m.renderWaitingStatus()) + content.WriteString("\n") + } else { + // Add newline before event history (part of scrollable content) + content.WriteString("\n") + // Render event history + content.WriteString(m.renderEventHistory()) + } + + // Update viewport content + m.viewport.SetContent(content.String()) + + // Calculate exact viewport height + // m.height is total LINES on screen + // We need: header lines + viewport lines + divider (1) + status (1) = m.height + + var viewportHeight int + if m.hasReceivedEvent { + // Total lines: header + viewport + divider + status + viewportHeight = m.height - headerHeight - 2 + } else { + // Total lines: header + viewport + viewportHeight = m.height - headerHeight + } + + if viewportHeight < 1 { + viewportHeight = 1 + } + m.viewport.Height = viewportHeight + + // Auto-scroll to bottom if tracking latest event + if !m.userNavigated && len(m.events) > 0 { + m.viewport.GotoBottom() + } + + // Build output with exact line control + output := headerStr // Header with its newlines + + // Viewport renders exactly viewportHeight lines + viewportOutput := m.viewport.View() + output += viewportOutput + + if m.hasReceivedEvent { + // Ensure we have a newline before divider if viewport doesn't end with one + if !strings.HasSuffix(viewportOutput, "\n") { + output += "\n" + } + + // Divider line + divider := strings.Repeat("─", m.width) + output += dividerStyle.Render(divider) + "\n" + + // Status bar - LAST line, no trailing newline + output += m.renderStatusBar() + } else { + // Remove any trailing newline if no status bar + output = strings.TrimSuffix(output, "\n") + } + + return output +} + +// renderConnectingStatus shows the connecting animation +func (m Model) renderConnectingStatus() string { + dot := "ā—" + if m.waitingFrameToggle { + dot = "ā—‹" + } + + return connectingDotStyle.Render(dot) + " Connecting..." +} + +// renderWaitingStatus shows the waiting animation before first event +func (m Model) renderWaitingStatus() string { + dot := "ā—" + if m.waitingFrameToggle { + dot = "ā—‹" + } + + return waitingDotStyle.Render(dot) + " Connected. Waiting for events..." +} + +// renderEventHistory renders all events with selection indicator on selected +func (m Model) renderEventHistory() string { + if len(m.events) == 0 { + return "" + } + + var s strings.Builder + + // Render all events with selection indicator + for i, event := range m.events { + if i == m.selectedIndex { + // Selected event - show with ">" prefix + s.WriteString(selectionIndicatorStyle.Render("> ")) + s.WriteString(event.LogLine) + } else { + // Non-selected event - no prefix + s.WriteString(event.LogLine) + } + s.WriteString("\n") + } + + return s.String() +} + +// renderStatusBar renders the bottom status bar with keyboard shortcuts +func (m Model) renderStatusBar() string { + selectedEvent := m.GetSelectedEvent() + if selectedEvent == nil { + return "" + } + + // Determine width-based verbosity + isNarrow := m.width < 100 + isVeryNarrow := m.width < 60 + + // Build status message + var statusMsg string + eventType := "Last event" + if m.userNavigated { + eventType = "Selected event" + } + + if selectedEvent.Success { + // Success status + checkmark := greenStyle.Render("āœ“") + if isVeryNarrow { + statusMsg = fmt.Sprintf("> %s %s [%d]", checkmark, eventType, selectedEvent.Status) + } else if isNarrow { + statusMsg = fmt.Sprintf("> %s %s succeeded [%d] | [r] [o] [d] [q]", + checkmark, eventType, selectedEvent.Status) + } else { + statusMsg = fmt.Sprintf("> %s %s succeeded with status %d | [r] Retry • [o] Open in dashboard • [d] Show data", + checkmark, eventType, selectedEvent.Status) + } + } else { + // Error status + xmark := redStyle.Render("x") + statusText := "failed" + if selectedEvent.Status == 0 { + statusText = "failed with error" + } else { + statusText = fmt.Sprintf("failed with status %d", selectedEvent.Status) + } + + if isVeryNarrow { + if selectedEvent.Status == 0 { + statusMsg = fmt.Sprintf("> %s %s [ERR]", xmark, eventType) + } else { + statusMsg = fmt.Sprintf("> %s %s [%d]", xmark, eventType, selectedEvent.Status) + } + } else if isNarrow { + if selectedEvent.Status == 0 { + statusMsg = fmt.Sprintf("> %s %s failed | [r] [o] [d] [q]", + xmark, eventType) + } else { + statusMsg = fmt.Sprintf("> %s %s failed [%d] | [r] [o] [d] [q]", + xmark, eventType, selectedEvent.Status) + } + } else { + statusMsg = fmt.Sprintf("> %s %s %s | [r] Retry • [o] Open in dashboard • [d] Show event data", + xmark, eventType, statusText) + } + } + + return statusBarStyle.Render(statusMsg) +} + +// FormatEventLog formats an event into a log line matching the current style +func FormatEventLog(event EventInfo, dashboardURL, consoleURL, projectMode string) string { + localTime := event.Time.Format(timeLayout) + + // Build event URL + var url string + if projectMode == "console" { + url = consoleURL + "/?event_id=" + event.ID + } else { + url = dashboardURL + "/events/" + event.ID + } + + // Format based on whether request failed or succeeded + if event.ResponseStatus == 0 && !event.Success { + // Request failed completely (no response) + return fmt.Sprintf("%s [%s] Failed to %s: network error", + faintStyle.Render(localTime), + redStyle.Render("ERROR"), + event.Data.Body.Request.Method, + ) + } + + // Format normal response + durationMs := event.ResponseDuration.Milliseconds() + requestURL := fmt.Sprintf("http://localhost%s", event.Data.Body.Path) // Simplified for now + + return fmt.Sprintf("%s [%s] %s %s %s %s %s", + faintStyle.Render(localTime), + ColorizeStatus(event.ResponseStatus), + event.Data.Body.Request.Method, + requestURL, + faintStyle.Render(fmt.Sprintf("(%dms)", durationMs)), + faintStyle.Render("→"), + faintStyle.Render(url), + ) +} + +// renderConnectionInfo renders the sources and connections header +func (m Model) renderConnectionInfo() string { + // If header is collapsed, show compact view + if m.headerCollapsed { + return m.renderCompactHeader() + } + + var s strings.Builder + + // Brand header + s.WriteString(m.renderBrandHeader()) + s.WriteString("\n\n") + + // Title with source/connection count and collapse hint + numSources := 0 + numConnections := 0 + if m.cfg.Sources != nil { + numSources = len(m.cfg.Sources) + } + if m.cfg.Connections != nil { + numConnections = len(m.cfg.Connections) + } + + sourcesText := fmt.Sprintf("%d source", numSources) + if numSources != 1 { + sourcesText += "s" + } + connectionsText := fmt.Sprintf("%d connection", numConnections) + if numConnections != 1 { + connectionsText += "s" + } + + listeningTitle := fmt.Sprintf("Listening on %s • %s • [i] Collapse", sourcesText, connectionsText) + s.WriteString(faintStyle.Render(listeningTitle)) + s.WriteString("\n\n") + + // Group connections by source + sourceConnections := make(map[string][]*struct { + connection *interface{} + destName string + cliPath string + }) + + if m.cfg.Sources != nil && m.cfg.Connections != nil { + for _, conn := range m.cfg.Connections { + sourceID := conn.Source.Id + destName := "" + cliPath := "" + + if conn.FullName != nil { + parts := strings.Split(*conn.FullName, "->") + if len(parts) == 2 { + destName = strings.TrimSpace(parts[1]) + } + } + + if conn.Destination.CliPath != nil { + cliPath = *conn.Destination.CliPath + } + + if sourceConnections[sourceID] == nil { + sourceConnections[sourceID] = make([]*struct { + connection *interface{} + destName string + cliPath string + }, 0) + } + + sourceConnections[sourceID] = append(sourceConnections[sourceID], &struct { + connection *interface{} + destName string + cliPath string + }{nil, destName, cliPath}) + } + + // Render each source + for i, source := range m.cfg.Sources { + s.WriteString(boldStyle.Render(source.Name)) + s.WriteString("\n") + + // Show webhook URL + s.WriteString("│ Requests to → ") + s.WriteString(source.Url) + s.WriteString("\n") + + // Show connections + if conns, exists := sourceConnections[source.Id]; exists { + numConns := len(conns) + for j, conn := range conns { + fullPath := m.cfg.TargetURL.Scheme + "://" + m.cfg.TargetURL.Host + conn.cliPath + + connDisplay := "" + if conn.destName != "" { + connDisplay = " " + faintStyle.Render(fmt.Sprintf("(%s)", conn.destName)) + } + + if j == numConns-1 { + s.WriteString("└─ Forwards to → ") + } else { + s.WriteString("ā”œā”€ Forwards to → ") + } + s.WriteString(fullPath) + s.WriteString(connDisplay) + s.WriteString("\n") + } + } + + // Add spacing between sources + if i < len(m.cfg.Sources)-1 { + s.WriteString("\n") + } + } + } + + // Dashboard/guest URL hint + s.WriteString("\n") + if m.cfg.GuestURL != "" { + s.WriteString("šŸ’” Sign up to make your webhook URL permanent: ") + s.WriteString(m.cfg.GuestURL) + } else { + // Build URL with team_id query parameter + var displayURL string + if m.cfg.ProjectMode == "console" { + displayURL = m.cfg.ConsoleBaseURL + "?team_id=" + m.cfg.ProjectID + } else { + displayURL = m.cfg.DashboardBaseURL + "/events/cli?team_id=" + m.cfg.ProjectID + } + s.WriteString("šŸ’” View dashboard to inspect, retry & bookmark events: ") + s.WriteString(displayURL) + } + s.WriteString("\n") + + return s.String() +} + +// renderBrandHeader renders the Hookdeck CLI brand header +func (m Model) renderBrandHeader() string { + // Connection visual with brand name + leftLine := brandAccentStyle.Render("ā—ā”€ā”€") + rightLine := brandAccentStyle.Render("ā”€ā”€ā—") + brandName := brandStyle.Render(" HOOKDECK CLI ") + return leftLine + brandName + rightLine +} + +// renderCompactHeader renders a collapsed/compact version of the connection header +func (m Model) renderCompactHeader() string { + var s strings.Builder + + // Brand header + s.WriteString(m.renderBrandHeader()) + s.WriteString("\n\n") + + // Count sources and connections + numSources := 0 + numConnections := 0 + if m.cfg.Sources != nil { + numSources = len(m.cfg.Sources) + } + if m.cfg.Connections != nil { + numConnections = len(m.cfg.Connections) + } + + // Compact summary with toggle hint + sourcesText := fmt.Sprintf("%d source", numSources) + if numSources != 1 { + sourcesText += "s" + } + connectionsText := fmt.Sprintf("%d connection", numConnections) + if numConnections != 1 { + connectionsText += "s" + } + + summary := fmt.Sprintf("Listening on %s • %s • [i] Expand", + sourcesText, + connectionsText) + s.WriteString(faintStyle.Render(summary)) + s.WriteString("\n") + + return s.String() +} + +// Utility function to strip ANSI codes for length calculation (if needed) +func stripANSI(s string) string { + // Lipgloss handles this internally, but we can provide a simple implementation + // For now, we'll use the string as-is since Lipgloss manages rendering + return lipgloss.NewStyle().Render(s) +}