diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 654c75e..72a4d4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.18.1 + go-version-file: "go.mod" - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -47,7 +47,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.18.1 + go-version-file: "go.mod" - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -66,7 +66,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.18.1 + go-version-file: "go.mod" - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -128,9 +128,9 @@ jobs: uses: EndBug/add-and-commit@v9 with: default_author: github_actions - message: 'Update package.json version to ${{ steps.tag-version.outputs.TAG_VERSION }}' - add: 'package.json' - + message: "Update package.json version to ${{ steps.tag-version.outputs.TAG_VERSION }}" + add: "package.json" + - run: npm ci - name: Determine npm tag for pre-releases diff --git a/.github/workflows/test-acceptance.yml b/.github/workflows/test-acceptance.yml index 1487f07..77b8692 100644 --- a/.github/workflows/test-acceptance.yml +++ b/.github/workflows/test-acceptance.yml @@ -15,9 +15,9 @@ jobs: uses: actions/checkout@v3 - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: - go-version: "1.18" + go-version-file: "go.mod" - name: Make script executable run: chmod +x test-scripts/test-acceptance.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a936e1f..47dee5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ name: test on: workflow_run: workflows: ["Dependabot PR Check"] - types: + types: - completed pull_request: branches: @@ -21,7 +21,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.18.1 + go-version-file: "go.mod" - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -77,6 +77,6 @@ jobs: uses: goreleaser/goreleaser-action@v5 with: version: v2.10.2 - args: release --skip=publish --snapshot -f .goreleaser/windows.yml --clean + args: release --skip=publish --snapshot -f .goreleaser/windows.yml --clean env: GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} diff --git a/.tool-versions b/.tool-versions index 0e32bb7..64ccbe6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -golang 1.18.1 +golang 1.24.8 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..2bfc236 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,16 @@ 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 github.com/BurntSushi/toml v1.5.0 github.com/briandowns/spinner v1.23.2 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/google/go-github/v28 v28.1.1 github.com/gorilla/websocket v1.5.3 github.com/gosimple/slug v1.15.0 @@ -19,12 +24,18 @@ 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/colorprofile v0.2.3-0.20250311203215-f60798e515dc // 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..5535430 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= @@ -164,8 +184,13 @@ github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 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 +209,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 +245,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 +296,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= @@ -282,6 +318,8 @@ golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -349,11 +387,13 @@ 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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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..60edd89 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "net/url" + "os" "regexp" "strings" @@ -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,16 @@ 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" { + fmt.Println() + printSourcesWithConnections(config, sources, connections, URL, guestURL) + fmt.Println() + } + // For interactive mode, connection info will be shown in TUI + + // Create proxy config + proxyCfg := &proxy.Config{ DeviceName: config.DeviceName, Key: config.Profile.APIKey, ProjectID: config.Profile.ProjectId, @@ -140,12 +146,37 @@ 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) + } + + // Create renderer based on output mode + rendererCfg := &proxy.RendererConfig{ + DeviceName: config.DeviceName, + APIKey: config.Profile.APIKey, + APIBaseURL: config.APIBaseURL, + DashboardBaseURL: config.DashboardBaseURL, + ConsoleBaseURL: config.ConsoleBaseURL, + ProjectMode: config.Profile.ProjectMode, + ProjectID: config.Profile.ProjectId, + GuestURL: guestURL, + TargetURL: URL, + Output: flags.Output, + Sources: sources, + Connections: connections, + } + + renderer := proxy.NewRenderer(rendererCfg) + + // Create and run proxy with renderer + p := proxy.New(proxyCfg, connections, renderer) err = p.Run(context.Background()) if err != nil { - return err + // Renderer is already cleaned up, safe to print error + fmt.Fprintf(os.Stderr, "\n%s\n", err) + os.Exit(1) } return nil diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 265c0b9..466198f 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -2,50 +2,91 @@ package listen import ( "fmt" + "net/url" + "strings" "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/config" hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" ) -func printListenMessage(config *config.Config, isMultiSource bool) { - if !isMultiSource { - return +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() - 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")) + // 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..d2ad190 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -20,7 +20,6 @@ import ( 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/websocket" hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" @@ -28,10 +27,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 +40,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 @@ -71,11 +67,11 @@ type Proxy struct { httpClient *http.Client transport *http.Transport activeRequests int32 - maxConnWarned bool // Track if we've warned about connection limit + maxConnWarned bool // Track if we've warned about connection limit + renderer Renderer } 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) @@ -94,7 +90,7 @@ func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { // - Create a new CLI session // - Create a new websocket connection func (p *Proxy) Run(parentCtx context.Context) error { - const maxConnectAttempts = 3 + const maxConnectAttempts = 10 nAttempts := 0 // Track whether or not we have connected successfully. @@ -117,17 +113,20 @@ func (p *Proxy) Run(parentCtx context.Context) error { }).Debug("Ctrl+C received, cleaning up...") }) - s := ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out) + // Notify renderer we're connecting + p.renderer.OnConnecting() session, err := p.createSession(signalCtx) if err != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - p.cfg.Log.Fatalf("Error while authenticating with Hookdeck: %v", err) + p.renderer.OnError(err) + p.renderer.Cleanup() + return fmt.Errorf("error while authenticating with Hookdeck: %v", err) } if session.Id == "" { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - p.cfg.Log.Fatalf("Error while starting a new session") + p.renderer.OnError(fmt.Errorf("error while starting a new session")) + p.renderer.Cleanup() + return fmt.Errorf("error while starting a new session") } // Main loop to keep attempting to connect to Hookdeck once @@ -145,14 +144,10 @@ func (p *Proxy) Run(parentCtx context.Context) error { }, ) - // Monitor the websocket for connection and update the spinner appropriately. + // Monitor the websocket for connection go func() { <-p.webSocketClient.Connected() - msg := "Ready! (^C to quit)" - if hasConnectedOnce { - msg = "Reconnected!" - } - ansi.StopSpinner(s, msg, p.cfg.Log.Out) + p.renderer.OnConnected() hasConnectedOnce = true }() @@ -160,25 +155,38 @@ func (p *Proxy) Run(parentCtx context.Context) error { go p.webSocketClient.Run(signalCtx) nAttempts++ - // Block until ctrl+c or the websocket connection is interrupted + // Block until ctrl+c, renderer quit, or websocket connection is interrupted select { case <-signalCtx.Done(): - ansi.StopSpinner(s, "", p.cfg.Log.Out) + return nil + case <-p.renderer.Done(): + // Renderer wants to quit (user pressed q or similar) + if p.webSocketClient != nil { + p.webSocketClient.Stop() + } + p.renderer.Cleanup() return nil case <-p.webSocketClient.NotifyExpired: - if canConnect() { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - s = ansi.StartNewSpinner("Connection lost, reconnecting...", p.cfg.Log.Out) - } else { - p.cfg.Log.Fatalf("Session expired. Terminating after %d failed attempts to reauthorize", nAttempts) + p.renderer.OnDisconnected() + if !canConnect() { + p.renderer.Cleanup() + return fmt.Errorf("Could not connect. Terminating after %d failed attempts to establish a connection.", nAttempts) } } - // Determine if we should backoff the connection retries. - attemptsOverMax := math.Max(0, float64(nAttempts-maxConnectAttempts)) - if canConnect() && attemptsOverMax > 0 { - // Determine the time to wait to reconnect, maximum of 10 second intervals - sleepDurationMS := int(math.Round(math.Min(100, math.Pow(attemptsOverMax, 2)) * 100)) + // Add backoff delay between all retry attempts + if canConnect() { + var sleepDurationMS int + + if nAttempts <= maxConnectAttempts { + // First 10 attempts: use a fixed 2 second delay + sleepDurationMS = 2000 + } else { + // After max attempts: exponential backoff, maximum of 10 second intervals + attemptsOverMax := float64(nAttempts - maxConnectAttempts) + sleepDurationMS = int(math.Round(math.Min(100, math.Pow(attemptsOverMax, 2)) * 100)) + } + log.WithField( "prefix", "proxy.Proxy.Run", ).Debugf( @@ -203,6 +211,9 @@ func (p *Proxy) Run(parentCtx context.Context) error { p.webSocketClient.Stop() } + // Clean up renderer + p.renderer.Cleanup() + log.WithFields(log.Fields{ "prefix": "proxy.Proxy.Run", }).Debug("Bye!") @@ -255,84 +266,92 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { } webhookEvent := msg.Attempt + eventID := webhookEvent.Body.EventID p.cfg.Log.WithFields(log.Fields{ "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) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Millisecond) - defer cancel() + activeCount := atomic.LoadInt32(&p.activeRequests) - 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 - } + // 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 - for key, value := range x { - unquoted_value, _ := strconv.Unquote(string(value)) - req.Header.Set(key, unquoted_value) - } + // Warn when approaching connection limit + if activeCount > warningThreshold && !p.maxConnWarned { + p.maxConnWarned = true + p.renderer.OnConnectionWarning(activeCount, p.transport.MaxConnsPerHost) + } 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 { + p.renderer.OnEventError(eventID, webhookEvent, err, time.Now()) + return + } + x := make(map[string]json.RawMessage) + err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) + if err != nil { + p.renderer.OnEventError(eventID, webhookEvent, err, time.Now()) + 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)) - req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) - req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) + // For interactive mode: start 100ms timer and HTTP request concurrently + requestStartTime := time.Now() + // 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) - 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, - ) + responseCh <- httpResult{res: res, err: err} + }() + + // For interactive mode, wait 100ms before showing pending event + timer := time.NewTimer(100 * time.Millisecond) + defer timer.Stop() - fmt.Println(errStr) + 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.renderer.OnEventError(eventID, webhookEvent, result.err, requestStartTime) p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ Event: "attempt_response", @@ -342,43 +361,64 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { }, }}) } 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() + p.processEndpointResponse(eventID, webhookEvent, result.res, requestStartTime) + result.res.Body.Close() } + return + + case <-timer.C: + // 100ms passed - show pending event (interactive mode only) + eventShown = true + p.renderer.OnEventPending(eventID, webhookEvent, requestStartTime) + + // Wait for HTTP response to complete + result = <-responseCh } -} -func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response) { - localTime := time.Now().Format(timeLayout) - color := ansi.Color(os.Stdout) - var url = p.cfg.DashboardBaseURL + "/cli/events/" + webhookEvent.Body.EventID - if p.cfg.ProjectMode == "console" { - url = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID + // If we showed pending event, now handle the final result + if eventShown { + if result.err != nil { + p.renderer.OnEventError(eventID, webhookEvent, result.err, requestStartTime) + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ + Event: "attempt_response", + Body: websocket.ErrorAttemptBody{ + AttemptId: webhookEvent.Body.AttemptId, + Error: true, + }, + }}) + } else { + p.processEndpointResponse(eventID, webhookEvent, result.res, requestStartTime) + result.res.Body.Close() + } } - outputStr := fmt.Sprintf("%s [%d] %s %s | %s", - color.Faint(localTime), - ansi.ColorizeStatus(resp.StatusCode), - resp.Request.Method, - resp.Request.URL, - url, - ) - fmt.Println(outputStr) +} +func (p *Proxy) processEndpointResponse(eventID string, webhookEvent *websocket.Attempt, resp *http.Response, requestStartTime time.Time) { 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"), - err, - ) - log.Errorf(errStr) - + log.Errorf("Failed to read response from endpoint, error = %v\n", err) return } + // Calculate response duration + responseDuration := time.Since(requestStartTime) + + // Prepare response headers + responseHeaders := make(map[string][]string) + for key, values := range resp.Header { + responseHeaders[key] = values + } + + // Call renderer with response data + p.renderer.OnEventComplete(eventID, webhookEvent, &EventResponse{ + StatusCode: resp.StatusCode, + Headers: responseHeaders, + Body: string(buf), + Duration: responseDuration, + }, requestStartTime) + + // Send response back to Hookdeck if p.webSocketClient != nil { p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ AttemptResponse: &websocket.AttemptResponse{ @@ -398,7 +438,7 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h // // New creates a new Proxy -func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { +func New(cfg *Config, connections []*hookdecksdk.Connection, renderer Renderer) *Proxy { if cfg.Log == nil { cfg.Log = &log.Logger{Out: ioutil.Discard} } @@ -413,12 +453,12 @@ func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { 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 + 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) + MaxConnsPerHost: maxConns, // User-configurable (default: 50) ResponseHeaderTimeout: 60 * time.Second, } @@ -431,6 +471,7 @@ func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { Transport: tr, // Timeout is controlled per-request via context in processAttempt }, + renderer: renderer, } return p diff --git a/pkg/proxy/renderer.go b/pkg/proxy/renderer.go new file mode 100644 index 0000000..23f0234 --- /dev/null +++ b/pkg/proxy/renderer.go @@ -0,0 +1,71 @@ +package proxy + +import ( + "net/url" + "time" + + "github.com/hookdeck/hookdeck-cli/pkg/websocket" + hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" +) + +// Renderer is the interface for handling proxy output +// Implementations handle different output modes (interactive, compact, quiet) +type Renderer interface { + // Lifecycle events + OnConnecting() + OnConnected() + OnDisconnected() + OnError(err error) + + // Event handling + OnEventPending(eventID string, attempt *websocket.Attempt, startTime time.Time) // For interactive mode (100ms delay) + OnEventComplete(eventID string, attempt *websocket.Attempt, response *EventResponse, startTime time.Time) + OnEventError(eventID string, attempt *websocket.Attempt, err error, startTime time.Time) + + // Connection warnings + OnConnectionWarning(activeRequests int32, maxConns int) + + // Cleanup is called before exit to clean up resources (e.g., stop TUI, stop spinner) + Cleanup() + + // Done returns a channel that signals when user wants to quit + Done() <-chan struct{} +} + +// EventResponse contains the HTTP response data +type EventResponse struct { + StatusCode int + Headers map[string][]string + Body string + Duration time.Duration +} + +// RendererConfig contains configuration for creating renderers +type RendererConfig struct { + DeviceName string + APIKey string + APIBaseURL string + DashboardBaseURL string + ConsoleBaseURL string + ProjectMode string + ProjectID string + GuestURL string + TargetURL *url.URL + Output string + Sources []*hookdecksdk.Source + Connections []*hookdecksdk.Connection +} + +// NewRenderer creates the appropriate renderer based on output mode +func NewRenderer(cfg *RendererConfig) Renderer { + switch cfg.Output { + case "interactive": + return NewInteractiveRenderer(cfg) + case "compact": + return NewSimpleRenderer(cfg, false) // verbose mode + case "quiet": + return NewSimpleRenderer(cfg, true) // quiet mode + default: + return NewSimpleRenderer(cfg, false) + } +} diff --git a/pkg/proxy/renderer_interactive.go b/pkg/proxy/renderer_interactive.go new file mode 100644 index 0000000..d3b5d0c --- /dev/null +++ b/pkg/proxy/renderer_interactive.go @@ -0,0 +1,229 @@ +package proxy + +import ( + "fmt" + "os" + "time" + + tea "github.com/charmbracelet/bubbletea" + log "github.com/sirupsen/logrus" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/tui" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +const interactiveTimeLayout = "2006-01-02 15:04:05" + +// InteractiveRenderer renders events using Bubble Tea TUI +type InteractiveRenderer struct { + cfg *RendererConfig + teaProgram *tea.Program + teaModel *tui.Model + doneCh chan struct{} +} + +// NewInteractiveRenderer creates a new interactive renderer with Bubble Tea +func NewInteractiveRenderer(cfg *RendererConfig) *InteractiveRenderer { + tuiCfg := &tui.Config{ + DeviceName: cfg.DeviceName, + APIKey: cfg.APIKey, + APIBaseURL: cfg.APIBaseURL, + DashboardBaseURL: cfg.DashboardBaseURL, + ConsoleBaseURL: cfg.ConsoleBaseURL, + ProjectMode: cfg.ProjectMode, + ProjectID: cfg.ProjectID, + GuestURL: cfg.GuestURL, + TargetURL: cfg.TargetURL, + Sources: cfg.Sources, + Connections: cfg.Connections, + } + + model := tui.NewModel(tuiCfg) + program := tea.NewProgram(&model, tea.WithAltScreen()) + + r := &InteractiveRenderer{ + cfg: cfg, + teaProgram: program, + teaModel: &model, + doneCh: make(chan struct{}), + } + + // Start TUI in background + go func() { + if _, err := r.teaProgram.Run(); err != nil { + log.WithField("prefix", "proxy.InteractiveRenderer"). + Errorf("Bubble Tea error: %v", err) + } + // Signal that TUI has exited + close(r.doneCh) + }() + + return r +} + +// OnConnecting is called when starting to connect +func (r *InteractiveRenderer) OnConnecting() { + if r.teaProgram != nil { + r.teaProgram.Send(tui.ConnectingMsg{}) + } +} + +// OnConnected is called when websocket connects +func (r *InteractiveRenderer) OnConnected() { + if r.teaProgram != nil { + r.teaProgram.Send(tui.ConnectedMsg{}) + } +} + +// OnDisconnected is called when websocket disconnects +func (r *InteractiveRenderer) OnDisconnected() { + if r.teaProgram != nil { + r.teaProgram.Send(tui.DisconnectedMsg{}) + } +} + +// OnError is called when an error occurs +func (r *InteractiveRenderer) OnError(err error) { + // Errors are handled through OnEventError +} + +// OnEventPending is called when an event starts (after 100ms delay) +func (r *InteractiveRenderer) OnEventPending(eventID string, attempt *websocket.Attempt, startTime time.Time) { + r.showPendingEvent(eventID, attempt, startTime) +} + +// OnEventComplete is called when an event completes successfully +func (r *InteractiveRenderer) OnEventComplete(eventID string, attempt *websocket.Attempt, response *EventResponse, startTime time.Time) { + eventTime := time.Now() + localTime := eventTime.Format(interactiveTimeLayout) + color := ansi.Color(os.Stdout) + + var displayURL string + if r.cfg.ProjectMode == "console" { + displayURL = r.cfg.ConsoleBaseURL + "/?event_id=" + eventID + } else { + displayURL = r.cfg.DashboardBaseURL + "/events/" + eventID + } + + durationMs := response.Duration.Milliseconds() + + outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", + color.Faint(localTime), + ansi.ColorizeStatus(response.StatusCode), + attempt.Body.Request.Method, + r.cfg.TargetURL.Scheme+"://"+r.cfg.TargetURL.Host+r.cfg.TargetURL.Path+attempt.Body.Path, + color.Faint(fmt.Sprintf("(%dms)", durationMs)), + color.Faint("→"), + color.Faint(displayURL), + ) + + eventStatus := response.StatusCode + eventSuccess := response.StatusCode >= 200 && response.StatusCode < 300 + + // Send update message to TUI (will update existing pending event or create new if not found) + if r.teaProgram != nil { + r.teaProgram.Send(tui.UpdateEventMsg{ + EventID: eventID, + AttemptID: attempt.Body.AttemptId, + Time: startTime, + Data: attempt, + Status: eventStatus, + Success: eventSuccess, + LogLine: outputStr, + ResponseStatus: eventStatus, + ResponseHeaders: response.Headers, + ResponseBody: response.Body, + ResponseDuration: response.Duration, + }) + } +} + +// showPendingEvent shows a pending event (waiting for response) +func (r *InteractiveRenderer) showPendingEvent(eventID string, attempt *websocket.Attempt, eventTime time.Time) { + color := ansi.Color(os.Stdout) + localTime := eventTime.Format(interactiveTimeLayout) + + pendingStr := fmt.Sprintf("%s [%s] %s %s %s", + color.Faint(localTime), + color.Faint("..."), + attempt.Body.Request.Method, + fmt.Sprintf("http://localhost%s", attempt.Body.Path), + color.Faint("(Waiting for response)"), + ) + + event := tui.EventInfo{ + ID: eventID, + AttemptID: attempt.Body.AttemptId, + Status: 0, + Success: false, + Time: eventTime, + Data: attempt, + LogLine: pendingStr, + ResponseStatus: 0, + ResponseDuration: 0, + } + + if r.teaProgram != nil { + r.teaProgram.Send(tui.NewEventMsg{Event: event}) + } +} + +// OnEventError is called when an event encounters an error +func (r *InteractiveRenderer) OnEventError(eventID string, attempt *websocket.Attempt, err error, startTime time.Time) { + color := ansi.Color(os.Stdout) + localTime := time.Now().Format(interactiveTimeLayout) + + errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", + color.Faint(localTime), + color.Red("ERROR").Bold(), + attempt.Body.Request.Method, + err, + ) + + event := tui.EventInfo{ + ID: eventID, + AttemptID: attempt.Body.AttemptId, + Status: 0, + Success: false, + Time: time.Now(), + Data: attempt, + LogLine: errStr, + ResponseStatus: 0, + ResponseDuration: 0, + } + + if r.teaProgram != nil { + r.teaProgram.Send(tui.NewEventMsg{Event: event}) + } +} + +// OnConnectionWarning is called when approaching connection limits +func (r *InteractiveRenderer) OnConnectionWarning(activeRequests int32, maxConns int) { + // In interactive mode, warnings could be shown in TUI + // For now, just log it + log.WithField("prefix", "proxy.InteractiveRenderer"). + Warnf("High connection load: %d active requests (limit: %d)", activeRequests, maxConns) +} + +// Cleanup gracefully stops the TUI and restores terminal +func (r *InteractiveRenderer) Cleanup() { + if r.teaProgram != nil { + r.teaProgram.Quit() + // Wait a moment for graceful shutdown + select { + case <-r.doneCh: + // TUI exited cleanly + case <-time.After(100 * time.Millisecond): + // Timeout, force kill + r.teaProgram.Kill() + } + // Give terminal a moment to fully restore after alt screen exit + time.Sleep(50 * time.Millisecond) + } +} + +// Done returns a channel that is closed when the renderer wants to quit +func (r *InteractiveRenderer) Done() <-chan struct{} { + return r.doneCh +} diff --git a/pkg/proxy/renderer_simple.go b/pkg/proxy/renderer_simple.go new file mode 100644 index 0000000..15a85ef --- /dev/null +++ b/pkg/proxy/renderer_simple.go @@ -0,0 +1,156 @@ +package proxy + +import ( + "fmt" + "os" + "time" + + "github.com/briandowns/spinner" + log "github.com/sirupsen/logrus" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +const simpleTimeLayout = "2006-01-02 15:04:05" + +// SimpleRenderer renders events to stdout for compact and quiet modes +type SimpleRenderer struct { + cfg *RendererConfig + quietMode bool + doneCh chan struct{} + spinner *spinner.Spinner + hasConnected bool // Track if we've successfully connected at least once + isReconnecting bool // Track if we're currently in reconnection mode +} + +// NewSimpleRenderer creates a new simple renderer +func NewSimpleRenderer(cfg *RendererConfig, quietMode bool) *SimpleRenderer { + return &SimpleRenderer{ + cfg: cfg, + quietMode: quietMode, + doneCh: make(chan struct{}), + } +} + +// OnConnecting is called when starting to connect +func (r *SimpleRenderer) OnConnecting() { + r.spinner = ansi.StartNewSpinner("Getting ready...", log.StandardLogger().Out) +} + +// OnConnected is called when websocket connects +func (r *SimpleRenderer) OnConnected() { + r.hasConnected = true + r.isReconnecting = false // Reset reconnection state + if r.spinner != nil { + ansi.StopSpinner(r.spinner, "", log.StandardLogger().Out) + r.spinner = nil + color := ansi.Color(os.Stdout) + fmt.Printf("%s\n\n", color.Faint("Connected. Waiting for events...")) + } +} + +// OnDisconnected is called when websocket disconnects +func (r *SimpleRenderer) OnDisconnected() { + // Only show "Connection lost" if we've successfully connected before + if r.hasConnected && !r.isReconnecting { + // First disconnection - print newline for visual separation + fmt.Println() + // Stop any existing spinner first + if r.spinner != nil { + ansi.StopSpinner(r.spinner, "", log.StandardLogger().Out) + } + // Start new spinner with reconnection message + r.spinner = ansi.StartNewSpinner("Connection lost, reconnecting...", log.StandardLogger().Out) + r.isReconnecting = true + } + // If we haven't connected yet, the "Getting ready..." spinner is still showing + // If already reconnecting, the spinner is already showing +} + +// OnError is called when an error occurs +func (r *SimpleRenderer) OnError(err error) { + color := ansi.Color(os.Stdout) + fmt.Printf("%s %v\n", color.Red("ERROR:"), err) +} + +// OnEventPending is called when an event starts (not used in simple renderer) +func (r *SimpleRenderer) OnEventPending(eventID string, attempt *websocket.Attempt, startTime time.Time) { + // Simple renderer doesn't show pending events +} + +// OnEventComplete is called when an event completes successfully +func (r *SimpleRenderer) OnEventComplete(eventID string, attempt *websocket.Attempt, response *EventResponse, startTime time.Time) { + localTime := time.Now().Format(simpleTimeLayout) + color := ansi.Color(os.Stdout) + + // Build display URL + var displayURL string + if r.cfg.ProjectMode == "console" { + displayURL = r.cfg.ConsoleBaseURL + "/?event_id=" + eventID + } else { + displayURL = r.cfg.DashboardBaseURL + "/events/" + eventID + } + + durationMs := response.Duration.Milliseconds() + + outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", + color.Faint(localTime), + ansi.ColorizeStatus(response.StatusCode), + attempt.Body.Request.Method, + r.cfg.TargetURL.Scheme+"://"+r.cfg.TargetURL.Host+r.cfg.TargetURL.Path+attempt.Body.Path, + color.Faint(fmt.Sprintf("(%dms)", durationMs)), + color.Faint("→"), + color.Faint(displayURL), + ) + + // In quiet mode, only print fatal errors + if r.quietMode { + // Only show if it's a fatal error (status 0 means connection error) + if response.StatusCode == 0 { + fmt.Println(outputStr) + } + } else { + // Compact mode: print everything + fmt.Println(outputStr) + } +} + +// OnEventError is called when an event encounters an error +func (r *SimpleRenderer) OnEventError(eventID string, attempt *websocket.Attempt, err error, startTime time.Time) { + color := ansi.Color(os.Stdout) + localTime := time.Now().Format(simpleTimeLayout) + + errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", + color.Faint(localTime), + color.Red("ERROR").Bold(), + attempt.Body.Request.Method, + err, + ) + + // Always print errors (both compact and quiet modes show errors) + fmt.Println(errStr) +} + +// OnConnectionWarning is called when approaching connection limits +func (r *SimpleRenderer) OnConnectionWarning(activeRequests int32, maxConns int) { + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s High connection load detected (%d active requests)\n", + color.Yellow("⚠ WARNING:"), activeRequests) + fmt.Printf(" The CLI is limited to %d concurrent connections per host.\n", maxConns) + 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) +} + +// Cleanup stops the spinner and cleans up resources +func (r *SimpleRenderer) Cleanup() { + if r.spinner != nil { + ansi.StopSpinner(r.spinner, "", log.StandardLogger().Out) + r.spinner = nil + } +} + +// Done returns a channel that is closed when the renderer wants to quit +func (r *SimpleRenderer) Done() <-chan struct{} { + return r.doneCh +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go new file mode 100644 index 0000000..7d8e420 --- /dev/null +++ b/pkg/tui/model.go @@ -0,0 +1,410 @@ +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 using Time + EventID + // This allows the same event to appear multiple times if retried at different times + // while preventing true duplicates from the same moment + 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, or creates a new one if not found +func (m *Model) UpdateEvent(update UpdateEventMsg) { + // Find event by EventID + Time (same uniqueness criteria as AddEvent) + 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 + } + } + + // Event not found (response came back in < 100ms, so pending event was never created) + // Create a new event with the complete data + newEvent := EventInfo{ + ID: update.EventID, + AttemptID: update.AttemptID, + Status: update.Status, + Success: update.Success, + Time: update.Time, + Data: update.Data, + LogLine: update.LogLine, + ResponseStatus: update.ResponseStatus, + ResponseHeaders: update.ResponseHeaders, + ResponseBody: update.ResponseBody, + ResponseDuration: update.ResponseDuration, + } + m.AddEvent(newEvent) +} + +// 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(faintStyle.Render("[d] Return to event list • [↑↓] Scroll • [PgUp/PgDn] Page")) + content.WriteString("\n\n") + + // Event metadata - compact single line format + var metadataLine strings.Builder + metadataLine.WriteString(event.ID) + metadataLine.WriteString(" • ") + metadataLine.WriteString(event.Time.Format(timeLayout)) + if event.ResponseDuration > 0 { + metadataLine.WriteString(" • ") + metadataLine.WriteString(event.ResponseDuration.String()) + } + content.WriteString(metadataLine.String()) + content.WriteString("\n") + content.WriteString(faintStyle.Render(strings.Repeat("─", 63))) + content.WriteString("\n\n") + + // Request section + if event.Data != nil { + content.WriteString(boldStyle.Render("Request")) + content.WriteString("\n\n") + + // HTTP request line: METHOD URL + requestURL := m.cfg.TargetURL.Scheme + "://" + m.cfg.TargetURL.Host + event.Data.Body.Path + content.WriteString(event.Data.Body.Request.Method + " " + requestURL + "\n\n") + + // Request headers + 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") + } + } + content.WriteString("\n") + + // Request body + if event.Data.Body.Request.DataString != "" { + // Try to pretty print JSON + prettyBody := m.prettyPrintJSON(event.Data.Body.Request.DataString) + content.WriteString(prettyBody + "\n") + } + content.WriteString("\n") + } + + // Response section + content.WriteString(boldStyle.Render("Response")) + content.WriteString("\n\n") + + if event.ResponseStatus > 0 { + // HTTP status line + content.WriteString(fmt.Sprintf("%d", event.ResponseStatus) + "\n\n") + + // Response headers + if len(event.ResponseHeaders) > 0 { + for key, values := range event.ResponseHeaders { + for _, value := range values { + content.WriteString(faintStyle.Render(key+": ") + value + "\n") + } + } + } + content.WriteString("\n") + + // Response body + if event.ResponseBody != "" { + // Try to pretty print JSON + prettyBody := m.prettyPrintJSON(event.ResponseBody) + content.WriteString(prettyBody + "\n") + } + } else { + content.WriteString(faintStyle.Render("(No response received yet)") + "\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 from Hookdeck + AttemptID string // Attempt ID (unique per connection) + Time time.Time // Event time + Data *websocket.Attempt // Full attempt data + 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..c90cfa0 --- /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 (new attempt will arrive via websocket as a new event) + 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 (new attempt will arrive via websocket) + 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..c68bb5e --- /dev/null +++ b/pkg/tui/view.go @@ -0,0 +1,470 @@ +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 with action bar + if m.showingDetails { + return m.renderDetailsView() + } + + // 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() +} + +// renderDetailsView renders the details view with action bar at bottom +func (m Model) renderDetailsView() string { + // Calculate space for action bar (divider + action bar = 2 lines) + viewportHeight := m.height - 2 + if viewportHeight < 1 { + viewportHeight = 1 + } + m.detailsViewport.Height = viewportHeight + + var output strings.Builder + + // Viewport content (scrollable) + output.WriteString(m.detailsViewport.View()) + output.WriteString("\n") + + // Divider line + divider := strings.Repeat("─", m.width) + output.WriteString(dividerStyle.Render(divider)) + output.WriteString("\n") + + // Action bar - LAST line, no trailing newline + actionBar := "[d] Return to event list • [↑↓] Scroll • [PgUp/PgDn] Page" + output.WriteString(statusBarStyle.Render(actionBar)) + + return output.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) +}