Merge branch 'main' into feature/webui
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
162
src/utils/native-module-helper.js
Normal file
162
src/utils/native-module-helper.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user