refactor: Reorganize src/ into modular folder structure
Split large monolithic files into focused modules: - cloudcode-client.js (1,107 lines) → src/cloudcode/ (9 files) - account-manager.js (639 lines) → src/account-manager/ (5 files) - Move auth files to src/auth/ (oauth, token-extractor, database) - Move CLI to src/cli/accounts.js Update all import paths and documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
93
src/auth/database.js
Normal file
93
src/auth/database.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* SQLite Database Access Module
|
||||
* Provides cross-platform database operations for Antigravity state.
|
||||
*
|
||||
* Uses better-sqlite3 for:
|
||||
* - Windows compatibility (no CLI dependency)
|
||||
* - Native performance
|
||||
* - Synchronous API (simple error handling)
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { ANTIGRAVITY_DB_PATH } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Query Antigravity database for authentication status
|
||||
* @param {string} [dbPath] - Optional custom database path
|
||||
* @returns {Object} Parsed auth data with apiKey, email, name, etc.
|
||||
* @throws {Error} If database doesn't exist, query fails, or no auth status found
|
||||
*/
|
||||
export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
||||
let db;
|
||||
try {
|
||||
// Open database in read-only mode
|
||||
db = new Database(dbPath, {
|
||||
readonly: true,
|
||||
fileMustExist: true
|
||||
});
|
||||
|
||||
// Prepare and execute query
|
||||
const stmt = db.prepare(
|
||||
"SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus'"
|
||||
);
|
||||
const row = stmt.get();
|
||||
|
||||
if (!row || !row.value) {
|
||||
throw new Error('No auth status found in database');
|
||||
}
|
||||
|
||||
// Parse JSON value
|
||||
const authData = JSON.parse(row.value);
|
||||
|
||||
if (!authData.apiKey) {
|
||||
throw new Error('Auth data missing apiKey field');
|
||||
}
|
||||
|
||||
return authData;
|
||||
} catch (error) {
|
||||
// Enhance error messages for common issues
|
||||
if (error.code === 'SQLITE_CANTOPEN') {
|
||||
throw new Error(
|
||||
`Database not found at ${dbPath}. ` +
|
||||
'Make sure Antigravity is installed and you are logged in.'
|
||||
);
|
||||
}
|
||||
// Re-throw with context if not already our error
|
||||
if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to read Antigravity database: ${error.message}`);
|
||||
} finally {
|
||||
// Always close database connection
|
||||
if (db) {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database exists and is accessible
|
||||
* @param {string} [dbPath] - Optional custom database path
|
||||
* @returns {boolean} True if database exists and can be opened
|
||||
*/
|
||||
export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) {
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath, {
|
||||
readonly: true,
|
||||
fileMustExist: true
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (db) {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getAuthStatus,
|
||||
isDatabaseAccessible
|
||||
};
|
||||
347
src/auth/oauth.js
Normal file
347
src/auth/oauth.js
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Google OAuth with PKCE for Antigravity
|
||||
*
|
||||
* Implements the same OAuth flow as opencode-antigravity-auth
|
||||
* to obtain refresh tokens for multiple Google accounts.
|
||||
* Uses a local callback server to automatically capture the auth code.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import http from 'http';
|
||||
import {
|
||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||
ANTIGRAVITY_HEADERS,
|
||||
OAUTH_CONFIG,
|
||||
OAUTH_REDIRECT_URI
|
||||
} from '../constants.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
function generatePKCE() {
|
||||
const verifier = crypto.randomBytes(32).toString('base64url');
|
||||
const challenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(verifier)
|
||||
.digest('base64url');
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: OAUTH_CONFIG.clientId,
|
||||
redirect_uri: OAUTH_REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: OAUTH_CONFIG.scopes.join(' '),
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state: state
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${OAUTH_CONFIG.authUrl}?${params.toString()}`,
|
||||
verifier,
|
||||
state
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:${OAUTH_CONFIG.callbackPort}`);
|
||||
|
||||
if (url.pathname !== '/oauth-callback') {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head><title>Authentication Failed</title></head>
|
||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
<p>You can close this window.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
server.close();
|
||||
reject(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== expectedState) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head><title>Authentication Failed</title></head>
|
||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
||||
<p>State mismatch - possible CSRF attack.</p>
|
||||
<p>You can close this window.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
server.close();
|
||||
reject(new Error('State mismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head><title>Authentication Failed</title></head>
|
||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
||||
<p>No authorization code received.</p>
|
||||
<p>You can close this window.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
server.close();
|
||||
reject(new Error('No authorization code'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Success!
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head><title>Authentication Successful</title></head>
|
||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||
<h1 style="color: #28a745;">✅ Authentication Successful!</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
server.close();
|
||||
resolve(code);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${OAUTH_CONFIG.callbackPort} is already in use. Close any other OAuth flows and try again.`));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(OAUTH_CONFIG.callbackPort, () => {
|
||||
logger.info(`[OAuth] Callback server listening on port ${OAUTH_CONFIG.callbackPort}`);
|
||||
});
|
||||
|
||||
// Timeout after specified duration
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
reject(new Error('OAuth callback timeout - no response received'));
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(OAUTH_CONFIG.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: OAUTH_CONFIG.clientId,
|
||||
client_secret: OAUTH_CONFIG.clientSecret,
|
||||
code: code,
|
||||
code_verifier: verifier,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: OAUTH_REDIRECT_URI
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
logger.error(`[OAuth] Token exchange failed: ${response.status} ${error}`);
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
if (!tokens.access_token) {
|
||||
logger.error('[OAuth] No access token in response:', tokens);
|
||||
throw new Error('No access token received');
|
||||
}
|
||||
|
||||
logger.info(`[OAuth] Token exchange successful, access_token length: ${tokens.access_token?.length}`);
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(OAUTH_CONFIG.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: OAUTH_CONFIG.clientId,
|
||||
client_secret: OAUTH_CONFIG.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token refresh failed: ${error}`);
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
expiresIn: tokens.expires_in
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(OAUTH_CONFIG.userInfoUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error(`[OAuth] getUserEmail failed: ${response.status} ${errorText}`);
|
||||
throw new Error(`Failed to get user info: ${response.status}`);
|
||||
}
|
||||
|
||||
const userInfo = await response.json();
|
||||
return userInfo.email;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...ANTIGRAVITY_HEADERS
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) continue;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (typeof data.cloudaicompanionProject === 'string') {
|
||||
return data.cloudaicompanionProject;
|
||||
}
|
||||
if (data.cloudaicompanionProject?.id) {
|
||||
return data.cloudaicompanionProject.id;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[OAuth] Project discovery failed at ${endpoint}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
const tokens = await exchangeCode(code, verifier);
|
||||
|
||||
// Get user email
|
||||
const email = await getUserEmail(tokens.accessToken);
|
||||
|
||||
// Discover project ID
|
||||
const projectId = await discoverProjectId(tokens.accessToken);
|
||||
|
||||
return {
|
||||
email,
|
||||
refreshToken: tokens.refreshToken,
|
||||
accessToken: tokens.accessToken,
|
||||
projectId
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
getAuthorizationUrl,
|
||||
startCallbackServer,
|
||||
exchangeCode,
|
||||
refreshAccessToken,
|
||||
getUserEmail,
|
||||
discoverProjectId,
|
||||
completeOAuthFlow
|
||||
};
|
||||
117
src/auth/token-extractor.js
Normal file
117
src/auth/token-extractor.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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 {
|
||||
TOKEN_REFRESH_INTERVAL_MS,
|
||||
ANTIGRAVITY_AUTH_PORT
|
||||
} from '../constants.js';
|
||||
import { getAuthStatus } from './database.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// Cache for the extracted token
|
||||
let cachedToken = null;
|
||||
let tokenExtractedAt = null;
|
||||
|
||||
/**
|
||||
* Extract the chat params from Antigravity's HTML page (fallback method)
|
||||
*/
|
||||
async function extractChatParams() {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${ANTIGRAVITY_AUTH_PORT}/`);
|
||||
const html = await response.text();
|
||||
|
||||
// Find the base64-encoded chatParams in the HTML
|
||||
const match = html.match(/window\.chatParams\s*=\s*'([^']+)'/);
|
||||
if (!match) {
|
||||
throw new Error('Could not find chatParams in Antigravity page');
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
const base64Data = match[1];
|
||||
const jsonString = Buffer.from(base64Data, 'base64').toString('utf-8');
|
||||
const config = JSON.parse(jsonString);
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new Error(
|
||||
`Cannot connect to Antigravity on port ${ANTIGRAVITY_AUTH_PORT}. ` +
|
||||
'Make sure Antigravity is running.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fresh token data - tries DB first, falls back to HTML page
|
||||
*/
|
||||
async function getTokenData() {
|
||||
// Try database first (preferred - always has fresh token)
|
||||
try {
|
||||
const dbData = getAuthStatus();
|
||||
if (dbData?.apiKey) {
|
||||
logger.info('[Token] Got fresh token from SQLite database');
|
||||
return dbData;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[Token] DB extraction failed, trying HTML page...');
|
||||
}
|
||||
|
||||
// Fallback to HTML page
|
||||
try {
|
||||
const pageData = await extractChatParams();
|
||||
if (pageData?.apiKey) {
|
||||
logger.warn('[Token] Got token from HTML page (may be stale)');
|
||||
return pageData;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[Token] HTML page extraction failed: ${err.message}`);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Could not extract token from Antigravity. ' +
|
||||
'Make sure Antigravity is running and you are logged in.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cached token needs refresh
|
||||
*/
|
||||
function needsRefresh() {
|
||||
if (!cachedToken || !tokenExtractedAt) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - tokenExtractedAt > TOKEN_REFRESH_INTERVAL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current OAuth token (with caching)
|
||||
*/
|
||||
export async function getToken() {
|
||||
if (needsRefresh()) {
|
||||
const data = await getTokenData();
|
||||
cachedToken = data.apiKey;
|
||||
tokenExtractedAt = Date.now();
|
||||
}
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh the token (useful if requests start failing)
|
||||
*/
|
||||
export async function forceRefresh() {
|
||||
cachedToken = null;
|
||||
tokenExtractedAt = null;
|
||||
return getToken();
|
||||
}
|
||||
|
||||
export default {
|
||||
getToken,
|
||||
forceRefresh
|
||||
};
|
||||
Reference in New Issue
Block a user