refactor: centralize constants, add error classes, and DRY test utilities

- Create src/errors.js with custom error classes (RateLimitError, AuthError, ApiError, etc.)
- Create src/utils/helpers.js with shared utilities (formatDuration, sleep)
- Create tests/helpers/http-client.cjs with shared test utilities (~250 lines deduped)
- Centralize OAuth config and other constants in src/constants.js
- Add JSDoc types to all major exported functions
- Refactor all test files to use shared http-client utilities
- Update CLAUDE.md with new architecture documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Badri Narayanan S
2025-12-24 18:11:45 +05:30
parent 712da8f7f2
commit 0edc718672
16 changed files with 641 additions and 626 deletions

View File

@@ -8,10 +8,9 @@ import { readFile, writeFile, mkdir, access } from 'fs/promises';
import { constants as fsConstants } from 'fs';
import { dirname } from 'path';
import { execSync } from 'child_process';
import { homedir } from 'os';
import { join } from 'path';
import {
ACCOUNT_CONFIG_PATH,
ANTIGRAVITY_DB_PATH,
DEFAULT_COOLDOWN_MS,
TOKEN_REFRESH_INTERVAL_MS,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
@@ -19,36 +18,7 @@ import {
DEFAULT_PROJECT_ID
} from './constants.js';
import { refreshAccessToken } from './oauth.js';
// Default Antigravity database path
const ANTIGRAVITY_DB_PATH = join(
homedir(),
'Library/Application Support/Antigravity/User/globalStorage/state.vscdb'
);
/**
* Format duration in milliseconds to human-readable string (e.g., "1h23m45s")
*/
function formatDuration(ms) {
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h${minutes}m${secs}s`;
} else if (minutes > 0) {
return `${minutes}m${secs}s`;
}
return `${secs}s`;
}
/**
* Sleep for specified milliseconds
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
import { formatDuration } from './utils/helpers.js';
export class AccountManager {
#accounts = [];
@@ -156,6 +126,7 @@ export class AccountManager {
/**
* Get the number of accounts
* @returns {number} Number of configured accounts
*/
getAccountCount() {
return this.#accounts.length;
@@ -163,6 +134,7 @@ export class AccountManager {
/**
* Check if all accounts are rate-limited
* @returns {boolean} True if all accounts are rate-limited
*/
isAllRateLimited() {
if (this.#accounts.length === 0) return true;
@@ -171,6 +143,7 @@ export class AccountManager {
/**
* Get list of available (non-rate-limited, non-invalid) accounts
* @returns {Array<Object>} Array of available account objects
*/
getAvailableAccounts() {
return this.#accounts.filter(acc => !acc.isRateLimited && !acc.isInvalid);
@@ -178,6 +151,7 @@ export class AccountManager {
/**
* Get list of invalid accounts
* @returns {Array<Object>} Array of invalid account objects
*/
getInvalidAccounts() {
return this.#accounts.filter(acc => acc.isInvalid);
@@ -185,6 +159,7 @@ export class AccountManager {
/**
* Clear expired rate limits
* @returns {number} Number of rate limits cleared
*/
clearExpiredLimits() {
const now = Date.now();
@@ -209,6 +184,7 @@ export class AccountManager {
/**
* Clear all rate limits to force a fresh check
* (Optimistic retry strategy)
* @returns {void}
*/
resetAllRateLimits() {
for (const account of this.#accounts) {
@@ -223,6 +199,7 @@ export class AccountManager {
/**
* Pick the next available account (round-robin)
* @returns {Object|null} The next available account or null if none available
*/
pickNext() {
this.clearExpiredLimits();
@@ -254,6 +231,8 @@ export class AccountManager {
/**
* Mark an account as rate-limited
* @param {string} email - Email of the account to mark
* @param {number|null} resetMs - Time in ms until rate limit resets (optional)
*/
markRateLimited(email, resetMs = null) {
const account = this.#accounts.find(a => a.email === email);
@@ -272,6 +251,8 @@ export class AccountManager {
/**
* Mark an account as invalid (credentials need re-authentication)
* @param {string} email - Email of the account to mark
* @param {string} reason - Reason for marking as invalid
*/
markInvalid(email, reason = 'Unknown error') {
const account = this.#accounts.find(a => a.email === email);
@@ -296,6 +277,7 @@ export class AccountManager {
/**
* Get the minimum wait time until any account becomes available
* @returns {number} Wait time in milliseconds
*/
getMinWaitTimeMs() {
if (!this.isAllRateLimited()) return 0;
@@ -323,6 +305,9 @@ export class AccountManager {
/**
* Get OAuth token for an account
* @param {Object} account - Account object with email and credentials
* @returns {Promise<string>} OAuth access token
* @throws {Error} If token refresh fails
*/
async getTokenForAccount(account) {
// Check cache first
@@ -372,6 +357,9 @@ export class AccountManager {
/**
* Get project ID for an account
* @param {Object} account - Account object
* @param {string} token - OAuth access token
* @returns {Promise<string>} Project ID
*/
async getProjectForAccount(account, token) {
// Check cache first
@@ -435,6 +423,7 @@ export class AccountManager {
/**
* Clear project cache for an account (useful on auth errors)
* @param {string|null} email - Email to clear cache for, or null to clear all
*/
clearProjectCache(email = null) {
if (email) {
@@ -446,6 +435,7 @@ export class AccountManager {
/**
* Clear token cache for an account (useful on auth errors)
* @param {string|null} email - Email to clear cache for, or null to clear all
*/
clearTokenCache(email = null) {
if (email) {
@@ -457,6 +447,7 @@ export class AccountManager {
/**
* Save current state to disk (async)
* @returns {Promise<void>}
*/
async saveToDisk() {
try {
@@ -491,6 +482,7 @@ export class AccountManager {
/**
* Get status object for logging/API
* @returns {{accounts: Array, settings: Object}} Status object with accounts and settings
*/
getStatus() {
const available = this.getAvailableAccounts();
@@ -517,13 +509,11 @@ export class AccountManager {
/**
* Get settings
* @returns {Object} Current settings object
*/
getSettings() {
return { ...this.#settings };
}
}
// Export helper functions
export { formatDuration, sleep };
export default AccountManager;

View File

@@ -19,7 +19,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { dirname } from 'path';
import { exec } from 'child_process';
import net from 'net';
import { ACCOUNT_CONFIG_PATH, DEFAULT_PORT } from './constants.js';
import { ACCOUNT_CONFIG_PATH, DEFAULT_PORT, MAX_ACCOUNTS } from './constants.js';
import {
getAuthorizationUrl,
startCallbackServer,
@@ -28,7 +28,6 @@ import {
getUserEmail
} from './oauth.js';
const MAX_ACCOUNTS = 10;
const SERVER_PORT = process.env.PORT || DEFAULT_PORT;
/**

View File

@@ -23,27 +23,23 @@ import {
convertAnthropicToGoogle,
convertGoogleToAnthropic
} from './format-converter.js';
import { formatDuration, sleep } from './account-manager.js';
import { formatDuration, sleep } from './utils/helpers.js';
import { isRateLimitError, isAuthError } from './errors.js';
/**
* Check if an error is a rate limit error (429 or RESOURCE_EXHAUSTED)
* @deprecated Use isRateLimitError from errors.js instead
*/
function is429Error(error) {
const msg = (error.message || '').toLowerCase();
return msg.includes('429') ||
msg.includes('resource_exhausted') ||
msg.includes('quota_exhausted') ||
msg.includes('rate limit');
return isRateLimitError(error);
}
/**
* Check if an error is an auth-invalid error (credentials need re-authentication)
* @deprecated Use isAuthError from errors.js instead
*/
function isAuthInvalidError(error) {
const msg = (error.message || '').toUpperCase();
return msg.includes('AUTH_INVALID') ||
msg.includes('INVALID_GRANT') ||
msg.includes('TOKEN REFRESH FAILED');
return isAuthError(error);
}
/**
@@ -230,7 +226,13 @@ function buildHeaders(token, model, accept = 'application/json') {
* Uses SSE endpoint for thinking models (non-streaming doesn't return thinking blocks)
*
* @param {Object} anthropicRequest - The Anthropic-format request
* @param {AccountManager} accountManager - The account manager instance
* @param {Object} anthropicRequest.model - Model name to use
* @param {Array} anthropicRequest.messages - Array of message objects
* @param {number} [anthropicRequest.max_tokens] - Maximum tokens to generate
* @param {Object} [anthropicRequest.thinking] - Thinking configuration
* @param {import('./account-manager.js').default} accountManager - The account manager instance
* @returns {Promise<Object>} Anthropic-format response object
* @throws {Error} If max retries exceeded or no accounts available
*/
export async function sendMessage(anthropicRequest, accountManager) {
const model = mapModelName(anthropicRequest.model);
@@ -479,7 +481,13 @@ async function parseThinkingSSEResponse(response, originalModel) {
* Streams events in real-time as they arrive from the server
*
* @param {Object} anthropicRequest - The Anthropic-format request
* @param {AccountManager} accountManager - The account manager instance
* @param {string} anthropicRequest.model - Model name to use
* @param {Array} anthropicRequest.messages - Array of message objects
* @param {number} [anthropicRequest.max_tokens] - Maximum tokens to generate
* @param {Object} [anthropicRequest.thinking] - Thinking configuration
* @param {import('./account-manager.js').default} accountManager - The account manager instance
* @yields {Object} Anthropic-format SSE events (message_start, content_block_start, content_block_delta, etc.)
* @throws {Error} If max retries exceeded or no accounts available
*/
export async function* sendMessageStream(anthropicRequest, accountManager) {
const model = mapModelName(anthropicRequest.model);
@@ -849,7 +857,9 @@ async function* streamSSEResponse(response, originalModel) {
}
/**
* List available models
* List available models in Anthropic API format
*
* @returns {{object: string, data: Array<{id: string, object: string, created: number, owned_by: string, description: string}>}} List of available models
*/
export function listModels() {
return {

View File

@@ -78,8 +78,16 @@ export const ACCOUNT_CONFIG_PATH = join(
homedir(),
'.config/antigravity-proxy/accounts.json'
);
// Antigravity app database path (for legacy single-account token extraction)
export const ANTIGRAVITY_DB_PATH = join(
homedir(),
'Library/Application Support/Antigravity/User/globalStorage/state.vscdb'
);
export const DEFAULT_COOLDOWN_MS = 60 * 1000; // 1 minute default cooldown
export const MAX_RETRIES = 5; // Max retry attempts across accounts
export const MAX_ACCOUNTS = 10; // Maximum number of accounts allowed
// Rate limit wait thresholds
export const MAX_WAIT_BEFORE_ERROR_MS = 120000; // 2 minutes - throw error if wait exceeds this
@@ -89,6 +97,24 @@ export const DEFAULT_THINKING_BUDGET = 16000; // Default thinking budget tokens
export const CLAUDE_THINKING_MAX_OUTPUT_TOKENS = 64000; // Max output tokens for thinking models
export const MIN_SIGNATURE_LENGTH = 50; // Minimum valid thinking signature length
// Google OAuth configuration (from opencode-antigravity-auth)
export const OAUTH_CONFIG = {
clientId: '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com',
clientSecret: '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',
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userInfoUrl: 'https://www.googleapis.com/oauth2/v1/userinfo',
callbackPort: 51121,
scopes: [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs'
]
};
export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_CONFIG.callbackPort}/oauth-callback`;
export default {
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_HEADERS,
@@ -100,10 +126,14 @@ export default {
ANTIGRAVITY_AUTH_PORT,
DEFAULT_PORT,
ACCOUNT_CONFIG_PATH,
ANTIGRAVITY_DB_PATH,
DEFAULT_COOLDOWN_MS,
MAX_RETRIES,
MAX_ACCOUNTS,
MAX_WAIT_BEFORE_ERROR_MS,
DEFAULT_THINKING_BUDGET,
CLAUDE_THINKING_MAX_OUTPUT_TOKENS,
MIN_SIGNATURE_LENGTH
MIN_SIGNATURE_LENGTH,
OAUTH_CONFIG,
OAUTH_REDIRECT_URI
};

159
src/errors.js Normal file
View File

@@ -0,0 +1,159 @@
/**
* Custom Error Classes
*
* Provides structured error types for better error handling and classification.
* Replaces string-based error detection with proper error class checking.
*/
/**
* Base error class for Antigravity proxy errors
*/
export class AntigravityError extends Error {
/**
* @param {string} message - Error message
* @param {string} code - Error code for programmatic handling
* @param {boolean} retryable - Whether the error is retryable
* @param {Object} metadata - Additional error metadata
*/
constructor(message, code, retryable = false, metadata = {}) {
super(message);
this.name = 'AntigravityError';
this.code = code;
this.retryable = retryable;
this.metadata = metadata;
}
/**
* Convert to JSON for API responses
*/
toJSON() {
return {
name: this.name,
code: this.code,
message: this.message,
retryable: this.retryable,
...this.metadata
};
}
}
/**
* Rate limit error (429 / RESOURCE_EXHAUSTED)
*/
export class RateLimitError extends AntigravityError {
/**
* @param {string} message - Error message
* @param {number|null} resetMs - Time in ms until rate limit resets
* @param {string} accountEmail - Email of the rate-limited account
*/
constructor(message, resetMs = null, accountEmail = null) {
super(message, 'RATE_LIMITED', true, { resetMs, accountEmail });
this.name = 'RateLimitError';
this.resetMs = resetMs;
this.accountEmail = accountEmail;
}
}
/**
* Authentication error (invalid credentials, token expired, etc.)
*/
export class AuthError extends AntigravityError {
/**
* @param {string} message - Error message
* @param {string} accountEmail - Email of the account with auth issues
* @param {string} reason - Specific reason for auth failure
*/
constructor(message, accountEmail = null, reason = null) {
super(message, 'AUTH_INVALID', false, { accountEmail, reason });
this.name = 'AuthError';
this.accountEmail = accountEmail;
this.reason = reason;
}
}
/**
* No accounts available error
*/
export class NoAccountsError extends AntigravityError {
/**
* @param {string} message - Error message
* @param {boolean} allRateLimited - Whether all accounts are rate limited
*/
constructor(message = 'No accounts available', allRateLimited = false) {
super(message, 'NO_ACCOUNTS', allRateLimited, { allRateLimited });
this.name = 'NoAccountsError';
this.allRateLimited = allRateLimited;
}
}
/**
* Max retries exceeded error
*/
export class MaxRetriesError extends AntigravityError {
/**
* @param {string} message - Error message
* @param {number} attempts - Number of attempts made
*/
constructor(message = 'Max retries exceeded', attempts = 0) {
super(message, 'MAX_RETRIES', false, { attempts });
this.name = 'MaxRetriesError';
this.attempts = attempts;
}
}
/**
* API error from upstream service
*/
export class ApiError extends AntigravityError {
/**
* @param {string} message - Error message
* @param {number} statusCode - HTTP status code
* @param {string} errorType - Type of API error
*/
constructor(message, statusCode = 500, errorType = 'api_error') {
super(message, errorType.toUpperCase(), statusCode >= 500, { statusCode, errorType });
this.name = 'ApiError';
this.statusCode = statusCode;
this.errorType = errorType;
}
}
/**
* Check if an error is a rate limit error
* Works with both custom error classes and legacy string-based errors
* @param {Error} error - Error to check
* @returns {boolean}
*/
export function isRateLimitError(error) {
if (error instanceof RateLimitError) return true;
const msg = (error.message || '').toLowerCase();
return msg.includes('429') ||
msg.includes('resource_exhausted') ||
msg.includes('quota_exhausted') ||
msg.includes('rate limit');
}
/**
* Check if an error is an authentication error
* Works with both custom error classes and legacy string-based errors
* @param {Error} error - Error to check
* @returns {boolean}
*/
export function isAuthError(error) {
if (error instanceof AuthError) return true;
const msg = (error.message || '').toUpperCase();
return msg.includes('AUTH_INVALID') ||
msg.includes('INVALID_GRANT') ||
msg.includes('TOKEN REFRESH FAILED');
}
export default {
AntigravityError,
RateLimitError,
AuthError,
NoAccountsError,
MaxRetriesError,
ApiError,
isRateLimitError,
isAuthError
};

View File

@@ -17,6 +17,8 @@ import {
/**
* Map Anthropic model name to Antigravity model name
* @param {string} anthropicModel - Anthropic format model name (e.g., 'claude-3-5-sonnet-20241022')
* @returns {string} Antigravity format model name (e.g., 'claude-sonnet-4-5')
*/
export function mapModelName(anthropicModel) {
return MODEL_MAPPINGS[anthropicModel] || anthropicModel;
@@ -24,6 +26,8 @@ export function mapModelName(anthropicModel) {
/**
* Check if a part is a thinking block
* @param {Object} part - Content part to check
* @returns {boolean} True if the part is a thinking block
*/
function isThinkingPart(part) {
return part.type === 'thinking' ||
@@ -96,6 +100,9 @@ function filterContentArray(contentArray) {
/**
* Filter unsigned thinking blocks from contents (Gemini format)
*
* @param {Array<{role: string, parts: Array}>} contents - Array of content objects in Gemini format
* @returns {Array<{role: string, parts: Array}>} Filtered contents with unsigned thinking blocks removed
*/
export function filterUnsignedThinkingBlocks(contents) {
return contents.map(content => {
@@ -113,6 +120,9 @@ export function filterUnsignedThinkingBlocks(contents) {
* Remove trailing unsigned thinking blocks from assistant messages.
* Claude/Gemini APIs require that assistant messages don't end with unsigned thinking blocks.
* This function removes thinking blocks from the end of content arrays.
*
* @param {Array<Object>} content - Array of content blocks
* @returns {Array<Object>} Content array with trailing unsigned thinking blocks removed
*/
export function removeTrailingThinkingBlocks(content) {
if (!Array.isArray(content)) return content;
@@ -174,6 +184,9 @@ function sanitizeAnthropicThinkingBlock(block) {
* Filter thinking blocks: keep only those with valid signatures.
* Blocks without signatures are dropped (API requires signatures).
* Also sanitizes blocks to remove extra fields like cache_control.
*
* @param {Array<Object>} content - Array of content blocks
* @returns {Array<Object>} Filtered content with only valid signed thinking blocks
*/
export function restoreThinkingSignatures(content) {
if (!Array.isArray(content)) return content;
@@ -208,6 +221,9 @@ export function restoreThinkingSignatures(content) {
* 3. Tool_use blocks come at the end (required before tool_result)
*
* Claude API requires that when thinking is enabled, assistant messages must start with thinking.
*
* @param {Array<Object>} content - Array of content blocks
* @returns {Array<Object>} Reordered content array
*/
export function reorderAssistantContent(content) {
if (!Array.isArray(content)) return content;

View File

@@ -8,27 +8,11 @@
import crypto from 'crypto';
import http from 'http';
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_HEADERS } from './constants.js';
// Google OAuth configuration (from opencode-antigravity-auth)
const GOOGLE_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const GOOGLE_CLIENT_SECRET = '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';
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo';
// Local callback server configuration
const CALLBACK_PORT = 51121;
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/oauth-callback`;
// Scopes needed for Cloud Code access (matching Antigravity)
const SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs'
].join(' ');
import {
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_HEADERS,
OAUTH_CONFIG
} from './constants.js';
/**
* Generate PKCE code verifier and challenge
@@ -45,16 +29,18 @@ function generatePKCE() {
/**
* Generate authorization URL for Google OAuth
* Returns the URL and the PKCE verifier (needed for token exchange)
*
* @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data
*/
export function getAuthorizationUrl() {
const { verifier, challenge } = generatePKCE();
const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
client_id: OAUTH_CONFIG.clientId,
redirect_uri: OAUTH_REDIRECT_URI,
response_type: 'code',
scope: SCOPES,
scope: OAUTH_CONFIG.scopes.join(' '),
access_type: 'offline',
prompt: 'consent',
code_challenge: challenge,
@@ -63,7 +49,7 @@ export function getAuthorizationUrl() {
});
return {
url: `${GOOGLE_AUTH_URL}?${params.toString()}`,
url: `${OAUTH_CONFIG.authUrl}?${params.toString()}`,
verifier,
state
};
@@ -72,11 +58,15 @@ export function getAuthorizationUrl() {
/**
* Start a local server to receive the OAuth callback
* Returns a promise that resolves with the authorization code
*
* @param {string} expectedState - Expected state parameter for CSRF protection
* @param {number} timeoutMs - Timeout in milliseconds (default 120000)
* @returns {Promise<string>} Authorization code from OAuth callback
*/
export function startCallbackServer(expectedState, timeoutMs = 120000) {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.callbackPort}`);
if (url.pathname !== '/oauth-callback') {
res.writeHead(404);
@@ -158,14 +148,14 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
reject(new Error(`Port ${CALLBACK_PORT} is already in use. Close any other OAuth flows and try again.`));
reject(new Error(`Port ${OAUTH_CONFIG.callbackPort} is already in use. Close any other OAuth flows and try again.`));
} else {
reject(err);
}
});
server.listen(CALLBACK_PORT, () => {
console.log(`[OAuth] Callback server listening on port ${CALLBACK_PORT}`);
server.listen(OAUTH_CONFIG.callbackPort, () => {
console.log(`[OAuth] Callback server listening on port ${OAUTH_CONFIG.callbackPort}`);
});
// Timeout after specified duration
@@ -178,20 +168,24 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
/**
* Exchange authorization code for tokens
*
* @param {string} code - Authorization code from OAuth callback
* @param {string} verifier - PKCE code verifier
* @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>} OAuth tokens
*/
export async function exchangeCode(code, verifier) {
const response = await fetch(GOOGLE_TOKEN_URL, {
const response = await fetch(OAUTH_CONFIG.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
client_id: OAUTH_CONFIG.clientId,
client_secret: OAUTH_CONFIG.clientSecret,
code: code,
code_verifier: verifier,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI
redirect_uri: OAUTH_REDIRECT_URI
})
});
@@ -219,16 +213,19 @@ export async function exchangeCode(code, verifier) {
/**
* Refresh access token using refresh token
*
* @param {string} refreshToken - OAuth refresh token
* @returns {Promise<{accessToken: string, expiresIn: number}>} New access token
*/
export async function refreshAccessToken(refreshToken) {
const response = await fetch(GOOGLE_TOKEN_URL, {
const response = await fetch(OAUTH_CONFIG.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
client_id: OAUTH_CONFIG.clientId,
client_secret: OAUTH_CONFIG.clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token'
})
@@ -248,9 +245,12 @@ export async function refreshAccessToken(refreshToken) {
/**
* Get user email from access token
*
* @param {string} accessToken - OAuth access token
* @returns {Promise<string>} User's email address
*/
export async function getUserEmail(accessToken) {
const response = await fetch(GOOGLE_USERINFO_URL, {
const response = await fetch(OAUTH_CONFIG.userInfoUrl, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
@@ -268,6 +268,9 @@ export async function getUserEmail(accessToken) {
/**
* Discover project ID for the authenticated user
*
* @param {string} accessToken - OAuth access token
* @returns {Promise<string|null>} Project ID or null if not found
*/
export async function discoverProjectId(accessToken) {
for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
@@ -308,6 +311,10 @@ export async function discoverProjectId(accessToken) {
/**
* Complete OAuth flow: exchange code and get all account info
*
* @param {string} code - Authorization code from OAuth callback
* @param {string} verifier - PKCE code verifier
* @returns {Promise<{email: string, refreshToken: string, accessToken: string, projectId: string|null}>} Complete account info
*/
export async function completeOAuthFlow(code, verifier) {
// Exchange code for tokens

View File

@@ -1,26 +1,22 @@
/**
* Token Extractor Module
* Extracts OAuth tokens from Antigravity's SQLite database
*
*
* The database is automatically updated by Antigravity when tokens refresh,
* so this approach doesn't require any manual intervention.
*/
import { execSync } from 'child_process';
import { homedir } from 'os';
import { join } from 'path';
import { TOKEN_REFRESH_INTERVAL_MS, ANTIGRAVITY_AUTH_PORT } from './constants.js';
import {
TOKEN_REFRESH_INTERVAL_MS,
ANTIGRAVITY_AUTH_PORT,
ANTIGRAVITY_DB_PATH
} from './constants.js';
// Cache for the extracted token
let cachedToken = null;
let tokenExtractedAt = null;
// Antigravity's SQLite database path
const ANTIGRAVITY_DB_PATH = join(
homedir(),
'Library/Application Support/Antigravity/User/globalStorage/state.vscdb'
);
/**
* Extract token from Antigravity's SQLite database
* This is the preferred method as the DB is auto-updated

33
src/utils/helpers.js Normal file
View File

@@ -0,0 +1,33 @@
/**
* Shared Utility Functions
*
* General-purpose helper functions used across multiple modules.
*/
/**
* Format duration in milliseconds to human-readable string
* @param {number} ms - Duration in milliseconds
* @returns {string} Human-readable duration (e.g., "1h23m45s")
*/
export function formatDuration(ms) {
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h${minutes}m${secs}s`;
} else if (minutes > 0) {
return `${minutes}m${secs}s`;
}
return `${secs}s`;
}
/**
* Sleep for specified milliseconds
* @param {number} ms - Duration to sleep in milliseconds
* @returns {Promise<void>} Resolves after the specified duration
*/
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}