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:
@@ -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}`);
|
||||
|
||||
111
src/account-manager/onboarding.js
Normal file
111
src/account-manager/onboarding.js
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user