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 { getAuthStatus } from '../auth/database.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { isNetworkError } from '../utils/helpers.js';
|
import { isNetworkError } from '../utils/helpers.js';
|
||||||
|
import { onboardUser, getDefaultTierId } from './onboarding.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get OAuth token for an account
|
* Get OAuth token for an account
|
||||||
@@ -115,6 +116,7 @@ export async function getProjectForAccount(account, token, projectCache) {
|
|||||||
export async function discoverProject(token) {
|
export async function discoverProject(token) {
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
let gotSuccessfulResponse = false;
|
let gotSuccessfulResponse = false;
|
||||||
|
let loadCodeAssistData = null;
|
||||||
|
|
||||||
for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) {
|
for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) {
|
||||||
try {
|
try {
|
||||||
@@ -144,6 +146,7 @@ export async function discoverProject(token) {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
gotSuccessfulResponse = true;
|
gotSuccessfulResponse = true;
|
||||||
|
loadCodeAssistData = data;
|
||||||
|
|
||||||
logger.debug(`[AccountManager] loadCodeAssist response from ${endpoint}:`, JSON.stringify(data));
|
logger.debug(`[AccountManager] loadCodeAssist response from ${endpoint}:`, JSON.stringify(data));
|
||||||
|
|
||||||
@@ -156,16 +159,29 @@ export async function discoverProject(token) {
|
|||||||
return data.cloudaicompanionProject.id;
|
return data.cloudaicompanionProject.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API returned success but no project - this is normal for Google One AI Pro accounts
|
// No project found - try to onboard the user
|
||||||
// Silently fall back to default project (matches opencode-antigravity-auth behavior)
|
logger.info(`[AccountManager] No project in loadCodeAssist response, attempting onboardUser...`);
|
||||||
logger.debug(`[AccountManager] No project in response, using default: ${DEFAULT_PROJECT_ID}`);
|
break;
|
||||||
return DEFAULT_PROJECT_ID;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error.message;
|
lastError = error.message;
|
||||||
logger.debug(`[AccountManager] loadCodeAssist error at ${endpoint}:`, 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)
|
// Only warn if all endpoints failed with errors (not just missing project)
|
||||||
if (!gotSuccessfulResponse) {
|
if (!gotSuccessfulResponse) {
|
||||||
logger.warn(`[AccountManager] loadCodeAssist failed for all endpoints: ${lastError}`);
|
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 http from 'http';
|
||||||
import {
|
import {
|
||||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||||
ANTIGRAVITY_HEADERS,
|
LOAD_CODE_ASSIST_HEADERS,
|
||||||
OAUTH_CONFIG,
|
OAUTH_CONFIG,
|
||||||
OAUTH_REDIRECT_URI
|
OAUTH_REDIRECT_URI
|
||||||
} from '../constants.js';
|
} from '../constants.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { onboardUser, getDefaultTierId } from '../account-manager/onboarding.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate PKCE code verifier and challenge
|
* 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
|
* @returns {Promise<string|null>} Project ID or null if not found
|
||||||
*/
|
*/
|
||||||
export async function discoverProjectId(accessToken) {
|
export async function discoverProjectId(accessToken) {
|
||||||
|
let loadCodeAssistData = null;
|
||||||
|
|
||||||
for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
|
for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||||
@@ -333,7 +336,7 @@ export async function discoverProjectId(accessToken) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...ANTIGRAVITY_HEADERS
|
...LOAD_CODE_ASSIST_HEADERS
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -347,6 +350,7 @@ export async function discoverProjectId(accessToken) {
|
|||||||
if (!response.ok) continue;
|
if (!response.ok) continue;
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
loadCodeAssistData = data;
|
||||||
|
|
||||||
if (typeof data.cloudaicompanionProject === 'string') {
|
if (typeof data.cloudaicompanionProject === 'string') {
|
||||||
return data.cloudaicompanionProject;
|
return data.cloudaicompanionProject;
|
||||||
@@ -354,11 +358,27 @@ export async function discoverProjectId(accessToken) {
|
|||||||
if (data.cloudaicompanionProject?.id) {
|
if (data.cloudaicompanionProject?.id) {
|
||||||
return 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) {
|
} catch (error) {
|
||||||
logger.warn(`[OAuth] Project discovery failed at ${endpoint}:`, error.message);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const LOAD_CODE_ASSIST_ENDPOINTS = [
|
|||||||
ANTIGRAVITY_ENDPOINT_DAILY
|
ANTIGRAVITY_ENDPOINT_DAILY
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Endpoint order for onboardUser (same as generateContent fallbacks)
|
||||||
|
export const ONBOARD_USER_ENDPOINTS = ANTIGRAVITY_ENDPOINT_FALLBACKS;
|
||||||
|
|
||||||
// Hybrid headers specifically for loadCodeAssist
|
// Hybrid headers specifically for loadCodeAssist
|
||||||
// Uses google-api-nodejs-client User-Agent (required for project discovery on some accounts)
|
// Uses google-api-nodejs-client User-Agent (required for project discovery on some accounts)
|
||||||
export const LOAD_CODE_ASSIST_HEADERS = {
|
export const LOAD_CODE_ASSIST_HEADERS = {
|
||||||
@@ -227,6 +230,7 @@ export default {
|
|||||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||||
ANTIGRAVITY_HEADERS,
|
ANTIGRAVITY_HEADERS,
|
||||||
LOAD_CODE_ASSIST_ENDPOINTS,
|
LOAD_CODE_ASSIST_ENDPOINTS,
|
||||||
|
ONBOARD_USER_ENDPOINTS,
|
||||||
LOAD_CODE_ASSIST_HEADERS,
|
LOAD_CODE_ASSIST_HEADERS,
|
||||||
DEFAULT_PROJECT_ID,
|
DEFAULT_PROJECT_ID,
|
||||||
TOKEN_REFRESH_INTERVAL_MS,
|
TOKEN_REFRESH_INTERVAL_MS,
|
||||||
|
|||||||
Reference in New Issue
Block a user