opencode-studio-server 1.2.0 → 1.2.2
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/.env +2 -0
- package/.env.example +2 -0
- package/AGENTS.md +37 -0
- package/cli.js +7 -6
- package/index.js +753 -80
- package/package.json +1 -1
package/.env
ADDED
package/.env.example
ADDED
package/AGENTS.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SERVER LAYER
|
|
2
|
+
|
|
3
|
+
Express API backend (port 3001). Single-file architecture.
|
|
4
|
+
|
|
5
|
+
## STRUCTURE
|
|
6
|
+
|
|
7
|
+
| File | Purpose |
|
|
8
|
+
|------|---------|
|
|
9
|
+
| `index.js` | All routes, config IO, auth, skills, plugins, usage stats |
|
|
10
|
+
| `cli.js` | npm bin entry, protocol URL parser, pending action queue |
|
|
11
|
+
| `register-protocol.js` | OS-specific `opencodestudio://` handler registration |
|
|
12
|
+
|
|
13
|
+
## WHERE TO LOOK
|
|
14
|
+
|
|
15
|
+
| Task | Location |
|
|
16
|
+
|------|----------|
|
|
17
|
+
| Add API endpoint | `index.js` - add `app.get/post/delete` |
|
|
18
|
+
| Config path detection | `index.js:getPaths()` - `CANDIDATE_PATHS` logic |
|
|
19
|
+
| Auth profile management | `index.js:440-620` - profiles CRUD |
|
|
20
|
+
| Google plugin switching | `index.js:803-860` - gemini/antigravity toggle |
|
|
21
|
+
| Usage stats aggregation | `index.js:687-800` - reads message storage |
|
|
22
|
+
| Protocol actions | `cli.js:34-84` - switch on action type |
|
|
23
|
+
| Windows registry | `register-protocol.js:8-55` |
|
|
24
|
+
|
|
25
|
+
## CONVENTIONS
|
|
26
|
+
|
|
27
|
+
- All routes in single file (no route modules)
|
|
28
|
+
- Studio prefs in `~/.config/opencode-studio/studio.json`, separate from opencode config
|
|
29
|
+
- Auth profiles namespaced: `google.gemini`, `google.antigravity`
|
|
30
|
+
- 30min idle timeout auto-shutdown
|
|
31
|
+
|
|
32
|
+
## ANTI-PATTERNS
|
|
33
|
+
|
|
34
|
+
- Hardcoding `~/.config/opencode` - use `getPaths().current`
|
|
35
|
+
- Importing from `client-next/` - separate processes
|
|
36
|
+
- Adding Next.js API routes - all API here
|
|
37
|
+
- Modifying opencode.json structure without updating `client-next/src/types/`
|
package/cli.js
CHANGED
|
@@ -40,16 +40,17 @@ if (protocolArg) {
|
|
|
40
40
|
break;
|
|
41
41
|
|
|
42
42
|
case 'install-mcp':
|
|
43
|
-
//
|
|
44
|
-
|
|
43
|
+
// Security: Do NOT accept 'cmd' or 'env' from deep links to prevent RCE.
|
|
44
|
+
// Only allow the name to be passed, user must configure the rest manually.
|
|
45
|
+
if (params.name) {
|
|
45
46
|
pendingAction = {
|
|
46
47
|
type: 'install-mcp',
|
|
47
|
-
name: params.name
|
|
48
|
-
command: params.cmd ? decodeURIComponent(params.cmd) : null,
|
|
49
|
-
env: params.env ? JSON.parse(decodeURIComponent(params.env)) : null,
|
|
48
|
+
name: params.name,
|
|
49
|
+
// command: params.cmd ? decodeURIComponent(params.cmd) : null, // DISABLED FOR SECURITY
|
|
50
|
+
// env: params.env ? JSON.parse(decodeURIComponent(params.env)) : null, // DISABLED FOR SECURITY
|
|
50
51
|
timestamp: Date.now(),
|
|
51
52
|
};
|
|
52
|
-
console.log(`Queued MCP install: ${pendingAction.name}`);
|
|
53
|
+
console.log(`Queued MCP install (name only): ${pendingAction.name}`);
|
|
53
54
|
}
|
|
54
55
|
break;
|
|
55
56
|
|
package/index.js
CHANGED
|
@@ -4,8 +4,24 @@ const bodyParser = require('body-parser');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
|
+
const crypto = require('crypto');
|
|
7
8
|
const { spawn, exec } = require('child_process');
|
|
8
9
|
|
|
10
|
+
// Atomic file write: write to temp file then rename to prevent corruption
|
|
11
|
+
const atomicWriteFileSync = (filePath, data, options = 'utf8') => {
|
|
12
|
+
const dir = path.dirname(filePath);
|
|
13
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
const tempPath = path.join(dir, `.${path.basename(filePath)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
15
|
+
try {
|
|
16
|
+
fs.writeFileSync(tempPath, data, options);
|
|
17
|
+
fs.renameSync(tempPath, filePath);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
// Clean up temp file if rename fails
|
|
20
|
+
try { fs.unlinkSync(tempPath); } catch {}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
9
25
|
const app = express();
|
|
10
26
|
const PORT = 3001;
|
|
11
27
|
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
@@ -129,6 +145,34 @@ function loadStudioConfig() {
|
|
|
129
145
|
"name": "2.5 Flash Lite",
|
|
130
146
|
"reasoning": false
|
|
131
147
|
},
|
|
148
|
+
"google/gemini-3-flash": {
|
|
149
|
+
"id": "google/gemini-3-flash",
|
|
150
|
+
"name": "3 Flash (Google)",
|
|
151
|
+
"reasoning": true,
|
|
152
|
+
"limit": { "context": 1048576, "output": 65536 },
|
|
153
|
+
"cost": { "input": 0.5, "output": 3, "cache_read": 0.05 },
|
|
154
|
+
"modalities": {
|
|
155
|
+
"input": ["text", "image", "video", "audio", "pdf"],
|
|
156
|
+
"output": ["text"]
|
|
157
|
+
},
|
|
158
|
+
"variants": {
|
|
159
|
+
"minimal": { "options": { "thinkingConfig": { "thinkingLevel": "minimal", "includeThoughts": true } } },
|
|
160
|
+
"low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
|
|
161
|
+
"medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
|
|
162
|
+
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } }
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
"opencode/glm-4.7-free": {
|
|
166
|
+
"id": "opencode/glm-4.7-free",
|
|
167
|
+
"name": "GLM 4.7 Free",
|
|
168
|
+
"reasoning": false,
|
|
169
|
+
"limit": { "context": 128000, "output": 4096 },
|
|
170
|
+
"cost": { "input": 0, "output": 0 },
|
|
171
|
+
"modalities": {
|
|
172
|
+
"input": ["text"],
|
|
173
|
+
"output": ["text"]
|
|
174
|
+
}
|
|
175
|
+
},
|
|
132
176
|
"gemini-claude-sonnet-4-5-thinking": {
|
|
133
177
|
"id": "gemini-claude-sonnet-4-5-thinking",
|
|
134
178
|
"name": "Sonnet 4.5",
|
|
@@ -176,9 +220,7 @@ function loadStudioConfig() {
|
|
|
176
220
|
|
|
177
221
|
function saveStudioConfig(config) {
|
|
178
222
|
try {
|
|
179
|
-
|
|
180
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
181
|
-
fs.writeFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
223
|
+
atomicWriteFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
182
224
|
return true;
|
|
183
225
|
} catch (err) {
|
|
184
226
|
console.error('Failed to save studio config:', err);
|
|
@@ -223,7 +265,12 @@ const loadConfig = () => {
|
|
|
223
265
|
const configPath = getConfigPath();
|
|
224
266
|
if (!configPath || !fs.existsSync(configPath)) return null;
|
|
225
267
|
try {
|
|
226
|
-
|
|
268
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
269
|
+
const studioConfig = loadStudioConfig();
|
|
270
|
+
if (studioConfig.activeGooglePlugin === 'antigravity' && !config.small_model) {
|
|
271
|
+
config.small_model = "google/gemini-3-flash";
|
|
272
|
+
}
|
|
273
|
+
return config;
|
|
227
274
|
} catch {
|
|
228
275
|
return null;
|
|
229
276
|
}
|
|
@@ -232,9 +279,7 @@ const loadConfig = () => {
|
|
|
232
279
|
const saveConfig = (config) => {
|
|
233
280
|
const configPath = getConfigPath();
|
|
234
281
|
if (!configPath) throw new Error('No config path found');
|
|
235
|
-
|
|
236
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
237
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
282
|
+
atomicWriteFileSync(configPath, JSON.stringify(config, null, 2));
|
|
238
283
|
};
|
|
239
284
|
|
|
240
285
|
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
|
@@ -284,6 +329,9 @@ app.get('/api/skills', (req, res) => {
|
|
|
284
329
|
});
|
|
285
330
|
|
|
286
331
|
app.get('/api/skills/:name', (req, res) => {
|
|
332
|
+
if (!/^[a-zA-Z0-9_\-\s]+$/.test(req.params.name)) {
|
|
333
|
+
return res.status(400).json({ error: 'Invalid skill name' });
|
|
334
|
+
}
|
|
287
335
|
const sd = getSkillDir();
|
|
288
336
|
const p = sd ? path.join(sd, req.params.name, 'SKILL.md') : null;
|
|
289
337
|
if (!p || !fs.existsSync(p)) return res.status(404).json({ error: 'Not found' });
|
|
@@ -291,6 +339,9 @@ app.get('/api/skills/:name', (req, res) => {
|
|
|
291
339
|
});
|
|
292
340
|
|
|
293
341
|
app.post('/api/skills/:name', (req, res) => {
|
|
342
|
+
if (!/^[a-zA-Z0-9_\-\s]+$/.test(req.params.name)) {
|
|
343
|
+
return res.status(400).json({ error: 'Invalid skill name' });
|
|
344
|
+
}
|
|
294
345
|
const sd = getSkillDir();
|
|
295
346
|
if (!sd) return res.status(404).json({ error: 'No config' });
|
|
296
347
|
const dp = path.join(sd, req.params.name);
|
|
@@ -300,6 +351,9 @@ app.post('/api/skills/:name', (req, res) => {
|
|
|
300
351
|
});
|
|
301
352
|
|
|
302
353
|
app.delete('/api/skills/:name', (req, res) => {
|
|
354
|
+
if (!/^[a-zA-Z0-9_\-\s]+$/.test(req.params.name)) {
|
|
355
|
+
return res.status(400).json({ error: 'Invalid skill name' });
|
|
356
|
+
}
|
|
303
357
|
const sd = getSkillDir();
|
|
304
358
|
const dp = sd ? path.join(sd, req.params.name) : null;
|
|
305
359
|
if (dp && fs.existsSync(dp)) fs.rmSync(dp, { recursive: true, force: true });
|
|
@@ -487,7 +541,11 @@ app.get('/api/auth', (req, res) => {
|
|
|
487
541
|
|
|
488
542
|
providers.forEach(p => {
|
|
489
543
|
const saved = listAuthProfiles(p.id, activePlugin);
|
|
490
|
-
|
|
544
|
+
let curr = !!authCfg[p.id];
|
|
545
|
+
if (p.id === 'google') {
|
|
546
|
+
const key = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
|
|
547
|
+
curr = !!authCfg[key] || !!authCfg.google;
|
|
548
|
+
}
|
|
491
549
|
credentials.push({ ...p, active: ac[p.id] || (curr ? 'current' : null), profiles: saved, hasCurrentAuth: curr });
|
|
492
550
|
});
|
|
493
551
|
res.json({
|
|
@@ -508,7 +566,13 @@ app.get('/api/auth/profiles', (req, res) => {
|
|
|
508
566
|
|
|
509
567
|
providers.forEach(p => {
|
|
510
568
|
const saved = listAuthProfiles(p, activePlugin);
|
|
511
|
-
|
|
569
|
+
// Correct current auth check: handle google vs google.gemini/antigravity
|
|
570
|
+
let curr = !!authCfg[p];
|
|
571
|
+
if (p === 'google') {
|
|
572
|
+
const key = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
|
|
573
|
+
curr = !!authCfg[key] || !!authCfg.google;
|
|
574
|
+
}
|
|
575
|
+
|
|
512
576
|
if (saved.length > 0 || curr) {
|
|
513
577
|
profiles[p] = { active: ac[p], profiles: saved, hasCurrentAuth: !!curr };
|
|
514
578
|
}
|
|
@@ -529,7 +593,7 @@ app.post('/api/auth/profiles/:provider', (req, res) => {
|
|
|
529
593
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
530
594
|
|
|
531
595
|
const profilePath = path.join(dir, `${name || Date.now()}.json`);
|
|
532
|
-
|
|
596
|
+
atomicWriteFileSync(profilePath, JSON.stringify(auth[provider], null, 2));
|
|
533
597
|
res.json({ success: true, name: path.basename(profilePath, '.json') });
|
|
534
598
|
});
|
|
535
599
|
|
|
@@ -560,7 +624,7 @@ app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
|
|
|
560
624
|
|
|
561
625
|
const cp = getConfigPath();
|
|
562
626
|
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
563
|
-
|
|
627
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
564
628
|
res.json({ success: true });
|
|
565
629
|
});
|
|
566
630
|
|
|
@@ -587,35 +651,92 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
|
587
651
|
const oldPath = path.join(AUTH_PROFILES_DIR, namespace, `${name}.json`);
|
|
588
652
|
const newPath = path.join(AUTH_PROFILES_DIR, namespace, `${newName}.json`);
|
|
589
653
|
if (fs.existsSync(oldPath)) fs.renameSync(oldPath, newPath);
|
|
654
|
+
|
|
655
|
+
// Update active profile name if it was the one renamed
|
|
656
|
+
const studio = loadStudioConfig();
|
|
657
|
+
if (studio.activeProfiles && studio.activeProfiles[provider] === name) {
|
|
658
|
+
studio.activeProfiles[provider] = newName;
|
|
659
|
+
saveStudioConfig(studio);
|
|
660
|
+
}
|
|
661
|
+
|
|
590
662
|
res.json({ success: true, name: newName });
|
|
591
663
|
});
|
|
592
664
|
|
|
593
665
|
app.post('/api/auth/login', (req, res) => {
|
|
594
666
|
let { provider } = req.body;
|
|
667
|
+
|
|
668
|
+
// Security: Validate provider against allowlist to prevent command injection
|
|
669
|
+
const ALLOWED_PROVIDERS = [
|
|
670
|
+
"", "google", "anthropic", "openai", "xai",
|
|
671
|
+
"openrouter", "github-copilot", "gemini",
|
|
672
|
+
"together", "mistral", "deepseek", "amazon-bedrock", "azure"
|
|
673
|
+
];
|
|
674
|
+
|
|
675
|
+
if (provider && !ALLOWED_PROVIDERS.includes(provider)) {
|
|
676
|
+
return res.status(400).json({ error: 'Invalid provider' });
|
|
677
|
+
}
|
|
678
|
+
|
|
595
679
|
if (typeof provider !== 'string') provider = "";
|
|
596
680
|
|
|
597
681
|
let cmd = 'opencode auth login';
|
|
598
682
|
if (provider) cmd += ` ${provider}`;
|
|
599
683
|
|
|
600
684
|
const platform = process.platform;
|
|
601
|
-
|
|
685
|
+
|
|
602
686
|
if (platform === 'win32') {
|
|
603
|
-
terminalCmd = `start "" cmd /c "call ${cmd} || pause"`;
|
|
687
|
+
const terminalCmd = `start "" cmd /c "call ${cmd} || pause"`;
|
|
688
|
+
console.log('Executing terminal command:', terminalCmd);
|
|
689
|
+
exec(terminalCmd, (err) => {
|
|
690
|
+
if (err) {
|
|
691
|
+
console.error('Failed to open terminal:', err);
|
|
692
|
+
return res.status(500).json({ error: 'Failed to open terminal', details: err.message });
|
|
693
|
+
}
|
|
694
|
+
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
695
|
+
});
|
|
604
696
|
} else if (platform === 'darwin') {
|
|
605
|
-
terminalCmd = `osascript -e 'tell application "Terminal" to do script "${cmd}"'`;
|
|
697
|
+
const terminalCmd = `osascript -e 'tell application "Terminal" to do script "${cmd}"'`;
|
|
698
|
+
console.log('Executing terminal command:', terminalCmd);
|
|
699
|
+
exec(terminalCmd, (err) => {
|
|
700
|
+
if (err) {
|
|
701
|
+
console.error('Failed to open terminal:', err);
|
|
702
|
+
return res.status(500).json({ error: 'Failed to open terminal', details: err.message });
|
|
703
|
+
}
|
|
704
|
+
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
705
|
+
});
|
|
606
706
|
} else {
|
|
607
|
-
|
|
707
|
+
const linuxTerminals = [
|
|
708
|
+
{ name: 'x-terminal-emulator', cmd: `x-terminal-emulator -e "${cmd}"` },
|
|
709
|
+
{ name: 'gnome-terminal', cmd: `gnome-terminal -- bash -c "${cmd}; read -p 'Press Enter to close...'"` },
|
|
710
|
+
{ name: 'konsole', cmd: `konsole -e bash -c "${cmd}; read -p 'Press Enter to close...'"` },
|
|
711
|
+
{ name: 'xfce4-terminal', cmd: `xfce4-terminal -e "bash -c \\"${cmd}; read -p 'Press Enter to close...'\\"" ` },
|
|
712
|
+
{ name: 'xterm', cmd: `xterm -e "bash -c '${cmd}; read -p Press_Enter_to_close...'"` }
|
|
713
|
+
];
|
|
714
|
+
|
|
715
|
+
const tryTerminal = (index) => {
|
|
716
|
+
if (index >= linuxTerminals.length) {
|
|
717
|
+
const fallbackCmd = cmd;
|
|
718
|
+
return res.json({
|
|
719
|
+
success: false,
|
|
720
|
+
message: 'No terminal emulator found',
|
|
721
|
+
note: 'Run this command manually in your terminal',
|
|
722
|
+
command: fallbackCmd
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const terminal = linuxTerminals[index];
|
|
727
|
+
console.log(`Trying terminal: ${terminal.name}`);
|
|
728
|
+
exec(terminal.cmd, (err) => {
|
|
729
|
+
if (err) {
|
|
730
|
+
console.log(`${terminal.name} failed, trying next...`);
|
|
731
|
+
tryTerminal(index + 1);
|
|
732
|
+
} else {
|
|
733
|
+
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
tryTerminal(0);
|
|
608
739
|
}
|
|
609
|
-
|
|
610
|
-
console.log('Executing terminal command:', terminalCmd);
|
|
611
|
-
|
|
612
|
-
exec(terminalCmd, (err) => {
|
|
613
|
-
if (err) {
|
|
614
|
-
console.error('Failed to open terminal:', err);
|
|
615
|
-
return res.status(500).json({ error: 'Failed to open terminal', details: err.message });
|
|
616
|
-
}
|
|
617
|
-
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
618
|
-
});
|
|
619
740
|
});
|
|
620
741
|
|
|
621
742
|
app.delete('/api/auth/:provider', (req, res) => {
|
|
@@ -624,7 +745,7 @@ app.delete('/api/auth/:provider', (req, res) => {
|
|
|
624
745
|
delete authCfg[provider];
|
|
625
746
|
const cp = getConfigPath();
|
|
626
747
|
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
627
|
-
|
|
748
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
628
749
|
|
|
629
750
|
const studio = loadStudioConfig();
|
|
630
751
|
if (studio.activeProfiles) delete studio.activeProfiles[provider];
|
|
@@ -633,6 +754,332 @@ app.delete('/api/auth/:provider', (req, res) => {
|
|
|
633
754
|
res.json({ success: true });
|
|
634
755
|
});
|
|
635
756
|
|
|
757
|
+
// ============================================
|
|
758
|
+
// ACCOUNT POOL MANAGEMENT (Antigravity-style)
|
|
759
|
+
// ============================================
|
|
760
|
+
|
|
761
|
+
const POOL_METADATA_FILE = path.join(HOME_DIR, '.config', 'opencode-studio', 'pool-metadata.json');
|
|
762
|
+
|
|
763
|
+
function loadPoolMetadata() {
|
|
764
|
+
if (!fs.existsSync(POOL_METADATA_FILE)) return {};
|
|
765
|
+
try {
|
|
766
|
+
return JSON.parse(fs.readFileSync(POOL_METADATA_FILE, 'utf8'));
|
|
767
|
+
} catch {
|
|
768
|
+
return {};
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function savePoolMetadata(metadata) {
|
|
773
|
+
const dir = path.dirname(POOL_METADATA_FILE);
|
|
774
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
775
|
+
atomicWriteFileSync(POOL_METADATA_FILE, JSON.stringify(metadata, null, 2));
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function getAccountStatus(meta, now) {
|
|
779
|
+
if (!meta) return 'ready';
|
|
780
|
+
if (meta.cooldownUntil && meta.cooldownUntil > now) return 'cooldown';
|
|
781
|
+
if (meta.expired) return 'expired';
|
|
782
|
+
return 'ready';
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function buildAccountPool(provider) {
|
|
786
|
+
const activePlugin = getActiveGooglePlugin();
|
|
787
|
+
const namespace = provider === 'google'
|
|
788
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
789
|
+
: provider;
|
|
790
|
+
|
|
791
|
+
const profileDir = path.join(AUTH_PROFILES_DIR, namespace);
|
|
792
|
+
const profiles = [];
|
|
793
|
+
const now = Date.now();
|
|
794
|
+
const metadata = loadPoolMetadata();
|
|
795
|
+
const providerMeta = metadata[namespace] || {};
|
|
796
|
+
|
|
797
|
+
// Get current active profile from studio config
|
|
798
|
+
const studio = loadStudioConfig();
|
|
799
|
+
const activeProfile = studio.activeProfiles?.[provider] || null;
|
|
800
|
+
|
|
801
|
+
if (fs.existsSync(profileDir)) {
|
|
802
|
+
const files = fs.readdirSync(profileDir).filter(f => f.endsWith('.json'));
|
|
803
|
+
files.forEach(file => {
|
|
804
|
+
const name = file.replace('.json', '');
|
|
805
|
+
const meta = providerMeta[name] || {};
|
|
806
|
+
const status = name === activeProfile ? 'active' : getAccountStatus(meta, now);
|
|
807
|
+
|
|
808
|
+
profiles.push({
|
|
809
|
+
name,
|
|
810
|
+
email: meta.email || null,
|
|
811
|
+
status,
|
|
812
|
+
lastUsed: meta.lastUsed || 0,
|
|
813
|
+
usageCount: meta.usageCount || 0,
|
|
814
|
+
cooldownUntil: meta.cooldownUntil || null,
|
|
815
|
+
createdAt: meta.createdAt || 0
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Sort: active first, then by lastUsed (LRU)
|
|
821
|
+
profiles.sort((a, b) => {
|
|
822
|
+
if (a.status === 'active') return -1;
|
|
823
|
+
if (b.status === 'active') return 1;
|
|
824
|
+
return a.lastUsed - b.lastUsed;
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
const available = profiles.filter(p => p.status === 'active' || p.status === 'ready').length;
|
|
828
|
+
const cooldown = profiles.filter(p => p.status === 'cooldown').length;
|
|
829
|
+
|
|
830
|
+
return {
|
|
831
|
+
provider,
|
|
832
|
+
namespace,
|
|
833
|
+
accounts: profiles,
|
|
834
|
+
activeAccount: activeProfile,
|
|
835
|
+
totalAccounts: profiles.length,
|
|
836
|
+
availableAccounts: available,
|
|
837
|
+
cooldownAccounts: cooldown
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// GET /api/auth/pool - Get account pool for Google (or specified provider)
|
|
842
|
+
app.get('/api/auth/pool', (req, res) => {
|
|
843
|
+
const provider = req.query.provider || 'google';
|
|
844
|
+
const pool = buildAccountPool(provider);
|
|
845
|
+
|
|
846
|
+
// Also include quota estimate (local tracking)
|
|
847
|
+
const metadata = loadPoolMetadata();
|
|
848
|
+
const activePlugin = getActiveGooglePlugin();
|
|
849
|
+
const namespace = provider === 'google'
|
|
850
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
851
|
+
: provider;
|
|
852
|
+
|
|
853
|
+
const quotaMeta = metadata._quota?.[namespace] || {};
|
|
854
|
+
const today = new Date().toISOString().split('T')[0];
|
|
855
|
+
const todayUsage = quotaMeta[today] || 0;
|
|
856
|
+
|
|
857
|
+
// Estimate: 1000 requests/day limit (configurable)
|
|
858
|
+
const dailyLimit = quotaMeta.dailyLimit || 1000;
|
|
859
|
+
const remaining = Math.max(0, dailyLimit - todayUsage);
|
|
860
|
+
const percentage = Math.round((remaining / dailyLimit) * 100);
|
|
861
|
+
|
|
862
|
+
const quota = {
|
|
863
|
+
dailyLimit,
|
|
864
|
+
remaining,
|
|
865
|
+
used: todayUsage,
|
|
866
|
+
percentage,
|
|
867
|
+
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString(),
|
|
868
|
+
byAccount: pool.accounts.map(acc => ({
|
|
869
|
+
name: acc.name,
|
|
870
|
+
email: acc.email,
|
|
871
|
+
used: acc.usageCount,
|
|
872
|
+
limit: Math.floor(dailyLimit / Math.max(1, pool.totalAccounts))
|
|
873
|
+
}))
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
res.json({ pool, quota });
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
// POST /api/auth/pool/rotate - Rotate to next available account
|
|
880
|
+
app.post('/api/auth/pool/rotate', (req, res) => {
|
|
881
|
+
const provider = req.body.provider || 'google';
|
|
882
|
+
const pool = buildAccountPool(provider);
|
|
883
|
+
|
|
884
|
+
if (pool.accounts.length === 0) {
|
|
885
|
+
return res.status(400).json({ error: 'No accounts in pool' });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const now = Date.now();
|
|
889
|
+
const available = pool.accounts.filter(acc =>
|
|
890
|
+
acc.status === 'ready' || (acc.status === 'cooldown' && acc.cooldownUntil && acc.cooldownUntil < now)
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
if (available.length === 0) {
|
|
894
|
+
return res.status(400).json({ error: 'No available accounts (all in cooldown or expired)' });
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Pick least recently used
|
|
898
|
+
const next = available.sort((a, b) => a.lastUsed - b.lastUsed)[0];
|
|
899
|
+
const previousActive = pool.activeAccount;
|
|
900
|
+
|
|
901
|
+
// Activate the new account
|
|
902
|
+
const activePlugin = getActiveGooglePlugin();
|
|
903
|
+
const namespace = provider === 'google'
|
|
904
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
905
|
+
: provider;
|
|
906
|
+
|
|
907
|
+
const profilePath = path.join(AUTH_PROFILES_DIR, namespace, `${next.name}.json`);
|
|
908
|
+
if (!fs.existsSync(profilePath)) {
|
|
909
|
+
return res.status(404).json({ error: 'Profile file not found' });
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const profileData = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
|
|
913
|
+
|
|
914
|
+
// Update auth.json
|
|
915
|
+
const authCfg = loadAuthConfig() || {};
|
|
916
|
+
authCfg[provider] = profileData;
|
|
917
|
+
if (provider === 'google') {
|
|
918
|
+
authCfg[namespace] = profileData;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const cp = getConfigPath();
|
|
922
|
+
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
923
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
924
|
+
|
|
925
|
+
// Update studio config
|
|
926
|
+
const studio = loadStudioConfig();
|
|
927
|
+
if (!studio.activeProfiles) studio.activeProfiles = {};
|
|
928
|
+
studio.activeProfiles[provider] = next.name;
|
|
929
|
+
saveStudioConfig(studio);
|
|
930
|
+
|
|
931
|
+
// Update metadata
|
|
932
|
+
const metadata = loadPoolMetadata();
|
|
933
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
934
|
+
metadata[namespace][next.name] = {
|
|
935
|
+
...metadata[namespace][next.name],
|
|
936
|
+
lastUsed: now
|
|
937
|
+
};
|
|
938
|
+
savePoolMetadata(metadata);
|
|
939
|
+
|
|
940
|
+
res.json({
|
|
941
|
+
success: true,
|
|
942
|
+
previousAccount: previousActive,
|
|
943
|
+
newAccount: next.name,
|
|
944
|
+
reason: 'manual_rotation'
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// PUT /api/auth/pool/:name/cooldown - Mark account as in cooldown
|
|
949
|
+
app.put('/api/auth/pool/:name/cooldown', (req, res) => {
|
|
950
|
+
const { name } = req.params;
|
|
951
|
+
const { duration = 3600000, provider = 'google' } = req.body; // default 1 hour
|
|
952
|
+
|
|
953
|
+
const activePlugin = getActiveGooglePlugin();
|
|
954
|
+
const namespace = provider === 'google'
|
|
955
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
956
|
+
: provider;
|
|
957
|
+
|
|
958
|
+
const metadata = loadPoolMetadata();
|
|
959
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
960
|
+
|
|
961
|
+
metadata[namespace][name] = {
|
|
962
|
+
...metadata[namespace][name],
|
|
963
|
+
cooldownUntil: Date.now() + duration,
|
|
964
|
+
lastCooldownReason: req.body.reason || 'rate_limit'
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
savePoolMetadata(metadata);
|
|
968
|
+
res.json({ success: true, cooldownUntil: metadata[namespace][name].cooldownUntil });
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// DELETE /api/auth/pool/:name/cooldown - Clear cooldown for account
|
|
972
|
+
app.delete('/api/auth/pool/:name/cooldown', (req, res) => {
|
|
973
|
+
const { name } = req.params;
|
|
974
|
+
const provider = req.query.provider || 'google';
|
|
975
|
+
|
|
976
|
+
const activePlugin = getActiveGooglePlugin();
|
|
977
|
+
const namespace = provider === 'google'
|
|
978
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
979
|
+
: provider;
|
|
980
|
+
|
|
981
|
+
const metadata = loadPoolMetadata();
|
|
982
|
+
if (metadata[namespace]?.[name]) {
|
|
983
|
+
delete metadata[namespace][name].cooldownUntil;
|
|
984
|
+
savePoolMetadata(metadata);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
res.json({ success: true });
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// POST /api/auth/pool/:name/usage - Increment usage counter (for tracking)
|
|
991
|
+
app.post('/api/auth/pool/:name/usage', (req, res) => {
|
|
992
|
+
const { name } = req.params;
|
|
993
|
+
const { provider = 'google' } = req.body;
|
|
994
|
+
|
|
995
|
+
const activePlugin = getActiveGooglePlugin();
|
|
996
|
+
const namespace = provider === 'google'
|
|
997
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
998
|
+
: provider;
|
|
999
|
+
|
|
1000
|
+
const metadata = loadPoolMetadata();
|
|
1001
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1002
|
+
if (!metadata[namespace][name]) metadata[namespace][name] = { usageCount: 0 };
|
|
1003
|
+
|
|
1004
|
+
metadata[namespace][name].usageCount = (metadata[namespace][name].usageCount || 0) + 1;
|
|
1005
|
+
metadata[namespace][name].lastUsed = Date.now();
|
|
1006
|
+
|
|
1007
|
+
// Track daily quota
|
|
1008
|
+
if (!metadata._quota) metadata._quota = {};
|
|
1009
|
+
if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
|
|
1010
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1011
|
+
metadata._quota[namespace][today] = (metadata._quota[namespace][today] || 0) + 1;
|
|
1012
|
+
|
|
1013
|
+
savePoolMetadata(metadata);
|
|
1014
|
+
res.json({ success: true, usageCount: metadata[namespace][name].usageCount });
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// PUT /api/auth/pool/:name/metadata - Update account metadata (email, etc.)
|
|
1018
|
+
app.put('/api/auth/pool/:name/metadata', (req, res) => {
|
|
1019
|
+
const { name } = req.params;
|
|
1020
|
+
const { provider = 'google', email, createdAt } = req.body;
|
|
1021
|
+
|
|
1022
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1023
|
+
const namespace = provider === 'google'
|
|
1024
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1025
|
+
: provider;
|
|
1026
|
+
|
|
1027
|
+
const metadata = loadPoolMetadata();
|
|
1028
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1029
|
+
if (!metadata[namespace][name]) metadata[namespace][name] = {};
|
|
1030
|
+
|
|
1031
|
+
if (email !== undefined) metadata[namespace][name].email = email;
|
|
1032
|
+
if (createdAt !== undefined) metadata[namespace][name].createdAt = createdAt;
|
|
1033
|
+
|
|
1034
|
+
savePoolMetadata(metadata);
|
|
1035
|
+
res.json({ success: true });
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// GET /api/auth/pool/quota - Get quota info
|
|
1039
|
+
app.get('/api/auth/pool/quota', (req, res) => {
|
|
1040
|
+
const provider = req.query.provider || 'google';
|
|
1041
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1042
|
+
const namespace = provider === 'google'
|
|
1043
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1044
|
+
: provider;
|
|
1045
|
+
|
|
1046
|
+
const metadata = loadPoolMetadata();
|
|
1047
|
+
const quotaMeta = metadata._quota?.[namespace] || {};
|
|
1048
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1049
|
+
const todayUsage = quotaMeta[today] || 0;
|
|
1050
|
+
const dailyLimit = quotaMeta.dailyLimit || 1000;
|
|
1051
|
+
|
|
1052
|
+
res.json({
|
|
1053
|
+
dailyLimit,
|
|
1054
|
+
remaining: Math.max(0, dailyLimit - todayUsage),
|
|
1055
|
+
used: todayUsage,
|
|
1056
|
+
percentage: Math.round(((dailyLimit - todayUsage) / dailyLimit) * 100),
|
|
1057
|
+
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString(),
|
|
1058
|
+
byAccount: []
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
// POST /api/auth/pool/quota/limit - Set daily quota limit
|
|
1063
|
+
app.post('/api/auth/pool/quota/limit', (req, res) => {
|
|
1064
|
+
const { provider = 'google', limit } = req.body;
|
|
1065
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1066
|
+
const namespace = provider === 'google'
|
|
1067
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1068
|
+
: provider;
|
|
1069
|
+
|
|
1070
|
+
const metadata = loadPoolMetadata();
|
|
1071
|
+
if (!metadata._quota) metadata._quota = {};
|
|
1072
|
+
if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
|
|
1073
|
+
metadata._quota[namespace].dailyLimit = limit;
|
|
1074
|
+
|
|
1075
|
+
savePoolMetadata(metadata);
|
|
1076
|
+
res.json({ success: true, dailyLimit: limit });
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
// ============================================
|
|
1080
|
+
// END ACCOUNT POOL MANAGEMENT
|
|
1081
|
+
// ============================================
|
|
1082
|
+
|
|
636
1083
|
app.get('/api/usage', async (req, res) => {
|
|
637
1084
|
try {
|
|
638
1085
|
const {projectId: fid, granularity = 'daily', range = '30d'} = req.query;
|
|
@@ -663,22 +1110,29 @@ app.get('/api/usage', async (req, res) => {
|
|
|
663
1110
|
|
|
664
1111
|
if (!md) return res.json({ totalCost: 0, totalTokens: 0, byModel: [], byDay: [], byProject: [] });
|
|
665
1112
|
|
|
666
|
-
|
|
667
1113
|
const pmap = new Map();
|
|
668
1114
|
if (fs.existsSync(sd)) {
|
|
669
|
-
fs.
|
|
1115
|
+
const sessionDirs = await fs.promises.readdir(sd);
|
|
1116
|
+
await Promise.all(sessionDirs.map(async d => {
|
|
670
1117
|
const fp = path.join(sd, d);
|
|
671
|
-
|
|
672
|
-
fs.
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1118
|
+
try {
|
|
1119
|
+
const stats = await fs.promises.stat(fp);
|
|
1120
|
+
if (stats.isDirectory()) {
|
|
1121
|
+
const files = await fs.promises.readdir(fp);
|
|
1122
|
+
await Promise.all(files.map(async f => {
|
|
1123
|
+
if (f.startsWith('ses_') && f.endsWith('.json')) {
|
|
1124
|
+
try {
|
|
1125
|
+
const m = JSON.parse(await fs.promises.readFile(path.join(fp, f), 'utf8'));
|
|
1126
|
+
pmap.set(f.replace('.json', ''), {
|
|
1127
|
+
name: m.directory ? path.basename(m.directory) : (m.projectID ? m.projectID.substring(0, 8) : 'Unknown'),
|
|
1128
|
+
id: m.projectID || d
|
|
1129
|
+
});
|
|
1130
|
+
} catch {}
|
|
1131
|
+
}
|
|
1132
|
+
}));
|
|
1133
|
+
}
|
|
1134
|
+
} catch {}
|
|
1135
|
+
}));
|
|
682
1136
|
}
|
|
683
1137
|
|
|
684
1138
|
const stats = { totalCost: 0, totalTokens: 0, byModel: {}, byTime: {}, byProject: {} };
|
|
@@ -690,42 +1144,61 @@ app.get('/api/usage', async (req, res) => {
|
|
|
690
1144
|
else if (range === '30d') min = now - 2592000000;
|
|
691
1145
|
else if (range === '1y') min = now - 31536000000;
|
|
692
1146
|
|
|
693
|
-
fs.
|
|
1147
|
+
const sessionDirs = await fs.promises.readdir(md);
|
|
1148
|
+
await Promise.all(sessionDirs.map(async s => {
|
|
694
1149
|
if (!s.startsWith('ses_')) return;
|
|
695
1150
|
const sp = path.join(md, s);
|
|
696
|
-
|
|
697
|
-
fs.
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const
|
|
703
|
-
if (
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
if (
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
if (
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1151
|
+
try {
|
|
1152
|
+
const spStats = await fs.promises.stat(sp);
|
|
1153
|
+
if (spStats.isDirectory()) {
|
|
1154
|
+
const files = await fs.promises.readdir(sp);
|
|
1155
|
+
for (const f of files) {
|
|
1156
|
+
if (!f.endsWith('.json')) continue;
|
|
1157
|
+
const fullPath = path.join(sp, f);
|
|
1158
|
+
if (seen.has(fullPath)) continue;
|
|
1159
|
+
seen.add(fullPath);
|
|
1160
|
+
|
|
1161
|
+
try {
|
|
1162
|
+
const msg = JSON.parse(await fs.promises.readFile(fullPath, 'utf8'));
|
|
1163
|
+
const pid = pmap.get(s)?.id || 'unknown';
|
|
1164
|
+
if (fid && fid !== 'all' && pid !== fid) continue;
|
|
1165
|
+
if (min > 0 && msg.time.created < min) continue;
|
|
1166
|
+
|
|
1167
|
+
if (msg.role === 'assistant' && msg.tokens) {
|
|
1168
|
+
const c = msg.cost || 0, it = msg.tokens.input || 0, ot = msg.tokens.output || 0, t = it + ot;
|
|
1169
|
+
const d = new Date(msg.time.created);
|
|
1170
|
+
let tk;
|
|
1171
|
+
if (granularity === 'hourly') tk = d.toISOString().substring(0, 13) + ':00:00Z';
|
|
1172
|
+
else if (granularity === 'weekly') {
|
|
1173
|
+
const day = d.getDay(), diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
|
1174
|
+
tk = new Date(d.setDate(diff)).toISOString().split('T')[0];
|
|
1175
|
+
} else if (granularity === 'monthly') tk = d.toISOString().substring(0, 7) + '-01';
|
|
1176
|
+
else tk = d.toISOString().split('T')[0];
|
|
1177
|
+
|
|
1178
|
+
const mid = msg.modelID || (msg.model && (msg.model.modelID || msg.model.id)) || 'unknown';
|
|
1179
|
+
stats.totalCost += c; stats.totalTokens += t;
|
|
1180
|
+
|
|
1181
|
+
if (!stats.byModel[mid]) stats.byModel[mid] = { name: mid, id: mid, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
1182
|
+
stats.byModel[mid].cost += c; stats.byModel[mid].tokens += t; stats.byModel[mid].inputTokens += it; stats.byModel[mid].outputTokens += ot;
|
|
1183
|
+
|
|
1184
|
+
if (!stats.byProject[pid]) stats.byProject[pid] = { name: pmap.get(s)?.name || 'Unassigned', id: pid, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
1185
|
+
stats.byProject[pid].cost += c; stats.byProject[pid].tokens += t; stats.byProject[pid].inputTokens += it; stats.byProject[pid].outputTokens += ot;
|
|
1186
|
+
|
|
1187
|
+
if (!stats.byTime[tk]) stats.byTime[tk] = { date: tk, name: tk, id: tk, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
1188
|
+
const te = stats.byTime[tk];
|
|
1189
|
+
te.cost += c; te.tokens += t; te.inputTokens += it; te.outputTokens += ot;
|
|
1190
|
+
if (!te[mid]) te[mid] = 0;
|
|
1191
|
+
te[mid] += c;
|
|
1192
|
+
|
|
1193
|
+
const kIn = `${mid}_input`, kOut = `${mid}_output`;
|
|
1194
|
+
te[kIn] = (te[kIn] || 0) + it;
|
|
1195
|
+
te[kOut] = (te[kOut] || 0) + ot;
|
|
1196
|
+
}
|
|
1197
|
+
} catch {}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
} catch {}
|
|
1201
|
+
}));
|
|
729
1202
|
|
|
730
1203
|
res.json({
|
|
731
1204
|
totalCost: stats.totalCost,
|
|
@@ -735,7 +1208,8 @@ app.get('/api/usage', async (req, res) => {
|
|
|
735
1208
|
byProject: Object.values(stats.byProject).sort((a, b) => b.cost - a.cost)
|
|
736
1209
|
});
|
|
737
1210
|
} catch (error) {
|
|
738
|
-
|
|
1211
|
+
console.error('Usage API error:', error);
|
|
1212
|
+
res.status(500).json({ error: 'Failed to fetch usage statistics' });
|
|
739
1213
|
}
|
|
740
1214
|
});
|
|
741
1215
|
|
|
@@ -748,11 +1222,14 @@ app.post('/api/auth/google/plugin', (req, res) => {
|
|
|
748
1222
|
try {
|
|
749
1223
|
const opencode = loadConfig();
|
|
750
1224
|
if (opencode) {
|
|
751
|
-
if (opencode.provider
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1225
|
+
if (!opencode.provider) opencode.provider = {};
|
|
1226
|
+
if (!opencode.provider.google) {
|
|
1227
|
+
opencode.provider.google = { models: {} };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const models = studio.pluginModels[plugin];
|
|
1231
|
+
if (models) {
|
|
1232
|
+
opencode.provider.google.models = models;
|
|
756
1233
|
}
|
|
757
1234
|
|
|
758
1235
|
if (!opencode.plugin) opencode.plugin = [];
|
|
@@ -783,7 +1260,7 @@ app.post('/api/auth/google/plugin', (req, res) => {
|
|
|
783
1260
|
} else if (plugin === 'gemini' && authCfg['google.gemini']) {
|
|
784
1261
|
authCfg.google = { ...authCfg['google.gemini'] };
|
|
785
1262
|
}
|
|
786
|
-
|
|
1263
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
787
1264
|
}
|
|
788
1265
|
}
|
|
789
1266
|
} catch (err) {
|
|
@@ -798,6 +1275,202 @@ app.get('/api/auth/google/plugin', (req, res) => {
|
|
|
798
1275
|
res.json({ activePlugin: studio.activeGooglePlugin || null });
|
|
799
1276
|
});
|
|
800
1277
|
|
|
1278
|
+
const GEMINI_CLIENT_ID = process.env.GEMINI_CLIENT_ID || "";
|
|
1279
|
+
const GEMINI_CLIENT_SECRET = process.env.GEMINI_CLIENT_SECRET || "";
|
|
1280
|
+
const GEMINI_SCOPES = [
|
|
1281
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
1282
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
1283
|
+
"https://www.googleapis.com/auth/userinfo.profile"
|
|
1284
|
+
];
|
|
1285
|
+
const OAUTH_CALLBACK_PORT = 8085;
|
|
1286
|
+
const GEMINI_REDIRECT_URI = `http://localhost:${OAUTH_CALLBACK_PORT}/oauth2callback`;
|
|
1287
|
+
|
|
1288
|
+
let pendingOAuthState = null;
|
|
1289
|
+
let oauthCallbackServer = null;
|
|
1290
|
+
|
|
1291
|
+
function generatePKCE() {
|
|
1292
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
1293
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
1294
|
+
return { verifier, challenge };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function encodeOAuthState(payload) {
|
|
1298
|
+
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
app.post('/api/auth/google/start', async (req, res) => {
|
|
1302
|
+
if (oauthCallbackServer) {
|
|
1303
|
+
return res.status(400).json({ error: 'OAuth flow already in progress' });
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const { verifier, challenge } = generatePKCE();
|
|
1307
|
+
const state = encodeOAuthState({ verifier });
|
|
1308
|
+
|
|
1309
|
+
pendingOAuthState = { verifier, status: 'pending', startedAt: Date.now() };
|
|
1310
|
+
|
|
1311
|
+
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
1312
|
+
authUrl.searchParams.set('client_id', GEMINI_CLIENT_ID);
|
|
1313
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
1314
|
+
authUrl.searchParams.set('redirect_uri', GEMINI_REDIRECT_URI);
|
|
1315
|
+
authUrl.searchParams.set('scope', GEMINI_SCOPES.join(' '));
|
|
1316
|
+
authUrl.searchParams.set('code_challenge', challenge);
|
|
1317
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
1318
|
+
authUrl.searchParams.set('state', state);
|
|
1319
|
+
authUrl.searchParams.set('access_type', 'offline');
|
|
1320
|
+
authUrl.searchParams.set('prompt', 'consent');
|
|
1321
|
+
|
|
1322
|
+
const callbackApp = express();
|
|
1323
|
+
|
|
1324
|
+
callbackApp.get('/oauth2callback', async (callbackReq, callbackRes) => {
|
|
1325
|
+
const { code, state: returnedState, error } = callbackReq.query;
|
|
1326
|
+
|
|
1327
|
+
if (error) {
|
|
1328
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error };
|
|
1329
|
+
callbackRes.send('<html><body><h2>Login Failed</h2><p>Error: ' + error + '</p><script>window.close()</script></body></html>');
|
|
1330
|
+
shutdownCallbackServer();
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (!code) {
|
|
1335
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error: 'No authorization code received' };
|
|
1336
|
+
callbackRes.send('<html><body><h2>Login Failed</h2><p>No code received</p><script>window.close()</script></body></html>');
|
|
1337
|
+
shutdownCallbackServer();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
try {
|
|
1342
|
+
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
|
1343
|
+
method: 'POST',
|
|
1344
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1345
|
+
body: new URLSearchParams({
|
|
1346
|
+
client_id: GEMINI_CLIENT_ID,
|
|
1347
|
+
client_secret: GEMINI_CLIENT_SECRET,
|
|
1348
|
+
code,
|
|
1349
|
+
grant_type: 'authorization_code',
|
|
1350
|
+
redirect_uri: GEMINI_REDIRECT_URI,
|
|
1351
|
+
code_verifier: pendingOAuthState.verifier
|
|
1352
|
+
})
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
if (!tokenResponse.ok) {
|
|
1356
|
+
const errText = await tokenResponse.text();
|
|
1357
|
+
throw new Error(errText);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const tokens = await tokenResponse.json();
|
|
1361
|
+
|
|
1362
|
+
let email = null;
|
|
1363
|
+
try {
|
|
1364
|
+
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', {
|
|
1365
|
+
headers: { 'Authorization': `Bearer ${tokens.access_token}` }
|
|
1366
|
+
});
|
|
1367
|
+
if (userInfoRes.ok) {
|
|
1368
|
+
const userInfo = await userInfoRes.json();
|
|
1369
|
+
email = userInfo.email;
|
|
1370
|
+
}
|
|
1371
|
+
} catch {}
|
|
1372
|
+
|
|
1373
|
+
const cp = getConfigPath();
|
|
1374
|
+
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
1375
|
+
const authCfg = fs.existsSync(ap) ? JSON.parse(fs.readFileSync(ap, 'utf8')) : {};
|
|
1376
|
+
|
|
1377
|
+
const studio = loadStudioConfig();
|
|
1378
|
+
const activePlugin = studio.activeGooglePlugin || 'gemini';
|
|
1379
|
+
const namespace = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
|
|
1380
|
+
|
|
1381
|
+
const credentials = {
|
|
1382
|
+
refresh_token: tokens.refresh_token,
|
|
1383
|
+
access_token: tokens.access_token,
|
|
1384
|
+
expiry: Date.now() + (tokens.expires_in * 1000),
|
|
1385
|
+
email
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
authCfg.google = credentials;
|
|
1389
|
+
authCfg[namespace] = credentials;
|
|
1390
|
+
|
|
1391
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
1392
|
+
|
|
1393
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'success', email };
|
|
1394
|
+
|
|
1395
|
+
callbackRes.send(`
|
|
1396
|
+
<html>
|
|
1397
|
+
<head><style>body{font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f0fdf4}
|
|
1398
|
+
.card{background:white;padding:2rem;border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);text-align:center}
|
|
1399
|
+
h2{color:#16a34a;margin:0 0 0.5rem}</style></head>
|
|
1400
|
+
<body><div class="card"><h2>✓ Login Successful!</h2><p>Logged in as ${email || 'Google User'}</p><p style="color:#666;font-size:0.875rem">You can close this window.</p></div>
|
|
1401
|
+
<script>setTimeout(()=>window.close(),2000)</script></body></html>
|
|
1402
|
+
`);
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error: err.message };
|
|
1405
|
+
callbackRes.send('<html><body><h2>Login Failed</h2><p>' + err.message + '</p><script>window.close()</script></body></html>');
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
shutdownCallbackServer();
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
function shutdownCallbackServer() {
|
|
1412
|
+
if (oauthCallbackServer) {
|
|
1413
|
+
oauthCallbackServer.close();
|
|
1414
|
+
oauthCallbackServer = null;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
try {
|
|
1419
|
+
oauthCallbackServer = callbackApp.listen(OAUTH_CALLBACK_PORT, () => {
|
|
1420
|
+
console.log(`OAuth callback server listening on port ${OAUTH_CALLBACK_PORT}`);
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
oauthCallbackServer.on('error', (err) => {
|
|
1424
|
+
console.error('Failed to start OAuth callback server:', err);
|
|
1425
|
+
pendingOAuthState = { status: 'error', error: `Port ${OAUTH_CALLBACK_PORT} in use` };
|
|
1426
|
+
oauthCallbackServer = null;
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
setTimeout(() => {
|
|
1430
|
+
if (oauthCallbackServer && pendingOAuthState?.status === 'pending') {
|
|
1431
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error: 'OAuth timeout (2 minutes)' };
|
|
1432
|
+
shutdownCallbackServer();
|
|
1433
|
+
}
|
|
1434
|
+
}, 120000);
|
|
1435
|
+
|
|
1436
|
+
const platform = process.platform;
|
|
1437
|
+
let openCmd;
|
|
1438
|
+
if (platform === 'win32') {
|
|
1439
|
+
openCmd = `start "" "${authUrl.toString()}"`;
|
|
1440
|
+
} else if (platform === 'darwin') {
|
|
1441
|
+
openCmd = `open "${authUrl.toString()}"`;
|
|
1442
|
+
} else {
|
|
1443
|
+
openCmd = `xdg-open "${authUrl.toString()}"`;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
exec(openCmd, (err) => {
|
|
1447
|
+
if (err) console.error('Failed to open browser:', err);
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
res.json({ success: true, authUrl: authUrl.toString(), message: 'Browser opened for Google login' });
|
|
1451
|
+
|
|
1452
|
+
} catch (err) {
|
|
1453
|
+
pendingOAuthState = null;
|
|
1454
|
+
res.status(500).json({ error: err.message });
|
|
1455
|
+
}
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
app.get('/api/auth/google/status', (req, res) => {
|
|
1459
|
+
if (!pendingOAuthState) {
|
|
1460
|
+
return res.json({ status: 'idle' });
|
|
1461
|
+
}
|
|
1462
|
+
res.json(pendingOAuthState);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
app.post('/api/auth/google/cancel', (req, res) => {
|
|
1466
|
+
if (oauthCallbackServer) {
|
|
1467
|
+
oauthCallbackServer.close();
|
|
1468
|
+
oauthCallbackServer = null;
|
|
1469
|
+
}
|
|
1470
|
+
pendingOAuthState = null;
|
|
1471
|
+
res.json({ success: true });
|
|
1472
|
+
});
|
|
1473
|
+
|
|
801
1474
|
app.get('/api/pending-action', (req, res) => {
|
|
802
1475
|
if (pendingActionMemory) return res.json({ action: pendingActionMemory });
|
|
803
1476
|
if (fs.existsSync(PENDING_ACTION_PATH)) {
|