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 <noreply@anthropic.com>
This commit is contained in:
Badri Narayanan S
2026-01-15 12:27:37 +05:30
parent 896bf81a36
commit 44632dc301
4 changed files with 157 additions and 6 deletions

View File

@@ -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}`);

View File

@@ -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<string|null>} 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;
}

View File

@@ -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<string|null>} 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;
}

View File

@@ -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,