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.
- package/index.js +474 -180
- 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
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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,
|
|
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/
|
|
863
|
+
app.post('/api/sync/push', async (req, res) => {
|
|
644
864
|
try {
|
|
645
865
|
const studio = loadStudioConfig();
|
|
646
|
-
|
|
647
|
-
|
|
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 =
|
|
870
|
+
const backup = buildBackupData();
|
|
871
|
+
const content = JSON.stringify(backup, null, 2);
|
|
653
872
|
|
|
654
|
-
if (
|
|
655
|
-
const
|
|
656
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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.
|
|
1012
|
+
app.post('/api/sync/auto', async (req, res) => {
|
|
689
1013
|
const studio = loadStudioConfig();
|
|
690
|
-
|
|
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
|
-
|
|
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 (
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2038
|
+
|
|
2039
|
+
return {
|
|
1778
2040
|
success: true,
|
|
1779
2041
|
previousAccount: previousActive,
|
|
1780
2042
|
newAccount: next.name,
|
|
1781
|
-
reason:
|
|
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
|
-
|
|
2538
|
-
|
|
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
|
-
|
|
2826
|
+
module.exports = {
|
|
2827
|
+
rotateAccount,
|
|
2828
|
+
processLogLine,
|
|
2829
|
+
loadPoolMetadata,
|
|
2830
|
+
savePoolMetadata,
|
|
2831
|
+
loadStudioConfig,
|
|
2832
|
+
saveStudioConfig,
|
|
2833
|
+
buildAccountPool
|
|
2834
|
+
};
|