From 44632dc3018c181cbae53876076faa45f00acb96 Mon Sep 17 00:00:00 2001 From: Badri Narayanan S Date: Thu, 15 Jan 2026 12:27:37 +0530 Subject: [PATCH] feat: add automatic user onboarding for accounts without projects When loadCodeAssist returns no project, automatically call onboardUser API to provision a managed project. This handles first-time setup for new accounts. Co-Authored-By: Claude --- src/account-manager/credentials.js | 24 +++++-- src/account-manager/onboarding.js | 111 +++++++++++++++++++++++++++++ src/auth/oauth.js | 24 ++++++- src/constants.js | 4 ++ 4 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 src/account-manager/onboarding.js diff --git a/src/account-manager/credentials.js b/src/account-manager/credentials.js index c223071..3ca67dd 100644 --- a/src/account-manager/credentials.js +++ b/src/account-manager/credentials.js @@ -15,6 +15,7 @@ import { refreshAccessToken } from '../auth/oauth.js'; import { getAuthStatus } from '../auth/database.js'; import { logger } from '../utils/logger.js'; import { isNetworkError } from '../utils/helpers.js'; +import { onboardUser, getDefaultTierId } from './onboarding.js'; /** * Get OAuth token for an account @@ -115,6 +116,7 @@ export async function getProjectForAccount(account, token, projectCache) { export async function discoverProject(token) { let lastError = null; let gotSuccessfulResponse = false; + let loadCodeAssistData = null; for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { try { @@ -144,6 +146,7 @@ export async function discoverProject(token) { const data = await response.json(); gotSuccessfulResponse = true; + loadCodeAssistData = data; logger.debug(`[AccountManager] loadCodeAssist response from ${endpoint}:`, JSON.stringify(data)); @@ -156,16 +159,29 @@ export async function discoverProject(token) { return data.cloudaicompanionProject.id; } - // API returned success but no project - this is normal for Google One AI Pro accounts - // Silently fall back to default project (matches opencode-antigravity-auth behavior) - logger.debug(`[AccountManager] No project in response, using default: ${DEFAULT_PROJECT_ID}`); - return DEFAULT_PROJECT_ID; + // No project found - try to onboard the user + logger.info(`[AccountManager] No project in loadCodeAssist response, attempting onboardUser...`); + break; } catch (error) { lastError = error.message; logger.debug(`[AccountManager] loadCodeAssist error at ${endpoint}:`, error.message); } } + // If we got a successful response but no project, try onboarding + if (gotSuccessfulResponse && loadCodeAssistData) { + const tierId = getDefaultTierId(loadCodeAssistData.allowedTiers) || 'FREE'; + logger.info(`[AccountManager] Onboarding user with tier: ${tierId}`); + + const onboardedProject = await onboardUser(token, tierId); + if (onboardedProject) { + logger.success(`[AccountManager] Successfully onboarded, project: ${onboardedProject}`); + return onboardedProject; + } + + logger.warn(`[AccountManager] Onboarding failed, using default project: ${DEFAULT_PROJECT_ID}`); + } + // Only warn if all endpoints failed with errors (not just missing project) if (!gotSuccessfulResponse) { logger.warn(`[AccountManager] loadCodeAssist failed for all endpoints: ${lastError}`); diff --git a/src/account-manager/onboarding.js b/src/account-manager/onboarding.js new file mode 100644 index 0000000..c4d8d70 --- /dev/null +++ b/src/account-manager/onboarding.js @@ -0,0 +1,111 @@ +/** + * User Onboarding + * + * Handles provisioning of managed projects for accounts that don't have one. + */ + +import { + ONBOARD_USER_ENDPOINTS, + ANTIGRAVITY_HEADERS +} from '../constants.js'; +import { logger } from '../utils/logger.js'; +import { sleep } from '../utils/helpers.js'; + +/** + * Get the default tier ID from allowed tiers list + * + * @param {Array} allowedTiers - List of allowed tiers from loadCodeAssist + * @returns {string|undefined} Default tier ID + */ +export function getDefaultTierId(allowedTiers) { + if (!allowedTiers || allowedTiers.length === 0) { + return undefined; + } + + // Find the tier marked as default + for (const tier of allowedTiers) { + if (tier?.isDefault) { + return tier.id; + } + } + + // Fall back to first tier + return allowedTiers[0]?.id; +} + +/** + * Onboard a user to get a managed project + * + * @param {string} token - OAuth access token + * @param {string} tierId - Tier ID (e.g., 'FREE', 'PRO', 'ULTRA') + * @param {string} [projectId] - Optional GCP project ID (required for non-FREE tiers) + * @param {number} [maxAttempts=10] - Maximum polling attempts + * @param {number} [delayMs=5000] - Delay between polling attempts + * @returns {Promise} Managed project ID or null if failed + */ +export async function onboardUser(token, tierId, projectId = null, maxAttempts = 10, delayMs = 5000) { + const metadata = { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' + }; + + if (projectId) { + metadata.duetProject = projectId; + } + + const requestBody = { + tierId, + metadata + }; + + // Non-FREE tiers require a cloudaicompanionProject + if (tierId !== 'FREE' && projectId) { + requestBody.cloudaicompanionProject = projectId; + } + + for (const endpoint of ONBOARD_USER_ENDPOINTS) { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const response = await fetch(`${endpoint}/v1internal:onboardUser`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...ANTIGRAVITY_HEADERS + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.debug(`[Onboarding] onboardUser failed at ${endpoint}: ${response.status} - ${errorText}`); + break; // Try next endpoint + } + + const data = await response.json(); + logger.debug(`[Onboarding] onboardUser response (attempt ${attempt + 1}):`, JSON.stringify(data)); + + // Check if onboarding is complete + const managedProjectId = data.response?.cloudaicompanionProject?.id; + if (data.done && managedProjectId) { + return managedProjectId; + } + if (data.done && projectId) { + return projectId; + } + + // Not done yet, wait and retry + if (attempt < maxAttempts - 1) { + logger.debug(`[Onboarding] onboardUser not complete, waiting ${delayMs}ms...`); + await sleep(delayMs); + } + } catch (error) { + logger.debug(`[Onboarding] onboardUser error at ${endpoint}:`, error.message); + break; // Try next endpoint + } + } + } + + return null; +} diff --git a/src/auth/oauth.js b/src/auth/oauth.js index dabdc18..fa9f2b3 100644 --- a/src/auth/oauth.js +++ b/src/auth/oauth.js @@ -10,11 +10,12 @@ import crypto from 'crypto'; import http from 'http'; import { ANTIGRAVITY_ENDPOINT_FALLBACKS, - ANTIGRAVITY_HEADERS, + LOAD_CODE_ASSIST_HEADERS, OAUTH_CONFIG, OAUTH_REDIRECT_URI } from '../constants.js'; import { logger } from '../utils/logger.js'; +import { onboardUser, getDefaultTierId } from '../account-manager/onboarding.js'; /** * Generate PKCE code verifier and challenge @@ -326,6 +327,8 @@ export async function getUserEmail(accessToken) { * @returns {Promise} Project ID or null if not found */ export async function discoverProjectId(accessToken) { + let loadCodeAssistData = null; + for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { try { const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { @@ -333,7 +336,7 @@ export async function discoverProjectId(accessToken) { headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', - ...ANTIGRAVITY_HEADERS + ...LOAD_CODE_ASSIST_HEADERS }, body: JSON.stringify({ metadata: { @@ -347,6 +350,7 @@ export async function discoverProjectId(accessToken) { if (!response.ok) continue; const data = await response.json(); + loadCodeAssistData = data; if (typeof data.cloudaicompanionProject === 'string') { return data.cloudaicompanionProject; @@ -354,11 +358,27 @@ export async function discoverProjectId(accessToken) { if (data.cloudaicompanionProject?.id) { return data.cloudaicompanionProject.id; } + + // No project found - try to onboard + logger.info('[OAuth] No project in loadCodeAssist response, attempting onboardUser...'); + break; } catch (error) { logger.warn(`[OAuth] Project discovery failed at ${endpoint}:`, error.message); } } + // Try onboarding if we got a response but no project + if (loadCodeAssistData) { + const tierId = getDefaultTierId(loadCodeAssistData.allowedTiers) || 'FREE'; + logger.info(`[OAuth] Onboarding user with tier: ${tierId}`); + + const onboardedProject = await onboardUser(accessToken, tierId); + if (onboardedProject) { + logger.success(`[OAuth] Successfully onboarded, project: ${onboardedProject}`); + return onboardedProject; + } + } + return null; } diff --git a/src/constants.js b/src/constants.js index d28d9e2..022ea48 100644 --- a/src/constants.js +++ b/src/constants.js @@ -64,6 +64,9 @@ export const LOAD_CODE_ASSIST_ENDPOINTS = [ ANTIGRAVITY_ENDPOINT_DAILY ]; +// Endpoint order for onboardUser (same as generateContent fallbacks) +export const ONBOARD_USER_ENDPOINTS = ANTIGRAVITY_ENDPOINT_FALLBACKS; + // Hybrid headers specifically for loadCodeAssist // Uses google-api-nodejs-client User-Agent (required for project discovery on some accounts) export const LOAD_CODE_ASSIST_HEADERS = { @@ -227,6 +230,7 @@ export default { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_HEADERS, LOAD_CODE_ASSIST_ENDPOINTS, + ONBOARD_USER_ENDPOINTS, LOAD_CODE_ASSIST_HEADERS, DEFAULT_PROJECT_ID, TOKEN_REFRESH_INTERVAL_MS,