login.go 5.2 KB

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