Skip to content

Commit 77bd043

Browse files
feat(cli): persist guest lineage and refresh sign-in links
Store guest_user_id, attest guest credentials on cli-auth login, and refresh guest sign-in URLs on listen start and via a TUI ticker. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e6d8d47 commit 77bd043

13 files changed

Lines changed: 158 additions & 29 deletions

File tree

pkg/config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ func (c *Config) setProfileFieldsInViper(v *viper.Viper) {
246246
if c.Profile.GuestURL != "" {
247247
v.Set(c.Profile.getConfigField("guest_url"), c.Profile.GuestURL)
248248
}
249+
if c.Profile.GuestUserID != "" {
250+
v.Set(c.Profile.getConfigField("guest_user_id"), c.Profile.GuestUserID)
251+
}
249252
}
250253

251254
// GetConfigFile returns the path of the currently loaded config file
@@ -352,6 +355,8 @@ func (c *Config) constructConfig() {
352355

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

358+
c.Profile.GuestUserID = stringCoalesce(c.Profile.GuestUserID, c.viper.GetString(c.Profile.getConfigField("guest_user_id")), c.viper.GetString("guest_user_id"), "")
359+
355360
// Telemetry opt-out: check config file for telemetry_disabled = true
356361
if c.viper.IsSet("telemetry_disabled") {
357362
c.TelemetryDisabled = c.viper.GetBool("telemetry_disabled")
@@ -398,6 +403,7 @@ func zeroProfileCredentialFields(p *Profile) {
398403
p.ProjectMode = ""
399404
p.ProjectType = ""
400405
p.GuestURL = ""
406+
p.GuestUserID = ""
401407
}
402408

403409
// SaveActiveProfileAfterLogin persists cfg.Profile credential fields and the active profile

pkg/config/profile.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type Profile struct {
1111
ProjectMode string
1212
ProjectType string // display type: Gateway, Outpost, Console
1313
GuestURL string // URL to create permanent account for guest users
14+
GuestUserID string // Hookdeck user id for guest accounts (for signup lineage)
1415

1516
Config *Config
1617
}
@@ -30,6 +31,7 @@ func (p *Profile) SaveProfile() error {
3031
}
3132
p.Config.viper.Set(p.getConfigField("project_type"), projectType)
3233
p.Config.viper.Set(p.getConfigField("guest_url"), p.GuestURL)
34+
p.Config.viper.Set(p.getConfigField("guest_user_id"), p.GuestUserID)
3335
return p.Config.writeConfig()
3436
}
3537

pkg/config/profile_credentials.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ func (p *Profile) ApplyValidateAPIKeyResponse(resp *hookdeck.ValidateAPIKeyRespo
1414
p.ProjectType = ModeToProjectType(resp.ProjectMode)
1515
if clearGuestURL {
1616
p.GuestURL = ""
17+
p.GuestUserID = ""
1718
}
1819
}
1920

2021
// ApplyPollAPIKeyResponse applies credentials from a completed CLI auth poll (browser or interactive login).
2122
// guestURL is the guest upgrade URL when applicable; use "" for a normal account login.
22-
func (p *Profile) ApplyPollAPIKeyResponse(resp *hookdeck.PollAPIKeyResponse, guestURL string) {
23+
// guestUserID is the Hookdeck user id when the session is a guest account.
24+
func (p *Profile) ApplyPollAPIKeyResponse(resp *hookdeck.PollAPIKeyResponse, guestURL string, guestUserID string) {
2325
if resp == nil {
2426
return
2527
}
@@ -28,6 +30,11 @@ func (p *Profile) ApplyPollAPIKeyResponse(resp *hookdeck.PollAPIKeyResponse, gue
2830
p.ProjectMode = resp.ProjectMode
2931
p.ProjectType = ModeToProjectType(resp.ProjectMode)
3032
p.GuestURL = guestURL
33+
if guestUserID != "" {
34+
p.GuestUserID = guestUserID
35+
} else if guestURL == "" {
36+
p.GuestUserID = ""
37+
}
3138
}
3239

3340
// ApplyCIClient applies credentials from hookdeck login --ci.
@@ -37,4 +44,5 @@ func (p *Profile) ApplyCIClient(ci hookdeck.CIClient) {
3744
p.ProjectMode = ci.ProjectMode
3845
p.ProjectType = ModeToProjectType(ci.ProjectMode)
3946
p.GuestURL = ""
47+
p.GuestUserID = ""
4048
}

pkg/config/profile_credentials_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func TestProfile_ApplyValidateAPIKeyResponse(t *testing.T) {
4242
func TestProfile_ApplyPollAPIKeyResponse(t *testing.T) {
4343
t.Run("nil response is no-op", func(t *testing.T) {
4444
p := &Profile{APIKey: "k", ProjectId: "p"}
45-
p.ApplyPollAPIKeyResponse(nil, "")
45+
p.ApplyPollAPIKeyResponse(nil, "", "")
4646
require.Equal(t, "k", p.APIKey)
4747
require.Equal(t, "p", p.ProjectId)
4848
})
@@ -53,20 +53,21 @@ func TestProfile_ApplyPollAPIKeyResponse(t *testing.T) {
5353
APIKey: "key_from_poll",
5454
ProjectID: "team_p",
5555
ProjectMode: "inbound",
56-
}, "https://guest")
56+
}, "https://guest", "guest_user_1")
5757
require.Equal(t, "key_from_poll", p.APIKey)
5858
require.Equal(t, "team_p", p.ProjectId)
5959
require.Equal(t, ProjectTypeGateway, p.ProjectType)
6060
require.Equal(t, "https://guest", p.GuestURL)
61+
require.Equal(t, "guest_user_1", p.GuestUserID)
6162
})
6263

