diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 654c75e..bfc53ac 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: 1.24.9 - 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: 1.24.9 - 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: 1.24.9 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: diff --git a/.github/workflows/test-acceptance.yml b/.github/workflows/test-acceptance.yml index 1487f07..1e2672d 100644 --- a/.github/workflows/test-acceptance.yml +++ b/.github/workflows/test-acceptance.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: "1.18" + go-version: "1.24.9" - 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..e39cacf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.18.1 + go-version: 1.24.9 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -53,7 +53,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.18.1 + go-version: 1.24.9 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -72,7 +72,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.18.1 + go-version: 1.24.9 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: diff --git a/.tool-versions b/.tool-versions index 0e32bb7..9391853 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -golang 1.18.1 +golang 1.24.9 diff --git a/README.md b/README.md index 376b0fd..be7677d 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 +šŸ’” Open 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 + +stripe +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn01 +└─ Forwards to → http://localhost:3000/webhooks/stripe (cli-stripe) + +shopify +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn02 +└─ Forwards to → http://localhost:3000/webhooks/shopify (cli-shopify) + +twilio +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn03 +└─ Forwards to → http://localhost:3000/webhooks/twilio (cli-twilio) -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 +šŸ’” Open dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... -Connections -stripe -> cli-stripe forwarding to /webhooks/stripe -shopify -> cli-shopify forwarding to /webhooks/shopify -twilio -> cli-twilio forwarding to /webhooks/twilio +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── -⣾ Getting ready... +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 +šŸ’” Open 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,104 @@ 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) + +šŸ’” Open dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... + +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── + +> 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 +``` -Connections -Orders Service forwarding to /events/shopify/orders +#### Controlling output verbosity +The `--output` flag controls how events are displayed. This is useful for reducing resource usage in high-throughput scenarios or when running in the background. + +**Available modes:** + +- `interactive` (default) - Full-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 -⣾ Getting ready... +# 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. + +#### Filtering events + +The CLI supports filtering events using Hookdeck's filter syntax. Filters allow you to receive only events that match specific conditions, reducing noise and focusing on the events you care about during development. + +**Filter flags:** + +- `--filter-body` - Filter events by request body content (JSON) +- `--filter-headers` - Filter events by request headers (JSON) +- `--filter-query` - Filter events by query parameters (JSON) +- `--filter-path` - Filter events by request path (JSON) + +All filter flags accept JSON using [Hookdeck's filter syntax](https://hookdeck.com/docs/filters). You can use exact matches or operators like `$exist`, `$gte`, `$lte`, `$in`, etc. + +**Examples:** + +```sh +# Filter events by body content (only events with matching data) +hookdeck listen 3000 github --filter-body '{"action": "opened"}' + +# Filter events with multiple conditions +hookdeck listen 3000 stripe --filter-body '{"type": "charge.succeeded"}' --filter-headers '{"x-stripe-signature": {"$exist": true}}' + +# Filter using operators +hookdeck listen 3000 api --filter-body '{"amount": {"$gte": 100}}' +``` + +When filters are active, the CLI will display a warning message indicating which filters are applied. Only events matching all specified filter conditions will be forwarded to your local server. + #### 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 +361,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 +392,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 +šŸ’” Open 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 +437,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 +484,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 +559,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..7f73ab5 100644 --- a/go.mod +++ b/go.mod @@ -1,55 +1,71 @@ module github.com/hookdeck/hookdeck-cli -go 1.18 +go 1.24.9 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 - github.com/hookdeck/hookdeck-go-sdk v0.4.1 + github.com/hookdeck/hookdeck-go-sdk v0.7.0 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/mitchellh/go-homedir v1.1.0 github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.9.1 - github.com/spf13/pflag v1.0.7 - github.com/spf13/viper v1.7.1 - github.com/stretchr/testify v1.11.0 + github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 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/term v0.27.0 + golang.org/x/sys v0.37.0 + golang.org/x/term v0.36.0 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // 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 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gosimple/unidecode v1.0.1 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect 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/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/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.14 // 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.19 // 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/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // 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 - golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect - golang.org/x/text v0.4.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/text v0.30.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/ini.v1 v1.61.0 // indirect - gopkg.in/yaml.v2 v2.3.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f2d45d3..d1a86da 100644 --- a/go.sum +++ b/go.sum @@ -1,193 +1,116 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -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/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= -github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +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/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/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= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +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.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +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.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/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/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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= +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.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v28 v28.1.1 h1:kORf5ekX5qwXO2mGzXXOjMe/g6ap8ahVe0sBEulhSxo= github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM= -github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= -github.com/hookdeck/hookdeck-go-sdk v0.4.1 h1:r/rZJeBuDq31amTIB1LDHkA5lTAG2jAmZGqhgHRYKy8= -github.com/hookdeck/hookdeck-go-sdk v0.4.1/go.mod h1:kfFn3/WEGcxuPkaaf8lAq9L+3nYg45GwGy4utH/Tnmg= +github.com/hookdeck/hookdeck-go-sdk v0.7.0 h1:s+4gVXcoTwTcukdn6Fc2BydewmkK2QXyIZvAUQsIoVs= +github.com/hookdeck/hookdeck-go-sdk v0.7.0/go.mod h1:fewtdP5f8hnU+x35l2s8F3SSiE94cGz+Q3bR4sI8zlk= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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/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= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= -github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8= -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/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +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/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.14.1 h1:jMU0WaQrP0a/YAEq8eJmJKjBoMs+pClEr1vDMlM/Do4= @@ -195,251 +118,117 @@ github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9k github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= -github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -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/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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/afero v1.4.0 h1:jsLTaI1zwYO3vjrzHalkVcIHXTNmdQFepW4OI8H3+x8= -github.com/spf13/afero v1.4.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= -github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 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= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -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/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= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.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= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.61.0 h1:LBCdW4FmFYL4s/vDZD1RQYX7oAR6IjujCYgMdbHBR10= -gopkg.in/ini.v1 v1.61.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 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..8d9c64c 100644 --- a/pkg/cmd/listen.go +++ b/pkg/cmd/listen.go @@ -16,12 +16,14 @@ limitations under the License. package cmd import ( + "encoding/json" "errors" "fmt" "net/url" "strconv" "strings" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "github.com/hookdeck/hookdeck-cli/pkg/listen" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -32,6 +34,11 @@ type listenCmd struct { noWSS bool path string maxConnections int + output string + filterBody string + filterHeaders string + filterQuery string + filterPath string } // Map --cli-path to --path @@ -43,6 +50,54 @@ func normalizeCliPathFlag(f *pflag.FlagSet, name string) pflag.NormalizedName { return pflag.NormalizedName(name) } +// parseFilters builds a SessionFilters object from the filter flag values +func (lc *listenCmd) parseFilters() (*hookdeck.SessionFilters, error) { + var hasFilters bool + filters := &hookdeck.SessionFilters{} + + if lc.filterBody != "" { + hasFilters = true + var rawMsg json.RawMessage + if err := json.Unmarshal([]byte(lc.filterBody), &rawMsg); err != nil { + return nil, fmt.Errorf("invalid JSON in --filter-body: %w", err) + } + filters.Body = &rawMsg + } + + if lc.filterHeaders != "" { + hasFilters = true + var rawMsg json.RawMessage + if err := json.Unmarshal([]byte(lc.filterHeaders), &rawMsg); err != nil { + return nil, fmt.Errorf("invalid JSON in --filter-headers: %w", err) + } + filters.Headers = &rawMsg + } + + if lc.filterQuery != "" { + hasFilters = true + var rawMsg json.RawMessage + if err := json.Unmarshal([]byte(lc.filterQuery), &rawMsg); err != nil { + return nil, fmt.Errorf("invalid JSON in --filter-query: %w", err) + } + filters.Query = &rawMsg + } + + if lc.filterPath != "" { + hasFilters = true + var rawMsg json.RawMessage + if err := json.Unmarshal([]byte(lc.filterPath), &rawMsg); err != nil { + return nil, fmt.Errorf("invalid JSON in --filter-path: %w", err) + } + filters.Path = &rawMsg + } + + if !hasFilters { + return nil, nil + } + + return filters, nil +} + func newListenCmd() *listenCmd { lc := &listenCmd{} @@ -98,6 +153,13 @@ 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)") + + lc.cmd.Flags().StringVar(&lc.filterBody, "filter-body", "", "Filter events by request body using Hookdeck filter syntax (JSON)") + lc.cmd.Flags().StringVar(&lc.filterHeaders, "filter-headers", "", "Filter events by request headers using Hookdeck filter syntax (JSON)") + lc.cmd.Flags().StringVar(&lc.filterQuery, "filter-query", "", "Filter events by query parameters using Hookdeck filter syntax (JSON)") + lc.cmd.Flags().StringVar(&lc.filterPath, "filter-path", "", "Filter events by request path using Hookdeck filter syntax (JSON)") + // --cli-path is an alias for lc.cmd.Flags().SetNormalizeFunc(normalizeCliPathFlag) @@ -116,20 +178,32 @@ Arguments: `, 1) usage += fmt.Sprintf(` - + Examples: Forward events from a Hookdeck Source named "shopify" to a local server running on port %[1]d: hookdeck listen %[1]d shopify - + Forward events to a local server running on "http://myapp.test": hookdeck listen %[1]d http://myapp.test - + Forward events to the path "/webhooks" on local server running on port %[1]d: hookdeck listen %[1]d --path /webhooks + + Filter events by body content (only events with matching data): + + hookdeck listen %[1]d github --filter-body '{"action": "opened"}' + + Filter events with multiple conditions: + + hookdeck listen %[1]d stripe --filter-body '{"type": "charge.succeeded"}' --filter-headers '{"x-stripe-signature": {"$exist": true}}' + + Filter using operators (see https://hookdeck.com/docs/filters for syntax): + + hookdeck listen %[1]d api --filter-body '{"amount": {"$gte": 100}}' `, 3000) lc.cmd.SetUsageTemplate(usage) @@ -147,6 +221,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 { @@ -163,9 +247,17 @@ func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error { url.Scheme = "http" } + // Parse and validate filters + filters, err := lc.parseFilters() + if err != nil { + return err + } + return listen.Listen(url, sourceQuery, connectionQuery, listen.Flags{ NoWSS: lc.noWSS, Path: lc.path, + Output: lc.output, MaxConnections: lc.maxConnections, + Filters: filters, }, &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/config_test.go b/pkg/config/config_test.go index 844d397..be57651 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -255,7 +255,7 @@ func TestWriteConfig(t *testing.T) { // Assert assert.NoError(t, err) contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) - assert.Contains(t, string(contentBytes), `project_mode = "new_team_mode"`) + assert.Contains(t, string(contentBytes), `project_mode = 'new_team_mode'`) }) t.Run("use project", func(t *testing.T) { @@ -272,7 +272,7 @@ func TestWriteConfig(t *testing.T) { // Assert assert.NoError(t, err) contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) - assert.Contains(t, string(contentBytes), `project_id = "new_team_id"`) + assert.Contains(t, string(contentBytes), `project_id = 'new_team_id'`) }) t.Run("use profile", func(t *testing.T) { @@ -290,7 +290,7 @@ func TestWriteConfig(t *testing.T) { // Assert assert.NoError(t, err) contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) - assert.Contains(t, string(contentBytes), `profile = "account_3"`) + assert.Contains(t, string(contentBytes), `profile = 'account_3'`) }) t.Run("remove profile", func(t *testing.T) { 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/hookdeck/sdkclient.go b/pkg/hookdeck/sdkclient.go index 27d8686..777597a 100644 --- a/pkg/hookdeck/sdkclient.go +++ b/pkg/hookdeck/sdkclient.go @@ -9,6 +9,7 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/useragent" hookdeckclient "github.com/hookdeck/hookdeck-go-sdk/client" + hookdeckoption "github.com/hookdeck/hookdeck-go-sdk/option" ) const apiVersion = "/2024-03-01" @@ -43,8 +44,8 @@ func CreateSDKClient(init SDKClientInit) *hookdeckclient.Client { } return hookdeckclient.NewClient( - hookdeckclient.WithBaseURL(parsedBaseURL.String()), - hookdeckclient.WithHTTPHeader(header), + hookdeckoption.WithBaseURL(parsedBaseURL.String()), + hookdeckoption.WithHTTPHeader(header), ) } diff --git a/pkg/hookdeck/session.go b/pkg/hookdeck/session.go index 31acda2..617436a 100644 --- a/pkg/hookdeck/session.go +++ b/pkg/hookdeck/session.go @@ -12,8 +12,16 @@ type Session struct { Id string } +type SessionFilters struct { + Body *json.RawMessage `json:"body,omitempty"` + Headers *json.RawMessage `json:"headers,omitempty"` + Query *json.RawMessage `json:"query,omitempty"` + Path *json.RawMessage `json:"path,omitempty"` +} + type CreateSessionInput struct { - ConnectionIds []string `json:"webhook_ids"` + ConnectionIds []string `json:"webhook_ids"` + Filters *SessionFilters `json:"filters,omitempty"` } func (c *Client) CreateSession(input CreateSessionInput) (Session, error) { 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..e03b48f 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -20,12 +20,14 @@ import ( "errors" "fmt" "net/url" + "os" "regexp" "strings" "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/listen/proxy" "github.com/hookdeck/hookdeck-cli/pkg/login" - "github.com/hookdeck/hookdeck-cli/pkg/proxy" hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" log "github.com/sirupsen/logrus" ) @@ -34,6 +36,8 @@ type Flags struct { NoWSS bool Path string MaxConnections int + Output string + Filters *hookdeck.SessionFilters } // listenCmd represents the listen command @@ -67,12 +71,13 @@ 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 } sdkClient := config.GetClient() - // Prepare data - sources, err := getSources(sdkClient, sourceAliases) if err != nil { return err @@ -118,16 +123,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 +145,39 @@ 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) + Filters: flags.Filters, + } + + // 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, + Filters: flags.Filters, + } + + 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 @@ -182,7 +214,7 @@ func isPath(value string) (bool, error) { func validateData(sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection) error { if len(connections) == 0 { - return errors.New("no connections provided") + return errors.New("no matching connections found") } return nil diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 265c0b9..4be7732 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("šŸ’” Open dashboard to inspect, retry & bookmark events: \033]8;;%s\033\\%s\033]8;;\033\\\n", url, displayURL) } } diff --git a/pkg/proxy/proxy.go b/pkg/listen/proxy/proxy.go similarity index 53% rename from pkg/proxy/proxy.go rename to pkg/listen/proxy/proxy.go index ade232d..575acb4 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/listen/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 @@ -58,6 +54,8 @@ type Config struct { // Example: Set to 100+ when load testing with many parallel webhooks. // Warning: Setting this too high may cause resource exhaustion. MaxConnections int + // Filters for this CLI session + Filters *hookdeck.SessionFilters } // A Proxy opens a websocket connection with Hookdeck, listens for incoming @@ -71,11 +69,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 +92,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 +115,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 +146,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 +157,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 +213,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!") @@ -232,6 +245,7 @@ func (p *Proxy) createSession(ctx context.Context) (hookdeck.Session, error) { for i := 0; i <= 5; i++ { session, err = client.CreateSession(hookdeck.CreateSessionInput{ ConnectionIds: connectionIDs, + Filters: p.cfg.Filters, }) if err == nil { @@ -255,84 +269,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 +364,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 +441,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 +456,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 +474,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/listen/proxy/renderer.go b/pkg/listen/proxy/renderer.go new file mode 100644 index 0000000..1dc3d20 --- /dev/null +++ b/pkg/listen/proxy/renderer.go @@ -0,0 +1,73 @@ +package proxy + +import ( + "net/url" + "time" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "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 + Filters *hookdeck.SessionFilters +} + +// 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/listen/proxy/renderer_interactive.go b/pkg/listen/proxy/renderer_interactive.go new file mode 100644 index 0000000..4f450dc --- /dev/null +++ b/pkg/listen/proxy/renderer_interactive.go @@ -0,0 +1,230 @@ +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/listen/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, + Filters: cfg.Filters, + } + + 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/listen/proxy/renderer_simple.go b/pkg/listen/proxy/renderer_simple.go new file mode 100644 index 0000000..15e4e3d --- /dev/null +++ b/pkg/listen/proxy/renderer_simple.go @@ -0,0 +1,175 @@ +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) + + // Display filter warning if filters are active + if r.cfg.Filters != nil { + fmt.Printf("\n%s Filters provided, only events matching the filter will be forwarded for this session\n", color.Yellow("āŗ")) + if r.cfg.Filters.Body != nil { + fmt.Printf(" • Body: %s\n", color.Faint(string(*r.cfg.Filters.Body))) + } + if r.cfg.Filters.Headers != nil { + fmt.Printf(" • Headers: %s\n", color.Faint(string(*r.cfg.Filters.Headers))) + } + if r.cfg.Filters.Query != nil { + fmt.Printf(" • Query: %s\n", color.Faint(string(*r.cfg.Filters.Query))) + } + if r.cfg.Filters.Path != nil { + fmt.Printf(" • Path: %s\n", color.Faint(string(*r.cfg.Filters.Path))) + } + fmt.Println() + } + + 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/listen/source.go b/pkg/listen/source.go index 4e8797d..89cd87d 100644 --- a/pkg/listen/source.go +++ b/pkg/listen/source.go @@ -4,11 +4,13 @@ import ( "context" "errors" "fmt" + "os" "github.com/AlecAivazis/survey/v2" "github.com/hookdeck/hookdeck-cli/pkg/slug" hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" hookdeckclient "github.com/hookdeck/hookdeck-go-sdk/client" + "golang.org/x/term" ) // There are 4 cases: @@ -59,6 +61,40 @@ 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 + + // Check if stdin is a TTY (interactive terminal) + // If not (e.g., in CI or piped input), auto-accept source creation + isInteractive := term.IsTerminal(int(os.Stdin.Fd())) + + if isInteractive { + 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 { + // If survey fails (e.g., in background process or broken pipe), auto-accept in non-interactive scenarios + // Check if it's a terminal-related error + if err.Error() == "interrupt" { + // User pressed Ctrl+C, exit cleanly + os.Exit(0) + } + // For other errors (like broken pipe, EOF), assume non-interactive and auto-accept + fmt.Printf("Cannot prompt for confirmation. Automatically creating source \"%s\".\n", sourceQuery[0]) + createConfirm = true + } else if !createConfirm { + // User declined to create source, exit cleanly without error message + os.Exit(0) + } + } else { + // Non-interactive mode: auto-accept source creation + fmt.Printf("Non-interactive mode detected. Automatically creating source \"%s\".\n", sourceQuery[0]) + createConfirm = true + } + // Create source with provided name source, err := createSource(sdkClient, &sourceQuery[0]) if err != nil { @@ -159,6 +195,8 @@ func selectSources(availableSources []*hookdecksdk.Source) ([]*hookdecksdk.Sourc func createSource(sdkClient *hookdeckclient.Client, name *string) (*hookdecksdk.Source, error) { var sourceName string + fmt.Println("\033[2mA source represents where requests originate from (ie. Github, Stripe, Shopify, etc.). Each source has it's own unique URL that you can use to send requests to.\033[0m") + if name != nil { sourceName = *name } else { @@ -168,7 +206,7 @@ func createSource(sdkClient *hookdeckclient.Client, name *string) (*hookdecksdk. var qs = []*survey.Question{ { Name: "label", - Prompt: &survey.Input{Message: "What should be your new source label?"}, + Prompt: &survey.Input{Message: "What should be the name of your first source?"}, Validate: survey.Required, }, } diff --git a/pkg/listen/tui/model.go b/pkg/listen/tui/model.go new file mode 100644 index 0000000..1d6fb91 --- /dev/null +++ b/pkg/listen/tui/model.go @@ -0,0 +1,411 @@ +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 + Filters interface{} // Session filters (stored as interface{} to avoid circular dependency) +} + +// 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/listen/tui/styles.go b/pkg/listen/tui/styles.go new file mode 100644 index 0000000..6458d75 --- /dev/null +++ b/pkg/listen/tui/styles.go @@ -0,0 +1,87 @@ +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) + + cyanStyle = lipgloss.NewStyle(). + Foreground(colorCyan) + + // 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/listen/tui/update.go b/pkg/listen/tui/update.go new file mode 100644 index 0000000..c90cfa0 --- /dev/null +++ b/pkg/listen/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/listen/tui/view.go b/pkg/listen/tui/view.go new file mode 100644 index 0000000..c3b2ee6 --- /dev/null +++ b/pkg/listen/tui/view.go @@ -0,0 +1,502 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// 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") + } + } + } + + // Show filters if any are active + if m.cfg.Filters != nil { + // Type assert to SessionFilters and display each filter + if filters, ok := m.cfg.Filters.(*hookdeck.SessionFilters); ok && filters != nil { + s.WriteString("\n") + s.WriteString(yellowStyle.Render("āŗ")) + s.WriteString(" Filters provided, only events matching the filter will be forwarded for this session\n") + + if filters.Body != nil { + s.WriteString(" • Body: ") + s.WriteString(faintStyle.Render(string(*filters.Body))) + s.WriteString("\n") + } + if filters.Headers != nil { + s.WriteString(" • Headers: ") + s.WriteString(faintStyle.Render(string(*filters.Headers))) + s.WriteString("\n") + } + if filters.Query != nil { + s.WriteString(" • Query: ") + s.WriteString(faintStyle.Render(string(*filters.Query))) + s.WriteString("\n") + } + if filters.Path != nil { + s.WriteString(" • Path: ") + s.WriteString(faintStyle.Render(string(*filters.Path))) + 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) +} diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 53766de..18180b2 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🚩 You are using the CLI for the first time without a permanent 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/validators/validate.go b/pkg/validators/validate.go index a530f67..c611610 100644 --- a/pkg/validators/validate.go +++ b/pkg/validators/validate.go @@ -14,7 +14,7 @@ type ArgValidator func(string) error var ( // ErrAPIKeyNotConfigured is the error returned when the loaded profile is missing the api key property - ErrAPIKeyNotConfigured = errors.New("you have not configured API keys yet") + ErrAPIKeyNotConfigured = errors.New("you aren't authenticated yet") // ErrDeviceNameNotConfigured is the error returned when the loaded profile is missing the device name property ErrDeviceNameNotConfigured = errors.New("you have not configured your device name yet") ) diff --git a/test-scripts/test-acceptance.sh b/test-scripts/test-acceptance.sh index 378b36f..1da2abd 100755 --- a/test-scripts/test-acceptance.sh +++ b/test-scripts/test-acceptance.sh @@ -51,7 +51,9 @@ echo "Verifying authentication..." echo_and_run $CLI_CMD whoami echo "Testing listen command..." -echo_and_run $CLI_CMD listen 8080 "test-$(date +%Y%m%d%H%M%S)" & +# Redirect stdin from /dev/null to signal non-interactive mode +# This will auto-create the source without prompting +echo_and_run $CLI_CMD listen 8080 "test-$(date +%Y%m%d%H%M%S)" --output compact < /dev/null & PID=$! # Wait for the listen command to initialize @@ -71,4 +73,4 @@ kill $PID echo "Calling logout..." $CLI_CMD logout -echo "All tests passed!" \ No newline at end of file +echo "All tests passed!"