opencode-studio-server 1.2.1 → 1.3.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/.env +2 -0
- package/.env.example +2 -0
- package/AGENTS.md +37 -0
- package/cli.js +7 -6
- package/index.js +896 -86
- 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;
|
|
@@ -63,6 +79,7 @@ function loadStudioConfig() {
|
|
|
63
79
|
activeProfiles: {},
|
|
64
80
|
activeGooglePlugin: 'gemini',
|
|
65
81
|
availableGooglePlugins: [],
|
|
82
|
+
presets: [],
|
|
66
83
|
pluginModels: {
|
|
67
84
|
gemini: {
|
|
68
85
|
"gemini-3-pro-preview": {
|
|
@@ -204,9 +221,7 @@ function loadStudioConfig() {
|
|
|
204
221
|
|
|
205
222
|
function saveStudioConfig(config) {
|
|
206
223
|
try {
|
|
207
|
-
|
|
208
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
209
|
-
fs.writeFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
224
|
+
atomicWriteFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
210
225
|
return true;
|
|
211
226
|
} catch (err) {
|
|
212
227
|
console.error('Failed to save studio config:', err);
|
|
@@ -265,9 +280,7 @@ const loadConfig = () => {
|
|
|
265
280
|
const saveConfig = (config) => {
|
|
266
281
|
const configPath = getConfigPath();
|
|
267
282
|
if (!configPath) throw new Error('No config path found');
|
|
268
|
-
|
|
269
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
270
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
283
|
+
atomicWriteFileSync(configPath, JSON.stringify(config, null, 2));
|
|
271
284
|
};
|
|
272
285
|
|
|
273
286
|
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
|
@@ -317,6 +330,9 @@ app.get('/api/skills', (req, res) => {
|
|
|
317
330
|
});
|
|
318
331
|
|
|
319
332
|
app.get('/api/skills/:name', (req, res) => {
|
|
333
|
+
if (!/^[a-zA-Z0-9_\-\s]+$/.test(req.params.name)) {
|
|
334
|
+
return res.status(400).json({ error: 'Invalid skill name' });
|
|
335
|
+
}
|
|
320
336
|
const sd = getSkillDir();
|
|
321
337
|
const p = sd ? path.join(sd, req.params.name, 'SKILL.md') : null;
|
|
322
338
|
if (!p || !fs.existsSync(p)) return res.status(404).json({ error: 'Not found' });
|
|
@@ -324,6 +340,9 @@ app.get('/api/skills/:name', (req, res) => {
|
|
|
324
340
|
});
|
|
325
341
|
|
|
326
342
|
app.post('/api/skills/:name', (req, res) => {
|
|
343
|
+
if (!/^[a-zA-Z0-9_\-\s]+$/.test(req.params.name)) {
|
|
344
|
+
return res.status(400).json({ error: 'Invalid skill name' });
|
|
345
|
+
}
|
|
327
346
|
const sd = getSkillDir();
|
|
328
347
|
if (!sd) return res.status(404).json({ error: 'No config' });
|
|
329
348
|
const dp = path.join(sd, req.params.name);
|
|
@@ -333,12 +352,30 @@ app.post('/api/skills/:name', (req, res) => {
|
|
|
333
352
|
});
|
|
334
353
|
|
|
335
354
|
app.delete('/api/skills/:name', (req, res) => {
|
|
355
|
+
if (!/^[a-zA-Z0-9_\-\s]+$/.test(req.params.name)) {
|
|
356
|
+
return res.status(400).json({ error: 'Invalid skill name' });
|
|
357
|
+
}
|
|
336
358
|
const sd = getSkillDir();
|
|
337
359
|
const dp = sd ? path.join(sd, req.params.name) : null;
|
|
338
360
|
if (dp && fs.existsSync(dp)) fs.rmSync(dp, { recursive: true, force: true });
|
|
339
361
|
res.json({ success: true });
|
|
340
362
|
});
|
|
341
363
|
|
|
364
|
+
app.post('/api/skills/:name/toggle', (req, res) => {
|
|
365
|
+
const { name } = req.params;
|
|
366
|
+
const studio = loadStudioConfig();
|
|
367
|
+
studio.disabledSkills = studio.disabledSkills || [];
|
|
368
|
+
|
|
369
|
+
if (studio.disabledSkills.includes(name)) {
|
|
370
|
+
studio.disabledSkills = studio.disabledSkills.filter(s => s !== name);
|
|
371
|
+
} else {
|
|
372
|
+
studio.disabledSkills.push(name);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
saveStudioConfig(studio);
|
|
376
|
+
res.json({ success: true, enabled: !studio.disabledSkills.includes(name) });
|
|
377
|
+
});
|
|
378
|
+
|
|
342
379
|
const getPluginDir = () => {
|
|
343
380
|
const cp = getConfigPath();
|
|
344
381
|
return cp ? path.join(path.dirname(cp), 'plugin') : null;
|
|
@@ -382,6 +419,79 @@ app.get('/api/plugins', (req, res) => {
|
|
|
382
419
|
res.json(plugins);
|
|
383
420
|
});
|
|
384
421
|
|
|
422
|
+
app.get('/api/plugins/:name', (req, res) => {
|
|
423
|
+
const { name } = req.params;
|
|
424
|
+
const pd = getPluginDir();
|
|
425
|
+
|
|
426
|
+
const possiblePaths = [
|
|
427
|
+
path.join(pd, name + '.js'),
|
|
428
|
+
path.join(pd, name + '.ts'),
|
|
429
|
+
path.join(pd, name, 'index.js'),
|
|
430
|
+
path.join(pd, name, 'index.ts')
|
|
431
|
+
];
|
|
432
|
+
|
|
433
|
+
for (const p of possiblePaths) {
|
|
434
|
+
if (fs.existsSync(p)) {
|
|
435
|
+
const content = fs.readFileSync(p, 'utf8');
|
|
436
|
+
return res.json({ name, content });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
res.status(404).json({ error: 'Plugin not found' });
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
app.post('/api/plugins/:name', (req, res) => {
|
|
443
|
+
const { name } = req.params;
|
|
444
|
+
const { content } = req.body;
|
|
445
|
+
const pd = getPluginDir();
|
|
446
|
+
if (!fs.existsSync(pd)) fs.mkdirSync(pd, { recursive: true });
|
|
447
|
+
|
|
448
|
+
// Default to .js if new
|
|
449
|
+
const filePath = path.join(pd, name.endsWith('.js') || name.endsWith('.ts') ? name : name + '.js');
|
|
450
|
+
atomicWriteFileSync(filePath, content);
|
|
451
|
+
res.json({ success: true });
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
app.delete('/api/plugins/:name', (req, res) => {
|
|
455
|
+
const { name } = req.params;
|
|
456
|
+
const pd = getPluginDir();
|
|
457
|
+
|
|
458
|
+
const possiblePaths = [
|
|
459
|
+
path.join(pd, name),
|
|
460
|
+
path.join(pd, name + '.js'),
|
|
461
|
+
path.join(pd, name + '.ts')
|
|
462
|
+
];
|
|
463
|
+
|
|
464
|
+
let deleted = false;
|
|
465
|
+
for (const p of possiblePaths) {
|
|
466
|
+
if (fs.existsSync(p)) {
|
|
467
|
+
if (fs.statSync(p).isDirectory()) {
|
|
468
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
469
|
+
} else {
|
|
470
|
+
fs.unlinkSync(p);
|
|
471
|
+
}
|
|
472
|
+
deleted = true;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (deleted) res.json({ success: true });
|
|
477
|
+
else res.status(404).json({ error: 'Plugin not found' });
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
app.post('/api/plugins/:name/toggle', (req, res) => {
|
|
481
|
+
const { name } = req.params;
|
|
482
|
+
const studio = loadStudioConfig();
|
|
483
|
+
studio.disabledPlugins = studio.disabledPlugins || [];
|
|
484
|
+
|
|
485
|
+
if (studio.disabledPlugins.includes(name)) {
|
|
486
|
+
studio.disabledPlugins = studio.disabledPlugins.filter(p => p !== name);
|
|
487
|
+
} else {
|
|
488
|
+
studio.disabledPlugins.push(name);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
saveStudioConfig(studio);
|
|
492
|
+
res.json({ success: true, enabled: !studio.disabledPlugins.includes(name) });
|
|
493
|
+
});
|
|
494
|
+
|
|
385
495
|
const getActiveGooglePlugin = () => {
|
|
386
496
|
const studio = loadStudioConfig();
|
|
387
497
|
return studio.activeGooglePlugin || null;
|
|
@@ -572,7 +682,7 @@ app.post('/api/auth/profiles/:provider', (req, res) => {
|
|
|
572
682
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
573
683
|
|
|
574
684
|
const profilePath = path.join(dir, `${name || Date.now()}.json`);
|
|
575
|
-
|
|
685
|
+
atomicWriteFileSync(profilePath, JSON.stringify(auth[provider], null, 2));
|
|
576
686
|
res.json({ success: true, name: path.basename(profilePath, '.json') });
|
|
577
687
|
});
|
|
578
688
|
|
|
@@ -603,7 +713,7 @@ app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
|
|
|
603
713
|
|
|
604
714
|
const cp = getConfigPath();
|
|
605
715
|
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
606
|
-
|
|
716
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
607
717
|
res.json({ success: true });
|
|
608
718
|
});
|
|
609
719
|
|
|
@@ -643,30 +753,79 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
|
643
753
|
|
|
644
754
|
app.post('/api/auth/login', (req, res) => {
|
|
645
755
|
let { provider } = req.body;
|
|
756
|
+
|
|
757
|
+
// Security: Validate provider against allowlist to prevent command injection
|
|
758
|
+
const ALLOWED_PROVIDERS = [
|
|
759
|
+
"", "google", "anthropic", "openai", "xai",
|
|
760
|
+
"openrouter", "github-copilot", "gemini",
|
|
761
|
+
"together", "mistral", "deepseek", "amazon-bedrock", "azure"
|
|
762
|
+
];
|
|
763
|
+
|
|
764
|
+
if (provider && !ALLOWED_PROVIDERS.includes(provider)) {
|
|
765
|
+
return res.status(400).json({ error: 'Invalid provider' });
|
|
766
|
+
}
|
|
767
|
+
|
|
646
768
|
if (typeof provider !== 'string') provider = "";
|
|
647
769
|
|
|
648
770
|
let cmd = 'opencode auth login';
|
|
649
771
|
if (provider) cmd += ` ${provider}`;
|
|
650
772
|
|
|
651
773
|
const platform = process.platform;
|
|
652
|
-
|
|
774
|
+
|
|
653
775
|
if (platform === 'win32') {
|
|
654
|
-
terminalCmd = `start "" cmd /c "call ${cmd} || pause"`;
|
|
776
|
+
const terminalCmd = `start "" cmd /c "call ${cmd} || pause"`;
|
|
777
|
+
console.log('Executing terminal command:', terminalCmd);
|
|
778
|
+
exec(terminalCmd, (err) => {
|
|
779
|
+
if (err) {
|
|
780
|
+
console.error('Failed to open terminal:', err);
|
|
781
|
+
return res.status(500).json({ error: 'Failed to open terminal', details: err.message });
|
|
782
|
+
}
|
|
783
|
+
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
784
|
+
});
|
|
655
785
|
} else if (platform === 'darwin') {
|
|
656
|
-
terminalCmd = `osascript -e 'tell application "Terminal" to do script "${cmd}"'`;
|
|
786
|
+
const terminalCmd = `osascript -e 'tell application "Terminal" to do script "${cmd}"'`;
|
|
787
|
+
console.log('Executing terminal command:', terminalCmd);
|
|
788
|
+
exec(terminalCmd, (err) => {
|
|
789
|
+
if (err) {
|
|
790
|
+
console.error('Failed to open terminal:', err);
|
|
791
|
+
return res.status(500).json({ error: 'Failed to open terminal', details: err.message });
|
|
792
|
+
}
|
|
793
|
+
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
794
|
+
});
|
|
657
795
|
} else {
|
|
658
|
-
|
|
796
|
+
const linuxTerminals = [
|
|
797
|
+
{ name: 'x-terminal-emulator', cmd: `x-terminal-emulator -e "${cmd}"` },
|
|
798
|
+
{ name: 'gnome-terminal', cmd: `gnome-terminal -- bash -c "${cmd}; read -p 'Press Enter to close...'"` },
|
|
799
|
+
{ name: 'konsole', cmd: `konsole -e bash -c "${cmd}; read -p 'Press Enter to close...'"` },
|
|
800
|
+
{ name: 'xfce4-terminal', cmd: `xfce4-terminal -e "bash -c \\"${cmd}; read -p 'Press Enter to close...'\\"" ` },
|
|
801
|
+
{ name: 'xterm', cmd: `xterm -e "bash -c '${cmd}; read -p Press_Enter_to_close...'"` }
|
|
802
|
+
];
|
|
803
|
+
|
|
804
|
+
const tryTerminal = (index) => {
|
|
805
|
+
if (index >= linuxTerminals.length) {
|
|
806
|
+
const fallbackCmd = cmd;
|
|
807
|
+
return res.json({
|
|
808
|
+
success: false,
|
|
809
|
+
message: 'No terminal emulator found',
|
|
810
|
+
note: 'Run this command manually in your terminal',
|
|
811
|
+
command: fallbackCmd
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const terminal = linuxTerminals[index];
|
|
816
|
+
console.log(`Trying terminal: ${terminal.name}`);
|
|
817
|
+
exec(terminal.cmd, (err) => {
|
|
818
|
+
if (err) {
|
|
819
|
+
console.log(`${terminal.name} failed, trying next...`);
|
|
820
|
+
tryTerminal(index + 1);
|
|
821
|
+
} else {
|
|
822
|
+
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
tryTerminal(0);
|
|
659
828
|
}
|
|
660
|
-
|
|
661
|
-
console.log('Executing terminal command:', terminalCmd);
|
|
662
|
-
|
|
663
|
-
exec(terminalCmd, (err) => {
|
|
664
|
-
if (err) {
|
|
665
|
-
console.error('Failed to open terminal:', err);
|
|
666
|
-
return res.status(500).json({ error: 'Failed to open terminal', details: err.message });
|
|
667
|
-
}
|
|
668
|
-
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
669
|
-
});
|
|
670
829
|
});
|
|
671
830
|
|
|
672
831
|
app.delete('/api/auth/:provider', (req, res) => {
|
|
@@ -675,7 +834,7 @@ app.delete('/api/auth/:provider', (req, res) => {
|
|
|
675
834
|
delete authCfg[provider];
|
|
676
835
|
const cp = getConfigPath();
|
|
677
836
|
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
678
|
-
|
|
837
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
679
838
|
|
|
680
839
|
const studio = loadStudioConfig();
|
|
681
840
|
if (studio.activeProfiles) delete studio.activeProfiles[provider];
|
|
@@ -684,6 +843,332 @@ app.delete('/api/auth/:provider', (req, res) => {
|
|
|
684
843
|
res.json({ success: true });
|
|
685
844
|
});
|
|
686
845
|
|
|
846
|
+
// ============================================
|
|
847
|
+
// ACCOUNT POOL MANAGEMENT (Antigravity-style)
|
|
848
|
+
// ============================================
|
|
849
|
+
|
|
850
|
+
const POOL_METADATA_FILE = path.join(HOME_DIR, '.config', 'opencode-studio', 'pool-metadata.json');
|
|
851
|
+
|
|
852
|
+
function loadPoolMetadata() {
|
|
853
|
+
if (!fs.existsSync(POOL_METADATA_FILE)) return {};
|
|
854
|
+
try {
|
|
855
|
+
return JSON.parse(fs.readFileSync(POOL_METADATA_FILE, 'utf8'));
|
|
856
|
+
} catch {
|
|
857
|
+
return {};
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function savePoolMetadata(metadata) {
|
|
862
|
+
const dir = path.dirname(POOL_METADATA_FILE);
|
|
863
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
864
|
+
atomicWriteFileSync(POOL_METADATA_FILE, JSON.stringify(metadata, null, 2));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function getAccountStatus(meta, now) {
|
|
868
|
+
if (!meta) return 'ready';
|
|
869
|
+
if (meta.cooldownUntil && meta.cooldownUntil > now) return 'cooldown';
|
|
870
|
+
if (meta.expired) return 'expired';
|
|
871
|
+
return 'ready';
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function buildAccountPool(provider) {
|
|
875
|
+
const activePlugin = getActiveGooglePlugin();
|
|
876
|
+
const namespace = provider === 'google'
|
|
877
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
878
|
+
: provider;
|
|
879
|
+
|
|
880
|
+
const profileDir = path.join(AUTH_PROFILES_DIR, namespace);
|
|
881
|
+
const profiles = [];
|
|
882
|
+
const now = Date.now();
|
|
883
|
+
const metadata = loadPoolMetadata();
|
|
884
|
+
const providerMeta = metadata[namespace] || {};
|
|
885
|
+
|
|
886
|
+
// Get current active profile from studio config
|
|
887
|
+
const studio = loadStudioConfig();
|
|
888
|
+
const activeProfile = studio.activeProfiles?.[provider] || null;
|
|
889
|
+
|
|
890
|
+
if (fs.existsSync(profileDir)) {
|
|
891
|
+
const files = fs.readdirSync(profileDir).filter(f => f.endsWith('.json'));
|
|
892
|
+
files.forEach(file => {
|
|
893
|
+
const name = file.replace('.json', '');
|
|
894
|
+
const meta = providerMeta[name] || {};
|
|
895
|
+
const status = name === activeProfile ? 'active' : getAccountStatus(meta, now);
|
|
896
|
+
|
|
897
|
+
profiles.push({
|
|
898
|
+
name,
|
|
899
|
+
email: meta.email || null,
|
|
900
|
+
status,
|
|
901
|
+
lastUsed: meta.lastUsed || 0,
|
|
902
|
+
usageCount: meta.usageCount || 0,
|
|
903
|
+
cooldownUntil: meta.cooldownUntil || null,
|
|
904
|
+
createdAt: meta.createdAt || 0
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Sort: active first, then by lastUsed (LRU)
|
|
910
|
+
profiles.sort((a, b) => {
|
|
911
|
+
if (a.status === 'active') return -1;
|
|
912
|
+
if (b.status === 'active') return 1;
|
|
913
|
+
return a.lastUsed - b.lastUsed;
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
const available = profiles.filter(p => p.status === 'active' || p.status === 'ready').length;
|
|
917
|
+
const cooldown = profiles.filter(p => p.status === 'cooldown').length;
|
|
918
|
+
|
|
919
|
+
return {
|
|
920
|
+
provider,
|
|
921
|
+
namespace,
|
|
922
|
+
accounts: profiles,
|
|
923
|
+
activeAccount: activeProfile,
|
|
924
|
+
totalAccounts: profiles.length,
|
|
925
|
+
availableAccounts: available,
|
|
926
|
+
cooldownAccounts: cooldown
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// GET /api/auth/pool - Get account pool for Google (or specified provider)
|
|
931
|
+
app.get('/api/auth/pool', (req, res) => {
|
|
932
|
+
const provider = req.query.provider || 'google';
|
|
933
|
+
const pool = buildAccountPool(provider);
|
|
934
|
+
|
|
935
|
+
// Also include quota estimate (local tracking)
|
|
936
|
+
const metadata = loadPoolMetadata();
|
|
937
|
+
const activePlugin = getActiveGooglePlugin();
|
|
938
|
+
const namespace = provider === 'google'
|
|
939
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
940
|
+
: provider;
|
|
941
|
+
|
|
942
|
+
const quotaMeta = metadata._quota?.[namespace] || {};
|
|
943
|
+
const today = new Date().toISOString().split('T')[0];
|
|
944
|
+
const todayUsage = quotaMeta[today] || 0;
|
|
945
|
+
|
|
946
|
+
// Estimate: 1000 requests/day limit (configurable)
|
|
947
|
+
const dailyLimit = quotaMeta.dailyLimit || 1000;
|
|
948
|
+
const remaining = Math.max(0, dailyLimit - todayUsage);
|
|
949
|
+
const percentage = Math.round((remaining / dailyLimit) * 100);
|
|
950
|
+
|
|
951
|
+
const quota = {
|
|
952
|
+
dailyLimit,
|
|
953
|
+
remaining,
|
|
954
|
+
used: todayUsage,
|
|
955
|
+
percentage,
|
|
956
|
+
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString(),
|
|
957
|
+
byAccount: pool.accounts.map(acc => ({
|
|
958
|
+
name: acc.name,
|
|
959
|
+
email: acc.email,
|
|
960
|
+
used: acc.usageCount,
|
|
961
|
+
limit: Math.floor(dailyLimit / Math.max(1, pool.totalAccounts))
|
|
962
|
+
}))
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
res.json({ pool, quota });
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// POST /api/auth/pool/rotate - Rotate to next available account
|
|
969
|
+
app.post('/api/auth/pool/rotate', (req, res) => {
|
|
970
|
+
const provider = req.body.provider || 'google';
|
|
971
|
+
const pool = buildAccountPool(provider);
|
|
972
|
+
|
|
973
|
+
if (pool.accounts.length === 0) {
|
|
974
|
+
return res.status(400).json({ error: 'No accounts in pool' });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const now = Date.now();
|
|
978
|
+
const available = pool.accounts.filter(acc =>
|
|
979
|
+
acc.status === 'ready' || (acc.status === 'cooldown' && acc.cooldownUntil && acc.cooldownUntil < now)
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
if (available.length === 0) {
|
|
983
|
+
return res.status(400).json({ error: 'No available accounts (all in cooldown or expired)' });
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Pick least recently used
|
|
987
|
+
const next = available.sort((a, b) => a.lastUsed - b.lastUsed)[0];
|
|
988
|
+
const previousActive = pool.activeAccount;
|
|
989
|
+
|
|
990
|
+
// Activate the new account
|
|
991
|
+
const activePlugin = getActiveGooglePlugin();
|
|
992
|
+
const namespace = provider === 'google'
|
|
993
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
994
|
+
: provider;
|
|
995
|
+
|
|
996
|
+
const profilePath = path.join(AUTH_PROFILES_DIR, namespace, `${next.name}.json`);
|
|
997
|
+
if (!fs.existsSync(profilePath)) {
|
|
998
|
+
return res.status(404).json({ error: 'Profile file not found' });
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const profileData = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
|
|
1002
|
+
|
|
1003
|
+
// Update auth.json
|
|
1004
|
+
const authCfg = loadAuthConfig() || {};
|
|
1005
|
+
authCfg[provider] = profileData;
|
|
1006
|
+
if (provider === 'google') {
|
|
1007
|
+
authCfg[namespace] = profileData;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const cp = getConfigPath();
|
|
1011
|
+
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
1012
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
1013
|
+
|
|
1014
|
+
// Update studio config
|
|
1015
|
+
const studio = loadStudioConfig();
|
|
1016
|
+
if (!studio.activeProfiles) studio.activeProfiles = {};
|
|
1017
|
+
studio.activeProfiles[provider] = next.name;
|
|
1018
|
+
saveStudioConfig(studio);
|
|
1019
|
+
|
|
1020
|
+
// Update metadata
|
|
1021
|
+
const metadata = loadPoolMetadata();
|
|
1022
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1023
|
+
metadata[namespace][next.name] = {
|
|
1024
|
+
...metadata[namespace][next.name],
|
|
1025
|
+
lastUsed: now
|
|
1026
|
+
};
|
|
1027
|
+
savePoolMetadata(metadata);
|
|
1028
|
+
|
|
1029
|
+
res.json({
|
|
1030
|
+
success: true,
|
|
1031
|
+
previousAccount: previousActive,
|
|
1032
|
+
newAccount: next.name,
|
|
1033
|
+
reason: 'manual_rotation'
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
// PUT /api/auth/pool/:name/cooldown - Mark account as in cooldown
|
|
1038
|
+
app.put('/api/auth/pool/:name/cooldown', (req, res) => {
|
|
1039
|
+
const { name } = req.params;
|
|
1040
|
+
const { duration = 3600000, provider = 'google' } = req.body; // default 1 hour
|
|
1041
|
+
|
|
1042
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1043
|
+
const namespace = provider === 'google'
|
|
1044
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1045
|
+
: provider;
|
|
1046
|
+
|
|
1047
|
+
const metadata = loadPoolMetadata();
|
|
1048
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1049
|
+
|
|
1050
|
+
metadata[namespace][name] = {
|
|
1051
|
+
...metadata[namespace][name],
|
|
1052
|
+
cooldownUntil: Date.now() + duration,
|
|
1053
|
+
lastCooldownReason: req.body.reason || 'rate_limit'
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
savePoolMetadata(metadata);
|
|
1057
|
+
res.json({ success: true, cooldownUntil: metadata[namespace][name].cooldownUntil });
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// DELETE /api/auth/pool/:name/cooldown - Clear cooldown for account
|
|
1061
|
+
app.delete('/api/auth/pool/:name/cooldown', (req, res) => {
|
|
1062
|
+
const { name } = req.params;
|
|
1063
|
+
const provider = req.query.provider || 'google';
|
|
1064
|
+
|
|
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[namespace]?.[name]) {
|
|
1072
|
+
delete metadata[namespace][name].cooldownUntil;
|
|
1073
|
+
savePoolMetadata(metadata);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
res.json({ success: true });
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
// POST /api/auth/pool/:name/usage - Increment usage counter (for tracking)
|
|
1080
|
+
app.post('/api/auth/pool/:name/usage', (req, res) => {
|
|
1081
|
+
const { name } = req.params;
|
|
1082
|
+
const { provider = 'google' } = req.body;
|
|
1083
|
+
|
|
1084
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1085
|
+
const namespace = provider === 'google'
|
|
1086
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1087
|
+
: provider;
|
|
1088
|
+
|
|
1089
|
+
const metadata = loadPoolMetadata();
|
|
1090
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1091
|
+
if (!metadata[namespace][name]) metadata[namespace][name] = { usageCount: 0 };
|
|
1092
|
+
|
|
1093
|
+
metadata[namespace][name].usageCount = (metadata[namespace][name].usageCount || 0) + 1;
|
|
1094
|
+
metadata[namespace][name].lastUsed = Date.now();
|
|
1095
|
+
|
|
1096
|
+
// Track daily quota
|
|
1097
|
+
if (!metadata._quota) metadata._quota = {};
|
|
1098
|
+
if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
|
|
1099
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1100
|
+
metadata._quota[namespace][today] = (metadata._quota[namespace][today] || 0) + 1;
|
|
1101
|
+
|
|
1102
|
+
savePoolMetadata(metadata);
|
|
1103
|
+
res.json({ success: true, usageCount: metadata[namespace][name].usageCount });
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// PUT /api/auth/pool/:name/metadata - Update account metadata (email, etc.)
|
|
1107
|
+
app.put('/api/auth/pool/:name/metadata', (req, res) => {
|
|
1108
|
+
const { name } = req.params;
|
|
1109
|
+
const { provider = 'google', email, createdAt } = req.body;
|
|
1110
|
+
|
|
1111
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1112
|
+
const namespace = provider === 'google'
|
|
1113
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1114
|
+
: provider;
|
|
1115
|
+
|
|
1116
|
+
const metadata = loadPoolMetadata();
|
|
1117
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1118
|
+
if (!metadata[namespace][name]) metadata[namespace][name] = {};
|
|
1119
|
+
|
|
1120
|
+
if (email !== undefined) metadata[namespace][name].email = email;
|
|
1121
|
+
if (createdAt !== undefined) metadata[namespace][name].createdAt = createdAt;
|
|
1122
|
+
|
|
1123
|
+
savePoolMetadata(metadata);
|
|
1124
|
+
res.json({ success: true });
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
// GET /api/auth/pool/quota - Get quota info
|
|
1128
|
+
app.get('/api/auth/pool/quota', (req, res) => {
|
|
1129
|
+
const provider = req.query.provider || 'google';
|
|
1130
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1131
|
+
const namespace = provider === 'google'
|
|
1132
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1133
|
+
: provider;
|
|
1134
|
+
|
|
1135
|
+
const metadata = loadPoolMetadata();
|
|
1136
|
+
const quotaMeta = metadata._quota?.[namespace] || {};
|
|
1137
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1138
|
+
const todayUsage = quotaMeta[today] || 0;
|
|
1139
|
+
const dailyLimit = quotaMeta.dailyLimit || 1000;
|
|
1140
|
+
|
|
1141
|
+
res.json({
|
|
1142
|
+
dailyLimit,
|
|
1143
|
+
remaining: Math.max(0, dailyLimit - todayUsage),
|
|
1144
|
+
used: todayUsage,
|
|
1145
|
+
percentage: Math.round(((dailyLimit - todayUsage) / dailyLimit) * 100),
|
|
1146
|
+
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString(),
|
|
1147
|
+
byAccount: []
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
// POST /api/auth/pool/quota/limit - Set daily quota limit
|
|
1152
|
+
app.post('/api/auth/pool/quota/limit', (req, res) => {
|
|
1153
|
+
const { provider = 'google', limit } = req.body;
|
|
1154
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1155
|
+
const namespace = provider === 'google'
|
|
1156
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1157
|
+
: provider;
|
|
1158
|
+
|
|
1159
|
+
const metadata = loadPoolMetadata();
|
|
1160
|
+
if (!metadata._quota) metadata._quota = {};
|
|
1161
|
+
if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
|
|
1162
|
+
metadata._quota[namespace].dailyLimit = limit;
|
|
1163
|
+
|
|
1164
|
+
savePoolMetadata(metadata);
|
|
1165
|
+
res.json({ success: true, dailyLimit: limit });
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
// ============================================
|
|
1169
|
+
// END ACCOUNT POOL MANAGEMENT
|
|
1170
|
+
// ============================================
|
|
1171
|
+
|
|
687
1172
|
app.get('/api/usage', async (req, res) => {
|
|
688
1173
|
try {
|
|
689
1174
|
const {projectId: fid, granularity = 'daily', range = '30d'} = req.query;
|
|
@@ -714,22 +1199,29 @@ app.get('/api/usage', async (req, res) => {
|
|
|
714
1199
|
|
|
715
1200
|
if (!md) return res.json({ totalCost: 0, totalTokens: 0, byModel: [], byDay: [], byProject: [] });
|
|
716
1201
|
|
|
717
|
-
|
|
718
1202
|
const pmap = new Map();
|
|
719
1203
|
if (fs.existsSync(sd)) {
|
|
720
|
-
fs.
|
|
1204
|
+
const sessionDirs = await fs.promises.readdir(sd);
|
|
1205
|
+
await Promise.all(sessionDirs.map(async d => {
|
|
721
1206
|
const fp = path.join(sd, d);
|
|
722
|
-
|
|
723
|
-
fs.
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
1207
|
+
try {
|
|
1208
|
+
const stats = await fs.promises.stat(fp);
|
|
1209
|
+
if (stats.isDirectory()) {
|
|
1210
|
+
const files = await fs.promises.readdir(fp);
|
|
1211
|
+
await Promise.all(files.map(async f => {
|
|
1212
|
+
if (f.startsWith('ses_') && f.endsWith('.json')) {
|
|
1213
|
+
try {
|
|
1214
|
+
const m = JSON.parse(await fs.promises.readFile(path.join(fp, f), 'utf8'));
|
|
1215
|
+
pmap.set(f.replace('.json', ''), {
|
|
1216
|
+
name: m.directory ? path.basename(m.directory) : (m.projectID ? m.projectID.substring(0, 8) : 'Unknown'),
|
|
1217
|
+
id: m.projectID || d
|
|
1218
|
+
});
|
|
1219
|
+
} catch {}
|
|
1220
|
+
}
|
|
1221
|
+
}));
|
|
1222
|
+
}
|
|
1223
|
+
} catch {}
|
|
1224
|
+
}));
|
|
733
1225
|
}
|
|
734
1226
|
|
|
735
1227
|
const stats = { totalCost: 0, totalTokens: 0, byModel: {}, byTime: {}, byProject: {} };
|
|
@@ -741,52 +1233,61 @@ app.get('/api/usage', async (req, res) => {
|
|
|
741
1233
|
else if (range === '30d') min = now - 2592000000;
|
|
742
1234
|
else if (range === '1y') min = now - 31536000000;
|
|
743
1235
|
|
|
744
|
-
fs.
|
|
1236
|
+
const sessionDirs = await fs.promises.readdir(md);
|
|
1237
|
+
await Promise.all(sessionDirs.map(async s => {
|
|
745
1238
|
if (!s.startsWith('ses_')) return;
|
|
746
1239
|
const sp = path.join(md, s);
|
|
747
|
-
|
|
748
|
-
fs.
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const
|
|
754
|
-
if (
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
if (
|
|
761
|
-
|
|
762
|
-
const day = d.getDay(), diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
|
763
|
-
tk = new Date(d.setDate(diff)).toISOString().split('T')[0];
|
|
764
|
-
} else if (granularity === 'monthly') tk = d.toISOString().substring(0, 7) + '-01';
|
|
765
|
-
else tk = d.toISOString().split('T')[0];
|
|
766
|
-
|
|
767
|
-
const mid = msg.modelID || (msg.model && (msg.model.modelID || msg.model.id)) || 'unknown';
|
|
768
|
-
stats.totalCost += c; stats.totalTokens += t;
|
|
769
|
-
[stats.byModel, stats.byProject].forEach((obj, i) => {
|
|
770
|
-
const key = i === 0 ? mid : pid;
|
|
771
|
-
if (!obj[key]) obj[key] = { name: key, id: key, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
772
|
-
if (i === 1) obj[key].name = pmap.get(s)?.name || 'Unassigned';
|
|
773
|
-
obj[key].cost += c; obj[key].tokens += t; obj[key].inputTokens += it; obj[key].outputTokens += ot;
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
if (!stats.byTime[tk]) stats.byTime[tk] = { date: tk, name: tk, id: tk, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
777
|
-
const te = stats.byTime[tk];
|
|
778
|
-
te.cost += c; te.tokens += t; te.inputTokens += it; te.outputTokens += ot;
|
|
779
|
-
if (!te[mid]) te[mid] = 0;
|
|
780
|
-
te[mid] += c;
|
|
1240
|
+
try {
|
|
1241
|
+
const spStats = await fs.promises.stat(sp);
|
|
1242
|
+
if (spStats.isDirectory()) {
|
|
1243
|
+
const files = await fs.promises.readdir(sp);
|
|
1244
|
+
for (const f of files) {
|
|
1245
|
+
if (!f.endsWith('.json')) continue;
|
|
1246
|
+
const fullPath = path.join(sp, f);
|
|
1247
|
+
if (seen.has(fullPath)) continue;
|
|
1248
|
+
seen.add(fullPath);
|
|
1249
|
+
|
|
1250
|
+
try {
|
|
1251
|
+
const msg = JSON.parse(await fs.promises.readFile(fullPath, 'utf8'));
|
|
1252
|
+
const pid = pmap.get(s)?.id || 'unknown';
|
|
1253
|
+
if (fid && fid !== 'all' && pid !== fid) continue;
|
|
1254
|
+
if (min > 0 && msg.time.created < min) continue;
|
|
781
1255
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1256
|
+
if (msg.role === 'assistant' && msg.tokens) {
|
|
1257
|
+
const c = msg.cost || 0, it = msg.tokens.input || 0, ot = msg.tokens.output || 0, t = it + ot;
|
|
1258
|
+
const d = new Date(msg.time.created);
|
|
1259
|
+
let tk;
|
|
1260
|
+
if (granularity === 'hourly') tk = d.toISOString().substring(0, 13) + ':00:00Z';
|
|
1261
|
+
else if (granularity === 'weekly') {
|
|
1262
|
+
const day = d.getDay(), diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
|
1263
|
+
tk = new Date(d.setDate(diff)).toISOString().split('T')[0];
|
|
1264
|
+
} else if (granularity === 'monthly') tk = d.toISOString().substring(0, 7) + '-01';
|
|
1265
|
+
else tk = d.toISOString().split('T')[0];
|
|
1266
|
+
|
|
1267
|
+
const mid = msg.modelID || (msg.model && (msg.model.modelID || msg.model.id)) || 'unknown';
|
|
1268
|
+
stats.totalCost += c; stats.totalTokens += t;
|
|
1269
|
+
|
|
1270
|
+
if (!stats.byModel[mid]) stats.byModel[mid] = { name: mid, id: mid, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
1271
|
+
stats.byModel[mid].cost += c; stats.byModel[mid].tokens += t; stats.byModel[mid].inputTokens += it; stats.byModel[mid].outputTokens += ot;
|
|
1272
|
+
|
|
1273
|
+
if (!stats.byProject[pid]) stats.byProject[pid] = { name: pmap.get(s)?.name || 'Unassigned', id: pid, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
1274
|
+
stats.byProject[pid].cost += c; stats.byProject[pid].tokens += t; stats.byProject[pid].inputTokens += it; stats.byProject[pid].outputTokens += ot;
|
|
1275
|
+
|
|
1276
|
+
if (!stats.byTime[tk]) stats.byTime[tk] = { date: tk, name: tk, id: tk, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
1277
|
+
const te = stats.byTime[tk];
|
|
1278
|
+
te.cost += c; te.tokens += t; te.inputTokens += it; te.outputTokens += ot;
|
|
1279
|
+
if (!te[mid]) te[mid] = 0;
|
|
1280
|
+
te[mid] += c;
|
|
1281
|
+
|
|
1282
|
+
const kIn = `${mid}_input`, kOut = `${mid}_output`;
|
|
1283
|
+
te[kIn] = (te[kIn] || 0) + it;
|
|
1284
|
+
te[kOut] = (te[kOut] || 0) + ot;
|
|
1285
|
+
}
|
|
1286
|
+
} catch {}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
} catch {}
|
|
1290
|
+
}));
|
|
790
1291
|
|
|
791
1292
|
res.json({
|
|
792
1293
|
totalCost: stats.totalCost,
|
|
@@ -796,7 +1297,8 @@ app.get('/api/usage', async (req, res) => {
|
|
|
796
1297
|
byProject: Object.values(stats.byProject).sort((a, b) => b.cost - a.cost)
|
|
797
1298
|
});
|
|
798
1299
|
} catch (error) {
|
|
799
|
-
|
|
1300
|
+
console.error('Usage API error:', error);
|
|
1301
|
+
res.status(500).json({ error: 'Failed to fetch usage statistics' });
|
|
800
1302
|
}
|
|
801
1303
|
});
|
|
802
1304
|
|
|
@@ -809,11 +1311,14 @@ app.post('/api/auth/google/plugin', (req, res) => {
|
|
|
809
1311
|
try {
|
|
810
1312
|
const opencode = loadConfig();
|
|
811
1313
|
if (opencode) {
|
|
812
|
-
if (opencode.provider
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1314
|
+
if (!opencode.provider) opencode.provider = {};
|
|
1315
|
+
if (!opencode.provider.google) {
|
|
1316
|
+
opencode.provider.google = { models: {} };
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const models = studio.pluginModels[plugin];
|
|
1320
|
+
if (models) {
|
|
1321
|
+
opencode.provider.google.models = models;
|
|
817
1322
|
}
|
|
818
1323
|
|
|
819
1324
|
if (!opencode.plugin) opencode.plugin = [];
|
|
@@ -844,7 +1349,7 @@ app.post('/api/auth/google/plugin', (req, res) => {
|
|
|
844
1349
|
} else if (plugin === 'gemini' && authCfg['google.gemini']) {
|
|
845
1350
|
authCfg.google = { ...authCfg['google.gemini'] };
|
|
846
1351
|
}
|
|
847
|
-
|
|
1352
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
848
1353
|
}
|
|
849
1354
|
}
|
|
850
1355
|
} catch (err) {
|
|
@@ -859,6 +1364,202 @@ app.get('/api/auth/google/plugin', (req, res) => {
|
|
|
859
1364
|
res.json({ activePlugin: studio.activeGooglePlugin || null });
|
|
860
1365
|
});
|
|
861
1366
|
|
|
1367
|
+
const GEMINI_CLIENT_ID = process.env.GEMINI_CLIENT_ID || "";
|
|
1368
|
+
const GEMINI_CLIENT_SECRET = process.env.GEMINI_CLIENT_SECRET || "";
|
|
1369
|
+
const GEMINI_SCOPES = [
|
|
1370
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
1371
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
1372
|
+
"https://www.googleapis.com/auth/userinfo.profile"
|
|
1373
|
+
];
|
|
1374
|
+
const OAUTH_CALLBACK_PORT = 8085;
|
|
1375
|
+
const GEMINI_REDIRECT_URI = `http://localhost:${OAUTH_CALLBACK_PORT}/oauth2callback`;
|
|
1376
|
+
|
|
1377
|
+
let pendingOAuthState = null;
|
|
1378
|
+
let oauthCallbackServer = null;
|
|
1379
|
+
|
|
1380
|
+
function generatePKCE() {
|
|
1381
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
1382
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
1383
|
+
return { verifier, challenge };
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function encodeOAuthState(payload) {
|
|
1387
|
+
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
app.post('/api/auth/google/start', async (req, res) => {
|
|
1391
|
+
if (oauthCallbackServer) {
|
|
1392
|
+
return res.status(400).json({ error: 'OAuth flow already in progress' });
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const { verifier, challenge } = generatePKCE();
|
|
1396
|
+
const state = encodeOAuthState({ verifier });
|
|
1397
|
+
|
|
1398
|
+
pendingOAuthState = { verifier, status: 'pending', startedAt: Date.now() };
|
|
1399
|
+
|
|
1400
|
+
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
1401
|
+
authUrl.searchParams.set('client_id', GEMINI_CLIENT_ID);
|
|
1402
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
1403
|
+
authUrl.searchParams.set('redirect_uri', GEMINI_REDIRECT_URI);
|
|
1404
|
+
authUrl.searchParams.set('scope', GEMINI_SCOPES.join(' '));
|
|
1405
|
+
authUrl.searchParams.set('code_challenge', challenge);
|
|
1406
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
1407
|
+
authUrl.searchParams.set('state', state);
|
|
1408
|
+
authUrl.searchParams.set('access_type', 'offline');
|
|
1409
|
+
authUrl.searchParams.set('prompt', 'consent');
|
|
1410
|
+
|
|
1411
|
+
const callbackApp = express();
|
|
1412
|
+
|
|
1413
|
+
callbackApp.get('/oauth2callback', async (callbackReq, callbackRes) => {
|
|
1414
|
+
const { code, state: returnedState, error } = callbackReq.query;
|
|
1415
|
+
|
|
1416
|
+
if (error) {
|
|
1417
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error };
|
|
1418
|
+
callbackRes.send('<html><body><h2>Login Failed</h2><p>Error: ' + error + '</p><script>window.close()</script></body></html>');
|
|
1419
|
+
shutdownCallbackServer();
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if (!code) {
|
|
1424
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error: 'No authorization code received' };
|
|
1425
|
+
callbackRes.send('<html><body><h2>Login Failed</h2><p>No code received</p><script>window.close()</script></body></html>');
|
|
1426
|
+
shutdownCallbackServer();
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
try {
|
|
1431
|
+
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
|
1432
|
+
method: 'POST',
|
|
1433
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1434
|
+
body: new URLSearchParams({
|
|
1435
|
+
client_id: GEMINI_CLIENT_ID,
|
|
1436
|
+
client_secret: GEMINI_CLIENT_SECRET,
|
|
1437
|
+
code,
|
|
1438
|
+
grant_type: 'authorization_code',
|
|
1439
|
+
redirect_uri: GEMINI_REDIRECT_URI,
|
|
1440
|
+
code_verifier: pendingOAuthState.verifier
|
|
1441
|
+
})
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
if (!tokenResponse.ok) {
|
|
1445
|
+
const errText = await tokenResponse.text();
|
|
1446
|
+
throw new Error(errText);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const tokens = await tokenResponse.json();
|
|
1450
|
+
|
|
1451
|
+
let email = null;
|
|
1452
|
+
try {
|
|
1453
|
+
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', {
|
|
1454
|
+
headers: { 'Authorization': `Bearer ${tokens.access_token}` }
|
|
1455
|
+
});
|
|
1456
|
+
if (userInfoRes.ok) {
|
|
1457
|
+
const userInfo = await userInfoRes.json();
|
|
1458
|
+
email = userInfo.email;
|
|
1459
|
+
}
|
|
1460
|
+
} catch {}
|
|
1461
|
+
|
|
1462
|
+
const cp = getConfigPath();
|
|
1463
|
+
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
1464
|
+
const authCfg = fs.existsSync(ap) ? JSON.parse(fs.readFileSync(ap, 'utf8')) : {};
|
|
1465
|
+
|
|
1466
|
+
const studio = loadStudioConfig();
|
|
1467
|
+
const activePlugin = studio.activeGooglePlugin || 'gemini';
|
|
1468
|
+
const namespace = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
|
|
1469
|
+
|
|
1470
|
+
const credentials = {
|
|
1471
|
+
refresh_token: tokens.refresh_token,
|
|
1472
|
+
access_token: tokens.access_token,
|
|
1473
|
+
expiry: Date.now() + (tokens.expires_in * 1000),
|
|
1474
|
+
email
|
|
1475
|
+
};
|
|
1476
|
+
|
|
1477
|
+
authCfg.google = credentials;
|
|
1478
|
+
authCfg[namespace] = credentials;
|
|
1479
|
+
|
|
1480
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
1481
|
+
|
|
1482
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'success', email };
|
|
1483
|
+
|
|
1484
|
+
callbackRes.send(`
|
|
1485
|
+
<html>
|
|
1486
|
+
<head><style>body{font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f0fdf4}
|
|
1487
|
+
.card{background:white;padding:2rem;border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);text-align:center}
|
|
1488
|
+
h2{color:#16a34a;margin:0 0 0.5rem}</style></head>
|
|
1489
|
+
<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>
|
|
1490
|
+
<script>setTimeout(()=>window.close(),2000)</script></body></html>
|
|
1491
|
+
`);
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error: err.message };
|
|
1494
|
+
callbackRes.send('<html><body><h2>Login Failed</h2><p>' + err.message + '</p><script>window.close()</script></body></html>');
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
shutdownCallbackServer();
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
function shutdownCallbackServer() {
|
|
1501
|
+
if (oauthCallbackServer) {
|
|
1502
|
+
oauthCallbackServer.close();
|
|
1503
|
+
oauthCallbackServer = null;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
try {
|
|
1508
|
+
oauthCallbackServer = callbackApp.listen(OAUTH_CALLBACK_PORT, () => {
|
|
1509
|
+
console.log(`OAuth callback server listening on port ${OAUTH_CALLBACK_PORT}`);
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
oauthCallbackServer.on('error', (err) => {
|
|
1513
|
+
console.error('Failed to start OAuth callback server:', err);
|
|
1514
|
+
pendingOAuthState = { status: 'error', error: `Port ${OAUTH_CALLBACK_PORT} in use` };
|
|
1515
|
+
oauthCallbackServer = null;
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
setTimeout(() => {
|
|
1519
|
+
if (oauthCallbackServer && pendingOAuthState?.status === 'pending') {
|
|
1520
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error: 'OAuth timeout (2 minutes)' };
|
|
1521
|
+
shutdownCallbackServer();
|
|
1522
|
+
}
|
|
1523
|
+
}, 120000);
|
|
1524
|
+
|
|
1525
|
+
const platform = process.platform;
|
|
1526
|
+
let openCmd;
|
|
1527
|
+
if (platform === 'win32') {
|
|
1528
|
+
openCmd = `start "" "${authUrl.toString()}"`;
|
|
1529
|
+
} else if (platform === 'darwin') {
|
|
1530
|
+
openCmd = `open "${authUrl.toString()}"`;
|
|
1531
|
+
} else {
|
|
1532
|
+
openCmd = `xdg-open "${authUrl.toString()}"`;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
exec(openCmd, (err) => {
|
|
1536
|
+
if (err) console.error('Failed to open browser:', err);
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
res.json({ success: true, authUrl: authUrl.toString(), message: 'Browser opened for Google login' });
|
|
1540
|
+
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
pendingOAuthState = null;
|
|
1543
|
+
res.status(500).json({ error: err.message });
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
app.get('/api/auth/google/status', (req, res) => {
|
|
1548
|
+
if (!pendingOAuthState) {
|
|
1549
|
+
return res.json({ status: 'idle' });
|
|
1550
|
+
}
|
|
1551
|
+
res.json(pendingOAuthState);
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
app.post('/api/auth/google/cancel', (req, res) => {
|
|
1555
|
+
if (oauthCallbackServer) {
|
|
1556
|
+
oauthCallbackServer.close();
|
|
1557
|
+
oauthCallbackServer = null;
|
|
1558
|
+
}
|
|
1559
|
+
pendingOAuthState = null;
|
|
1560
|
+
res.json({ success: true });
|
|
1561
|
+
});
|
|
1562
|
+
|
|
862
1563
|
app.get('/api/pending-action', (req, res) => {
|
|
863
1564
|
if (pendingActionMemory) return res.json({ action: pendingActionMemory });
|
|
864
1565
|
if (fs.existsSync(PENDING_ACTION_PATH)) {
|
|
@@ -908,4 +1609,113 @@ app.post('/api/plugins/config/add', (req, res) => {
|
|
|
908
1609
|
res.json({ added, skipped });
|
|
909
1610
|
});
|
|
910
1611
|
|
|
1612
|
+
// Presets
|
|
1613
|
+
app.get('/api/presets', (req, res) => {
|
|
1614
|
+
const studio = loadStudioConfig();
|
|
1615
|
+
res.json(studio.presets || []);
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
app.post('/api/presets', (req, res) => {
|
|
1619
|
+
const { name, description, config } = req.body;
|
|
1620
|
+
const studio = loadStudioConfig();
|
|
1621
|
+
const id = crypto.randomUUID();
|
|
1622
|
+
const preset = { id, name, description, config };
|
|
1623
|
+
studio.presets = studio.presets || [];
|
|
1624
|
+
studio.presets.push(preset);
|
|
1625
|
+
saveStudioConfig(studio);
|
|
1626
|
+
res.json(preset);
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
app.put('/api/presets/:id', (req, res) => {
|
|
1630
|
+
const { id } = req.params;
|
|
1631
|
+
const { name, description, config } = req.body;
|
|
1632
|
+
const studio = loadStudioConfig();
|
|
1633
|
+
const index = (studio.presets || []).findIndex(p => p.id === id);
|
|
1634
|
+
if (index === -1) return res.status(404).json({ error: 'Preset not found' });
|
|
1635
|
+
|
|
1636
|
+
studio.presets[index] = { ...studio.presets[index], name, description, config };
|
|
1637
|
+
saveStudioConfig(studio);
|
|
1638
|
+
res.json(studio.presets[index]);
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
app.delete('/api/presets/:id', (req, res) => {
|
|
1642
|
+
const { id } = req.params;
|
|
1643
|
+
const studio = loadStudioConfig();
|
|
1644
|
+
studio.presets = (studio.presets || []).filter(p => p.id !== id);
|
|
1645
|
+
saveStudioConfig(studio);
|
|
1646
|
+
res.json({ success: true });
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
app.post('/api/presets/:id/apply', (req, res) => {
|
|
1650
|
+
const { id } = req.params;
|
|
1651
|
+
const { mode } = req.body; // 'exclusive', 'additive'
|
|
1652
|
+
|
|
1653
|
+
const studio = loadStudioConfig();
|
|
1654
|
+
const preset = (studio.presets || []).find(p => p.id === id);
|
|
1655
|
+
if (!preset) return res.status(404).json({ error: 'Preset not found' });
|
|
1656
|
+
|
|
1657
|
+
const config = loadConfig() || {};
|
|
1658
|
+
const cp = getConfigPath();
|
|
1659
|
+
const configDir = path.dirname(cp);
|
|
1660
|
+
const skillDir = path.join(configDir, 'skill');
|
|
1661
|
+
const pluginDir = path.join(configDir, 'plugin');
|
|
1662
|
+
|
|
1663
|
+
// Skills
|
|
1664
|
+
if (preset.config.skills !== undefined && preset.config.skills !== null) {
|
|
1665
|
+
const targetSkills = new Set(preset.config.skills);
|
|
1666
|
+
if (mode === 'exclusive') {
|
|
1667
|
+
const allSkills = [];
|
|
1668
|
+
if (fs.existsSync(skillDir)) {
|
|
1669
|
+
const dirents = fs.readdirSync(skillDir, { withFileTypes: true });
|
|
1670
|
+
for (const dirent of dirents) {
|
|
1671
|
+
if (dirent.isDirectory()) {
|
|
1672
|
+
if (fs.existsSync(path.join(skillDir, dirent.name, 'SKILL.md'))) {
|
|
1673
|
+
allSkills.push(dirent.name);
|
|
1674
|
+
}
|
|
1675
|
+
} else if (dirent.name.endsWith('.md')) {
|
|
1676
|
+
allSkills.push(dirent.name.replace('.md', ''));
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
studio.disabledSkills = allSkills.filter(s => !targetSkills.has(s));
|
|
1681
|
+
} else { // additive
|
|
1682
|
+
studio.disabledSkills = (studio.disabledSkills || []).filter(s => !targetSkills.has(s));
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Plugins
|
|
1687
|
+
if (preset.config.plugins !== undefined && preset.config.plugins !== null) {
|
|
1688
|
+
const targetPlugins = new Set(preset.config.plugins);
|
|
1689
|
+
if (mode === 'exclusive') {
|
|
1690
|
+
const allPlugins = [...(config.plugin || [])];
|
|
1691
|
+
if (fs.existsSync(pluginDir)) {
|
|
1692
|
+
const files = fs.readdirSync(pluginDir).filter(f => f.endsWith('.js') || f.endsWith('.ts'));
|
|
1693
|
+
allPlugins.push(...files.map(f => f.replace(/\.[^/.]+$/, "")));
|
|
1694
|
+
}
|
|
1695
|
+
const uniquePlugins = [...new Set(allPlugins)];
|
|
1696
|
+
studio.disabledPlugins = uniquePlugins.filter(p => !targetPlugins.has(p));
|
|
1697
|
+
} else { // additive
|
|
1698
|
+
studio.disabledPlugins = (studio.disabledPlugins || []).filter(p => !targetPlugins.has(p));
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// MCPs
|
|
1703
|
+
if (preset.config.mcps !== undefined && preset.config.mcps !== null) {
|
|
1704
|
+
const targetMcps = new Set(preset.config.mcps);
|
|
1705
|
+
if (config.mcp) {
|
|
1706
|
+
for (const key in config.mcp) {
|
|
1707
|
+
if (mode === 'exclusive') {
|
|
1708
|
+
config.mcp[key].enabled = targetMcps.has(key);
|
|
1709
|
+
} else { // additive
|
|
1710
|
+
if (targetMcps.has(key)) config.mcp[key].enabled = true;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
saveStudioConfig(studio);
|
|
1717
|
+
saveConfig(config);
|
|
1718
|
+
res.json({ success: true });
|
|
1719
|
+
});
|
|
1720
|
+
|
|
911
1721
|
app.listen(PORT, () => console.log(`Server running at http://localhost:${PORT}`));
|