63-
t.Run("clears-style guest with empty string", func(t *testing.T) {
64+
t.Run("clears guest URL when empty string passed", func(t *testing.T) {
6465
p := &Profile{GuestURL: "old"}
6566
p.ApplyPollAPIKeyResponse(&hookdeck.PollAPIKeyResponse{
6667
APIKey: "k123456789012",
6768
ProjectID: "t",
6869
ProjectMode: "inbound",
69-
}, "")
70+
}, "", "")
7071
require.Empty(t, p.GuestURL)
7172
})
7273
}

pkg/gateway/mcp/tool_login.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler {
110110

111111
// Initiate browser-based device auth flow.
112112
authClient := &hookdeck.Client{BaseURL: parsedBaseURL, TelemetryDisabled: cfg.TelemetryDisabled}
113-
session, err := authClient.StartLogin(deviceName)
113+
session, err := authClient.StartLogin(hookdeck.StartLoginInput{
114+
DeviceName: deviceName,
115+
})
114116
if err != nil {
115117
return ErrorResult(fmt.Sprintf("Failed to start login: %s", err)), nil
116118
}
@@ -160,7 +162,7 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler {
160162
}
161163

162164
// Persist credentials so future MCP sessions start authenticated.
163-
cfg.Profile.ApplyPollAPIKeyResponse(response, "")
165+
cfg.Profile.ApplyPollAPIKeyResponse(response, "", "")
164166

165167
cfg.SaveActiveProfileAfterLogin()
166168

pkg/hookdeck/auth.go

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ type UpdateClientInput struct {
4949
DeviceName string `json:"device_name"`
5050
}
5151

52+
// StartLoginInput configures POST /cli-auth for browser login.
53+
type StartLoginInput struct {
54+
DeviceName string `json:"device_name"`
55+
GuestUserID string `json:"guest_user_id,omitempty"`
56+
GuestAPIKey string `json:"guest_api_key,omitempty"`
57+
}
58+
5259
// LoginSession represents an in-progress login flow
5360
type LoginSession struct {
5461
BrowserURL string
@@ -57,20 +64,15 @@ type LoginSession struct {
5764

5865
// GuestSession represents an in-progress guest login flow
5966
type GuestSession struct {
60-
BrowserURL string
61-
GuestURL string
62-
pollURL string
67+
BrowserURL string
68+
GuestURL string
69+
GuestUserID string
70+
pollURL string
6371
}
6472

6573
// StartLogin initiates the login flow and returns a session to wait for completion
66-
func (c *Client) StartLogin(deviceName string) (*LoginSession, error) {
67-
data := struct {
68-
DeviceName string `json:"device_name"`
69-
}{
70-
DeviceName: deviceName,
71-
}
72-
73-
jsonData, err := json.Marshal(data)
74+
func (c *Client) StartLogin(input StartLoginInput) (*LoginSession, error) {
75+
jsonData, err := json.Marshal(input)
7476
if err != nil {
7577
return nil, err
7678
}
@@ -111,9 +113,10 @@ func (c *Client) StartGuestLogin(deviceName string) (*GuestSession, error) {
111113
}
112114

113115
return &GuestSession{
114-
BrowserURL: guest.BrowserURL,
115-
GuestURL: guest.Url,
116-
pollURL: guest.PollURL,
116+
BrowserURL: guest.BrowserURL,
117+
GuestURL: guest.Url,
118+
GuestUserID: guest.Id,
119+
pollURL: guest.PollURL,
117120
}, nil
118121
}
119122

pkg/hookdeck/guest.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ type GuestUser struct {
1515
PollURL string `json:"poll_url"`
1616
}
1717

18+
type GuestSigninLinkResponse struct {
19+
Id string `json:"id"`
20+
Url string `json:"link"`
21+
ExpiresAt string `json:"expires_at"`
22+
}
23+
1824
type CreateGuestUserInput struct {
1925
DeviceName string `json:"device_name"`
2026
}
@@ -35,3 +41,16 @@ func (c *Client) CreateGuestUser(input CreateGuestUserInput) (GuestUser, error)
3541
postprocessJsonResponse(res, &guest_user)
3642
return guest_user, nil
3743
}
44+
45+
func (c *Client) RefreshGuestSigninLink() (GuestSigninLinkResponse, error) {
46+
res, err := c.Post(context.Background(), APIPathPrefix+"/cli/guest/signin-link", nil, nil)
47+
if err != nil {
48+
return GuestSigninLinkResponse{}, err
49+
}
50+
if res.StatusCode != http.StatusOK {
51+
return GuestSigninLinkResponse{}, fmt.Errorf("unexpected http status code: %d %s", res.StatusCode, err)
52+
}
53+
response := GuestSigninLinkResponse{}
54+
postprocessJsonResponse(res, &response)
55+
return response, nil
56+
}

pkg/listen/listen.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ func Listen(URL *url.URL, sourceQuery string, connectionFilterString string, fla
7474
}
7575
} else if config.Profile.GuestURL != "" && config.Profile.APIKey != "" {
7676
// User is logged in with a guest account (has both GuestURL and APIKey)
77-
guestURL = config.Profile.GuestURL
77+
guestURL = login.RefreshGuestSigninLink(config)
7878
}
7979

8080
apiClient := config.GetAPIClient()

pkg/listen/tui/model.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
const (
1717
maxEvents = 1000 // Maximum events to keep in memory (all navigable)
1818
timeLayout = "2006-01-02 15:04:05" // Time format for display
19+
guestURLRefreshInterval = 50 * time.Minute
1920
)
2021

2122
// EventInfo represents a single event with all its data
@@ -103,9 +104,11 @@ func NewModel(cfg *Config) Model {
103104

104105
// Init initializes the model (required by Bubble Tea)
105106
func (m Model) Init() tea.Cmd {
106-
return tea.Batch(
107-
tickWaitingAnimation(),
108-
)
107+
cmds := []tea.Cmd{tickWaitingAnimation()}
108+
if m.cfg != nil && m.cfg.GuestURL != "" && m.client != nil {
109+
cmds = append(cmds, tickGuestURLRefresh())
110+
}
111+
return tea.Batch(cmds...)
109112
}
110113

111114
// AddEvent adds a new event to the history
@@ -419,6 +422,33 @@ func tickWaitingAnimation() tea.Cmd {
419422
})
420423
}
421424

425+
// TickGuestURLRefreshMsg triggers a guest sign-in link refresh.
426+
type TickGuestURLRefreshMsg struct{}
427+
428+
// GuestURLRefreshedMsg carries an updated guest sign-in URL.
429+
type GuestURLRefreshedMsg struct {
430+
GuestURL string
431+
}
432+
433+
func tickGuestURLRefresh() tea.Cmd {
434+
return tea.Tick(guestURLRefreshInterval, func(t time.Time) tea.Msg {
435+
return TickGuestURLRefreshMsg{}
436+
})
437+
}
438+
439+
func refreshGuestURLCmd(client *hookdeck.Client) tea.Cmd {
440+
if client == nil {
441+
return nil
442+
}
443+
return func() tea.Msg {
444+
response, err := client.RefreshGuestSigninLink()
445+
if err != nil || response.Url == "" {
446+
return nil
447+
}
448+
return GuestURLRefreshedMsg{GuestURL: response.Url}
449+
}
450+
}
451+
422452
// ServerHealthMsg is sent when server health status changes
423453
type ServerHealthMsg struct {
424454
Healthy bool

pkg/listen/tui/update.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7272
}
7373
return m, nil
7474

75+
case TickGuestURLRefreshMsg:
76+
return m, tea.Batch(refreshGuestURLCmd(m.client), tickGuestURLRefresh())
77+
78+
case GuestURLRefreshedMsg:
79+
if msg.GuestURL != "" && m.cfg != nil {
80+
m.cfg.GuestURL = msg.GuestURL
81+
}
82+
return m, nil
83+
7584
case retryResultMsg:
7685
// Retry completed (new attempt will arrive via websocket as a new event)
7786
return m, nil

0 commit comments

Comments
 (0)