opencode-studio-server 1.7.0 → 1.9.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.
Files changed (2) hide show
  1. package/index.js +474 -180
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -204,17 +204,59 @@ function processLogLine(line) {
204
204
 
205
205
  if (isQuotaError) {
206
206
  console.log(`[LogWatcher] Detected quota exhaustion for ${namespace}`);
207
- // Mark exhausted for today
208
- metadata._quota[namespace].exhausted = true;
209
- metadata._quota[namespace].exhaustedDate = today;
210
207
 
211
- // Adaptive limit learning: if we hit a limit, maybe that's the ceiling?
212
- // Only update if we have meaningful usage (>5) to avoid false positives on glitches
213
- const currentUsage = metadata._quota[namespace][today] || 0;
208
+ // Reload metadata to ensure freshness
209
+ const currentMeta = loadPoolMetadata();
210
+ if (!currentMeta._quota) currentMeta._quota = {};
211
+ if (!currentMeta._quota[namespace]) currentMeta._quota[namespace] = {};
212
+
213
+ // Debounce check
214
+ const lastRotation = currentMeta._quota[namespace].lastRotation || 0;
215
+ if (Date.now() - lastRotation < 10000) {
216
+ console.log(`[LogWatcher] Ignoring 429 (rotation debounce active)`);
217
+ return;
218
+ }
219
+
220
+ const studio = loadStudioConfig();
221
+ const activeAccount = studio.activeProfiles?.[provider];
222
+
223
+ let rotated = false;
224
+
225
+ if (activeAccount) {
226
+ console.log(`[LogWatcher] Auto-rotating due to rate limit on ${activeAccount}`);
227
+
228
+ if (!currentMeta[namespace]) currentMeta[namespace] = {};
229
+ if (!currentMeta[namespace][activeAccount]) currentMeta[namespace][activeAccount] = {};
230
+
231
+ // Mark cooldown (1 hour)
232
+ currentMeta[namespace][activeAccount].cooldownUntil = Date.now() + 3600000;
233
+ currentMeta[namespace][activeAccount].lastCooldownReason = 'auto_429';
234
+
235
+ savePoolMetadata(currentMeta);
236
+
237
+ // Attempt rotation
238
+ const result = rotateAccount(provider, 'auto_rotation_429');
239
+ if (result.success) {
240
+ console.log(`[LogWatcher] Successfully rotated to ${result.newAccount}`);
241
+ rotated = true;
242
+ } else {
243
+ console.log(`[LogWatcher] Auto-rotation failed: ${result.error}`);
244
+ }
245
+ }
246
+
247
+ if (rotated) return;
248
+
249
+ // Fallback: Mark namespace exhausted
250
+ currentMeta._quota[namespace].exhausted = true;
251
+ currentMeta._quota[namespace].exhaustedDate = today;
252
+
253
+ const currentUsage = currentMeta._quota[namespace][today] || 0;
214
254
  if (currentUsage > 5) {
215
- // Update daily limit to current usage (maybe round up)
216
- metadata._quota[namespace].dailyLimit = currentUsage;
255
+ currentMeta._quota[namespace].dailyLimit = currentUsage;
217
256
  }
257
+
258
+ savePoolMetadata(currentMeta);
259
+ return;
218
260
  }
219
261
  }
220
262
 
@@ -590,91 +632,373 @@ app.post('/api/restore', (req, res) => {
590
632
  }
591
633
  });
592
634
 
