@@ -4,7 +4,6 @@ import (
9 "charm.land/bubbles/v2/help"
8 "charm.land/bubbles/v2/help"
10 "charm.land/bubbles/v2/key"
9 "charm.land/bubbles/v2/key"
@@ -14,13 +13,19 @@ import (
14 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
15 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/oauth"
15 "github.com/charmbracelet/crush/internal/oauth"
17− "github.com/charmbracelet/crush/internal/oauth/hyper"
18 "github.com/charmbracelet/crush/internal/ui/common"
16 "github.com/charmbracelet/crush/internal/ui/common"
19 "github.com/charmbracelet/crush/internal/uiutil"
17 "github.com/charmbracelet/crush/internal/uiutil"
20 uv "github.com/charmbracelet/ultraviolet"
18 uv "github.com/charmbracelet/ultraviolet"
21 "github.com/pkg/browser"
19 "github.com/pkg/browser"
22+type OAuthProvider interface {
24+ initiateAuth() tea.Msg
25+ startPolling(deviceCode string, expiresIn int) tea.Cmd
26+ stopPolling() tea.Msg
24// OAuthState represents the current state of the device flow.
29// OAuthState represents the current state of the device flow.
25type OAuthState int
30type OAuthState int
@@ -38,9 +43,10 @@ const OAuthID = "oauth"
38type OAuth struct {
43type OAuth struct {
39 com *common.Common
44 com *common.Common
41− provider catwalk.Provider
46+ provider catwalk.Provider
42− model config.SelectedModel
47+ model config.SelectedModel
43− modelType config.SelectedModelType
48+ modelType config.SelectedModelType
49+ oAuthProvider OAuthProvider
45 State OAuthState
51 State OAuthState
@@ -63,8 +69,8 @@ type OAuth struct {
64var _ Dialog = (*OAuth)(nil)
70var _ Dialog = (*OAuth)(nil)
66−// NewOAuth creates a new device flow component.
72+// newOAuth creates a new device flow component.
67−func NewOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) {
73+func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, error) {
68 t := com.Styles
74 t := com.Styles
70 m := OAuth{}
76 m := OAuth{}
@@ -72,6 +78,7 @@ func NewOAuth(com *common.Common, provider catwalk.Provider, model config.Select
72 m.provider = provider
78 m.provider = provider
73 m.model = model
79 m.model = model
74 m.modelType = modelType
80 m.modelType = modelType
81+ m.oAuthProvider = oAuthProvider
75 m.width = 60
82 m.width = 60
76 m.State = OAuthStateInitializing
83 m.State = OAuthStateInitializing
@@ -103,7 +110,7 @@ func (m *OAuth) ID() string {
104// Init implements Dialog.
111// Init implements Dialog.
105func (m *OAuth) Init() tea.Cmd {
112func (m *OAuth) Init() tea.Cmd {
106− return tea.Batch(m.spinner.Tick, m.initiateDeviceAuth)
113+ return tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth)
109// HandleMsg handles messages and state transitions.
116// HandleMsg handles messages and state transitions.
@@ -151,16 +158,16 @@ func (m *OAuth) HandleMsg(msg tea.Msg) Action {
151 m.expiresIn = msg.ExpiresIn
158 m.expiresIn = msg.ExpiresIn
152 m.verificationURL = msg.VerificationURL
159 m.verificationURL = msg.VerificationURL
153 m.State = OAuthStateDisplay
160 m.State = OAuthStateDisplay
154− return ActionCmd{m.startPolling(msg.DeviceCode)}
161+ return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)}
156 case ActionCompleteOAuth:
163 case ActionCompleteOAuth:
157 m.State = OAuthStateSuccess
164 m.State = OAuthStateSuccess
158 m.token = msg.Token
165 m.token = msg.Token
159− return ActionCmd{m.stopPolling}
166+ return ActionCmd{m.oAuthProvider.stopPolling}
161 case ActionOAuthErrored:
168 case ActionOAuthErrored:
162 m.State = OAuthStateError
169 m.State = OAuthStateError
163− cmd := tea.Batch(m.stopPolling, uiutil.ReportError(msg.Error))
170+ cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error))
164 return ActionCmd{cmd}
171 return ActionCmd{cmd}
166 return nil
173 return nil
@@ -204,7 +211,7 @@ func (m *OAuth) headerContent() string {
204 dialogStyle = t.Dialog.View.Width(m.width)
211 dialogStyle = t.Dialog.View.Width(m.width)
205 headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
212 headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
207− return common.DialogTitle(t, titleStyle.Render("Authenticate with Hyper"), m.width-headerOffset)
214+ return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset)
210func (m *OAuth) innerDialogContent() string {
217func (m *OAuth) innerDialogContent() string {
@@ -357,67 +364,3 @@ func (m *OAuth) saveKeyAndContinue() Action {
357 ModelType: m.modelType,
364 ModelType: m.modelType,
361−func (m *OAuth) initiateDeviceAuth() tea.Msg {
362− minimumWait := 750 * time.Millisecond
363− startTime := time.Now()
365− ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
368− authResp, err := hyper.InitiateDeviceAuth(ctx)
370− ellapsed := time.Since(startTime)
371− if ellapsed < minimumWait {
372− time.Sleep(minimumWait - ellapsed)
376− return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)}
379− return ActionInitiateOAuth{
380− DeviceCode: authResp.DeviceCode,
381− UserCode: authResp.UserCode,
382− ExpiresIn: authResp.ExpiresIn,
383− VerificationURL: authResp.VerificationURL,
387−// startPolling starts polling for the device token.
388−func (m *OAuth) startPolling(deviceCode string) tea.Cmd {
389− return func() tea.Msg {
390− ctx, cancel := context.WithCancel(context.Background())
391− m.cancelFunc = cancel
393− refreshToken, err := hyper.PollForToken(ctx, deviceCode, m.expiresIn)
395− if ctx.Err() != nil {
398− return ActionOAuthErrored{err}
401− token, err := hyper.ExchangeToken(ctx, refreshToken)
403− return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)}
406− introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
408− return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)}
410− if !introspect.Active {
411− return ActionOAuthErrored{fmt.Errorf("access token is not active")}
414− return ActionCompleteOAuth{token}
418−func (m *OAuth) stopPolling() tea.Msg {
419− if m.cancelFunc != nil {