From 006a577f9fc31c2f8932353aba1338b9e46c130a Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 11 Nov 2025 20:00:58 +0000 Subject: [PATCH 01/10] chore(ci): Disable changelog generation in goreleaser configuration for Linux, Mac, and Windows --- .goreleaser/linux.yml | 6 +----- .goreleaser/mac.yml | 6 +----- .goreleaser/windows.yml | 6 +----- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/.goreleaser/linux.yml b/.goreleaser/linux.yml index 28ae406..f12d013 100644 --- a/.goreleaser/linux.yml +++ b/.goreleaser/linux.yml @@ -33,11 +33,7 @@ release: prerelease: auto mode: append changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" + disable: true checksum: name_template: "{{ .ProjectName }}-linux-checksums.txt" snapshot: diff --git a/.goreleaser/mac.yml b/.goreleaser/mac.yml index fdbf806..24b8d04 100644 --- a/.goreleaser/mac.yml +++ b/.goreleaser/mac.yml @@ -32,11 +32,7 @@ release: prerelease: auto mode: append changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" + disable: true checksum: name_template: "{{ .ProjectName }}-checksums.txt" snapshot: diff --git a/.goreleaser/windows.yml b/.goreleaser/windows.yml index e30f3e6..7183763 100644 --- a/.goreleaser/windows.yml +++ b/.goreleaser/windows.yml @@ -28,11 +28,7 @@ release: prerelease: auto mode: append changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" + disable: true checksum: name_template: "{{ .ProjectName }}-windows-checksums.txt" snapshot: From b10170702ad3b743d6ee3c6b093bdca37f918b52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:16:04 +0000 Subject: [PATCH 02/10] chore(deps): bump golang.org/x/term from 0.36.0 to 0.37.0 Bumps [golang.org/x/term](https://github.com/golang/term) from 0.36.0 to 0.37.0. - [Commits](https://github.com/golang/term/compare/v0.36.0...v0.37.0) --- updated-dependencies: - dependency-name: golang.org/x/term dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 276ee9e..edcfaee 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/tidwall/pretty v1.2.1 github.com/x-cray/logrus-prefixed-formatter v0.5.2 golang.org/x/sys v0.38.0 - golang.org/x/term v0.36.0 + golang.org/x/term v0.37.0 ) require ( diff --git a/go.sum b/go.sum index b0f2934..73ca8c7 100644 --- a/go.sum +++ b/go.sum @@ -197,8 +197,8 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.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.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/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= From d8e11c306e10fdab571da81837a5ec2e0b32e375 Mon Sep 17 00:00:00 2001 From: Nicolas Beauvais Date: Tue, 16 Dec 2025 15:32:42 +0100 Subject: [PATCH 03/10] Update listen command documentation (#181) --- pkg/cmd/listen.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/listen.go b/pkg/cmd/listen.go index 8d9c64c..0c54ecb 100644 --- a/pkg/cmd/listen.go +++ b/pkg/cmd/listen.go @@ -185,9 +185,9 @@ Examples: hookdeck listen %[1]d shopify - Forward events to a local server running on "http://myapp.test": + Forward events to a local server running on "http://myapp.test:%[1]d": - hookdeck listen %[1]d http://myapp.test + hookdeck listen http://myapp.test:%[1]d Forward events to the path "/webhooks" on local server running on port %[1]d: From 0b32122b8975118ca6925b4c77fc54c91df1c0b6 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 16 Dec 2025 22:03:25 +0700 Subject: [PATCH 04/10] refactor: hookdeck api calls using latest version refactor: move hookdeck api calls to pkg/hookdeck chore: hookdeck api calls using latest version --- main.go | 2 +- package.json | 2 +- pkg/hookdeck/auth.go | 271 +++++++++++++++++++++++++++++++++ pkg/hookdeck/ci.go | 2 +- pkg/hookdeck/events.go | 18 +++ pkg/hookdeck/guest.go | 2 +- pkg/hookdeck/projects.go | 2 +- pkg/hookdeck/session.go | 2 +- pkg/listen/tui/model.go | 13 +- pkg/listen/tui/styles.go | 2 +- pkg/listen/tui/update.go | 25 +-- pkg/login/client_login.go | 88 +++-------- pkg/login/interactive_login.go | 22 +-- pkg/login/poll.go | 136 ----------------- pkg/login/validate.go | 38 +---- 15 files changed, 338 insertions(+), 287 deletions(-) create mode 100644 pkg/hookdeck/auth.go create mode 100644 pkg/hookdeck/events.go delete mode 100644 pkg/login/poll.go diff --git a/main.go b/main.go index 0218016..2cd5b60 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, diff --git a/package.json b/package.json index 9e2b8c0..ab1b063 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "1.3.0-beta.1", + "version": "1.3.0", "description": "Hookdeck CLI", "repository": { "type": "git", diff --git a/pkg/hookdeck/auth.go b/pkg/hookdeck/auth.go new file mode 100644 index 0000000..3aac5df --- /dev/null +++ b/pkg/hookdeck/auth.go @@ -0,0 +1,271 @@ +package hookdeck + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/url" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const maxAttemptsDefault = 2 * 60 +const intervalDefault = 2 * time.Second +const maxBackoffInterval = 30 * time.Second + +// ValidateAPIKeyResponse returns the user and team associated with a key +type ValidateAPIKeyResponse struct { + UserID string `json:"user_id"` + UserName string `json:"user_name"` + UserEmail string `json:"user_email"` + OrganizationName string `json:"organization_name"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"team_id"` + ProjectName string `json:"team_name_no_org"` + ProjectMode string `json:"team_mode"` + ClientID string `json:"client_id"` +} + +// PollAPIKeyResponse returns the data of the polling client login +type PollAPIKeyResponse struct { + Claimed bool `json:"claimed"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + UserEmail string `json:"user_email"` + OrganizationName string `json:"organization_name"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"team_id"` + ProjectName string `json:"team_name"` + ProjectMode string `json:"team_mode"` + APIKey string `json:"key"` + ClientID string `json:"client_id"` +} + +// UpdateClientInput represents the input for updating a CLI client +type UpdateClientInput struct { + DeviceName string `json:"device_name"` +} + +// LoginSession represents an in-progress login flow +type LoginSession struct { + BrowserURL string + pollURL string +} + +// GuestSession represents an in-progress guest login flow +type GuestSession struct { + BrowserURL string + GuestURL string + pollURL string +} + +// StartLogin initiates the login flow and returns a session to wait for completion +func (c *Client) StartLogin(deviceName string) (*LoginSession, error) { + data := struct { + DeviceName string `json:"device_name"` + }{ + DeviceName: deviceName, + } + + jsonData, err := json.Marshal(data) + if err != nil { + return nil, err + } + + res, err := c.Post(context.Background(), "/2025-07-01/cli-auth", jsonData, nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var links struct { + BrowserURL string `json:"browser_url"` + PollURL string `json:"poll_url"` + } + err = json.Unmarshal(body, &links) + if err != nil { + return nil, err + } + + return &LoginSession{ + BrowserURL: links.BrowserURL, + pollURL: links.PollURL, + }, nil +} + +// StartGuestLogin initiates a guest login flow and returns a session to wait for completion +func (c *Client) StartGuestLogin(deviceName string) (*GuestSession, error) { + guest, err := c.CreateGuestUser(CreateGuestUserInput{ + DeviceName: deviceName, + }) + if err != nil { + return nil, err + } + + return &GuestSession{ + BrowserURL: guest.BrowserURL, + GuestURL: guest.Url, + pollURL: guest.PollURL, + }, nil +} + +// WaitForAPIKey polls until the user completes login and returns the API key response +func (s *LoginSession) WaitForAPIKey(interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) { + return pollForAPIKey(s.pollURL, interval, maxAttempts) +} + +// WaitForAPIKey polls until the user completes login and returns the API key response +func (s *GuestSession) WaitForAPIKey(interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) { + return pollForAPIKey(s.pollURL, interval, maxAttempts) +} + +// PollForAPIKeyWithKey polls for login completion using a CLI API key (for interactive login) +func (c *Client) PollForAPIKeyWithKey(apiKey string, interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) { + pollURL := c.BaseURL.String() + "/2025-07-01/cli-auth/poll?key=" + apiKey + return pollForAPIKey(pollURL, interval, maxAttempts) +} + +// ValidateAPIKey validates an API key and returns user/project information +func (c *Client) ValidateAPIKey() (*ValidateAPIKeyResponse, error) { + res, err := c.Get(context.Background(), "/2025-07-01/cli-auth/validate", "", nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var response ValidateAPIKeyResponse + err = json.Unmarshal(body, &response) + if err != nil { + return nil, err + } + + return &response, nil +} + +// pollForAPIKey polls Hookdeck at the specified interval until either the API key is available or we've reached the max attempts. +// This is an internal function that creates its own client for polling with rate limit suppression. +func pollForAPIKey(pollURL string, interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) { + if maxAttempts == 0 { + maxAttempts = maxAttemptsDefault + } + + if interval == 0 { + interval = intervalDefault + } + + parsedURL, err := url.Parse(pollURL) + if err != nil { + return nil, err + } + + baseURL := &url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host} + + client := &Client{ + BaseURL: baseURL, + SuppressRateLimitErrors: true, // Rate limiting is expected during polling + } + + var count = 0 + currentInterval := interval + consecutiveRateLimits := 0 + + for count < maxAttempts { + res, err := client.Get(context.TODO(), parsedURL.Path, parsedURL.Query().Encode(), nil) + + // Check if error is due to rate limiting (429) + if err != nil && isRateLimitError(err) { + consecutiveRateLimits++ + backoffInterval := calculateBackoff(currentInterval, consecutiveRateLimits) + + log.WithFields(log.Fields{ + "attempt": count + 1, + "max_attempts": maxAttempts, + "backoff_interval": backoffInterval, + "rate_limits": consecutiveRateLimits, + }).Debug("Rate limited while polling, waiting before retry...") + + time.Sleep(backoffInterval) + currentInterval = backoffInterval + count++ + continue + } + + // Reset back-off on successful request + if err == nil { + consecutiveRateLimits = 0 + currentInterval = interval + } + + // Handle other errors (non-429) + if err != nil { + return nil, err + } + + var response PollAPIKeyResponse + + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &response) + if err != nil { + return nil, err + } + + if response.Claimed { + return &response, nil + } + + count++ + time.Sleep(currentInterval) + } + + return nil, errors.New("exceeded max attempts") +} + +// UpdateClient updates a CLI client's device name +func (c *Client) UpdateClient(clientID string, input UpdateClientInput) error { + jsonData, err := json.Marshal(input) + if err != nil { + return err + } + + _, err = c.Put(context.Background(), "/2025-07-01/cli/"+clientID, jsonData, nil) + return err +} + +// isRateLimitError checks if an error is a 429 rate limit error +func isRateLimitError(err error) bool { + if err == nil { + return false + } + errMsg := err.Error() + return strings.Contains(errMsg, "429") || strings.Contains(errMsg, "Too Many Requests") +} + +// calculateBackoff implements exponential back-off with a maximum cap +func calculateBackoff(baseInterval time.Duration, consecutiveFailures int) time.Duration { + // Exponential: baseInterval * 2^consecutiveFailures + backoff := baseInterval * time.Duration(1< maxBackoffInterval { + backoff = maxBackoffInterval + } + + return backoff +} diff --git a/pkg/hookdeck/ci.go b/pkg/hookdeck/ci.go index 04770da..13aeeb7 100644 --- a/pkg/hookdeck/ci.go +++ b/pkg/hookdeck/ci.go @@ -29,7 +29,7 @@ func (c *Client) CreateCIClient(input CreateCIClientInput) (CIClient, error) { if err != nil { return CIClient{}, err } - res, err := c.Post(context.Background(), "/cli-auth/ci", input_bytes, nil) + res, err := c.Post(context.Background(), "/2025-07-01/cli-auth/ci", input_bytes, nil) if err != nil { return CIClient{}, err } diff --git a/pkg/hookdeck/events.go b/pkg/hookdeck/events.go new file mode 100644 index 0000000..5ef0397 --- /dev/null +++ b/pkg/hookdeck/events.go @@ -0,0 +1,18 @@ +package hookdeck + +import ( + "context" + "fmt" +) + +// RetryEvent retries an event by ID +func (c *Client) RetryEvent(eventID string) error { + retryURL := fmt.Sprintf("/2025-07-01/events/%s/retry", eventID) + resp, err := c.Post(context.Background(), retryURL, []byte("{}"), nil) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} diff --git a/pkg/hookdeck/guest.go b/pkg/hookdeck/guest.go index 41e041b..40b4801 100644 --- a/pkg/hookdeck/guest.go +++ b/pkg/hookdeck/guest.go @@ -24,7 +24,7 @@ func (c *Client) CreateGuestUser(input CreateGuestUserInput) (GuestUser, error) if err != nil { return GuestUser{}, err } - res, err := c.Post(context.Background(), "/cli/guest", input_bytes, nil) + res, err := c.Post(context.Background(), "/2025-07-01/cli/guest", input_bytes, nil) if err != nil { return GuestUser{}, err } diff --git a/pkg/hookdeck/projects.go b/pkg/hookdeck/projects.go index 7ca1d09..cfacd58 100644 --- a/pkg/hookdeck/projects.go +++ b/pkg/hookdeck/projects.go @@ -13,7 +13,7 @@ type Project struct { } func (c *Client) ListProjects() ([]Project, error) { - res, err := c.Get(context.Background(), "/teams", "", nil) + res, err := c.Get(context.Background(), "/2025-07-01/teams", "", nil) if err != nil { return []Project{}, err } diff --git a/pkg/hookdeck/session.go b/pkg/hookdeck/session.go index 617436a..3c86086 100644 --- a/pkg/hookdeck/session.go +++ b/pkg/hookdeck/session.go @@ -29,7 +29,7 @@ func (c *Client) CreateSession(input CreateSessionInput) (Session, error) { if err != nil { return Session{}, err } - res, err := c.Post(context.Background(), "/cli-sessions", input_bytes, nil) + res, err := c.Post(context.Background(), "/2025-07-01/cli-sessions", input_bytes, nil) if err != nil { return Session{}, err } diff --git a/pkg/listen/tui/model.go b/pkg/listen/tui/model.go index 1d6fb91..11f69e4 100644 --- a/pkg/listen/tui/model.go +++ b/pkg/listen/tui/model.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -39,6 +40,9 @@ type Model struct { // Configuration cfg *Config + // API client (initialized once) + client *hookdeck.Client + // Event history events []EventInfo selectedIndex int @@ -83,8 +87,15 @@ type Config struct { // NewModel creates a new TUI model func NewModel(cfg *Config) Model { + parsedBaseURL, _ := url.Parse(cfg.APIBaseURL) + return Model{ - cfg: cfg, + cfg: cfg, + client: &hookdeck.Client{ + BaseURL: parsedBaseURL, + APIKey: cfg.APIKey, + ProjectID: cfg.ProjectID, + }, events: make([]EventInfo, 0), selectedIndex: -1, ready: false, diff --git a/pkg/listen/tui/styles.go b/pkg/listen/tui/styles.go index 6458d75..e5a6bca 100644 --- a/pkg/listen/tui/styles.go +++ b/pkg/listen/tui/styles.go @@ -41,7 +41,7 @@ var ( Bold(true) brandAccentStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("4")) // Blue + Foreground(lipgloss.Color("4")) // Blue // Component styles selectionIndicatorStyle = lipgloss.NewStyle(). diff --git a/pkg/listen/tui/update.go b/pkg/listen/tui/update.go index c90cfa0..efe1466 100644 --- a/pkg/listen/tui/update.go +++ b/pkg/listen/tui/update.go @@ -1,15 +1,11 @@ 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 @@ -179,31 +175,14 @@ func (m Model) retrySelectedEvent() tea.Cmd { } eventID := selectedEvent.ID - apiKey := m.cfg.APIKey - apiBaseURL := m.cfg.APIBaseURL - projectID := m.cfg.ProjectID + client := m.client return func() tea.Msg { - // Create HTTP client - parsedBaseURL, err := url.Parse(apiBaseURL) + err := client.RetryEvent(eventID) 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} } } diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 18180b2..05dd0d0 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -1,12 +1,8 @@ package login import ( - "context" - "encoding/json" "fmt" "io" - "io/ioutil" - "net/http" "net/url" "os" @@ -24,14 +20,6 @@ import ( var openBrowser = open.Browser var canOpenBrowser = open.CanOpenBrowser -const hookdeckCLIAuthPath = "/cli-auth" - -// Links provides the URLs for the CLI to continue the login flow -type Links struct { - BrowserURL string `json:"browser_url"` - PollURL string `json:"poll_url"` -} - // Login function is used to obtain credentials via hookdeck dashboard. func Login(config *config.Config, input io.Reader) error { var s *spinner.Spinner @@ -61,13 +49,22 @@ func Login(config *config.Config, input io.Reader) error { return nil } - links, err := getLinks(config.APIBaseURL, config.DeviceName) + parsedBaseURL, err := url.Parse(config.APIBaseURL) + if err != nil { + return err + } + + client := &hookdeck.Client{ + BaseURL: parsedBaseURL, + } + + session, err := client.StartLogin(config.DeviceName) if err != nil { return err } if isSSH() || !canOpenBrowser() { - fmt.Printf("To authenticate with Hookdeck, please go to: %s\n", links.BrowserURL) + fmt.Printf("To authenticate with Hookdeck, please go to: %s\n", session.BrowserURL) s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout) } else { @@ -76,16 +73,15 @@ func Login(config *config.Config, input io.Reader) error { s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout) - err = openBrowser(links.BrowserURL) + err = openBrowser(session.BrowserURL) if err != nil { - msg := fmt.Sprintf("Failed to open browser, please go to %s manually.", links.BrowserURL) + msg := fmt.Sprintf("Failed to open browser, please go to %s manually.", session.BrowserURL) ansi.StopSpinner(s, msg, os.Stdout) s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout) } } - // Call poll function - response, err := PollForKey(links.PollURL, 0, 0) + response, err := session.WaitForAPIKey(0, 0) if err != nil { return err } @@ -125,15 +121,12 @@ func GuestLogin(config *config.Config) (string, error) { 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, - }) + session, err := client.StartGuestLogin(config.DeviceName) if err != nil { return "", err } - // Call poll function - response, err := PollForKey(guest_user.PollURL, 0, 0) + response, err := session.WaitForAPIKey(0, 0) if err != nil { return "", err } @@ -145,7 +138,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 + config.Profile.GuestURL = session.GuestURL if err = config.Profile.SaveProfile(); err != nil { return "", err @@ -154,7 +147,7 @@ func GuestLogin(config *config.Config) (string, error) { return "", err } - return guest_user.Url, nil + return session.GuestURL, nil } func CILogin(config *config.Config, apiKey string, name string) error { @@ -205,51 +198,6 @@ func CILogin(config *config.Config, apiKey string, name string) error { return nil } -func getLinks(baseURL string, deviceName string) (*Links, error) { - parsedBaseURL, err := url.Parse(baseURL) - if err != nil { - return nil, err - } - - client := &hookdeck.Client{ - BaseURL: parsedBaseURL, - } - - data := struct { - DeviceName string `json:"device_name"` - }{} - data.DeviceName = deviceName - json_data, err := json.Marshal(data) - if err != nil { - return nil, err - } - - res, err := client.Post(context.TODO(), hookdeckCLIAuthPath, json_data, nil) - if err != nil { - return nil, err - } - - defer res.Body.Close() - - bodyBytes, err := ioutil.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected http status code: %d %s", res.StatusCode, string(bodyBytes)) - } - - var links Links - - err = json.Unmarshal(bodyBytes, &links) - if err != nil { - return nil, err - } - - return &links, nil -} - func isSSH() bool { if os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" { return true diff --git a/pkg/login/interactive_login.go b/pkg/login/interactive_login.go index 3893e14..31db904 100644 --- a/pkg/login/interactive_login.go +++ b/pkg/login/interactive_login.go @@ -2,8 +2,6 @@ package login import ( "bufio" - "context" - "encoding/json" "errors" "fmt" "io" @@ -32,12 +30,6 @@ func InteractiveLogin(config *config.Config) error { s := ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout) - // Call poll function - response, err := PollForKey(config.APIBaseURL+"/cli-auth/poll?key="+apiKey, 0, 0) - if err != nil { - return err - } - parsedBaseURL, err := url.Parse(config.APIBaseURL) if err != nil { return err @@ -45,19 +37,19 @@ func InteractiveLogin(config *config.Config) error { client := &hookdeck.Client{ BaseURL: parsedBaseURL, - APIKey: response.APIKey, } - data := struct { - DeviceName string `json:"device_name"` - }{} - data.DeviceName = config.DeviceName - json_data, err := json.Marshal(data) + response, err := client.PollForAPIKeyWithKey(apiKey, 0, 0) if err != nil { return err } - _, err = client.Put(context.TODO(), "/cli/"+response.ClientID, json_data, nil) + // Update client with the new API key to make the UpdateClient call + client.APIKey = response.APIKey + + err = client.UpdateClient(response.ClientID, hookdeck.UpdateClientInput{ + DeviceName: config.DeviceName, + }) if err != nil { return err } diff --git a/pkg/login/poll.go b/pkg/login/poll.go deleted file mode 100644 index 41d3bc3..0000000 --- a/pkg/login/poll.go +++ /dev/null @@ -1,136 +0,0 @@ -package login - -import ( - "context" - "encoding/json" - "errors" - "io/ioutil" - "net/url" - "strings" - "time" - - "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" - log "github.com/sirupsen/logrus" -) - -const maxAttemptsDefault = 2 * 60 -const intervalDefault = 2 * time.Second -const maxBackoffInterval = 30 * time.Second - -// PollAPIKeyResponse returns the data of the polling client login -type PollAPIKeyResponse struct { - Claimed bool `json:"claimed"` - UserID string `json:"user_id"` - UserName string `json:"user_name"` - UserEmail string `json:"user_email"` - OrganizationName string `json:"organization_name"` - OrganizationID string `json:"organization_id"` - ProjectID string `json:"team_id"` - ProjectName string `json:"team_name"` - ProjectMode string `json:"team_mode"` - APIKey string `json:"key"` - ClientID string `json:"client_id"` -} - -// PollForKey polls Hookdeck at the specified interval until either the API key is available or we've reached the max attempts. -func PollForKey(pollURL string, interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) { - if maxAttempts == 0 { - maxAttempts = maxAttemptsDefault - } - - if interval == 0 { - interval = intervalDefault - } - - parsedURL, err := url.Parse(pollURL) - if err != nil { - return nil, err - } - - baseURL := &url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host} - - client := &hookdeck.Client{ - BaseURL: baseURL, - SuppressRateLimitErrors: true, // Rate limiting is expected during polling - } - - var count = 0 - currentInterval := interval - consecutiveRateLimits := 0 - - for count < maxAttempts { - res, err := client.Get(context.TODO(), parsedURL.Path, parsedURL.Query().Encode(), nil) - - // Check if error is due to rate limiting (429) - if err != nil && isRateLimitError(err) { - consecutiveRateLimits++ - backoffInterval := calculateBackoff(currentInterval, consecutiveRateLimits) - - log.WithFields(log.Fields{ - "attempt": count + 1, - "max_attempts": maxAttempts, - "backoff_interval": backoffInterval, - "rate_limits": consecutiveRateLimits, - }).Debug("Rate limited while polling, waiting before retry...") - - time.Sleep(backoffInterval) - currentInterval = backoffInterval - count++ - continue - } - - // Reset back-off on successful request - if err == nil { - consecutiveRateLimits = 0 - currentInterval = interval - } - - // Handle other errors (non-429) - if err != nil { - return nil, err - } - - var response PollAPIKeyResponse - - defer res.Body.Close() - body, err := ioutil.ReadAll(res.Body) - if err != nil { - return nil, err - } - err = json.Unmarshal(body, &response) - if err != nil { - return nil, err - } - - if response.Claimed { - return &response, nil - } - - count++ - time.Sleep(currentInterval) - } - - return nil, errors.New("exceeded max attempts") -} - -// isRateLimitError checks if an error is a 429 rate limit error -func isRateLimitError(err error) bool { - if err == nil { - return false - } - errMsg := err.Error() - return strings.Contains(errMsg, "429") || strings.Contains(errMsg, "Too Many Requests") -} - -// calculateBackoff implements exponential back-off with a maximum cap -func calculateBackoff(baseInterval time.Duration, consecutiveFailures int) time.Duration { - // Exponential: baseInterval * 2^consecutiveFailures - backoff := baseInterval * time.Duration(1< maxBackoffInterval { - backoff = maxBackoffInterval - } - - return backoff -} diff --git a/pkg/login/validate.go b/pkg/login/validate.go index 963124c..cc647fb 100644 --- a/pkg/login/validate.go +++ b/pkg/login/validate.go @@ -1,28 +1,13 @@ package login import ( - "context" - "encoding/json" - "io/ioutil" "net/url" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" ) -// ValidateAPIKeyResponse returns the user and team associated with a key -type ValidateAPIKeyResponse struct { - UserID string `json:"user_id"` - UserName string `json:"user_name"` - UserEmail string `json:"user_email"` - OrganizationName string `json:"organization_name"` - OrganizationID string `json:"organization_id"` - ProjectID string `json:"team_id"` - ProjectName string `json:"team_name_no_org"` - ProjectMode string `json:"team_mode"` - ClientID string `json:"client_id"` -} - -func ValidateKey(baseURL string, key string, projectId string) (*ValidateAPIKeyResponse, error) { +// ValidateKey validates an API key and returns user/project information +func ValidateKey(baseURL string, key string, projectId string) (*hookdeck.ValidateAPIKeyResponse, error) { parsedBaseURL, err := url.Parse(baseURL) if err != nil { return nil, err @@ -34,22 +19,5 @@ func ValidateKey(baseURL string, key string, projectId string) (*ValidateAPIKeyR ProjectID: projectId, } - res, err := client.Get(context.Background(), "/cli-auth/validate", "", nil) - if err != nil { - return nil, err - } - - var response ValidateAPIKeyResponse - - defer res.Body.Close() - body, err := ioutil.ReadAll(res.Body) - if err != nil { - return nil, err - } - err = json.Unmarshal(body, &response) - if err != nil { - return nil, err - } - - return &response, nil + return client.ValidateAPIKey() } From 27687da74ce2842d9e93473108cb029bc46d9a36 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:13:01 +0000 Subject: [PATCH 05/10] Update package.json version to 1.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ab1b063..e34c9f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "1.3.0", + "version": "1.4.0", "description": "Hookdeck CLI", "repository": { "type": "git", From 7e3f4e388ab897e7954c1d035ee51265c9a5ea48 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 16 Dec 2025 16:08:05 +0000 Subject: [PATCH 06/10] chore(ci): update permissions for OIDC trusted publishing --- .github/workflows/release.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfc53ac..ae508ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,9 @@ jobs: publish-npm: runs-on: ubuntu-latest needs: [build-windows, build-linux, build-mac] + permissions: + id-token: write # Required for OIDC trusted publishing + contents: write # Required for committing package.json changes steps: - uses: actions/checkout@v4 with: @@ -128,9 +131,9 @@ jobs: uses: EndBug/add-and-commit@v9 with: default_author: github_actions - message: 'Update package.json version to ${{ steps.tag-version.outputs.TAG_VERSION }}' - add: 'package.json' - + message: "Update package.json version to ${{ steps.tag-version.outputs.TAG_VERSION }}" + add: "package.json" + - run: npm ci - name: Determine npm tag for pre-releases @@ -144,6 +147,4 @@ jobs: echo "tag=${NPM_TAG}" >> $GITHUB_OUTPUT echo "npm tag: ${NPM_TAG}" - - run: npm publish --tag ${{ steps.npm_tag.outputs.tag }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: npm publish --provenance --tag ${{ steps.npm_tag.outputs.tag }} From df4fbba95f962702a91e474c512cd81ef323e95c Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 16 Dec 2025 18:13:13 +0000 Subject: [PATCH 07/10] Remove registry-url to allow OIDC authentication --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae508ff..3dceb1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,7 +110,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20.x" - registry-url: "https://registry.npmjs.org" - name: Get GitHub tag version # Store the version, stripping any v-prefix From 820a09d1b818735386534fcba5cebc48abf3df94 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:14:46 +0000 Subject: [PATCH 08/10] Update package.json version to 1.4.0-alpha.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e34c9f1..53a5353 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "1.4.0", + "version": "1.4.0-alpha.1", "description": "Hookdeck CLI", "repository": { "type": "git", From 8d2d39435ed225db28f8c7a43bf2f6ef64c2ab88 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 16 Dec 2025 18:50:51 +0000 Subject: [PATCH 09/10] Use OIDC authentication for npm publishing --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3dceb1d..ae508ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,6 +110,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20.x" + registry-url: "https://registry.npmjs.org" - name: Get GitHub tag version # Store the version, stripping any v-prefix From 8e6d331c18dfd849b5d5179e83791ad07e7bc6e5 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 16 Dec 2025 19:18:52 +0000 Subject: [PATCH 10/10] Upgrade npm to latest for trusted publishing support --- .github/workflows/release.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae508ff..70df7d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,20 +79,17 @@ jobs: runs-on: ubuntu-latest needs: [build-windows, build-linux, build-mac] permissions: - id-token: write # Required for OIDC trusted publishing - contents: write # Required for committing package.json changes + id-token: write # Required for OIDC + contents: write # For git operations steps: - uses: actions/checkout@v4 with: - # With permission to push to a protected branch token: ${{ secrets.READ_WRITE_PAT }} - fetch-depth: 0 # Required to find branches for a tag + fetch-depth: 0 - name: Determine release branch id: get_branch run: | - # Find the branch that contains the tag. - # Prefers 'main', then 'master', then the first branch found. BRANCHES=$(git branch -r --contains ${{ github.ref_name }} | sed 's/ *origin\///' | grep -v HEAD) if echo "$BRANCHES" | grep -q -w "main"; then RELEASE_BRANCH="main" @@ -112,8 +109,10 @@ jobs: node-version: "20.x" registry-url: "https://registry.npmjs.org" + - name: Upgrade npm to latest version + run: npm install -g npm@latest + - name: Get GitHub tag version - # Store the version, stripping any v-prefix id: tag-version run: | TAG_VERSION=${GITHUB_REF_NAME#v}