| |
| |
| |
| package cmd |
|
|
| import ( |
| "bufio" |
| "bytes" |
| "context" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "io" |
| "net/http" |
| "os" |
| "strconv" |
| "strings" |
| "time" |
|
|
| "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/config" |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" |
| sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" |
| cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" |
| log "github.com/sirupsen/logrus" |
| "github.com/tidwall/gjson" |
| ) |
|
|
| const ( |
| geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com" |
| geminiCLIVersion = "v1internal" |
| geminiCLIUserAgent = "google-api-nodejs-client/9.15.1" |
| geminiCLIApiClient = "gl-node/22.17.0" |
| geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI" |
| ) |
|
|
| type projectSelectionRequiredError struct{} |
|
|
| func (e *projectSelectionRequiredError) Error() string { |
| return "gemini cli: project selection required" |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) { |
| if options == nil { |
| options = &LoginOptions{} |
| } |
|
|
| ctx := context.Background() |
|
|
| promptFn := options.Prompt |
| if promptFn == nil { |
| promptFn = defaultProjectPrompt() |
| } |
|
|
| trimmedProjectID := strings.TrimSpace(projectID) |
| callbackPrompt := promptFn |
| if trimmedProjectID == "" { |
| callbackPrompt = nil |
| } |
|
|
| loginOpts := &sdkAuth.LoginOptions{ |
| NoBrowser: options.NoBrowser, |
| ProjectID: trimmedProjectID, |
| CallbackPort: options.CallbackPort, |
| Metadata: map[string]string{}, |
| Prompt: callbackPrompt, |
| } |
|
|
| authenticator := sdkAuth.NewGeminiAuthenticator() |
| record, errLogin := authenticator.Login(ctx, cfg, loginOpts) |
| if errLogin != nil { |
| log.Errorf("Gemini authentication failed: %v", errLogin) |
| return |
| } |
|
|
| storage, okStorage := record.Storage.(*gemini.GeminiTokenStorage) |
| if !okStorage || storage == nil { |
| log.Error("Gemini authentication failed: unsupported token storage") |
| return |
| } |
|
|
| geminiAuth := gemini.NewGeminiAuth() |
| httpClient, errClient := geminiAuth.GetAuthenticatedClient(ctx, storage, cfg, &gemini.WebLoginOptions{ |
| NoBrowser: options.NoBrowser, |
| CallbackPort: options.CallbackPort, |
| Prompt: callbackPrompt, |
| }) |
| if errClient != nil { |
| log.Errorf("Gemini authentication failed: %v", errClient) |
| return |
| } |
|
|
| log.Info("Authentication successful.") |
|
|
| projects, errProjects := fetchGCPProjects(ctx, httpClient) |
| if errProjects != nil { |
| log.Errorf("Failed to get project list: %v", errProjects) |
| return |
| } |
|
|
| selectedProjectID := promptForProjectSelection(projects, trimmedProjectID, promptFn) |
| projectSelections, errSelection := resolveProjectSelections(selectedProjectID, projects) |
| if errSelection != nil { |
| log.Errorf("Invalid project selection: %v", errSelection) |
| return |
| } |
| if len(projectSelections) == 0 { |
| log.Error("No project selected; aborting login.") |
| return |
| } |
|
|
| activatedProjects := make([]string, 0, len(projectSelections)) |
| seenProjects := make(map[string]bool) |
| for _, candidateID := range projectSelections { |
| log.Infof("Activating project %s", candidateID) |
| if errSetup := performGeminiCLISetup(ctx, httpClient, storage, candidateID); errSetup != nil { |
| var projectErr *projectSelectionRequiredError |
| if errors.As(errSetup, &projectErr) { |
| log.Error("Failed to start user onboarding: A project ID is required.") |
| showProjectSelectionHelp(storage.Email, projects) |
| return |
| } |
| log.Errorf("Failed to complete user setup: %v", errSetup) |
| return |
| } |
| finalID := strings.TrimSpace(storage.ProjectID) |
| if finalID == "" { |
| finalID = candidateID |
| } |
|
|
| |
| if seenProjects[finalID] { |
| log.Infof("Project %s already activated, skipping", finalID) |
| continue |
| } |
| seenProjects[finalID] = true |
| activatedProjects = append(activatedProjects, finalID) |
| } |
|
|
| storage.Auto = false |
| storage.ProjectID = strings.Join(activatedProjects, ",") |
|
|
| if !storage.Auto && !storage.Checked { |
| for _, pid := range activatedProjects { |
| isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, pid) |
| if errCheck != nil { |
| log.Errorf("Failed to check if Cloud AI API is enabled for %s: %v", pid, errCheck) |
| return |
| } |
| if !isChecked { |
| log.Errorf("Failed to check if Cloud AI API is enabled for project %s. If you encounter an error message, please create an issue.", pid) |
| return |
| } |
| } |
| storage.Checked = true |
| } |
|
|
| updateAuthRecord(record, storage) |
|
|
| store := sdkAuth.GetTokenStore() |
| if setter, okSetter := store.(interface{ SetBaseDir(string) }); okSetter && cfg != nil { |
| setter.SetBaseDir(cfg.AuthDir) |
| } |
|
|
| savedPath, errSave := store.Save(ctx, record) |
| if errSave != nil { |
| log.Errorf("Failed to save token to file: %v", errSave) |
| return |
| } |
|
|
| if savedPath != "" { |
| fmt.Printf("Authentication saved to %s\n", savedPath) |
| } |
|
|
| fmt.Println("Gemini authentication successful!") |
| } |
|
|
| func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *gemini.GeminiTokenStorage, requestedProject string) error { |
| metadata := map[string]string{ |
| "ideType": "IDE_UNSPECIFIED", |
| "platform": "PLATFORM_UNSPECIFIED", |
| "pluginType": "GEMINI", |
| } |
|
|
| trimmedRequest := strings.TrimSpace(requestedProject) |
| explicitProject := trimmedRequest != "" |
|
|
| loadReqBody := map[string]any{ |
| "metadata": metadata, |
| } |
| if explicitProject { |
| loadReqBody["cloudaicompanionProject"] = trimmedRequest |
| } |
|
|
| var loadResp map[string]any |
| if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil { |
| return fmt.Errorf("load code assist: %w", errLoad) |
| } |
|
|
| tierID := "legacy-tier" |
| if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers { |
| for _, rawTier := range tiers { |
| tier, okTier := rawTier.(map[string]any) |
| if !okTier { |
| continue |
| } |
| if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault { |
| if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" { |
| tierID = strings.TrimSpace(id) |
| break |
| } |
| } |
| } |
| } |
|
|
| projectID := trimmedRequest |
| if projectID == "" { |
| if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject { |
| projectID = strings.TrimSpace(id) |
| } |
| if projectID == "" { |
| if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject { |
| if id, okID := projectMap["id"].(string); okID { |
| projectID = strings.TrimSpace(id) |
| } |
| } |
| } |
| } |
| if projectID == "" { |
| return &projectSelectionRequiredError{} |
| } |
|
|
| onboardReqBody := map[string]any{ |
| "tierId": tierID, |
| "metadata": metadata, |
| "cloudaicompanionProject": projectID, |
| } |
|
|
| |
| storage.ProjectID = projectID |
|
|
| for { |
| var onboardResp map[string]any |
| if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil { |
| return fmt.Errorf("onboard user: %w", errOnboard) |
| } |
|
|
| if done, okDone := onboardResp["done"].(bool); okDone && done { |
| responseProjectID := "" |
| if resp, okResp := onboardResp["response"].(map[string]any); okResp { |
| switch projectValue := resp["cloudaicompanionProject"].(type) { |
| case map[string]any: |
| if id, okID := projectValue["id"].(string); okID { |
| responseProjectID = strings.TrimSpace(id) |
| } |
| case string: |
| responseProjectID = strings.TrimSpace(projectValue) |
| } |
| } |
|
|
| finalProjectID := projectID |
| if responseProjectID != "" { |
| if explicitProject && !strings.EqualFold(responseProjectID, projectID) { |
| |
| isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") || |
| strings.EqualFold(tierID, "FREE") || |
| strings.EqualFold(tierID, "LEGACY") |
|
|
| if isFreeUser { |
| |
| fmt.Printf("\nGoogle returned a different project ID:\n") |
| fmt.Printf(" Requested (frontend): %s\n", projectID) |
| fmt.Printf(" Returned (backend): %s\n\n", responseProjectID) |
| fmt.Printf(" Backend project IDs have access to preview models (gemini-3-*).\n") |
| fmt.Printf(" This is normal for free tier users.\n\n") |
| fmt.Printf("Which project ID would you like to use?\n") |
| fmt.Printf(" [1] Backend (recommended): %s\n", responseProjectID) |
| fmt.Printf(" [2] Frontend: %s\n\n", projectID) |
| fmt.Printf("Enter choice [1]: ") |
|
|
| reader := bufio.NewReader(os.Stdin) |
| choice, _ := reader.ReadString('\n') |
| choice = strings.TrimSpace(choice) |
|
|
| if choice == "2" { |
| log.Infof("Using frontend project ID: %s", projectID) |
| fmt.Println(". Warning: Frontend project IDs may not have access to preview models.") |
| finalProjectID = projectID |
| } else { |
| log.Infof("Using backend project ID: %s (recommended)", responseProjectID) |
| finalProjectID = responseProjectID |
| } |
| } else { |
| |
| log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID) |
| } |
| } else { |
| finalProjectID = responseProjectID |
| } |
| } |
|
|
| storage.ProjectID = strings.TrimSpace(finalProjectID) |
| if storage.ProjectID == "" { |
| storage.ProjectID = strings.TrimSpace(projectID) |
| } |
| if storage.ProjectID == "" { |
| return fmt.Errorf("onboard user completed without project id") |
| } |
| log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID) |
| return nil |
| } |
|
|
| log.Println("Onboarding in progress, waiting 5 seconds...") |
| time.Sleep(5 * time.Second) |
| } |
| } |
|
|
| func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error { |
| url := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint) |
| if strings.HasPrefix(endpoint, "operations/") { |
| url = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint) |
| } |
|
|
| var reader io.Reader |
| if body != nil { |
| rawBody, errMarshal := json.Marshal(body) |
| if errMarshal != nil { |
| return fmt.Errorf("marshal request body: %w", errMarshal) |
| } |
| reader = bytes.NewReader(rawBody) |
| } |
|
|
| req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, reader) |
| if errRequest != nil { |
| return fmt.Errorf("create request: %w", errRequest) |
| } |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("User-Agent", geminiCLIUserAgent) |
| req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient) |
| req.Header.Set("Client-Metadata", geminiCLIClientMetadata) |
|
|
| resp, errDo := httpClient.Do(req) |
| if errDo != nil { |
| return fmt.Errorf("execute request: %w", errDo) |
| } |
| defer func() { |
| if errClose := resp.Body.Close(); errClose != nil { |
| log.Errorf("response body close error: %v", errClose) |
| } |
| }() |
|
|
| if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { |
| bodyBytes, _ := io.ReadAll(resp.Body) |
| return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) |
| } |
|
|
| if result == nil { |
| _, _ = io.Copy(io.Discard, resp.Body) |
| return nil |
| } |
|
|
| if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil { |
| return fmt.Errorf("decode response body: %w", errDecode) |
| } |
|
|
| return nil |
| } |
|
|
| func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) { |
| req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil) |
| if errRequest != nil { |
| return nil, fmt.Errorf("could not create project list request: %w", errRequest) |
| } |
|
|
| resp, errDo := httpClient.Do(req) |
| if errDo != nil { |
| return nil, fmt.Errorf("failed to execute project list request: %w", errDo) |
| } |
| defer func() { |
| if errClose := resp.Body.Close(); errClose != nil { |
| log.Errorf("response body close error: %v", errClose) |
| } |
| }() |
|
|
| if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { |
| bodyBytes, _ := io.ReadAll(resp.Body) |
| return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) |
| } |
|
|
| var projects interfaces.GCPProject |
| if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil { |
| return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode) |
| } |
|
|
| return projects.Projects, nil |
| } |
|
|
| |
| func promptForProjectSelection(projects []interfaces.GCPProjectProjects, presetID string, promptFn func(string) (string, error)) string { |
| trimmedPreset := strings.TrimSpace(presetID) |
| if len(projects) == 0 { |
| if trimmedPreset != "" { |
| return trimmedPreset |
| } |
| fmt.Println("No Google Cloud projects are available for selection.") |
| return "" |
| } |
|
|
| fmt.Println("Available Google Cloud projects:") |
| defaultIndex := 0 |
| for idx, project := range projects { |
| fmt.Printf("[%d] %s (%s)\n", idx+1, project.ProjectID, project.Name) |
| if trimmedPreset != "" && project.ProjectID == trimmedPreset { |
| defaultIndex = idx |
| } |
| } |
| fmt.Println("Type 'ALL' to onboard every listed project.") |
|
|
| defaultID := projects[defaultIndex].ProjectID |
|
|
| if trimmedPreset != "" { |
| if strings.EqualFold(trimmedPreset, "ALL") { |
| return "ALL" |
| } |
| for _, project := range projects { |
| if project.ProjectID == trimmedPreset { |
| return trimmedPreset |
| } |
| } |
| log.Warnf("Provided project ID %s not found in available projects; please choose from the list.", trimmedPreset) |
| } |
|
|
| for { |
| promptMsg := fmt.Sprintf("Enter project ID [%s] or ALL: ", defaultID) |
| answer, errPrompt := promptFn(promptMsg) |
| if errPrompt != nil { |
| log.Errorf("Project selection prompt failed: %v", errPrompt) |
| return defaultID |
| } |
| answer = strings.TrimSpace(answer) |
| if strings.EqualFold(answer, "ALL") { |
| return "ALL" |
| } |
| if answer == "" { |
| return defaultID |
| } |
|
|
| for _, project := range projects { |
| if project.ProjectID == answer { |
| return project.ProjectID |
| } |
| } |
|
|
| if idx, errAtoi := strconv.Atoi(answer); errAtoi == nil { |
| if idx >= 1 && idx <= len(projects) { |
| return projects[idx-1].ProjectID |
| } |
| } |
|
|
| fmt.Println("Invalid selection, enter a project ID or a number from the list.") |
| } |
| } |
|
|
| func resolveProjectSelections(selection string, projects []interfaces.GCPProjectProjects) ([]string, error) { |
| trimmed := strings.TrimSpace(selection) |
| if trimmed == "" { |
| return nil, nil |
| } |
| available := make(map[string]struct{}, len(projects)) |
| ordered := make([]string, 0, len(projects)) |
| for _, project := range projects { |
| id := strings.TrimSpace(project.ProjectID) |
| if id == "" { |
| continue |
| } |
| if _, exists := available[id]; exists { |
| continue |
| } |
| available[id] = struct{}{} |
| ordered = append(ordered, id) |
| } |
| if strings.EqualFold(trimmed, "ALL") { |
| if len(ordered) == 0 { |
| return nil, fmt.Errorf("no projects available for ALL selection") |
| } |
| return append([]string(nil), ordered...), nil |
| } |
| parts := strings.Split(trimmed, ",") |
| selections := make([]string, 0, len(parts)) |
| seen := make(map[string]struct{}, len(parts)) |
| for _, part := range parts { |
| id := strings.TrimSpace(part) |
| if id == "" { |
| continue |
| } |
| if _, dup := seen[id]; dup { |
| continue |
| } |
| if len(available) > 0 { |
| if _, ok := available[id]; !ok { |
| return nil, fmt.Errorf("project %s not found in available projects", id) |
| } |
| } |
| seen[id] = struct{}{} |
| selections = append(selections, id) |
| } |
| return selections, nil |
| } |
|
|
| func defaultProjectPrompt() func(string) (string, error) { |
| reader := bufio.NewReader(os.Stdin) |
| return func(prompt string) (string, error) { |
| fmt.Print(prompt) |
| line, errRead := reader.ReadString('\n') |
| if errRead != nil { |
| if errors.Is(errRead, io.EOF) { |
| return strings.TrimSpace(line), nil |
| } |
| return "", errRead |
| } |
| return strings.TrimSpace(line), nil |
| } |
| } |
|
|
| func showProjectSelectionHelp(email string, projects []interfaces.GCPProjectProjects) { |
| if email != "" { |
| log.Infof("Your account %s needs to specify a project ID.", email) |
| } else { |
| log.Info("You need to specify a project ID.") |
| } |
|
|
| if len(projects) > 0 { |
| fmt.Println("========================================================================") |
| for _, p := range projects { |
| fmt.Printf("Project ID: %s\n", p.ProjectID) |
| fmt.Printf("Project Name: %s\n", p.Name) |
| fmt.Println("------------------------------------------------------------------------") |
| } |
| } else { |
| fmt.Println("No active projects were returned for this account.") |
| } |
|
|
| fmt.Printf("Please run this command to login again with a specific project:\n\n%s --login --project_id <project_id>\n", os.Args[0]) |
| } |
|
|
| func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) { |
| serviceUsageURL := "https://serviceusage.googleapis.com" |
| requiredServices := []string{ |
| |
| "cloudaicompanion.googleapis.com", |
| } |
| for _, service := range requiredServices { |
| checkUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service) |
| req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl, nil) |
| if errRequest != nil { |
| return false, fmt.Errorf("failed to create request: %w", errRequest) |
| } |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("User-Agent", geminiCLIUserAgent) |
| resp, errDo := httpClient.Do(req) |
| if errDo != nil { |
| return false, fmt.Errorf("failed to execute request: %w", errDo) |
| } |
|
|
| if resp.StatusCode == http.StatusOK { |
| bodyBytes, _ := io.ReadAll(resp.Body) |
| if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" { |
| _ = resp.Body.Close() |
| continue |
| } |
| } |
| _ = resp.Body.Close() |
|
|
| enableUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service) |
| req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableUrl, strings.NewReader("{}")) |
| if errRequest != nil { |
| return false, fmt.Errorf("failed to create request: %w", errRequest) |
| } |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("User-Agent", geminiCLIUserAgent) |
| resp, errDo = httpClient.Do(req) |
| if errDo != nil { |
| return false, fmt.Errorf("failed to execute request: %w", errDo) |
| } |
|
|
| bodyBytes, _ := io.ReadAll(resp.Body) |
| errMessage := string(bodyBytes) |
| errMessageResult := gjson.GetBytes(bodyBytes, "error.message") |
| if errMessageResult.Exists() { |
| errMessage = errMessageResult.String() |
| } |
| if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated { |
| _ = resp.Body.Close() |
| continue |
| } else if resp.StatusCode == http.StatusBadRequest { |
| _ = resp.Body.Close() |
| if strings.Contains(strings.ToLower(errMessage), "already enabled") { |
| continue |
| } |
| } |
| _ = resp.Body.Close() |
| return false, fmt.Errorf("project activation required: %s", errMessage) |
| } |
| return true, nil |
| } |
|
|
| func updateAuthRecord(record *cliproxyauth.Auth, storage *gemini.GeminiTokenStorage) { |
| if record == nil || storage == nil { |
| return |
| } |
|
|
| finalName := gemini.CredentialFileName(storage.Email, storage.ProjectID, false) |
|
|
| if record.Metadata == nil { |
| record.Metadata = make(map[string]any) |
| } |
| record.Metadata["email"] = storage.Email |
| record.Metadata["project_id"] = storage.ProjectID |
| record.Metadata["auto"] = storage.Auto |
| record.Metadata["checked"] = storage.Checked |
|
|
| record.ID = finalName |
| record.FileName = finalName |
| record.Storage = storage |
| } |
|
|