1package cmd
2
3import (
4 "cmp"
5 "context"
6 "fmt"
7 "os"
8 "os/signal"
9
10 "charm.land/lipgloss/v2"
11 "github.com/atotto/clipboard"
12 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/oauth"
15 "github.com/charmbracelet/crush/internal/oauth/copilot"
16 "github.com/charmbracelet/crush/internal/oauth/hyper"
17 "github.com/pkg/browser"
18 "github.com/spf13/cobra"
19)
20
21var loginCmd = &cobra.Command{
22 Aliases: []string{"auth"},
23 Use: "login [platform]",
24 Short: "Login Crush to a platform",
25 Long: `Login Crush to a specified platform.
26The platform should be provided as an argument.
27Available platforms are: hyper, copilot.`,
28 Example: `
29# Authenticate with Charm Hyper
30crush login
31
32# Authenticate with GitHub Copilot
33crush login copilot
34 `,
35 ValidArgs: []cobra.Completion{
36 "hyper",
37 "copilot",
38 "github",
39 "github-copilot",
40 },
41 Args: cobra.MaximumNArgs(1),
42 RunE: func(cmd *cobra.Command, args []string) error {
43 app, err := setupAppWithProgressBar(cmd)
44 if err != nil {
45 return err
46 }
47 defer app.Shutdown()
48
49 provider := "hyper"
50 if len(args) > 0 {
51 provider = args[0]
52 }
53 switch provider {
54 case "hyper":
55 return loginHyper(app.Config())
56 case "copilot", "github", "github-copilot":
57 return loginCopilot(app.Config())
58 default:
59 return fmt.Errorf("unknown platform: %s", args[0])
60 }
61 },
62}
63
64func loginHyper(cfg *config.Config) error {
65 if !hyperp.Enabled() {
66 return fmt.Errorf("hyper not enabled")
67 }
68 ctx := getLoginContext()
69
70 resp, err := hyper.InitiateDeviceAuth(ctx)
71 if err != nil {
72 return err
73 }
74
75 if clipboard.WriteAll(resp.UserCode) == nil {
76 fmt.Println("The following code should be on clipboard already:")
77 } else {
78 fmt.Println("Copy the following code:")
79 }
80
81 fmt.Println()
82 fmt.Println(lipgloss.NewStyle().Bold(true).Render(resp.UserCode))
83 fmt.Println()
84 fmt.Println("Press enter to open this URL, and then paste it there:")
85 fmt.Println()
86 fmt.Println(lipgloss.NewStyle().Hyperlink(resp.VerificationURL, "id=hyper").Render(resp.VerificationURL))
87 fmt.Println()
88 waitEnter()
89 if err := browser.OpenURL(resp.VerificationURL); err != nil {
90 fmt.Println("Could not open the URL. You'll need to manually open the URL in your browser.")
91 }
92
93 fmt.Println("Exchanging authorization code...")
94 refreshToken, err := hyper.PollForToken(ctx, resp.DeviceCode, resp.ExpiresIn)
95 if err != nil {
96 return err
97 }
98
99 fmt.Println("Exchanging refresh token for access token...")
100 token, err := hyper.ExchangeToken(ctx, refreshToken)
101 if err != nil {
102 return err
103 }
104
105 fmt.Println("Verifying access token...")
106 introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
107 if err != nil {
108 return fmt.Errorf("token introspection failed: %w", err)
109 }
110 if !introspect.Active {
111 return fmt.Errorf("access token is not active")
112 }
113
114 if err := cmp.Or(
115 cfg.SetConfigField("providers.hyper.api_key", token.AccessToken),
116 cfg.SetConfigField("providers.hyper.oauth", token),
117 ); err != nil {
118 return err
119 }
120
121 fmt.Println()
122 fmt.Println("You're now authenticated with Hyper!")
123 return nil
124}
125
126func loginCopilot(cfg *config.Config) error {
127 ctx := getLoginContext()
128
129 if cfg.HasConfigField("providers.copilot.oauth") {
130 fmt.Println("You are already logged in to GitHub Copilot.")
131 return nil
132 }
133
134 diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
135 var token *oauth.Token
136
137 switch {
138 case hasDiskToken:
139 fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
140
141 t, err := copilot.RefreshToken(ctx, diskToken)
142 if err != nil {
143 return fmt.Errorf("unable to refresh token from disk: %w", err)
144 }
145 token = t
146 default:
147 fmt.Println("Requesting device code from GitHub...")
148 dc, err := copilot.RequestDeviceCode(ctx)
149 if err != nil {
150 return err
151 }
152
153 fmt.Println()
154 fmt.Println("Open the following URL and follow the instructions to authenticate with GitHub Copilot:")
155 fmt.Println()
156 fmt.Println(lipgloss.NewStyle().Hyperlink(dc.VerificationURI, "id=copilot").Render(dc.VerificationURI))
157 fmt.Println()
158 fmt.Println("Code:", lipgloss.NewStyle().Bold(true).Render(dc.UserCode))
159 fmt.Println()
160 fmt.Println("Waiting for authorization...")
161
162 t, err := copilot.PollForToken(ctx, dc)
163 if err == copilot.ErrNotAvailable {
164 fmt.Println()
165 fmt.Println("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
166 fmt.Println()
167 fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL))
168 fmt.Println()
169 fmt.Println("You may be able to request free access if eligible. For more information, see:")
170 fmt.Println()
171 fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL))
172 }
173 if err != nil {
174 return err
175 }
176 token = t
177 }
178
179 if err := cmp.Or(
180 cfg.SetConfigField("providers.copilot.api_key", token.AccessToken),
181 cfg.SetConfigField("providers.copilot.oauth", token),
182 ); err != nil {
183 return err
184 }
185
186 fmt.Println()
187 fmt.Println("You're now authenticated with GitHub Copilot!")
188 return nil
189}
190
191func getLoginContext() context.Context {
192 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
193 go func() {
194 <-ctx.Done()
195 cancel()
196 os.Exit(1)
197 }()
198 return ctx
199}
200
201func waitEnter() {
202 _, _ = fmt.Scanln()
203}