Merge branch 'main' into feature/webui

This commit is contained in:
Badri Narayanan S
2026-01-07 02:01:23 +05:30
committed by GitHub
11 changed files with 652 additions and 24 deletions

View File

@@ -6,10 +6,80 @@
* - Windows compatibility (no CLI dependency)
* - Native performance
* - Synchronous API (simple error handling)
*
* Includes auto-rebuild capability for handling Node.js version updates
* that cause native module incompatibility.
*/
import Database from 'better-sqlite3';
import { createRequire } from 'module';
import { ANTIGRAVITY_DB_PATH } from '../constants.js';
import { isModuleVersionError, attemptAutoRebuild, clearRequireCache } from '../utils/native-module-helper.js';
import { logger } from '../utils/logger.js';
import { NativeModuleError } from '../errors.js';
const require = createRequire(import.meta.url);
// Lazy-loaded Database constructor
let Database = null;
let moduleLoadError = null;
/**
* Load the better-sqlite3 module with auto-rebuild on version mismatch
* Uses synchronous require to maintain API compatibility
* @returns {Function} The Database constructor
* @throws {Error} If module cannot be loaded even after rebuild
*/
function loadDatabaseModule() {
// Return cached module if already loaded
if (Database) return Database;
// Re-throw cached error if previous load failed permanently
if (moduleLoadError) throw moduleLoadError;
try {
Database = require('better-sqlite3');
return Database;
} catch (error) {
if (isModuleVersionError(error)) {
logger.warn('[Database] Native module version mismatch detected');
if (attemptAutoRebuild(error)) {
// Clear require cache and retry
try {
const resolvedPath = require.resolve('better-sqlite3');
// Clear the module and all its dependencies from cache
clearRequireCache(resolvedPath, require.cache);
Database = require('better-sqlite3');
logger.success('[Database] Module reloaded successfully after rebuild');
return Database;
} catch (retryError) {
// Rebuild succeeded but reload failed - user needs to restart
moduleLoadError = new NativeModuleError(
'Native module rebuild completed. Please restart the server to apply the fix.',
true, // rebuildSucceeded
true // restartRequired
);
logger.info('[Database] Rebuild succeeded - server restart required');
throw moduleLoadError;
}
} else {
moduleLoadError = new NativeModuleError(
'Failed to auto-rebuild native module. Please run manually:\n' +
' npm rebuild better-sqlite3\n' +
'Or if using npx, find the package location in the error and run:\n' +
' cd /path/to/better-sqlite3 && npm rebuild',
false, // rebuildSucceeded
false // restartRequired
);
throw moduleLoadError;
}
}
// Non-version-mismatch error, just throw it
throw error;
}
}
/**
* Query Antigravity database for authentication status
@@ -18,10 +88,11 @@ import { ANTIGRAVITY_DB_PATH } from '../constants.js';
* @throws {Error} If database doesn't exist, query fails, or no auth status found
*/
export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
const Db = loadDatabaseModule();
let db;
try {
// Open database in read-only mode
db = new Database(dbPath, {
db = new Db(dbPath, {
readonly: true,
fileMustExist: true
});
@@ -56,6 +127,10 @@ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) {
throw error;
}
// Re-throw native module errors from loadDatabaseModule without wrapping
if (error instanceof NativeModuleError) {
throw error;
}
throw new Error(`Failed to read Antigravity database: ${error.message}`);
} finally {
// Always close database connection
@@ -73,7 +148,8 @@ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) {
let db;
try {
db = new Database(dbPath, {
const Db = loadDatabaseModule();
db = new Db(dbPath, {
readonly: true,
fileMustExist: true
});

View File

@@ -57,6 +57,56 @@ export function getAuthorizationUrl() {
};
}
/**
* Extract authorization code and state from user input.
* User can paste either:
* - Full callback URL: http://localhost:51121/oauth-callback?code=xxx&state=xxx
* - Just the code parameter: 4/0xxx...
*
* @param {string} input - User input (URL or code)
* @returns {{code: string, state: string|null}} Extracted code and optional state
*/
export function extractCodeFromInput(input) {
if (!input || typeof input !== 'string') {
throw new Error('No input provided');
}
const trimmed = input.trim();
// Check if it looks like a URL
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
try {
const url = new URL(trimmed);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) {
throw new Error(`OAuth error: ${error}`);
}
if (!code) {
throw new Error('No authorization code found in URL');
}
return { code, state };
} catch (e) {
if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) {
throw e;
}
throw new Error('Invalid URL format');
}
}
// Assume it's a raw code
// Google auth codes typically start with "4/" and are long
if (trimmed.length < 10) {
throw new Error('Input is too short to be a valid authorization code');
}
return { code: trimmed, state: null };
}
/**
* Start a local server to receive the OAuth callback
* Returns a promise that resolves with the authorization code
@@ -81,10 +131,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
const error = url.searchParams.get('error');
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<head><title>Authentication Failed</title></head>
<head><meta charset="UTF-8"><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>
@@ -98,10 +148,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
}
if (state !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<head><title>Authentication Failed</title></head>
<head><meta charset="UTF-8"><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>
@@ -115,10 +165,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
}
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<head><title>Authentication Failed</title></head>
<head><meta charset="UTF-8"><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>
@@ -132,10 +182,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
}
// Success!
res.writeHead(200, { 'Content-Type': 'text/html' });
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<head><title>Authentication Successful</title></head>
<head><meta charset="UTF-8"><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>
@@ -338,6 +388,7 @@ export async function completeOAuthFlow(code, verifier) {
export default {
getAuthorizationUrl,
extractCodeFromInput,
startCallbackServer,
exchangeCode,
refreshAccessToken,

View File

@@ -25,7 +25,8 @@ import {
startCallbackServer,
completeOAuthFlow,
refreshAccessToken,
getUserEmail
getUserEmail,
extractCodeFromInput
} from '../auth/oauth.js';
const SERVER_PORT = process.env.PORT || DEFAULT_PORT;
@@ -229,6 +230,63 @@ async function addAccount(existingAccounts) {
}
}
/**
* Add a new account via OAuth with manual code input (no-browser mode)
* For headless servers without a desktop environment
*/
async function addAccountNoBrowser(existingAccounts, rl) {
console.log('\n=== Add Google Account (No-Browser Mode) ===\n');
// Generate authorization URL
const { url, verifier, state } = getAuthorizationUrl();
console.log('Copy the following URL and open it in a browser on another device:\n');
console.log(` ${url}\n`);
console.log('After signing in, you will be redirected to a localhost URL.');
console.log('Copy the ENTIRE redirect URL or just the authorization code.\n');
const input = await rl.question('Paste the callback URL or authorization code: ');
try {
const { code, state: extractedState } = extractCodeFromInput(input);
// Validate state if present
if (extractedState && extractedState !== state) {
console.log('\n⚠ State mismatch detected. This could indicate a security issue.');
console.log('Proceeding anyway as this is manual mode...');
}
console.log('\nExchanging authorization code for tokens...');
const result = await completeOAuthFlow(code, verifier);
// Check if account already exists
const existing = existingAccounts.find(a => a.email === result.email);
if (existing) {
console.log(`\n⚠ Account ${result.email} already exists. Updating tokens.`);
existing.refreshToken = result.refreshToken;
existing.projectId = result.projectId;
existing.addedAt = new Date().toISOString();
return null; // Don't add duplicate
}
console.log(`\n✓ Successfully authenticated: ${result.email}`);
if (result.projectId) {
console.log(` Project ID: ${result.projectId}`);
}
return {
email: result.email,
refreshToken: result.refreshToken,
projectId: result.projectId,
addedAt: new Date().toISOString(),
modelRateLimits: {}
};
} catch (error) {
console.error(`\n✗ Authentication failed: ${error.message}`);
return null;
}
}
/**
* Interactive remove accounts flow
*/
@@ -275,8 +333,14 @@ async function interactiveRemove(rl) {
/**
* Interactive add accounts flow (Main Menu)
* @param {Object} rl - readline interface
* @param {boolean} noBrowser - if true, use manual code input mode
*/
async function interactiveAdd(rl) {
async function interactiveAdd(rl, noBrowser = false) {
if (noBrowser) {
console.log('\n📋 No-browser mode: You will manually paste the authorization code.\n');
}
const accounts = loadAccounts();
if (accounts.length > 0) {
@@ -307,7 +371,11 @@ async function interactiveAdd(rl) {
return;
}
const newAccount = await addAccount(accounts);
// Use appropriate add function based on mode
const newAccount = noBrowser
? await addAccountNoBrowser(accounts, rl)
: await addAccount(accounts);
if (newAccount) {
accounts.push(newAccount);
saveAccounts(accounts);
@@ -388,9 +456,11 @@ async function verifyAccounts() {
async function main() {
const args = process.argv.slice(2);
const command = args[0] || 'add';
const noBrowser = args.includes('--no-browser');
console.log('╔════════════════════════════════════════╗');
console.log('║ Antigravity Proxy Account Manager ║');
console.log('║ Use --no-browser for headless mode ║');
console.log('╚════════════════════════════════════════╝');
const rl = createRL();
@@ -399,7 +469,7 @@ async function main() {
switch (command) {
case 'add':
await ensureServerStopped();
await interactiveAdd(rl);
await interactiveAdd(rl, noBrowser);
break;
case 'list':
await listAccounts();
@@ -418,6 +488,8 @@ async function main() {
console.log(' node src/cli/accounts.js verify Verify account tokens');
console.log(' node src/cli/accounts.js clear Remove all accounts');
console.log(' node src/cli/accounts.js help Show this help');
console.log('\nOptions:');
console.log(' --no-browser Manual authorization code input (for headless servers)');
break;
case 'remove':
await ensureServerStopped();

View File

@@ -76,7 +76,7 @@ export const ACCOUNT_CONFIG_PATH = config?.accountConfigPath || join(
// Uses platform-specific path detection
export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath();
export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (60 * 1000); // From config or 1 minute
export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (10 * 1000); // From config or 10 seconds
export const MAX_RETRIES = config?.maxRetries || 5; // From config or 5
export const MAX_ACCOUNTS = config?.maxAccounts || 10; // From config or 10

View File

@@ -118,6 +118,23 @@ export class ApiError extends AntigravityError {
}
}
/**
* Native module error (version mismatch, rebuild required)
*/
export class NativeModuleError extends AntigravityError {
/**
* @param {string} message - Error message
* @param {boolean} rebuildSucceeded - Whether auto-rebuild succeeded
* @param {boolean} restartRequired - Whether server restart is needed
*/
constructor(message, rebuildSucceeded = false, restartRequired = false) {
super(message, 'NATIVE_MODULE_ERROR', false, { rebuildSucceeded, restartRequired });
this.name = 'NativeModuleError';
this.rebuildSucceeded = rebuildSucceeded;
this.restartRequired = restartRequired;
}
}
/**
* Check if an error is a rate limit error
* Works with both custom error classes and legacy string-based errors
@@ -154,6 +171,7 @@ export default {
NoAccountsError,
MaxRetriesError,
ApiError,
NativeModuleError,
isRateLimitError,
isAuthError
};

View File

@@ -0,0 +1,162 @@
/**
* Native Module Helper
* Detects and auto-rebuilds native Node.js modules when they become
* incompatible after a Node.js version update.
*/
import { execSync } from 'child_process';
import { dirname, join } from 'path';
import { existsSync } from 'fs';
import { logger } from './logger.js';
/**
* Check if an error is a NODE_MODULE_VERSION mismatch error
* @param {Error} error - The error to check
* @returns {boolean} True if it's a version mismatch error
*/
export function isModuleVersionError(error) {
const message = error?.message || '';
return message.includes('NODE_MODULE_VERSION') &&
message.includes('was compiled against a different Node.js version');
}
/**
* Extract the module path from a NODE_MODULE_VERSION error message
* @param {Error} error - The error containing the module path
* @returns {string|null} The path to the .node file, or null if not found
*/
export function extractModulePath(error) {
const message = error?.message || '';
// Match pattern like: "The module '/path/to/module.node'"
const match = message.match(/The module '([^']+\.node)'/);
return match ? match[1] : null;
}
/**
* Find the package root directory from a .node file path
* @param {string} nodeFilePath - Path to the .node file
* @returns {string|null} Path to the package root, or null if not found
*/
export function findPackageRoot(nodeFilePath) {
// Walk up from the .node file to find package.json
let dir = dirname(nodeFilePath);
while (dir) {
const packageJsonPath = join(dir, 'package.json');
if (existsSync(packageJsonPath)) {
return dir;
}
const parentDir = dirname(dir);
// Stop when we've reached the filesystem root (dirname returns same path)
if (parentDir === dir) {
break;
}
dir = parentDir;
}
return null;
}
/**
* Attempt to rebuild a native module
* @param {string} packagePath - Path to the package root directory
* @returns {boolean} True if rebuild succeeded, false otherwise
*/
export function rebuildModule(packagePath) {
try {
logger.info(`[NativeModule] Rebuilding native module at: ${packagePath}`);
// Run npm rebuild in the package directory
const output = execSync('npm rebuild', {
cwd: packagePath,
stdio: 'pipe', // Capture output instead of printing
timeout: 120000 // 2 minute timeout
});
// Log rebuild output for debugging
const outputStr = output?.toString().trim();
if (outputStr) {
logger.debug(`[NativeModule] Rebuild output:\n${outputStr}`);
}
logger.success('[NativeModule] Rebuild completed successfully');
return true;
} catch (error) {
// Include stdout/stderr from the failed command for troubleshooting
const stdout = error.stdout?.toString().trim();
const stderr = error.stderr?.toString().trim();
let errorDetails = `[NativeModule] Rebuild failed: ${error.message}`;
if (stdout) {
errorDetails += `\n[NativeModule] stdout: ${stdout}`;
}
if (stderr) {
errorDetails += `\n[NativeModule] stderr: ${stderr}`;
}
logger.error(errorDetails);
return false;
}
}
/**
* Attempt to auto-rebuild a native module from an error
* @param {Error} error - The NODE_MODULE_VERSION error
* @returns {boolean} True if rebuild succeeded, false otherwise
*/
export function attemptAutoRebuild(error) {
const nodePath = extractModulePath(error);
if (!nodePath) {
logger.error('[NativeModule] Could not extract module path from error');
return false;
}
const packagePath = findPackageRoot(nodePath);
if (!packagePath) {
logger.error('[NativeModule] Could not find package root');
return false;
}
logger.warn('[NativeModule] Native module version mismatch detected');
logger.info('[NativeModule] Attempting automatic rebuild...');
return rebuildModule(packagePath);
}
/**
* Recursively clear a module and its dependencies from the require cache
* This is needed after rebuilding a native module to force re-import
* @param {string} modulePath - Resolved path to the module
* @param {object} cache - The require.cache object
* @param {Set} [visited] - Set of already-visited paths to prevent cycles
*/
export function clearRequireCache(modulePath, cache, visited = new Set()) {
if (visited.has(modulePath)) return;
visited.add(modulePath);
const mod = cache[modulePath];
if (!mod) return;
// Recursively clear children first
if (mod.children) {
for (const child of mod.children) {
clearRequireCache(child.id, cache, visited);
}
}
// Remove from parent's children array
if (mod.parent && mod.parent.children) {
const idx = mod.parent.children.indexOf(mod);
if (idx !== -1) {
mod.parent.children.splice(idx, 1);
}
}
// Delete from cache
delete cache[modulePath];
}
export default {
isModuleVersionError,
extractModulePath,
findPackageRoot,
rebuildModule,
attemptAutoRebuild,
clearRequireCache
};