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 ADDED
@@ -0,0 +1,2 @@
1
+ GEMINI_CLIENT_ID=681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com
2
+ GEMINI_CLIENT_SECRET=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
package/.env.example ADDED
@@ -0,0 +1,2 @@
1
+ GEMINI_CLIENT_ID=your_client_id_here
2
+ GEMINI_CLIENT_SECRET=your_client_secret_here
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
- // Queue MCP server installation
44
- if (params.cmd || params.name) {
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 || 'MCP Server',
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
- const dir = path.dirname(STUDIO_CONFIG_PATH);
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
- const dir = path.dirname(configPath);
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
- fs.writeFileSync(profilePath, JSON.stringify(auth[provider], null, 2), 'utf8');
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
- fs.writeFileSync(ap, JSON.stringify(authCfg, null, 2), 'utf8');
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
- let terminalCmd;
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
- terminalCmd = `x-terminal-emulator -e "${cmd}"`;
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
- fs.writeFileSync(ap, JSON.stringify(authCfg, null, 2), 'utf8');
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.readdirSync(sd).forEach(d => {
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
- if (fs.statSync(fp).isDirectory()) {
723
- fs.readdirSync(fp).forEach(f => {
724
- if (f.startsWith('ses_') && f.endsWith('.json')) {
725
- try {
726
- const m = JSON.parse(fs.readFileSync(path.join(fp, f), 'utf8'));
727
- pmap.set(f.replace('.json', ''), { name: m.directory ? path.basename(m.directory) : (m.projectID ? m.projectID.substring(0, 8) : 'Unknown'), id: m.projectID || d });
728
- } catch {}
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.readdirSync(md).forEach(s => {
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
- if (fs.statSync(sp).isDirectory()) {
748
- fs.readdirSync(sp).forEach(f => {
749
- if (!f.endsWith('.json') || seen.has(path.join(sp, f))) return;
750
- seen.add(path.join(sp, f));
751
- try {
752
- const msg = JSON.parse(fs.readFileSync(path.join(sp, f), 'utf8'));
753
- const pid = pmap.get(s)?.id || 'unknown';
754
- if (fid && fid !== 'all' && pid !== fid) return;
755
- if (min > 0 && msg.time.created < min) return;
756
- if (msg.role === 'assistant' && msg.tokens) {
757
- const c = msg.cost || 0, it = msg.tokens.input || 0, ot = msg.tokens.output || 0, t = it + ot;
758
- const d = new Date(msg.time.created);
759
- let tk;
760
- if (granularity === 'hourly') tk = d.toISOString().substring(0, 13) + ':00:00Z';
761
- else if (granularity === 'weekly') {
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
- const kIn = `${mid}_input`, kOut = `${mid}_output`;
783
- te[kIn] = (te[kIn] || 0) + it;
784
- te[kOut] = (te[kOut] || 0) + ot;
785
- }
786
- } catch {}
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
- res.status(500).json({ error: 'Failed' });
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?.google) {
813
- const models = studio.pluginModels[plugin];
814
- if (models) {
815
- opencode.provider.google.models = models;
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
- fs.writeFileSync(ap, JSON.stringify(authCfg, null, 2), 'utf8');
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}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-studio-server",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Backend server for OpenCode Studio - manages opencode configurations",
5
5
  "main": "index.js",
6
6
  "bin": {