opencode-studio-server 1.2.0 → 1.2.2

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