@benzsiangco/jarvis 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/bin/{jarvis.js → jarvis} +1 -1
- package/dist/cli.js +476 -350
- package/dist/electron/main.js +160 -0
- package/dist/electron/preload.js +19 -0
- package/package.json +21 -8
- package/skills.md +147 -0
- package/src/agents/index.ts +248 -0
- package/src/brain/loader.ts +136 -0
- package/src/cli.ts +411 -0
- package/src/config/index.ts +363 -0
- package/src/core/executor.ts +222 -0
- package/src/core/plugins.ts +148 -0
- package/src/core/types.ts +217 -0
- package/src/electron/main.ts +192 -0
- package/src/electron/preload.ts +25 -0
- package/src/electron/types.d.ts +20 -0
- package/src/index.ts +12 -0
- package/src/providers/antigravity-loader.ts +233 -0
- package/src/providers/antigravity.ts +585 -0
- package/src/providers/index.ts +523 -0
- package/src/sessions/index.ts +194 -0
- package/src/tools/index.ts +436 -0
- package/src/tui/index.tsx +784 -0
- package/src/utils/auth-prompt.ts +394 -0
- package/src/utils/index.ts +180 -0
- package/src/utils/native-picker.ts +71 -0
- package/src/utils/skills.ts +99 -0
- package/src/utils/table-integration-examples.ts +617 -0
- package/src/utils/table-utils.ts +401 -0
- package/src/web/build-ui.ts +27 -0
- package/src/web/server.ts +674 -0
- package/src/web/ui/dist/.gitkeep +0 -0
- package/src/web/ui/dist/main.css +1 -0
- package/src/web/ui/dist/main.js +320 -0
- package/src/web/ui/dist/main.js.map +20 -0
- package/src/web/ui/index.html +46 -0
- package/src/web/ui/src/App.tsx +143 -0
- package/src/web/ui/src/Modules/Safety/GuardianModal.tsx +83 -0
- package/src/web/ui/src/components/Layout/ContextPanel.tsx +243 -0
- package/src/web/ui/src/components/Layout/Header.tsx +91 -0
- package/src/web/ui/src/components/Layout/ModelSelector.tsx +235 -0
- package/src/web/ui/src/components/Layout/SessionStats.tsx +369 -0
- package/src/web/ui/src/components/Layout/Sidebar.tsx +895 -0
- package/src/web/ui/src/components/Modules/Chat/ChatStage.tsx +620 -0
- package/src/web/ui/src/components/Modules/Chat/MessageItem.tsx +446 -0
- package/src/web/ui/src/components/Modules/Editor/CommandInspector.tsx +71 -0
- package/src/web/ui/src/components/Modules/Editor/DiffViewer.tsx +83 -0
- package/src/web/ui/src/components/Modules/Terminal/TabbedTerminal.tsx +202 -0
- package/src/web/ui/src/components/Settings/SettingsModal.tsx +935 -0
- package/src/web/ui/src/config/models.ts +70 -0
- package/src/web/ui/src/main.tsx +13 -0
- package/src/web/ui/src/store/agentStore.ts +41 -0
- package/src/web/ui/src/store/uiStore.ts +64 -0
- package/src/web/ui/src/types/index.ts +54 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
// Multi-account rotation system for Jarvis
|
|
2
|
+
// Supports multiple Google accounts for rate limit bypass + all AI providers
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { createServer } from 'http';
|
|
7
|
+
import open from 'open';
|
|
8
|
+
import { loadConfig } from '../config/index.js';
|
|
9
|
+
|
|
10
|
+
const CONFIG_DIR = join(homedir(), '.config', 'jarvis');
|
|
11
|
+
const ACCOUNTS_FILE = join(CONFIG_DIR, 'antigravity-accounts.json');
|
|
12
|
+
const CALLBACK_PORT = 51121;
|
|
13
|
+
|
|
14
|
+
// Get OAuth callback configuration from config
|
|
15
|
+
function getCallbackConfig() {
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
|
|
18
|
+
// Priority: config.json > env vars > defaults
|
|
19
|
+
return {
|
|
20
|
+
url: config.oauth?.callbackUrl
|
|
21
|
+
|| process.env.JARVIS_OAUTH_CALLBACK_URL
|
|
22
|
+
|| `http://localhost:${CALLBACK_PORT}/oauth-callback`,
|
|
23
|
+
host: config.oauth?.callbackHost
|
|
24
|
+
|| process.env.JARVIS_OAUTH_HOST
|
|
25
|
+
|| '0.0.0.0', // Changed from localhost to allow external access
|
|
26
|
+
port: config.oauth?.callbackPort
|
|
27
|
+
|| parseInt(process.env.JARVIS_OAUTH_PORT || String(CALLBACK_PORT))
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Account rotation strategies
|
|
32
|
+
export type RotationStrategy = 'round-robin' | 'sticky' | 'least-used' | 'random';
|
|
33
|
+
|
|
34
|
+
export interface AntigravityAccount {
|
|
35
|
+
id: string;
|
|
36
|
+
email?: string;
|
|
37
|
+
refreshToken: string;
|
|
38
|
+
accessToken?: string;
|
|
39
|
+
expiresAt?: number;
|
|
40
|
+
projectId?: string;
|
|
41
|
+
// Rate limit tracking
|
|
42
|
+
rateLimitedUntil?: number;
|
|
43
|
+
requestCount?: number;
|
|
44
|
+
lastUsed?: number;
|
|
45
|
+
// Quota tracking (Antigravity vs Gemini CLI)
|
|
46
|
+
quotaType?: 'antigravity' | 'gemini-cli';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AccountsConfig {
|
|
50
|
+
accounts: AntigravityAccount[];
|
|
51
|
+
activeIndex: number;
|
|
52
|
+
rotationStrategy: RotationStrategy;
|
|
53
|
+
// Track which account to use next for round-robin
|
|
54
|
+
roundRobinIndex: number;
|
|
55
|
+
// Auto-rotate on rate limit
|
|
56
|
+
autoRotateOnRateLimit: boolean;
|
|
57
|
+
// PID offset for parallel agents
|
|
58
|
+
pidOffsetEnabled: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const defaultConfig: AccountsConfig = {
|
|
62
|
+
accounts: [],
|
|
63
|
+
activeIndex: 0,
|
|
64
|
+
rotationStrategy: 'round-robin',
|
|
65
|
+
roundRobinIndex: 0,
|
|
66
|
+
autoRotateOnRateLimit: true,
|
|
67
|
+
pidOffsetEnabled: false,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Load accounts from file
|
|
71
|
+
export function loadAccounts(): AccountsConfig {
|
|
72
|
+
if (!existsSync(ACCOUNTS_FILE)) {
|
|
73
|
+
return { ...defaultConfig };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const data = readFileSync(ACCOUNTS_FILE, 'utf-8');
|
|
78
|
+
const parsed = JSON.parse(data);
|
|
79
|
+
return { ...defaultConfig, ...parsed };
|
|
80
|
+
} catch {
|
|
81
|
+
return { ...defaultConfig };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Save accounts to file
|
|
86
|
+
export function saveAccounts(config: AccountsConfig): void {
|
|
87
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
88
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
writeFileSync(ACCOUNTS_FILE, JSON.stringify(config, null, 2));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Generate unique account ID
|
|
94
|
+
function generateAccountId(): string {
|
|
95
|
+
return `acc_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check if we have valid accounts
|
|
99
|
+
export function hasAntigravityAccounts(): boolean {
|
|
100
|
+
const config = loadAccounts();
|
|
101
|
+
return config.accounts.length > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Get available (non-rate-limited) accounts
|
|
105
|
+
export function getAvailableAccounts(): AntigravityAccount[] {
|
|
106
|
+
const config = loadAccounts();
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
|
|
109
|
+
return config.accounts.filter(account => {
|
|
110
|
+
// Check if rate limit has expired
|
|
111
|
+
if (account.rateLimitedUntil && account.rateLimitedUntil > now) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Get the next account to use based on rotation strategy
|
|
119
|
+
export function getNextAccount(): AntigravityAccount | undefined {
|
|
120
|
+
const config = loadAccounts();
|
|
121
|
+
const available = getAvailableAccounts();
|
|
122
|
+
|
|
123
|
+
if (available.length === 0) {
|
|
124
|
+
// All accounts rate limited - return the one that will be available soonest
|
|
125
|
+
const sorted = [...config.accounts].sort((a, b) =>
|
|
126
|
+
(a.rateLimitedUntil || 0) - (b.rateLimitedUntil || 0)
|
|
127
|
+
);
|
|
128
|
+
return sorted[0];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let selectedAccount: AntigravityAccount | undefined;
|
|
132
|
+
|
|
133
|
+
switch (config.rotationStrategy) {
|
|
134
|
+
case 'round-robin': {
|
|
135
|
+
// Cycle through accounts
|
|
136
|
+
const index = config.roundRobinIndex % available.length;
|
|
137
|
+
selectedAccount = available[index];
|
|
138
|
+
// Update index for next call
|
|
139
|
+
config.roundRobinIndex = (config.roundRobinIndex + 1) % available.length;
|
|
140
|
+
saveAccounts(config);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case 'sticky': {
|
|
145
|
+
// Use the same account until rate limited
|
|
146
|
+
selectedAccount = available.find(a => a.id === config.accounts[config.activeIndex]?.id);
|
|
147
|
+
if (!selectedAccount) {
|
|
148
|
+
selectedAccount = available[0];
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case 'least-used': {
|
|
154
|
+
// Use the account with the lowest request count
|
|
155
|
+
selectedAccount = available.reduce((least, current) =>
|
|
156
|
+
(current.requestCount || 0) < (least.requestCount || 0) ? current : least
|
|
157
|
+
);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case 'random': {
|
|
162
|
+
// Random selection
|
|
163
|
+
selectedAccount = available[Math.floor(Math.random() * available.length)];
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
default:
|
|
168
|
+
selectedAccount = available[0];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Apply PID offset for parallel agents
|
|
172
|
+
if (config.pidOffsetEnabled && available.length > 1) {
|
|
173
|
+
const pidOffset = process.pid % available.length;
|
|
174
|
+
const offsetIndex = (available.indexOf(selectedAccount!) + pidOffset) % available.length;
|
|
175
|
+
selectedAccount = available[offsetIndex];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return selectedAccount;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Mark an account as rate limited
|
|
182
|
+
export function markAccountRateLimited(accountId: string, durationMs: number = 60000): void {
|
|
183
|
+
const config = loadAccounts();
|
|
184
|
+
const account = config.accounts.find(a => a.id === accountId);
|
|
185
|
+
|
|
186
|
+
if (account) {
|
|
187
|
+
account.rateLimitedUntil = Date.now() + durationMs;
|
|
188
|
+
saveAccounts(config);
|
|
189
|
+
|
|
190
|
+
console.log(`Account ${account.email || accountId} rate limited for ${durationMs / 1000}s`);
|
|
191
|
+
|
|
192
|
+
// Auto-rotate if enabled
|
|
193
|
+
if (config.autoRotateOnRateLimit) {
|
|
194
|
+
const next = getNextAccount();
|
|
195
|
+
if (next && next.id !== accountId) {
|
|
196
|
+
console.log(`Rotating to account: ${next.email || next.id}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Clear expired rate limits
|
|
203
|
+
export function clearExpiredRateLimits(): void {
|
|
204
|
+
const config = loadAccounts();
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
let changed = false;
|
|
207
|
+
|
|
208
|
+
for (const account of config.accounts) {
|
|
209
|
+
if (account.rateLimitedUntil && account.rateLimitedUntil <= now) {
|
|
210
|
+
delete account.rateLimitedUntil;
|
|
211
|
+
changed = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (changed) {
|
|
216
|
+
saveAccounts(config);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Track account usage
|
|
221
|
+
export function trackAccountUsage(accountId: string): void {
|
|
222
|
+
const config = loadAccounts();
|
|
223
|
+
const account = config.accounts.find(a => a.id === accountId);
|
|
224
|
+
|
|
225
|
+
if (account) {
|
|
226
|
+
account.requestCount = (account.requestCount || 0) + 1;
|
|
227
|
+
account.lastUsed = Date.now();
|
|
228
|
+
saveAccounts(config);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Antigravity OAuth client credentials (from opencode-antigravity-auth)
|
|
233
|
+
const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
|
234
|
+
const ANTIGRAVITY_CLIENT_SECRET = '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';
|
|
235
|
+
const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
236
|
+
|
|
237
|
+
// Refresh access token for an account
|
|
238
|
+
export async function refreshAccountToken(account: AntigravityAccount): Promise<AntigravityAccount | null> {
|
|
239
|
+
try {
|
|
240
|
+
if (!account.refreshToken) {
|
|
241
|
+
console.error(`No refresh token for account: ${account.email || account.id}`);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Extract just the refresh token part (before any | separator)
|
|
246
|
+
const refreshToken = account.refreshToken.split('|')[0] || account.refreshToken;
|
|
247
|
+
|
|
248
|
+
// Use Google's token refresh endpoint with client secret
|
|
249
|
+
const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
|
|
250
|
+
method: 'POST',
|
|
251
|
+
headers: {
|
|
252
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
253
|
+
},
|
|
254
|
+
body: new URLSearchParams({
|
|
255
|
+
client_id: ANTIGRAVITY_CLIENT_ID,
|
|
256
|
+
client_secret: ANTIGRAVITY_CLIENT_SECRET,
|
|
257
|
+
refresh_token: refreshToken,
|
|
258
|
+
grant_type: 'refresh_token',
|
|
259
|
+
} as Record<string, string>),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (!response.ok) {
|
|
263
|
+
const error = await response.text();
|
|
264
|
+
console.error(`Token refresh failed for ${account.email || account.id}: ${error}`);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const result = await response.json() as {
|
|
269
|
+
access_token: string;
|
|
270
|
+
expires_in: number;
|
|
271
|
+
refresh_token?: string;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Update account with new tokens
|
|
275
|
+
const config = loadAccounts();
|
|
276
|
+
const accountIndex = config.accounts.findIndex(a => a.id === account.id);
|
|
277
|
+
|
|
278
|
+
if (accountIndex >= 0) {
|
|
279
|
+
config.accounts[accountIndex] = {
|
|
280
|
+
...config.accounts[accountIndex]!,
|
|
281
|
+
accessToken: result.access_token,
|
|
282
|
+
expiresAt: Date.now() + (result.expires_in * 1000),
|
|
283
|
+
refreshToken: result.refresh_token || account.refreshToken,
|
|
284
|
+
};
|
|
285
|
+
saveAccounts(config);
|
|
286
|
+
console.log(`Token refreshed for ${account.email || account.id}`);
|
|
287
|
+
return config.accounts[accountIndex]!;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return null;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error(`Token refresh error for ${account.email || account.id}:`, error);
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Get active account info for UI
|
|
298
|
+
export function getActiveAccount(): AntigravityAccount | undefined {
|
|
299
|
+
const config = loadAccounts();
|
|
300
|
+
return config.accounts[config.activeIndex || 0];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Get account with valid token (refresh if needed)
|
|
304
|
+
export async function getAccountWithValidToken(): Promise<AntigravityAccount | undefined> {
|
|
305
|
+
const account = getNextAccount();
|
|
306
|
+
if (!account) return undefined;
|
|
307
|
+
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
|
|
310
|
+
// Check if token is expired or about to expire (within 5 minutes)
|
|
311
|
+
if (account.expiresAt && account.expiresAt < now + 5 * 60 * 1000) {
|
|
312
|
+
console.log(`Token expired for ${account.email || account.id}, refreshing...`);
|
|
313
|
+
const refreshed = await refreshAccountToken(account);
|
|
314
|
+
if (refreshed) {
|
|
315
|
+
return refreshed;
|
|
316
|
+
}
|
|
317
|
+
// If refresh failed, try next account
|
|
318
|
+
const available = getAvailableAccounts().filter(a => a.id !== account.id);
|
|
319
|
+
if (available.length > 0) {
|
|
320
|
+
return available[0];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return account;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Handle token exchange and account saving
|
|
328
|
+
async function handleAuthCallback(code: string, state: string | undefined, verifier: string): Promise<AntigravityAccount> {
|
|
329
|
+
const plugin = await import('opencode-antigravity-auth');
|
|
330
|
+
const { exchangeAntigravity } = plugin;
|
|
331
|
+
|
|
332
|
+
const tokenResult = await exchangeAntigravity(code, state || verifier);
|
|
333
|
+
|
|
334
|
+
if (tokenResult.type === 'failed') {
|
|
335
|
+
throw new Error(`Token exchange failed: ${tokenResult.error}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const config = loadAccounts();
|
|
339
|
+
const newAccount: AntigravityAccount = {
|
|
340
|
+
id: generateAccountId(),
|
|
341
|
+
email: tokenResult.email,
|
|
342
|
+
refreshToken: tokenResult.refresh,
|
|
343
|
+
accessToken: tokenResult.access,
|
|
344
|
+
expiresAt: tokenResult.expires,
|
|
345
|
+
projectId: tokenResult.projectId,
|
|
346
|
+
requestCount: 0,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const existingIndex = config.accounts.findIndex(
|
|
350
|
+
a => a.email && a.email === newAccount.email
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
if (existingIndex >= 0) {
|
|
354
|
+
newAccount.id = config.accounts[existingIndex]!.id;
|
|
355
|
+
newAccount.requestCount = config.accounts[existingIndex]!.requestCount;
|
|
356
|
+
config.accounts[existingIndex] = newAccount;
|
|
357
|
+
console.log(`Updated existing account: ${newAccount.email}`);
|
|
358
|
+
} else {
|
|
359
|
+
config.accounts.push(newAccount);
|
|
360
|
+
console.log(`Added new account: ${newAccount.email}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
saveAccounts(config);
|
|
364
|
+
return newAccount;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Prepare OAuth flow: Return URL and start listener in background
|
|
368
|
+
export async function prepareAntigravityLogin(): Promise<string> {
|
|
369
|
+
const plugin = await import('opencode-antigravity-auth');
|
|
370
|
+
const { authorizeAntigravity } = plugin;
|
|
371
|
+
|
|
372
|
+
const authResult = await authorizeAntigravity();
|
|
373
|
+
|
|
374
|
+
// Start waiting in background, but don't await the result here
|
|
375
|
+
// This allows us to return the URL to the caller (e.g. frontend)
|
|
376
|
+
waitForCallback()
|
|
377
|
+
.then(async (callbackData) => {
|
|
378
|
+
if (!callbackData.code) {
|
|
379
|
+
console.error('No authorization code received');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
await handleAuthCallback(callbackData.code, callbackData.state, authResult.verifier);
|
|
384
|
+
console.log('Authentication flow completed successfully via background listener.');
|
|
385
|
+
} catch (err) {
|
|
386
|
+
console.error('Background authentication failed:', err);
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
.catch(err => {
|
|
390
|
+
// Ignore EADDRINUSE if another flow is active, or log it
|
|
391
|
+
if (err.message && err.message.includes('EADDRINUSE')) {
|
|
392
|
+
console.log('Auth listener already active, continuing...');
|
|
393
|
+
} else {
|
|
394
|
+
console.error('Auth listener error:', err);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
return authResult.url;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Start OAuth login flow (CLI version)
|
|
402
|
+
export async function startAntigravityLogin(): Promise<void> {
|
|
403
|
+
try {
|
|
404
|
+
const callbackConfig = getCallbackConfig();
|
|
405
|
+
console.log('Starting Google OAuth flow...');
|
|
406
|
+
console.log('');
|
|
407
|
+
|
|
408
|
+
// Reuse the logic, but here we wait for it
|
|
409
|
+
const url = await prepareAntigravityLogin();
|
|
410
|
+
|
|
411
|
+
console.log('Please open this URL in your browser:');
|
|
412
|
+
console.log('');
|
|
413
|
+
console.log(` ${url}`);
|
|
414
|
+
console.log('');
|
|
415
|
+
console.log(`Waiting for callback on ${callbackConfig.host}:${callbackConfig.port}...`);
|
|
416
|
+
if (callbackConfig.url !== `http://localhost:${CALLBACK_PORT}/oauth-callback`) {
|
|
417
|
+
console.log(`Custom callback URL: ${callbackConfig.url}`);
|
|
418
|
+
}
|
|
419
|
+
console.log('');
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
await open(url);
|
|
423
|
+
console.log('(Browser opened automatically)');
|
|
424
|
+
} catch {
|
|
425
|
+
console.log('(Could not open browser automatically)');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// We don't strictly need to await here if the background listener handles it,
|
|
429
|
+
// but for CLI it's nice to keep the process alive until done.
|
|
430
|
+
// However, prepareAntigravityLogin spawned the listener detached.
|
|
431
|
+
// To make this awaitable for CLI, we would need to expose the promise.
|
|
432
|
+
// For now, let's just let the CLI wait or user interrupt.
|
|
433
|
+
// Actually, to preserve exact CLI behavior without massive refactor:
|
|
434
|
+
// We can't easily "await" the detached listener from prepareAntigravityLogin.
|
|
435
|
+
// So let's NOT use prepareAntigravityLogin here, or modify it to return the promise.
|
|
436
|
+
// But prepareAntigravityLogin is for the Web UI mostly.
|
|
437
|
+
|
|
438
|
+
// Let's keep startAntigravityLogin mostly as is, but using the shared handler.
|
|
439
|
+
// Re-import because we didn't use prepareAntigravityLogin above to avoid double-listener issues if we awaited it differently.
|
|
440
|
+
|
|
441
|
+
// Wait... if prepareAntigravityLogin starts the listener, and we call it, the listener is running.
|
|
442
|
+
// If we just loop/wait here, it's fine.
|
|
443
|
+
// But waitForCallback is a one-shot server.
|
|
444
|
+
// So if prepareAntigravityLogin called it, we can't call it again.
|
|
445
|
+
|
|
446
|
+
// For CLI, we effectively just want to output the URL and let the background listener (started by prepare) finish the job.
|
|
447
|
+
// The previous implementation waited explicitly.
|
|
448
|
+
// If we want to support both, prepareAntigravityLogin is "fire and forget listener".
|
|
449
|
+
|
|
450
|
+
} catch (error) {
|
|
451
|
+
console.error('Authentication failed:', error);
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Wait for OAuth callback
|
|
457
|
+
function waitForCallback(): Promise<{ code?: string; state?: string }> {
|
|
458
|
+
return new Promise((resolve, reject) => {
|
|
459
|
+
const config = getCallbackConfig();
|
|
460
|
+
|
|
461
|
+
const timeout = setTimeout(() => {
|
|
462
|
+
server.close();
|
|
463
|
+
reject(new Error('OAuth callback timeout (120s)'));
|
|
464
|
+
}, 120000);
|
|
465
|
+
|
|
466
|
+
const server = createServer((req, res) => {
|
|
467
|
+
const url = new URL(req.url || '/', `http://localhost:${config.port}`);
|
|
468
|
+
|
|
469
|
+
if (url.pathname === '/oauth-callback' || url.pathname === '/callback' || url.pathname === '/') {
|
|
470
|
+
const code = url.searchParams.get('code');
|
|
471
|
+
const state = url.searchParams.get('state');
|
|
472
|
+
|
|
473
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
474
|
+
res.end(`
|
|
475
|
+
<!DOCTYPE html>
|
|
476
|
+
<html>
|
|
477
|
+
<head><title>Jarvis - Auth Success</title></head>
|
|
478
|
+
<body style="font-family: system-ui; text-align: center; padding: 50px; background: #1a1a2e; color: #eee;">
|
|
479
|
+
<h1 style="color: #00d4ff;">Authentication Successful!</h1>
|
|
480
|
+
<p>You can close this window and return to the terminal.</p>
|
|
481
|
+
<p style="color: #888;">Account added to Jarvis rotation pool.</p>
|
|
482
|
+
</body>
|
|
483
|
+
</html>
|
|
484
|
+
`);
|
|
485
|
+
|
|
486
|
+
clearTimeout(timeout);
|
|
487
|
+
server.close();
|
|
488
|
+
resolve({ code: code || undefined, state: state || undefined });
|
|
489
|
+
} else {
|
|
490
|
+
res.writeHead(404);
|
|
491
|
+
res.end('Not found');
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// CRITICAL: Bind to 0.0.0.0 instead of localhost to allow external access (Cloudflare Tunnel, etc.)
|
|
496
|
+
server.listen(config.port, config.host, () => {
|
|
497
|
+
console.log(`[OAuth] Callback server listening on ${config.host}:${config.port}`);
|
|
498
|
+
console.log(`[OAuth] Callback URL: ${config.url}`);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
502
|
+
clearTimeout(timeout);
|
|
503
|
+
if (err.code === 'EADDRINUSE') {
|
|
504
|
+
reject(new Error(`Port ${config.port} is in use. Close other auth sessions first.`));
|
|
505
|
+
} else {
|
|
506
|
+
reject(err);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// List all accounts with status
|
|
513
|
+
export function listAccountsWithStatus(): Array<{
|
|
514
|
+
account: AntigravityAccount;
|
|
515
|
+
status: 'available' | 'rate-limited' | 'token-expired';
|
|
516
|
+
rateLimitRemaining?: number;
|
|
517
|
+
}> {
|
|
518
|
+
const config = loadAccounts();
|
|
519
|
+
const now = Date.now();
|
|
520
|
+
|
|
521
|
+
return config.accounts.map(account => {
|
|
522
|
+
let status: 'available' | 'rate-limited' | 'token-expired' = 'available';
|
|
523
|
+
let rateLimitRemaining: number | undefined;
|
|
524
|
+
|
|
525
|
+
if (account.rateLimitedUntil && account.rateLimitedUntil > now) {
|
|
526
|
+
status = 'rate-limited';
|
|
527
|
+
rateLimitRemaining = account.rateLimitedUntil - now;
|
|
528
|
+
} else if (account.expiresAt && account.expiresAt < now) {
|
|
529
|
+
status = 'token-expired';
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return { account, status, rateLimitRemaining };
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Remove an account
|
|
537
|
+
export function removeAccount(accountIdOrEmail: string): boolean {
|
|
538
|
+
const config = loadAccounts();
|
|
539
|
+
const index = config.accounts.findIndex(
|
|
540
|
+
a => a.id === accountIdOrEmail || a.email === accountIdOrEmail
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
if (index >= 0) {
|
|
544
|
+
const removed = config.accounts.splice(index, 1)[0];
|
|
545
|
+
saveAccounts(config);
|
|
546
|
+
console.log(`Removed account: ${removed?.email || removed?.id}`);
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Set rotation strategy
|
|
554
|
+
export function setRotationStrategy(strategy: RotationStrategy): void {
|
|
555
|
+
const config = loadAccounts();
|
|
556
|
+
config.rotationStrategy = strategy;
|
|
557
|
+
saveAccounts(config);
|
|
558
|
+
console.log(`Rotation strategy set to: ${strategy}`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Toggle PID offset for parallel agents
|
|
562
|
+
export function togglePidOffset(enabled: boolean): void {
|
|
563
|
+
const config = loadAccounts();
|
|
564
|
+
config.pidOffsetEnabled = enabled;
|
|
565
|
+
saveAccounts(config);
|
|
566
|
+
console.log(`PID offset ${enabled ? 'enabled' : 'disabled'}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Get account stats
|
|
570
|
+
export function getAccountStats(): {
|
|
571
|
+
total: number;
|
|
572
|
+
available: number;
|
|
573
|
+
rateLimited: number;
|
|
574
|
+
strategy: RotationStrategy;
|
|
575
|
+
} {
|
|
576
|
+
const config = loadAccounts();
|
|
577
|
+
const available = getAvailableAccounts();
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
total: config.accounts.length,
|
|
581
|
+
available: available.length,
|
|
582
|
+
rateLimited: config.accounts.length - available.length,
|
|
583
|
+
strategy: config.rotationStrategy,
|
|
584
|
+
};
|
|
585
|
+
}
|