login.go

  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}