593
- app.post('/api/sync/push', (req, res) => {
635
+ const DROPBOX_CLIENT_ID = 'your-dropbox-app-key';
636
+ const GDRIVE_CLIENT_ID = 'your-google-client-id';
637
+
638
+ function buildBackupData() {
639
+ const studio = loadStudioConfig();
640
+ const opencodeConfig = loadConfig();
641
+ const skills = [];
642
+ const plugins = [];
643
+
644
+ const sd = getSkillDir();
645
+ if (sd && fs.existsSync(sd)) {
646
+ fs.readdirSync(sd, { withFileTypes: true })
647
+ .filter(e => e.isDirectory() && fs.existsSync(path.join(sd, e.name, 'SKILL.md')))
648
+ .forEach(e => {
649
+ skills.push({ name: e.name, content: fs.readFileSync(path.join(sd, e.name, 'SKILL.md'), 'utf8') });
650
+ });
651
+ }
652
+
653
+ const pd = getPluginDir();
654
+ if (pd && fs.existsSync(pd)) {
655
+ fs.readdirSync(pd, { withFileTypes: true }).forEach(e => {
656
+ if (e.isFile() && /\.(js|ts)$/.test(e.name)) {
657
+ plugins.push({ name: e.name.replace(/\.(js|ts)$/, ''), content: fs.readFileSync(path.join(pd, e.name), 'utf8') });
658
+ }
659
+ });
660
+ }
661
+
662
+ const cloudSettings = studio.cloudProvider ? { provider: studio.cloudProvider } : {};
663
+
664
+ return {
665
+ version: 1,
666
+ timestamp: new Date().toISOString(),
667
+ studioConfig: { ...studio, cloudToken: undefined, cloudProvider: undefined },
668
+ opencodeConfig,
669
+ skills,
670
+ plugins
671
+ };
672
+ }
673
+
674
+ function restoreFromBackup(backup, studio) {
675
+ if (backup.studioConfig) {
676
+ const merged = {
677
+ ...backup.studioConfig,
678
+ cloudProvider: studio.cloudProvider,
679
+ cloudToken: studio.cloudToken,
680
+ autoSync: studio.autoSync,
681
+ lastSyncAt: studio.lastSyncAt
682
+ };
683
+ saveStudioConfig(merged);
684
+ }
685
+ if (backup.opencodeConfig) saveConfig(backup.opencodeConfig);
686
+
687
+ const sd = getSkillDir();
688
+ if (sd && backup.skills && Array.isArray(backup.skills)) {
689
+ if (!fs.existsSync(sd)) fs.mkdirSync(sd, { recursive: true });
690
+ backup.skills.forEach(s => {
691
+ const skillDir = path.join(sd, s.name);
692
+ if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
693
+ atomicWriteFileSync(path.join(skillDir, 'SKILL.md'), s.content);
694
+ });
695
+ }
696
+
697
+ const pd = getPluginDir();
698
+ if (pd && backup.plugins && Array.isArray(backup.plugins)) {
699
+ if (!fs.existsSync(pd)) fs.mkdirSync(pd, { recursive: true });
700
+ backup.plugins.forEach(p => {
701
+ atomicWriteFileSync(path.join(pd, `${p.name}.js`), p.content);
702
+ });
703
+ }
704
+ }
705
+
706
+ app.get('/api/sync/status', (req, res) => {
707
+ const studio = loadStudioConfig();
708
+ res.json({
709
+ provider: studio.cloudProvider || null,
710
+ connected: !!(studio.cloudProvider && studio.cloudToken),
711
+ lastSync: studio.lastSyncAt || null,
712
+ autoSync: !!studio.autoSync
713
+ });
714
+ });
715
+
716
+ app.post('/api/sync/config', (req, res) => {
717
+ const { autoSync } = req.body;
718
+ const studio = loadStudioConfig();
719
+ if (autoSync !== undefined) studio.autoSync = !!autoSync;
720
+ saveStudioConfig(studio);
721
+ res.json({ success: true, autoSync: !!studio.autoSync });
722
+ });
723
+
724
+ app.post('/api/sync/disconnect', (req, res) => {
725
+ const studio = loadStudioConfig();
726
+ delete studio.cloudProvider;
727
+ delete studio.cloudToken;
728
+ delete studio.cloudRefreshToken;
729
+ saveStudioConfig(studio);
730
+ res.json({ success: true });
731
+ });
732
+
733
+ app.get('/api/sync/dropbox/auth-url', (req, res) => {
734
+ const state = crypto.randomBytes(16).toString('hex');
735
+ const studio = loadStudioConfig();
736
+ studio.oauthState = state;
737
+ saveStudioConfig(studio);
738
+
739
+ const redirectUri = req.query.redirect_uri || 'http://localhost:3000/settings';
740
+ studio.oauthRedirectUri = redirectUri;
741
+ saveStudioConfig(studio);
742
+
743
+ const params = new URLSearchParams({
744
+ client_id: DROPBOX_CLIENT_ID,
745
+ response_type: 'code',
746
+ token_access_type: 'offline',
747
+ redirect_uri: redirectUri,
748
+ state: state
749
+ });
750
+ res.json({ url: `https://www.dropbox.com/oauth2/authorize?${params}` });
751
+ });
752
+
753
+ app.post('/api/sync/dropbox/callback', async (req, res) => {
594
754
  try {
755
+ const { code, state } = req.body;
595
756
  const studio = loadStudioConfig();
596
- const syncFolder = studio.syncFolder;
597
- if (!syncFolder) return res.status(400).json({ error: 'Sync folder not configured' });
598
- if (!fs.existsSync(syncFolder)) return res.status(400).json({ error: 'Sync folder does not exist' });
599
757
 
600
- const opencodeConfig = loadConfig();
601
- const skills = [];
602
- const plugins = [];
758
+ if (state !== studio.oauthState) {
759
+ return res.status(400).json({ error: 'Invalid state' });
760
+ }
761
+ const redirectUri = studio.oauthRedirectUri || 'http://localhost:3000/settings';
762
+ delete studio.oauthState;
763
+ delete studio.oauthRedirectUri;
603
764
 
604
- const sd = getSkillDir();
605
- if (sd && fs.existsSync(sd)) {
606
- fs.readdirSync(sd, { withFileTypes: true })
607
- .filter(e => e.isDirectory() && fs.existsSync(path.join(sd, e.name, 'SKILL.md')))
608
- .forEach(e => {
609
- skills.push({ name: e.name, content: fs.readFileSync(path.join(sd, e.name, 'SKILL.md'), 'utf8') });
610
- });
765
+ const response = await fetch('https://api.dropboxapi.com/oauth2/token', {
766
+ method: 'POST',
767
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
768
+ body: new URLSearchParams({
769
+ code,
770
+ grant_type: 'authorization_code',
771
+ client_id: DROPBOX_CLIENT_ID,
772
+ redirect_uri: redirectUri
773
+ })
774
+ });
775
+
776
+ if (!response.ok) {
777
+ const err = await response.text();
778
+ return res.status(400).json({ error: `Dropbox auth failed: ${err}` });
611
779
  }
612
780
 
613
- const pd = getPluginDir();
614
- if (pd && fs.existsSync(pd)) {
615
- fs.readdirSync(pd, { withFileTypes: true }).forEach(e => {
616
- if (e.isFile() && /\.(js|ts)$/.test(e.name)) {
617
- plugins.push({ name: e.name.replace(/\.(js|ts)$/, ''), content: fs.readFileSync(path.join(pd, e.name), 'utf8') });
618
- }
619
- });
781
+ const tokens = await response.json();
782
+ studio.cloudProvider = 'dropbox';
783
+ studio.cloudToken = tokens.access_token;
784
+ if (tokens.refresh_token) studio.cloudRefreshToken = tokens.refresh_token;
785
+ saveStudioConfig(studio);
786
+
787
+ res.json({ success: true, provider: 'dropbox' });
788
+ } catch (err) {
789
+ res.status(500).json({ error: err.message });
790
+ }
791
+ });
792
+
793
+ app.get('/api/sync/gdrive/auth-url', (req, res) => {
794
+ const state = crypto.randomBytes(16).toString('hex');
795
+ const studio = loadStudioConfig();
796
+ studio.oauthState = state;
797
+
798
+ const redirectUri = req.query.redirect_uri || 'http://localhost:3000/settings';
799
+ studio.oauthRedirectUri = redirectUri;
800
+ saveStudioConfig(studio);
801
+
802
+ const params = new URLSearchParams({
803
+ client_id: GDRIVE_CLIENT_ID,
804
+ response_type: 'code',
805
+ scope: 'https://www.googleapis.com/auth/drive.file',
806
+ access_type: 'offline',
807
+ prompt: 'consent',
808
+ redirect_uri: redirectUri,
809
+ state: state
810
+ });
811
+ res.json({ url: `https://accounts.google.com/o/oauth2/v2/auth?${params}` });
812
+ });
813
+
814
+ app.post('/api/sync/gdrive/callback', async (req, res) => {
815
+ try {
816
+ const { code, state } = req.body;
817
+ const studio = loadStudioConfig();
818
+
819
+ if (state !== studio.oauthState) {
820
+ return res.status(400).json({ error: 'Invalid state' });
620
821
  }
822
+ const redirectUri = studio.oauthRedirectUri || 'http://localhost:3000/settings';
823
+ delete studio.oauthState;
824
+ delete studio.oauthRedirectUri;
621
825
 
622
- const backup = {
623
- version: 1,
624
- timestamp: new Date().toISOString(),
625
- studioConfig: { ...studio, syncFolder: undefined },
626
- opencodeConfig,
627
- skills,
628
- plugins
629
- };
826
+ const response = await fetch('https://oauth2.googleapis.com/token', {
827
+ method: 'POST',
828
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
829
+ body: new URLSearchParams({
830
+ code,
831
+ client_id: GDRIVE_CLIENT_ID,
832
+ // client_secret: 'GDRIVE_CLIENT_SECRET', // NOTE: OAuth flow for installed apps usually requires client secret.
833
+ // For simplicity in this demo, we assume PKCE or a public client flow if supported,
834
+ // OR the user will need to provide the secret in environment variables.
835
+ // Since this is a local server, we might need the secret.
836
+ // Let's assume for now we'll put a placeholder or rely on env vars.
837
+ // Google "Installed App" flow doesn't always need secret if type is "Desktop".
838
+ // However, for web flow it does.
839
+ // Let's assume we need a client secret env var for now.
840
+ client_secret: process.env.GDRIVE_CLIENT_SECRET || 'your-google-client-secret',
841
+ redirect_uri: redirectUri,
842
+ grant_type: 'authorization_code'
843
+ })
844
+ });
630
845
 
631
- const backupPath = path.join(syncFolder, 'opencode-studio-sync.json');
632
- atomicWriteFileSync(backupPath, JSON.stringify(backup, null, 2));
846
+ if (!response.ok) {
847
+ const err = await response.text();
848
+ return res.status(400).json({ error: `Google auth failed: ${err}` });
849
+ }
633
850
 
634
- studio.lastSyncAt = backup.timestamp;
851
+ const tokens = await response.json();
852
+ studio.cloudProvider = 'gdrive';
853
+ studio.cloudToken = tokens.access_token;
854
+ if (tokens.refresh_token) studio.cloudRefreshToken = tokens.refresh_token;
635
855
  saveStudioConfig(studio);
636
856
 
637
- res.json({ success: true, path: backupPath, timestamp: backup.timestamp });
857
+ res.json({ success: true, provider: 'gdrive' });
638
858
  } catch (err) {
639
859
  res.status(500).json({ error: err.message });
640
860
  }
641
861
  });
642
862
 
643
- app.post('/api/sync/pull', (req, res) => {
863
+ app.post('/api/sync/push', async (req, res) => {
644
864
  try {
645
865
  const studio = loadStudioConfig();
646
- const syncFolder = studio.syncFolder;
647
- if (!syncFolder) return res.status(400).json({ error: 'Sync folder not configured' });
648
-
649
- const backupPath = path.join(syncFolder, 'opencode-studio-sync.json');
650
- if (!fs.existsSync(backupPath)) return res.status(404).json({ error: 'No sync file found in folder' });
866
+ if (!studio.cloudProvider || !studio.cloudToken) {
867
+ return res.status(400).json({ error: 'No cloud provider connected' });
868
+ }
651
869
 
652
- const backup = JSON.parse(fs.readFileSync(backupPath, 'utf8'));
870
+ const backup = buildBackupData();
871
+ const content = JSON.stringify(backup, null, 2);
653
872
 
654
- if (backup.studioConfig) {
655
- const merged = { ...backup.studioConfig, syncFolder: studio.syncFolder };
656
- saveStudioConfig(merged);
873
+ if (studio.cloudProvider === 'dropbox') {
874
+ const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
875
+ method: 'POST',
876
+ headers: {
877
+ 'Authorization': `Bearer ${studio.cloudToken}`,
878
+ 'Content-Type': 'application/octet-stream',
879
+ 'Dropbox-API-Arg': JSON.stringify({
880
+ path: '/opencode-studio-sync.json',
881
+ mode: 'overwrite',
882
+ autorename: false
883
+ })
884
+ },
885
+ body: content
886
+ });
887
+
888
+ if (!response.ok) {
889
+ const err = await response.text();
890
+ return res.status(400).json({ error: `Dropbox upload failed: ${err}` });
891
+ }
892
+ } else if (studio.cloudProvider === 'gdrive') {
893
+ // Find existing file
894
+ const listRes = await fetch('https://www.googleapis.com/drive/v3/files?q=name=\'opencode-studio-sync.json\' and trashed=false', {
895
+ headers: { 'Authorization': `Bearer ${studio.cloudToken}` }
896
+ });
897
+ const listData = await listRes.json();
898
+ const existingFile = listData.files?.[0];
899
+
900
+ if (existingFile) {
901
+ // Update content
902
+ const updateRes = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingFile.id}?uploadType=media`, {
903
+ method: 'PATCH',
904
+ headers: {
905
+ 'Authorization': `Bearer ${studio.cloudToken}`,
906
+ 'Content-Type': 'application/json'
907
+ },
908
+ body: content
909
+ });
910
+ if (!updateRes.ok) {
911
+ const err = await updateRes.text();
912
+ return res.status(400).json({ error: `Google Drive update failed: ${err}` });
913
+ }
914
+ } else {
915
+ // Create new file (multipart)
916
+ const metadata = { name: 'opencode-studio-sync.json', mimeType: 'application/json' };
917
+ const boundary = '-------314159265358979323846';
918
+ const delimiter = "\r\n--" + boundary + "\r\n";
919
+ const close_delim = "\r\n--" + boundary + "--";
920
+
921
+ const multipartBody =
922
+ delimiter +
923
+ 'Content-Type: application/json\r\n\r\n' +
924
+ JSON.stringify(metadata) +
925
+ delimiter +
926
+ 'Content-Type: application/json\r\n\r\n' +
927
+ content +
928
+ close_delim;
929
+
930
+ const createRes = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', {
931
+ method: 'POST',
932
+ headers: {
933
+ 'Authorization': `Bearer ${studio.cloudToken}`,
934
+ 'Content-Type': `multipart/related; boundary="${boundary}"`
935
+ },
936
+ body: multipartBody
937
+ });
938
+ if (!createRes.ok) {
939
+ const err = await createRes.text();
940
+ return res.status(400).json({ error: `Google Drive create failed: ${err}` });
941
+ }
942
+ }
657
943
  }
658
- if (backup.opencodeConfig) saveConfig(backup.opencodeConfig);
659
944
 
660
- const sd = getSkillDir();
661
- if (sd && backup.skills && Array.isArray(backup.skills)) {
662
- if (!fs.existsSync(sd)) fs.mkdirSync(sd, { recursive: true });
663
- backup.skills.forEach(s => {
664
- const skillDir = path.join(sd, s.name);
665
- if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
666
- atomicWriteFileSync(path.join(skillDir, 'SKILL.md'), s.content);
667
- });
945
+ studio.lastSyncAt = backup.timestamp;
946
+ saveStudioConfig(studio);
947
+ res.json({ success: true, timestamp: backup.timestamp });
948
+ } catch (err) {
949
+ res.status(500).json({ error: err.message });
950
+ }
951
+ });
952
+
953
+ app.post('/api/sync/pull', async (req, res) => {
954
+ try {
955
+ const studio = loadStudioConfig();
956
+ if (!studio.cloudProvider || !studio.cloudToken) {
957
+ return res.status(400).json({ error: 'No cloud provider connected' });
668
958
  }
669
959
 
670
- const pd = getPluginDir();
671
- if (pd && backup.plugins && Array.isArray(backup.plugins)) {
672
- if (!fs.existsSync(pd)) fs.mkdirSync(pd, { recursive: true });
673
- backup.plugins.forEach(p => {
674
- atomicWriteFileSync(path.join(pd, `${p.name}.js`), p.content);
960
+ let content;
961
+
962
+ if (studio.cloudProvider === 'dropbox') {
963
+ const response = await fetch('https://content.dropboxapi.com/2/files/download', {
964
+ method: 'POST',
965
+ headers: {
966
+ 'Authorization': `Bearer ${studio.cloudToken}`,
967
+ 'Dropbox-API-Arg': JSON.stringify({ path: '/opencode-studio-sync.json' })
968
+ }
675
969
  });
970
+
971
+ if (!response.ok) {
972
+ if (response.status === 409) {
973
+ return res.status(404).json({ error: 'No sync file found in cloud' });
974
+ }
975
+ const err = await response.text();
976
+ return res.status(400).json({ error: `Dropbox download failed: ${err}` });
977
+ }
978
+
979
+ content = await response.text();
980
+ } else if (studio.cloudProvider === 'gdrive') {
981
+ const listRes = await fetch('https://www.googleapis.com/drive/v3/files?q=name=\'opencode-studio-sync.json\' and trashed=false', {
982
+ headers: { 'Authorization': `Bearer ${studio.cloudToken}` }
983
+ });
984
+ const listData = await listRes.json();
985
+ const existingFile = listData.files?.[0];
986
+
987
+ if (!existingFile) return res.status(404).json({ error: 'No sync file found in cloud' });
988
+
989
+ const downloadRes = await fetch(`https://www.googleapis.com/drive/v3/files/${existingFile.id}?alt=media`, {
990
+ headers: { 'Authorization': `Bearer ${studio.cloudToken}` }
991
+ });
992
+ if (!downloadRes.ok) {
993
+ const err = await downloadRes.text();
994
+ return res.status(400).json({ error: `Google Drive download failed: ${err}` });
995
+ }
996
+ content = await downloadRes.text();
676
997
  }
677
998
 
999
+ const backup = JSON.parse(content);
1000
+ restoreFromBackup(backup, studio);
1001
+
678
1002
  const updated = loadStudioConfig();
679
1003
  updated.lastSyncAt = new Date().toISOString();
680
1004
  saveStudioConfig(updated);
@@ -685,98 +1009,41 @@ app.post('/api/sync/pull', (req, res) => {
685
1009
  }
686
1010
  });
687
1011
 
688
- app.get('/api/sync/status', (req, res) => {
1012
+ app.post('/api/sync/auto', async (req, res) => {
689
1013
  const studio = loadStudioConfig();
690
- const syncFolder = studio.syncFolder;
691
- let fileExists = false;
692
- let fileTimestamp = null;
693
-
694
- if (syncFolder) {
695
- const backupPath = path.join(syncFolder, 'opencode-studio-sync.json');
696
- if (fs.existsSync(backupPath)) {
697
- fileExists = true;
698
- try {
699
- const backup = JSON.parse(fs.readFileSync(backupPath, 'utf8'));
700
- fileTimestamp = backup.timestamp;
701
- } catch {}
702
- }
703
- }
704
-
705
- res.json({
706
- configured: !!syncFolder,
707
- folder: syncFolder || null,
708
- lastSync: studio.lastSyncAt || null,
709
- autoSync: !!studio.autoSync,
710
- fileExists,
711
- fileTimestamp
712
- });
713
- });
714
-
715
- app.post('/api/sync/config', (req, res) => {
716
- const { folder, autoSync } = req.body;
717
- const studio = loadStudioConfig();
718
-
719
- if (folder !== undefined) {
720
- if (folder) {
721
- if (!fs.existsSync(folder)) {
722
- return res.status(400).json({ error: 'Folder does not exist' });
723
- }
724
- studio.syncFolder = folder;
725
- } else {
726
- delete studio.syncFolder;
727
- }
728
- }
729
-
730
- if (autoSync !== undefined) {
731
- studio.autoSync = !!autoSync;
732
- }
733
-
734
- saveStudioConfig(studio);
735
- res.json({ success: true, folder: studio.syncFolder || null, autoSync: !!studio.autoSync });
736
- });
737
-
738
- app.post('/api/sync/auto', (req, res) => {
739
- const studio = loadStudioConfig();
740
- if (!studio.syncFolder || !studio.autoSync) {
1014
+ if (!studio.cloudProvider || !studio.cloudToken || !studio.autoSync) {
741
1015
  return res.json({ action: 'none', reason: 'auto-sync not configured' });
742
1016
  }
743
1017
 
744
- const syncFolder = studio.syncFolder;
745
- const backupPath = path.join(syncFolder, 'opencode-studio-sync.json');
746
-
747
- if (!fs.existsSync(backupPath)) {
748
- return res.json({ action: 'none', reason: 'no remote file' });
749
- }
750
-
751
1018
  try {
752
- const remote = JSON.parse(fs.readFileSync(backupPath, 'utf8'));
753
- const remoteTime = new Date(remote.timestamp).getTime();
754
- const localTime = studio.lastSyncAt ? new Date(studio.lastSyncAt).getTime() : 0;
1019
+ let content;
755
1020
 
756
- if (remoteTime > localTime) {
757
- if (remote.studioConfig) {
758
- const merged = { ...remote.studioConfig, syncFolder: studio.syncFolder, autoSync: studio.autoSync, lastSyncAt: studio.lastSyncAt };
759
- saveStudioConfig(merged);
760
- }
761
- if (remote.opencodeConfig) saveConfig(remote.opencodeConfig);
1021
+ if (studio.cloudProvider === 'dropbox') {
1022
+ const response = await fetch('https://content.dropboxapi.com/2/files/download', {
1023
+ method: 'POST',
1024
+ headers: {
1025
+ 'Authorization': `Bearer ${studio.cloudToken}`,
1026
+ 'Dropbox-API-Arg': JSON.stringify({ path: '/opencode-studio-sync.json' })
1027
+ }
1028
+ });
762
1029
 
763
- const sd = getSkillDir();
764
- if (sd && remote.skills && Array.isArray(remote.skills)) {
765
- if (!fs.existsSync(sd)) fs.mkdirSync(sd, { recursive: true });
766
- remote.skills.forEach(s => {
767
- const skillDir = path.join(sd, s.name);
768
- if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
769
- atomicWriteFileSync(path.join(skillDir, 'SKILL.md'), s.content);
770
- });
1030
+ if (!response.ok) {
1031
+ return res.json({ action: 'none', reason: 'no remote file' });
771
1032
  }
772
1033
 
773
- const pd = getPluginDir();
774
- if (pd && remote.plugins && Array.isArray(remote.plugins)) {
775
- if (!fs.existsSync(pd)) fs.mkdirSync(pd, { recursive: true });
776
- remote.plugins.forEach(p => {
777
- atomicWriteFileSync(path.join(pd, `${p.name}.js`), p.content);
778
- });
779
- }
1034
+ content = await response.text();
1035
+ }
1036
+
1037
+ if (!content) {
1038
+ return res.json({ action: 'none', reason: 'no content' });
1039
+ }
1040
+
1041
+ const remote = JSON.parse(content);
1042
+ const remoteTime = new Date(remote.timestamp).getTime();
1043
+ const localTime = studio.lastSyncAt ? new Date(studio.lastSyncAt).getTime() : 0;
1044
+
1045
+ if (remoteTime > localTime) {
1046
+ restoreFromBackup(remote, studio);
780
1047
 
781
1048
  const updated = loadStudioConfig();
782
1049
  updated.lastSyncAt = new Date().toISOString();
@@ -1697,67 +1964,56 @@ function getPoolQuota(provider, pool) {
1697
1964
  };
1698
1965
  }
1699
1966
 
1700
- // GET /api/auth/pool - Get account pool for Google (or specified provider)
1701
- app.get('/api/auth/pool', (req, res) => {
1702
- const provider = req.query.provider || 'google';
1703
- syncAntigravityPool();
1967
+ function rotateAccount(provider, reason = 'manual_rotation') {
1704
1968
  const pool = buildAccountPool(provider);
1705
- const quota = getPoolQuota(provider, pool);
1706
- res.json({ pool, quota });
1707
- });
1708
1969
 
1709
- // POST /api/auth/pool/rotate - Rotate to next available account
1710
- app.post('/api/auth/pool/rotate', (req, res) => {
1711
- const provider = req.body.provider || 'google';
1712
- const pool = buildAccountPool(provider);
1713
-
1714
1970
  if (pool.accounts.length === 0) {
1715
- return res.status(400).json({ error: 'No accounts in pool' });
1971
+ return { success: false, error: 'No accounts in pool' };
1716
1972
  }
1717
-
1973
+
1718
1974
  const now = Date.now();
1719
1975
  const available = pool.accounts.filter(acc =>
1720
1976
  acc.status === 'ready' || (acc.status === 'cooldown' && acc.cooldownUntil && acc.cooldownUntil < now)
1721
1977
  );
1722
-
1978
+
1723
1979
  if (available.length === 0) {
1724
- return res.status(400).json({ error: 'No available accounts (all in cooldown or expired)' });
1980
+ return { success: false, error: 'No available accounts (all in cooldown or expired)' };
1725
1981
  }
1726
-
1982
+
1727
1983
  // Pick least recently used
1728
1984
  const next = available.sort((a, b) => a.lastUsed - b.lastUsed)[0];
1729
1985
  const previousActive = pool.activeAccount;
1730
-
1986
+
1731
1987
  // Activate the new account
1732
1988
  const activePlugin = getActiveGooglePlugin();
1733
1989
  const namespace = provider === 'google'
1734
1990
  ? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
1735
1991
  : provider;
1736
-
1992
+
1737
1993
  const profilePath = path.join(AUTH_PROFILES_DIR, namespace, `${next.name}.json`);
1738
1994
  if (!fs.existsSync(profilePath)) {
1739
- return res.status(404).json({ error: 'Profile file not found' });
1995
+ return { success: false, error: 'Profile file not found' };
1740
1996
  }
1741
-
1997
+
1742
1998
  const profileData = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
1743
-
1999
+
1744
2000
  // Update auth.json
1745
2001
  const authCfg = loadAuthConfig() || {};
1746
2002
  authCfg[provider] = profileData;
1747
2003
  if (provider === 'google') {
1748
2004
  authCfg[namespace] = profileData;
1749
2005
  }
1750
-
2006
+
1751
2007
  const cp = getConfigPath();
1752
2008
  const ap = path.join(path.dirname(cp), 'auth.json');
1753
2009
  atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
1754
-
2010
+
1755
2011
  // Update studio config
1756
2012
  const studio = loadStudioConfig();
1757
2013
  if (!studio.activeProfiles) studio.activeProfiles = {};
1758
2014
  studio.activeProfiles[provider] = next.name;
1759
2015
  saveStudioConfig(studio);
1760
-
2016
+
1761
2017
  // Update metadata
1762
2018
  const metadata = loadPoolMetadata();
1763
2019
  if (!metadata[namespace]) metadata[namespace] = {};
@@ -1767,19 +2023,46 @@ app.post('/api/auth/pool/rotate', (req, res) => {
1767
2023
  usageCount: (metadata[namespace][next.name]?.usageCount || 0) + 1
1768
2024
  };
1769
2025
 
2026
+ // Unmark exhaustion if we successfully rotated
2027
+ if (metadata._quota?.[namespace]?.exhausted) {
2028
+ delete metadata._quota[namespace].exhausted;
2029
+ }
2030
+
1770
2031
  if (!metadata._quota) metadata._quota = {};
1771
2032
  if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
1772
2033
  const today = new Date().toISOString().split('T')[0];
1773
2034
  metadata._quota[namespace][today] = (metadata._quota[namespace][today] || 0) + 1;
2035
+ metadata._quota[namespace].lastRotation = now;
1774
2036
 
1775
2037
  savePoolMetadata(metadata);
1776
-
1777
- res.json({
2038
+
2039
+ return {
1778
2040
  success: true,
1779
2041
  previousAccount: previousActive,
1780
2042
  newAccount: next.name,
1781
- reason: 'manual_rotation'
1782
- });
2043
+ reason: reason
2044
+ };
2045
+ }
2046
+
2047
+ // GET /api/auth/pool - Get account pool for Google (or specified provider)
2048
+ app.get('/api/auth/pool', (req, res) => {
2049
+ const provider = req.query.provider || 'google';
2050
+ syncAntigravityPool();
2051
+ const pool = buildAccountPool(provider);
2052
+ const quota = getPoolQuota(provider, pool);
2053
+ res.json({ pool, quota });
2054
+ });
2055
+
2056
+ // POST /api/auth/pool/rotate - Rotate to next available account
2057
+ app.post('/api/auth/pool/rotate', (req, res) => {
2058
+ const provider = req.body.provider || 'google';
2059
+ const result = rotateAccount(provider, 'manual_rotation');
2060
+
2061
+ if (!result.success) {
2062
+ return res.status(400).json(result);
2063
+ }
2064
+
2065
+ res.json(result);
1783
2066
  });
1784
2067
 
1785
2068
  // POST /api/auth/pool/limit - Set daily quota limit
@@ -2534,7 +2817,18 @@ app.post('/api/presets/:id/apply', (req, res) => {
2534
2817
  });
2535
2818
 
2536
2819
  // Start watcher on server start
2537
- setupLogWatcher();
2538
- importExistingAuth();
2820
+ if (require.main === module) {
2821
+ setupLogWatcher();
2822
+ importExistingAuth();
2823
+ app.listen(PORT, () => console.log(`Server running at http://localhost:${PORT}`));
2824
+ }
2539
2825
 
2540
- app.listen(PORT, () => console.log(`Server running at http://localhost:${PORT}`));
2826
+ module.exports = {
2827
+ rotateAccount,
2828
+ processLogLine,
2829
+ loadPoolMetadata,
2830
+ savePoolMetadata,
2831
+ loadStudioConfig,
2832
+ saveStudioConfig,
2833
+ buildAccountPool
2834
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-studio-server",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Backend server for OpenCode Studio - manages opencode configurations",
5
5
  "main": "index.js",
6
6
  "bin": {