opencode-studio-server 1.0.13 → 1.1.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 +986 -986
- package/launcher.vbs +6 -6
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,986 +1,986 @@
|
|
|
1
|
-
const express = require('express');
|
|
2
|
-
const cors = require('cors');
|
|
3
|
-
const bodyParser = require('body-parser');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const os = require('os');
|
|
7
|
-
const { exec, spawn } = require('child_process');
|
|
8
|
-
|
|
9
|
-
const app = express();
|
|
10
|
-
const PORT = 3001;
|
|
11
|
-
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
12
|
-
|
|
13
|
-
let lastActivityTime = Date.now();
|
|
14
|
-
let idleTimer = null;
|
|
15
|
-
|
|
16
|
-
function resetIdleTimer() {
|
|
17
|
-
lastActivityTime = Date.now();
|
|
18
|
-
if (idleTimer) clearTimeout(idleTimer);
|
|
19
|
-
idleTimer = setTimeout(() => {
|
|
20
|
-
console.log('Server idle for 30 minutes, shutting down...');
|
|
21
|
-
process.exit(0);
|
|
22
|
-
}, IDLE_TIMEOUT_MS);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
resetIdleTimer();
|
|
26
|
-
|
|
27
|
-
app.use((req, res, next) => {
|
|
28
|
-
resetIdleTimer();
|
|
29
|
-
next();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
const ALLOWED_ORIGINS = [
|
|
33
|
-
'http://localhost:3000',
|
|
34
|
-
'http://127.0.0.1:3000',
|
|
35
|
-
'https://opencode-studio.vercel.app',
|
|
36
|
-
'https://opencode.micr.dev',
|
|
37
|
-
'https://opencode-studio.micr.dev',
|
|
38
|
-
/\.vercel\.app$/,
|
|
39
|
-
/\.micr\.dev$/,
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
app.use(cors({
|
|
43
|
-
origin: (origin, callback) => {
|
|
44
|
-
if (!origin) return callback(null, true);
|
|
45
|
-
const allowed = ALLOWED_ORIGINS.some(o =>
|
|
46
|
-
o instanceof RegExp ? o.test(origin) : o === origin
|
|
47
|
-
);
|
|
48
|
-
callback(null, allowed);
|
|
49
|
-
},
|
|
50
|
-
credentials: true,
|
|
51
|
-
}));
|
|
52
|
-
app.use(bodyParser.json({ limit: '50mb' }));
|
|
53
|
-
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
|
54
|
-
|
|
55
|
-
const HOME_DIR = os.homedir();
|
|
56
|
-
const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
|
|
57
|
-
const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
|
|
58
|
-
|
|
59
|
-
let pendingActionMemory = null;
|
|
60
|
-
|
|
61
|
-
function loadStudioConfig() {
|
|
62
|
-
if (!fs.existsSync(STUDIO_CONFIG_PATH)) {
|
|
63
|
-
return {};
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
return JSON.parse(fs.readFileSync(STUDIO_CONFIG_PATH, 'utf8'));
|
|
67
|
-
} catch {
|
|
68
|
-
return {};
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function saveStudioConfig(config) {
|
|
73
|
-
try {
|
|
74
|
-
const dir = path.dirname(STUDIO_CONFIG_PATH);
|
|
75
|
-
if (!fs.existsSync(dir)) {
|
|
76
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
-
}
|
|
78
|
-
fs.writeFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
79
|
-
return true;
|
|
80
|
-
} catch (err) {
|
|
81
|
-
console.error('Failed to save studio config:', err);
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function loadPendingAction() {
|
|
87
|
-
if (pendingActionMemory) return pendingActionMemory;
|
|
88
|
-
|
|
89
|
-
if (fs.existsSync(PENDING_ACTION_PATH)) {
|
|
90
|
-
try {
|
|
91
|
-
const action = JSON.parse(fs.readFileSync(PENDING_ACTION_PATH, 'utf8'));
|
|
92
|
-
if (action.timestamp && Date.now() - action.timestamp < 60000) {
|
|
93
|
-
pendingActionMemory = action;
|
|
94
|
-
fs.unlinkSync(PENDING_ACTION_PATH);
|
|
95
|
-
return action;
|
|
96
|
-
}
|
|
97
|
-
fs.unlinkSync(PENDING_ACTION_PATH);
|
|
98
|
-
} catch {
|
|
99
|
-
try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
app.get('/api/pending-action', (req, res) => {
|
|
106
|
-
const action = loadPendingAction();
|
|
107
|
-
res.json({ action });
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
app.delete('/api/pending-action', (req, res) => {
|
|
111
|
-
pendingActionMemory = null;
|
|
112
|
-
if (fs.existsSync(PENDING_ACTION_PATH)) {
|
|
113
|
-
try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
|
|
114
|
-
}
|
|
115
|
-
res.json({ success: true });
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const getPaths = () => {
|
|
119
|
-
const platform = process.platform;
|
|
120
|
-
const home = os.homedir();
|
|
121
|
-
|
|
122
|
-
let candidates = [];
|
|
123
|
-
if (platform === 'win32') {
|
|
124
|
-
candidates = [
|
|
125
|
-
path.join(process.env.APPDATA, 'opencode', 'opencode.json'),
|
|
126
|
-
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
127
|
-
];
|
|
128
|
-
} else {
|
|
129
|
-
candidates = [
|
|
130
|
-
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
131
|
-
path.join(home, '.opencode', 'opencode.json'),
|
|
132
|
-
];
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const studioConfig = loadStudioConfig();
|
|
136
|
-
const manualPath = studioConfig.configPath;
|
|
137
|
-
|
|
138
|
-
let detected = null;
|
|
139
|
-
for (const p of candidates) {
|
|
140
|
-
if (fs.existsSync(p)) {
|
|
141
|
-
detected = p;
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
detected,
|
|
148
|
-
manual: manualPath,
|
|
149
|
-
current: manualPath || detected,
|
|
150
|
-
candidates
|
|
151
|
-
};
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
const getConfigPath = () => {
|
|
155
|
-
const paths = getPaths();
|
|
156
|
-
return paths.current;
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const loadConfig = () => {
|
|
160
|
-
const configPath = getConfigPath();
|
|
161
|
-
if (!configPath || !fs.existsSync(configPath)) {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
try {
|
|
165
|
-
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
166
|
-
} catch {
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const saveConfig = (config) => {
|
|
172
|
-
const configPath = getConfigPath();
|
|
173
|
-
if (!configPath) {
|
|
174
|
-
throw new Error('No config path found');
|
|
175
|
-
}
|
|
176
|
-
const dir = path.dirname(configPath);
|
|
177
|
-
if (!fs.existsSync(dir)) {
|
|
178
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
179
|
-
}
|
|
180
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
app.get('/api/health', (req, res) => {
|
|
184
|
-
res.json({ status: 'ok' });
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
app.post('/api/shutdown', (req, res) => {
|
|
188
|
-
res.json({ success: true });
|
|
189
|
-
setTimeout(() => process.exit(0), 100);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
app.get('/api/paths', (req, res) => {
|
|
193
|
-
res.json(getPaths());
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
app.post('/api/paths', (req, res) => {
|
|
197
|
-
const { configPath } = req.body;
|
|
198
|
-
const studioConfig = loadStudioConfig();
|
|
199
|
-
studioConfig.configPath = configPath;
|
|
200
|
-
saveStudioConfig(studioConfig);
|
|
201
|
-
res.json({ success: true, current: getConfigPath() });
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
app.get('/api/config', (req, res) => {
|
|
205
|
-
const config = loadConfig();
|
|
206
|
-
if (!config) {
|
|
207
|
-
return res.status(404).json({ error: 'Config not found' });
|
|
208
|
-
}
|
|
209
|
-
res.json(config);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
app.post('/api/config', (req, res) => {
|
|
213
|
-
const config = req.body;
|
|
214
|
-
try {
|
|
215
|
-
saveConfig(config);
|
|
216
|
-
res.json({ success: true });
|
|
217
|
-
} catch (err) {
|
|
218
|
-
res.status(500).json({ error: err.message });
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// Helper to get skill dir
|
|
223
|
-
const getSkillDir = () => {
|
|
224
|
-
const configPath = getConfigPath();
|
|
225
|
-
if (!configPath) return null;
|
|
226
|
-
return path.join(path.dirname(configPath), 'skill');
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
app.get('/api/skills', (req, res) => {
|
|
230
|
-
const skillDir = getSkillDir();
|
|
231
|
-
if (!skillDir || !fs.existsSync(skillDir)) {
|
|
232
|
-
return res.json([]);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const skills = [];
|
|
236
|
-
const entries = fs.readdirSync(skillDir, { withFileTypes: true });
|
|
237
|
-
|
|
238
|
-
for (const entry of entries) {
|
|
239
|
-
if (entry.isDirectory()) {
|
|
240
|
-
const skillPath = path.join(skillDir, entry.name, 'SKILL.md');
|
|
241
|
-
if (fs.existsSync(skillPath)) {
|
|
242
|
-
skills.push({
|
|
243
|
-
name: entry.name,
|
|
244
|
-
path: skillPath,
|
|
245
|
-
enabled: !entry.name.endsWith('.disabled') // Simplified logic
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
res.json(skills);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
app.get('/api/skills/:name', (req, res) => {
|
|
254
|
-
const skillDir = getSkillDir();
|
|
255
|
-
if (!skillDir) return res.status(404).json({ error: 'No config' });
|
|
256
|
-
|
|
257
|
-
const name = req.params.name;
|
|
258
|
-
const skillPath = path.join(skillDir, name, 'SKILL.md');
|
|
259
|
-
|
|
260
|
-
if (!fs.existsSync(skillPath)) {
|
|
261
|
-
return res.status(404).json({ error: 'Skill not found' });
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const content = fs.readFileSync(skillPath, 'utf8');
|
|
265
|
-
res.json({ name, content });
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
app.post('/api/skills/:name', (req, res) => {
|
|
269
|
-
const skillDir = getSkillDir();
|
|
270
|
-
if (!skillDir) return res.status(404).json({ error: 'No config' });
|
|
271
|
-
|
|
272
|
-
const name = req.params.name;
|
|
273
|
-
const { content } = req.body;
|
|
274
|
-
const dirPath = path.join(skillDir, name);
|
|
275
|
-
|
|
276
|
-
if (!fs.existsSync(dirPath)) {
|
|
277
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
fs.writeFileSync(path.join(dirPath, 'SKILL.md'), content, 'utf8');
|
|
281
|
-
res.json({ success: true });
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
app.delete('/api/skills/:name', (req, res) => {
|
|
285
|
-
const skillDir = getSkillDir();
|
|
286
|
-
if (!skillDir) return res.status(404).json({ error: 'No config' });
|
|
287
|
-
|
|
288
|
-
const name = req.params.name;
|
|
289
|
-
const dirPath = path.join(skillDir, name);
|
|
290
|
-
|
|
291
|
-
if (fs.existsSync(dirPath)) {
|
|
292
|
-
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
293
|
-
}
|
|
294
|
-
res.json({ success: true });
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
app.post('/api/skills/:name/toggle', (req, res) => {
|
|
298
|
-
// Simplified: renaming not implemented for now to avoid complexity
|
|
299
|
-
// In real implementation we would rename folder to .disabled
|
|
300
|
-
res.json({ success: true, enabled: true });
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
// Helper to get plugin dir
|
|
304
|
-
const getPluginDir = () => {
|
|
305
|
-
const configPath = getConfigPath();
|
|
306
|
-
if (!configPath) return null;
|
|
307
|
-
return path.join(path.dirname(configPath), 'plugin');
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
app.get('/api/plugins', (req, res) => {
|
|
311
|
-
const pluginDir = getPluginDir();
|
|
312
|
-
if (!pluginDir || !fs.existsSync(pluginDir)) {
|
|
313
|
-
return res.json([]);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const plugins = [];
|
|
317
|
-
const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
|
|
318
|
-
|
|
319
|
-
for (const entry of entries) {
|
|
320
|
-
if (entry.isDirectory()) {
|
|
321
|
-
// Check for index.js or index.ts
|
|
322
|
-
const jsPath = path.join(pluginDir, entry.name, 'index.js');
|
|
323
|
-
const tsPath = path.join(pluginDir, entry.name, 'index.ts');
|
|
324
|
-
|
|
325
|
-
if (fs.existsSync(jsPath) || fs.existsSync(tsPath)) {
|
|
326
|
-
plugins.push({
|
|
327
|
-
name: entry.name,
|
|
328
|
-
path: fs.existsSync(jsPath) ? jsPath : tsPath,
|
|
329
|
-
enabled: true
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
res.json(plugins);
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
app.get('/api/plugins/:name', (req, res) => {
|
|
338
|
-
const pluginDir = getPluginDir();
|
|
339
|
-
if (!pluginDir) return res.status(404).json({ error: 'No config' });
|
|
340
|
-
|
|
341
|
-
const name = req.params.name;
|
|
342
|
-
const dirPath = path.join(pluginDir, name);
|
|
343
|
-
|
|
344
|
-
let content = '';
|
|
345
|
-
let filename = '';
|
|
346
|
-
|
|
347
|
-
if (fs.existsSync(path.join(dirPath, 'index.js'))) {
|
|
348
|
-
content = fs.readFileSync(path.join(dirPath, 'index.js'), 'utf8');
|
|
349
|
-
filename = 'index.js';
|
|
350
|
-
} else if (fs.existsSync(path.join(dirPath, 'index.ts'))) {
|
|
351
|
-
content = fs.readFileSync(path.join(dirPath, 'index.ts'), 'utf8');
|
|
352
|
-
filename = 'index.ts';
|
|
353
|
-
} else {
|
|
354
|
-
return res.status(404).json({ error: 'Plugin not found' });
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
res.json({ name, content, filename });
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
app.post('/api/plugins/:name', (req, res) => {
|
|
361
|
-
const pluginDir = getPluginDir();
|
|
362
|
-
if (!pluginDir) return res.status(404).json({ error: 'No config' });
|
|
363
|
-
|
|
364
|
-
const name = req.params.name;
|
|
365
|
-
const { content } = req.body;
|
|
366
|
-
const dirPath = path.join(pluginDir, name);
|
|
367
|
-
|
|
368
|
-
if (!fs.existsSync(dirPath)) {
|
|
369
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Determine file extension based on content? Default to .js if new
|
|
373
|
-
const filePath = path.join(dirPath, 'index.js');
|
|
374
|
-
fs.writeFileSync(filePath, content, 'utf8');
|
|
375
|
-
res.json({ success: true });
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
app.delete('/api/plugins/:name', (req, res) => {
|
|
379
|
-
const pluginDir = getPluginDir();
|
|
380
|
-
if (!pluginDir) return res.status(404).json({ error: 'No config' });
|
|
381
|
-
|
|
382
|
-
const name = req.params.name;
|
|
383
|
-
const dirPath = path.join(pluginDir, name);
|
|
384
|
-
|
|
385
|
-
if (fs.existsSync(dirPath)) {
|
|
386
|
-
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
387
|
-
}
|
|
388
|
-
res.json({ success: true });
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
app.post('/api/plugins/:name/toggle', (req, res) => {
|
|
392
|
-
res.json({ success: true, enabled: true });
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
app.post('/api/fetch-url', async (req, res) => {
|
|
396
|
-
const { url } = req.body;
|
|
397
|
-
if (!url) return res.status(400).json({ error: 'URL required' });
|
|
398
|
-
|
|
399
|
-
try {
|
|
400
|
-
const fetch = (await import('node-fetch')).default;
|
|
401
|
-
const response = await fetch(url);
|
|
402
|
-
if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
|
|
403
|
-
const content = await response.text();
|
|
404
|
-
const filename = path.basename(new URL(url).pathname) || 'file.txt';
|
|
405
|
-
|
|
406
|
-
res.json({ content, filename, url });
|
|
407
|
-
} catch (err) {
|
|
408
|
-
res.status(500).json({ error: err.message });
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
app.post('/api/bulk-fetch', async (req, res) => {
|
|
413
|
-
const { urls } = req.body;
|
|
414
|
-
if (!Array.isArray(urls)) return res.status(400).json({ error: 'URLs array required' });
|
|
415
|
-
|
|
416
|
-
const fetch = (await import('node-fetch')).default;
|
|
417
|
-
const results = [];
|
|
418
|
-
|
|
419
|
-
// Limit concurrency? For now sequential is safer
|
|
420
|
-
for (const url of urls) {
|
|
421
|
-
try {
|
|
422
|
-
const response = await fetch(url);
|
|
423
|
-
if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
|
|
424
|
-
const content = await response.text();
|
|
425
|
-
const filename = path.basename(new URL(url).pathname) || 'file.txt';
|
|
426
|
-
|
|
427
|
-
// Try to extract name/description from content (simple regex for markdown/js)
|
|
428
|
-
// This is basic heuristic
|
|
429
|
-
results.push({
|
|
430
|
-
url,
|
|
431
|
-
success: true,
|
|
432
|
-
content,
|
|
433
|
-
filename,
|
|
434
|
-
name: filename.replace(/\.(md|js|ts)$/, ''),
|
|
435
|
-
});
|
|
436
|
-
} catch (err) {
|
|
437
|
-
results.push({
|
|
438
|
-
url,
|
|
439
|
-
success: false,
|
|
440
|
-
error: err.message
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
res.json({ results });
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
app.get('/api/backup', (req, res) => {
|
|
449
|
-
const studioConfig = loadStudioConfig();
|
|
450
|
-
const opencodeConfig = loadConfig();
|
|
451
|
-
|
|
452
|
-
const skills = [];
|
|
453
|
-
const skillDir = getSkillDir();
|
|
454
|
-
if (skillDir && fs.existsSync(skillDir)) {
|
|
455
|
-
const entries = fs.readdirSync(skillDir, { withFileTypes: true });
|
|
456
|
-
for (const entry of entries) {
|
|
457
|
-
if (entry.isDirectory()) {
|
|
458
|
-
const p = path.join(skillDir, entry.name, 'SKILL.md');
|
|
459
|
-
if (fs.existsSync(p)) {
|
|
460
|
-
skills.push({ name: entry.name, content: fs.readFileSync(p, 'utf8') });
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
const plugins = [];
|
|
467
|
-
const pluginDir = getPluginDir();
|
|
468
|
-
if (pluginDir && fs.existsSync(pluginDir)) {
|
|
469
|
-
const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
|
|
470
|
-
for (const entry of entries) {
|
|
471
|
-
if (entry.isDirectory()) {
|
|
472
|
-
const p = path.join(pluginDir, entry.name, 'index.js');
|
|
473
|
-
if (fs.existsSync(p)) {
|
|
474
|
-
plugins.push({ name: entry.name, content: fs.readFileSync(p, 'utf8') });
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
res.json({
|
|
481
|
-
version: 1,
|
|
482
|
-
timestamp: new Date().toISOString(),
|
|
483
|
-
studioConfig,
|
|
484
|
-
opencodeConfig,
|
|
485
|
-
skills,
|
|
486
|
-
plugins
|
|
487
|
-
});
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
app.post('/api/restore', (req, res) => {
|
|
491
|
-
const backup = req.body;
|
|
492
|
-
|
|
493
|
-
if (backup.studioConfig) {
|
|
494
|
-
saveStudioConfig(backup.studioConfig);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
if (backup.opencodeConfig) {
|
|
498
|
-
saveConfig(backup.opencodeConfig);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (Array.isArray(backup.skills)) {
|
|
502
|
-
const skillDir = getSkillDir();
|
|
503
|
-
if (skillDir) {
|
|
504
|
-
if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
|
|
505
|
-
for (const skill of backup.skills) {
|
|
506
|
-
const dir = path.join(skillDir, skill.name);
|
|
507
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
508
|
-
fs.writeFileSync(path.join(dir, 'SKILL.md'), skill.content, 'utf8');
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (Array.isArray(backup.plugins)) {
|
|
514
|
-
const pluginDir = getPluginDir();
|
|
515
|
-
if (pluginDir) {
|
|
516
|
-
if (!fs.existsSync(pluginDir)) fs.mkdirSync(pluginDir, { recursive: true });
|
|
517
|
-
for (const plugin of backup.plugins) {
|
|
518
|
-
const dir = path.join(pluginDir, plugin.name);
|
|
519
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
520
|
-
fs.writeFileSync(path.join(dir, 'index.js'), plugin.content, 'utf8');
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
res.json({ success: true });
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
// Auth Handlers
|
|
529
|
-
function loadAuthConfig() {
|
|
530
|
-
const configPath = getConfigPath();
|
|
531
|
-
if (!configPath) return null;
|
|
532
|
-
|
|
533
|
-
// Auth config is usually in ~/.config/opencode/auth.json ?
|
|
534
|
-
// Or inside opencode.json?
|
|
535
|
-
// Based on opencode CLI, it seems to store auth in separate files or system keychain.
|
|
536
|
-
// But for this studio, we might just look at opencode.json "providers" section or similar.
|
|
537
|
-
|
|
538
|
-
// ACTUALLY, opencode stores auth tokens in ~/.config/opencode/auth.json
|
|
539
|
-
const authPath = path.join(path.dirname(configPath), 'auth.json');
|
|
540
|
-
if (!fs.existsSync(authPath)) return null;
|
|
541
|
-
try {
|
|
542
|
-
return JSON.parse(fs.readFileSync(authPath, 'utf8'));
|
|
543
|
-
} catch {
|
|
544
|
-
return null;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
app.get('/api/auth', (req, res) => {
|
|
549
|
-
const authConfig = loadAuthConfig();
|
|
550
|
-
res.json(authConfig || {});
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
app.post('/api/auth/login', (req, res) => {
|
|
554
|
-
const { provider } = req.body;
|
|
555
|
-
// Launch opencode CLI auth login
|
|
556
|
-
// This requires 'opencode' to be in PATH
|
|
557
|
-
|
|
558
|
-
const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
|
|
559
|
-
|
|
560
|
-
// We spawn it so it opens the browser
|
|
561
|
-
const child = spawn(cmd, ['auth', 'login', provider], {
|
|
562
|
-
stdio: 'inherit',
|
|
563
|
-
shell: true
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
res.json({ success: true, message: 'Launched auth flow', note: 'Please check the terminal window where the server is running' });
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
app.delete('/api/auth/:provider', (req, res) => {
|
|
570
|
-
// Logout logic
|
|
571
|
-
// Maybe just delete from auth.json? Or run opencode auth logout?
|
|
572
|
-
const { provider } = req.params;
|
|
573
|
-
const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
|
|
574
|
-
|
|
575
|
-
spawn(cmd, ['auth', 'logout', provider], {
|
|
576
|
-
stdio: 'inherit',
|
|
577
|
-
shell: true
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
res.json({ success: true });
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
app.get('/api/auth/providers', (req, res) => {
|
|
584
|
-
// List of supported providers (hardcoded for now as it's not easily discoverable via CLI)
|
|
585
|
-
const providers = [
|
|
586
|
-
{ id: 'google', name: 'Google AI', type: 'oauth', description: 'Use Google Gemini models' },
|
|
587
|
-
{ id: 'anthropic', name: 'Anthropic', type: 'api', description: 'Use Claude models' },
|
|
588
|
-
{ id: 'openai', name: 'OpenAI', type: 'api', description: 'Use GPT models' },
|
|
589
|
-
{ id: 'xai', name: 'xAI', type: 'api', description: 'Use Grok models' },
|
|
590
|
-
{ id: 'groq', name: 'Groq', type: 'api', description: 'Fast inference' },
|
|
591
|
-
{ id: 'together', name: 'Together AI', type: 'api', description: 'Open source models' },
|
|
592
|
-
{ id: 'mistral', name: 'Mistral', type: 'api', description: 'Mistral models' },
|
|
593
|
-
{ id: 'deepseek', name: 'DeepSeek', type: 'api', description: 'DeepSeek models' },
|
|
594
|
-
{ id: 'openrouter', name: 'OpenRouter', type: 'api', description: 'Multiple providers' },
|
|
595
|
-
{ id: 'amazon-bedrock', name: 'Amazon Bedrock', type: 'api', description: 'AWS models' },
|
|
596
|
-
{ id: 'azure', name: 'Azure OpenAI', type: 'api', description: 'Azure GPT models' },
|
|
597
|
-
];
|
|
598
|
-
res.json(providers);
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
const AUTH_PROFILES_DIR = path.join(HOME_DIR, '.config', 'opencode-studio', 'auth-profiles');
|
|
602
|
-
|
|
603
|
-
function ensureAuthProfilesDir() {
|
|
604
|
-
if (!fs.existsSync(AUTH_PROFILES_DIR)) {
|
|
605
|
-
fs.mkdirSync(AUTH_PROFILES_DIR, { recursive: true });
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
function getProviderProfilesDir(provider) {
|
|
610
|
-
return path.join(AUTH_PROFILES_DIR, provider);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function listAuthProfiles(provider) {
|
|
614
|
-
const dir = getProviderProfilesDir(provider);
|
|
615
|
-
if (!fs.existsSync(dir)) return [];
|
|
616
|
-
|
|
617
|
-
try {
|
|
618
|
-
return fs.readdirSync(dir)
|
|
619
|
-
.filter(f => f.endsWith('.json'))
|
|
620
|
-
.map(f => f.replace('.json', ''));
|
|
621
|
-
} catch {
|
|
622
|
-
return [];
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function getNextProfileName(provider) {
|
|
627
|
-
const existing = listAuthProfiles(provider);
|
|
628
|
-
let num = 1;
|
|
629
|
-
while (existing.includes(`account-${num}`)) {
|
|
630
|
-
num++;
|
|
631
|
-
}
|
|
632
|
-
return `account-${num}`;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
function saveAuthProfile(provider, profileName, data) {
|
|
636
|
-
ensureAuthProfilesDir();
|
|
637
|
-
const dir = getProviderProfilesDir(provider);
|
|
638
|
-
if (!fs.existsSync(dir)) {
|
|
639
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
640
|
-
}
|
|
641
|
-
const filePath = path.join(dir, `${profileName}.json`);
|
|
642
|
-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
|
643
|
-
return true;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function loadAuthProfile(provider, profileName) {
|
|
647
|
-
const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
|
|
648
|
-
if (!fs.existsSync(filePath)) return null;
|
|
649
|
-
try {
|
|
650
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
651
|
-
} catch {
|
|
652
|
-
return null;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
function deleteAuthProfile(provider, profileName) {
|
|
657
|
-
const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
|
|
658
|
-
if (fs.existsSync(filePath)) {
|
|
659
|
-
fs.unlinkSync(filePath);
|
|
660
|
-
return true;
|
|
661
|
-
}
|
|
662
|
-
return false;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
function getActiveProfiles() {
|
|
666
|
-
const studioConfig = loadStudioConfig();
|
|
667
|
-
return studioConfig.activeProfiles || {};
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function setActiveProfile(provider, profileName) {
|
|
671
|
-
const studioConfig = loadStudioConfig();
|
|
672
|
-
studioConfig.activeProfiles = studioConfig.activeProfiles || {};
|
|
673
|
-
studioConfig.activeProfiles[provider] = profileName;
|
|
674
|
-
saveStudioConfig(studioConfig);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
function verifyActiveProfile(p, n, c) { console.log("Verifying:", p, n); if (!n || !c) return false; const d = loadAuthProfile(p, n); if (d && c) { console.log("Profile refresh:", d.refresh); console.log("Current refresh:", c.refresh); } if (!d) return false; return JSON.stringify(d) === JSON.stringify(c); }
|
|
678
|
-
|
|
679
|
-
app.get('/api/auth/profiles', (req, res) => {
|
|
680
|
-
ensureAuthProfilesDir();
|
|
681
|
-
const activeProfiles = getActiveProfiles();
|
|
682
|
-
const authConfig = loadAuthConfig() || {};
|
|
683
|
-
|
|
684
|
-
const profiles = {};
|
|
685
|
-
const providers = [
|
|
686
|
-
'google', 'anthropic', 'openai', 'xai', 'groq',
|
|
687
|
-
'together', 'mistral', 'deepseek', 'openrouter',
|
|
688
|
-
'amazon-bedrock', 'azure'
|
|
689
|
-
];
|
|
690
|
-
|
|
691
|
-
providers.forEach(p => {
|
|
692
|
-
const saved = listAuthProfiles(p);
|
|
693
|
-
const active = activeProfiles[p];
|
|
694
|
-
const current = authConfig[p];
|
|
695
|
-
|
|
696
|
-
if (saved.length > 0 || current) {
|
|
697
|
-
profiles[p] = {
|
|
698
|
-
active: active,
|
|
699
|
-
saved: saved,
|
|
700
|
-
hasCurrent: !!current
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
res.json(profiles);
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
app.get('/api/debug/paths', (req, res) => {
|
|
709
|
-
const home = os.homedir();
|
|
710
|
-
const candidatePaths = [
|
|
711
|
-
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'opencode', 'storage', 'message') : null,
|
|
712
|
-
path.join(home, '.local', 'share', 'opencode', 'storage', 'message'),
|
|
713
|
-
path.join(home, '.opencode', 'storage', 'message')
|
|
714
|
-
].filter(p => p);
|
|
715
|
-
|
|
716
|
-
const results = candidatePaths.map(p => ({
|
|
717
|
-
path: p,
|
|
718
|
-
exists: fs.existsSync(p),
|
|
719
|
-
isDirectory: fs.existsSync(p) && fs.statSync(p).isDirectory(),
|
|
720
|
-
fileCount: (fs.existsSync(p) && fs.statSync(p).isDirectory()) ? fs.readdirSync(p).length : 0
|
|
721
|
-
}));
|
|
722
|
-
|
|
723
|
-
res.json({
|
|
724
|
-
home,
|
|
725
|
-
platform: process.platform,
|
|
726
|
-
candidates: results
|
|
727
|
-
});
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
app.get('/api/usage', async (req, res) => {
|
|
731
|
-
try {
|
|
732
|
-
const home = os.homedir();
|
|
733
|
-
const candidatePaths = [
|
|
734
|
-
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'opencode', 'storage', 'message') : null,
|
|
735
|
-
path.join(home, '.local', 'share', 'opencode', 'storage', 'message'),
|
|
736
|
-
path.join(home, '.opencode', 'storage', 'message')
|
|
737
|
-
].filter(p => p && fs.existsSync(p));
|
|
738
|
-
|
|
739
|
-
const stats = {
|
|
740
|
-
totalCost: 0,
|
|
741
|
-
totalTokens: 0,
|
|
742
|
-
byModel: {},
|
|
743
|
-
byDay: {}
|
|
744
|
-
};
|
|
745
|
-
|
|
746
|
-
const processedFiles = new Set();
|
|
747
|
-
|
|
748
|
-
const processMessage = (filePath) => {
|
|
749
|
-
if (processedFiles.has(filePath)) return;
|
|
750
|
-
processedFiles.add(filePath);
|
|
751
|
-
|
|
752
|
-
try {
|
|
753
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
754
|
-
const msg = JSON.parse(content);
|
|
755
|
-
|
|
756
|
-
const model = msg.modelID || (msg.model && (msg.model.modelID || msg.model.id)) || 'unknown';
|
|
757
|
-
|
|
758
|
-
if (msg.role === 'assistant' && msg.tokens) {
|
|
759
|
-
const cost = msg.cost || 0;
|
|
760
|
-
const tokens = (msg.tokens.input || 0) + (msg.tokens.output || 0);
|
|
761
|
-
const date = new Date(msg.time.created).toISOString().split('T')[0];
|
|
762
|
-
|
|
763
|
-
if (tokens > 0) {
|
|
764
|
-
stats.totalCost += cost;
|
|
765
|
-
stats.totalTokens += tokens;
|
|
766
|
-
|
|
767
|
-
if (!stats.byModel[model]) {
|
|
768
|
-
stats.byModel[model] = { name: model, cost: 0, tokens: 0 };
|
|
769
|
-
}
|
|
770
|
-
stats.byModel[model].cost += cost;
|
|
771
|
-
stats.byModel[model].tokens += tokens;
|
|
772
|
-
|
|
773
|
-
if (!stats.byDay[date]) {
|
|
774
|
-
stats.byDay[date] = { date, cost: 0, tokens: 0 };
|
|
775
|
-
}
|
|
776
|
-
stats.byDay[date].cost += cost;
|
|
777
|
-
stats.byDay[date].tokens += tokens;
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
} catch (err) {
|
|
781
|
-
}
|
|
782
|
-
};
|
|
783
|
-
|
|
784
|
-
for (const logDir of candidatePaths) {
|
|
785
|
-
try {
|
|
786
|
-
const sessions = fs.readdirSync(logDir);
|
|
787
|
-
for (const session of sessions) {
|
|
788
|
-
if (!session.startsWith('ses_')) continue;
|
|
789
|
-
|
|
790
|
-
const sessionDir = path.join(logDir, session);
|
|
791
|
-
if (fs.statSync(sessionDir).isDirectory()) {
|
|
792
|
-
const messages = fs.readdirSync(sessionDir);
|
|
793
|
-
for (const msgFile of messages) {
|
|
794
|
-
if (msgFile.endsWith('.json')) {
|
|
795
|
-
processMessage(path.join(sessionDir, msgFile));
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
} catch (err) {
|
|
801
|
-
console.error(`Error reading log dir ${logDir}:`, err);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
const response = {
|
|
806
|
-
totalCost: stats.totalCost,
|
|
807
|
-
totalTokens: stats.totalTokens,
|
|
808
|
-
byModel: Object.values(stats.byModel).sort((a, b) => b.cost - a.cost),
|
|
809
|
-
byDay: Object.values(stats.byDay).sort((a, b) => a.date.localeCompare(b.date))
|
|
810
|
-
};
|
|
811
|
-
|
|
812
|
-
res.json(response);
|
|
813
|
-
} catch (error) {
|
|
814
|
-
console.error('Error fetching usage stats:', error);
|
|
815
|
-
res.status(500).json({ error: 'Failed to fetch usage stats' });
|
|
816
|
-
}
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
app.get('/api/auth/profiles/:provider', (req, res) => {
|
|
820
|
-
const { provider } = req.params;
|
|
821
|
-
const providerProfiles = listAuthProfiles(provider);
|
|
822
|
-
const activeProfiles = getActiveProfiles();
|
|
823
|
-
const authConfig = loadAuthConfig() || {};
|
|
824
|
-
|
|
825
|
-
res.json({
|
|
826
|
-
profiles: providerProfiles,
|
|
827
|
-
active: (activeProfiles[provider] && verifyActiveProfile(provider, activeProfiles[provider], authConfig[provider])) ? activeProfiles[provider] : null,
|
|
828
|
-
hasCurrentAuth: !!authConfig[provider],
|
|
829
|
-
});
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
app.post('/api/auth/profiles/:provider', (req, res) => {
|
|
833
|
-
const { provider } = req.params;
|
|
834
|
-
const { name } = req.body;
|
|
835
|
-
|
|
836
|
-
const authConfig = loadAuthConfig();
|
|
837
|
-
if (!authConfig || !authConfig[provider]) {
|
|
838
|
-
return res.status(400).json({ error: `No active auth for ${provider} to save` });
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
const profileName = name || getNextProfileName(provider);
|
|
842
|
-
const data = authConfig[provider];
|
|
843
|
-
|
|
844
|
-
try {
|
|
845
|
-
saveAuthProfile(provider, profileName, data);
|
|
846
|
-
setActiveProfile(provider, profileName);
|
|
847
|
-
res.json({ success: true, name: profileName });
|
|
848
|
-
} catch (err) {
|
|
849
|
-
res.status(500).json({ error: 'Failed to save profile', details: err.message });
|
|
850
|
-
}
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
|
|
854
|
-
const { provider, name } = req.params;
|
|
855
|
-
|
|
856
|
-
const profileData = loadAuthProfile(provider, name);
|
|
857
|
-
if (!profileData) {
|
|
858
|
-
return res.status(404).json({ error: 'Profile not found' });
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
const authConfig = loadAuthConfig() || {};
|
|
862
|
-
authConfig[provider] = profileData;
|
|
863
|
-
|
|
864
|
-
// Save to ~/.config/opencode/auth.json
|
|
865
|
-
const configPath = getConfigPath();
|
|
866
|
-
if (configPath) {
|
|
867
|
-
const authPath = path.join(path.dirname(configPath), 'auth.json');
|
|
868
|
-
try {
|
|
869
|
-
fs.writeFileSync(authPath, JSON.stringify(authConfig, null, 2), 'utf8');
|
|
870
|
-
setActiveProfile(provider, name);
|
|
871
|
-
res.json({ success: true });
|
|
872
|
-
} catch (err) {
|
|
873
|
-
res.status(500).json({ error: 'Failed to write auth config', details: err.message });
|
|
874
|
-
}
|
|
875
|
-
} else {
|
|
876
|
-
res.status(500).json({ error: 'Config path not found' });
|
|
877
|
-
}
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
app.delete('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
881
|
-
const { provider, name } = req.params;
|
|
882
|
-
const success = deleteAuthProfile(provider, name);
|
|
883
|
-
if (success) {
|
|
884
|
-
const activeProfiles = getActiveProfiles();
|
|
885
|
-
if (activeProfiles[provider] === name) {
|
|
886
|
-
const studioConfig = loadStudioConfig();
|
|
887
|
-
if (studioConfig.activeProfiles) {
|
|
888
|
-
delete studioConfig.activeProfiles[provider];
|
|
889
|
-
saveStudioConfig(studioConfig);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
res.json({ success: true });
|
|
893
|
-
} else {
|
|
894
|
-
res.status(404).json({ error: 'Profile not found' });
|
|
895
|
-
}
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
app.put('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
899
|
-
const { provider, name } = req.params;
|
|
900
|
-
const { newName } = req.body;
|
|
901
|
-
|
|
902
|
-
if (!newName) return res.status(400).json({ error: 'New name required' });
|
|
903
|
-
|
|
904
|
-
const profileData = loadAuthProfile(provider, name);
|
|
905
|
-
if (!profileData) return res.status(404).json({ error: 'Profile not found' });
|
|
906
|
-
|
|
907
|
-
const success = saveAuthProfile(provider, newName, profileData);
|
|
908
|
-
if (success) {
|
|
909
|
-
deleteAuthProfile(provider, name);
|
|
910
|
-
|
|
911
|
-
const activeProfiles = getActiveProfiles();
|
|
912
|
-
if (activeProfiles[provider] === name) {
|
|
913
|
-
setActiveProfile(provider, newName);
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
res.json({ success: true, name: newName });
|
|
917
|
-
} else {
|
|
918
|
-
res.status(500).json({ error: 'Failed to rename profile' });
|
|
919
|
-
}
|
|
920
|
-
});
|
|
921
|
-
|
|
922
|
-
app.post('/api/plugins/config/add', (req, res) => {
|
|
923
|
-
const { plugins } = req.body;
|
|
924
|
-
if (!Array.isArray(plugins) || plugins.length === 0) {
|
|
925
|
-
return res.status(400).json({ error: 'Plugins array required' });
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
const config = loadConfig();
|
|
929
|
-
if (!config) return res.status(404).json({ error: 'Config not found' });
|
|
930
|
-
|
|
931
|
-
if (!config.plugins) config.plugins = {};
|
|
932
|
-
|
|
933
|
-
const result = {
|
|
934
|
-
added: [],
|
|
935
|
-
skipped: []
|
|
936
|
-
};
|
|
937
|
-
|
|
938
|
-
for (const pluginName of plugins) {
|
|
939
|
-
// Check if plugin exists in studio (for validation)
|
|
940
|
-
const pluginDir = getPluginDir();
|
|
941
|
-
const dirPath = path.join(pluginDir, pluginName);
|
|
942
|
-
const hasJs = fs.existsSync(path.join(dirPath, 'index.js'));
|
|
943
|
-
const hasTs = fs.existsSync(path.join(dirPath, 'index.ts'));
|
|
944
|
-
|
|
945
|
-
if (!hasJs && !hasTs) {
|
|
946
|
-
result.skipped.push(`${pluginName} (not found)`);
|
|
947
|
-
continue;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
// Add to config
|
|
951
|
-
// Default config for a plugin? Usually empty object or enabled: true
|
|
952
|
-
if (config.plugins[pluginName]) {
|
|
953
|
-
result.skipped.push(`${pluginName} (already configured)`);
|
|
954
|
-
} else {
|
|
955
|
-
config.plugins[pluginName] = { enabled: true };
|
|
956
|
-
result.added.push(pluginName);
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
if (result.added.length > 0) {
|
|
961
|
-
saveConfig(config);
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
res.json(result);
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
app.delete('/api/plugins/config/:name', (req, res) => {
|
|
968
|
-
const pluginName = decodeURIComponent(req.params.name);
|
|
969
|
-
const config = loadConfig();
|
|
970
|
-
|
|
971
|
-
if (!config || !config.plugins) {
|
|
972
|
-
return res.status(404).json({ error: 'Config not found or no plugins' });
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
if (config.plugins[pluginName]) {
|
|
976
|
-
delete config.plugins[pluginName];
|
|
977
|
-
saveConfig(config);
|
|
978
|
-
res.json({ success: true });
|
|
979
|
-
} else {
|
|
980
|
-
res.status(404).json({ error: 'Plugin not in config' });
|
|
981
|
-
}
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
app.listen(PORT, () => {
|
|
985
|
-
console.log(`Server running at http://localhost:${PORT}`);
|
|
986
|
-
});
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const cors = require('cors');
|
|
3
|
+
const bodyParser = require('body-parser');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { exec, spawn } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const app = express();
|
|
10
|
+
const PORT = 3001;
|
|
11
|
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
12
|
+
|
|
13
|
+
let lastActivityTime = Date.now();
|
|
14
|
+
let idleTimer = null;
|
|
15
|
+
|
|
16
|
+
function resetIdleTimer() {
|
|
17
|
+
lastActivityTime = Date.now();
|
|
18
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
19
|
+
idleTimer = setTimeout(() => {
|
|
20
|
+
console.log('Server idle for 30 minutes, shutting down...');
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}, IDLE_TIMEOUT_MS);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
resetIdleTimer();
|
|
26
|
+
|
|
27
|
+
app.use((req, res, next) => {
|
|
28
|
+
resetIdleTimer();
|
|
29
|
+
next();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const ALLOWED_ORIGINS = [
|
|
33
|
+
'http://localhost:3000',
|
|
34
|
+
'http://127.0.0.1:3000',
|
|
35
|
+
'https://opencode-studio.vercel.app',
|
|
36
|
+
'https://opencode.micr.dev',
|
|
37
|
+
'https://opencode-studio.micr.dev',
|
|
38
|
+
/\.vercel\.app$/,
|
|
39
|
+
/\.micr\.dev$/,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
app.use(cors({
|
|
43
|
+
origin: (origin, callback) => {
|
|
44
|
+
if (!origin) return callback(null, true);
|
|
45
|
+
const allowed = ALLOWED_ORIGINS.some(o =>
|
|
46
|
+
o instanceof RegExp ? o.test(origin) : o === origin
|
|
47
|
+
);
|
|
48
|
+
callback(null, allowed);
|
|
49
|
+
},
|
|
50
|
+
credentials: true,
|
|
51
|
+
}));
|
|
52
|
+
app.use(bodyParser.json({ limit: '50mb' }));
|
|
53
|
+
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
|
54
|
+
|
|
55
|
+
const HOME_DIR = os.homedir();
|
|
56
|
+
const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
|
|
57
|
+
const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
|
|
58
|
+
|
|
59
|
+
let pendingActionMemory = null;
|
|
60
|
+
|
|
61
|
+
function loadStudioConfig() {
|
|
62
|
+
if (!fs.existsSync(STUDIO_CONFIG_PATH)) {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(fs.readFileSync(STUDIO_CONFIG_PATH, 'utf8'));
|
|
67
|
+
} catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function saveStudioConfig(config) {
|
|
73
|
+
try {
|
|
74
|
+
const dir = path.dirname(STUDIO_CONFIG_PATH);
|
|
75
|
+
if (!fs.existsSync(dir)) {
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
fs.writeFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
79
|
+
return true;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error('Failed to save studio config:', err);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function loadPendingAction() {
|
|
87
|
+
if (pendingActionMemory) return pendingActionMemory;
|
|
88
|
+
|
|
89
|
+
if (fs.existsSync(PENDING_ACTION_PATH)) {
|
|
90
|
+
try {
|
|
91
|
+
const action = JSON.parse(fs.readFileSync(PENDING_ACTION_PATH, 'utf8'));
|
|
92
|
+
if (action.timestamp && Date.now() - action.timestamp < 60000) {
|
|
93
|
+
pendingActionMemory = action;
|
|
94
|
+
fs.unlinkSync(PENDING_ACTION_PATH);
|
|
95
|
+
return action;
|
|
96
|
+
}
|
|
97
|
+
fs.unlinkSync(PENDING_ACTION_PATH);
|
|
98
|
+
} catch {
|
|
99
|
+
try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
app.get('/api/pending-action', (req, res) => {
|
|
106
|
+
const action = loadPendingAction();
|
|
107
|
+
res.json({ action });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.delete('/api/pending-action', (req, res) => {
|
|
111
|
+
pendingActionMemory = null;
|
|
112
|
+
if (fs.existsSync(PENDING_ACTION_PATH)) {
|
|
113
|
+
try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
|
|
114
|
+
}
|
|
115
|
+
res.json({ success: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const getPaths = () => {
|
|
119
|
+
const platform = process.platform;
|
|
120
|
+
const home = os.homedir();
|
|
121
|
+
|
|
122
|
+
let candidates = [];
|
|
123
|
+
if (platform === 'win32') {
|
|
124
|
+
candidates = [
|
|
125
|
+
path.join(process.env.APPDATA, 'opencode', 'opencode.json'),
|
|
126
|
+
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
127
|
+
];
|
|
128
|
+
} else {
|
|
129
|
+
candidates = [
|
|
130
|
+
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
131
|
+
path.join(home, '.opencode', 'opencode.json'),
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const studioConfig = loadStudioConfig();
|
|
136
|
+
const manualPath = studioConfig.configPath;
|
|
137
|
+
|
|
138
|
+
let detected = null;
|
|
139
|
+
for (const p of candidates) {
|
|
140
|
+
if (fs.existsSync(p)) {
|
|
141
|
+
detected = p;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
detected,
|
|
148
|
+
manual: manualPath,
|
|
149
|
+
current: manualPath || detected,
|
|
150
|
+
candidates
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const getConfigPath = () => {
|
|
155
|
+
const paths = getPaths();
|
|
156
|
+
return paths.current;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const loadConfig = () => {
|
|
160
|
+
const configPath = getConfigPath();
|
|
161
|
+
if (!configPath || !fs.existsSync(configPath)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const saveConfig = (config) => {
|
|
172
|
+
const configPath = getConfigPath();
|
|
173
|
+
if (!configPath) {
|
|
174
|
+
throw new Error('No config path found');
|
|
175
|
+
}
|
|
176
|
+
const dir = path.dirname(configPath);
|
|
177
|
+
if (!fs.existsSync(dir)) {
|
|
178
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
app.get('/api/health', (req, res) => {
|
|
184
|
+
res.json({ status: 'ok' });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
app.post('/api/shutdown', (req, res) => {
|
|
188
|
+
res.json({ success: true });
|
|
189
|
+
setTimeout(() => process.exit(0), 100);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
app.get('/api/paths', (req, res) => {
|
|
193
|
+
res.json(getPaths());
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
app.post('/api/paths', (req, res) => {
|
|
197
|
+
const { configPath } = req.body;
|
|
198
|
+
const studioConfig = loadStudioConfig();
|
|
199
|
+
studioConfig.configPath = configPath;
|
|
200
|
+
saveStudioConfig(studioConfig);
|
|
201
|
+
res.json({ success: true, current: getConfigPath() });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
app.get('/api/config', (req, res) => {
|
|
205
|
+
const config = loadConfig();
|
|
206
|
+
if (!config) {
|
|
207
|
+
return res.status(404).json({ error: 'Config not found' });
|
|
208
|
+
}
|
|
209
|
+
res.json(config);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
app.post('/api/config', (req, res) => {
|
|
213
|
+
const config = req.body;
|
|
214
|
+
try {
|
|
215
|
+
saveConfig(config);
|
|
216
|
+
res.json({ success: true });
|
|
217
|
+
} catch (err) {
|
|
218
|
+
res.status(500).json({ error: err.message });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Helper to get skill dir
|
|
223
|
+
const getSkillDir = () => {
|
|
224
|
+
const configPath = getConfigPath();
|
|
225
|
+
if (!configPath) return null;
|
|
226
|
+
return path.join(path.dirname(configPath), 'skill');
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
app.get('/api/skills', (req, res) => {
|
|
230
|
+
const skillDir = getSkillDir();
|
|
231
|
+
if (!skillDir || !fs.existsSync(skillDir)) {
|
|
232
|
+
return res.json([]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const skills = [];
|
|
236
|
+
const entries = fs.readdirSync(skillDir, { withFileTypes: true });
|
|
237
|
+
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
if (entry.isDirectory()) {
|
|
240
|
+
const skillPath = path.join(skillDir, entry.name, 'SKILL.md');
|
|
241
|
+
if (fs.existsSync(skillPath)) {
|
|
242
|
+
skills.push({
|
|
243
|
+
name: entry.name,
|
|
244
|
+
path: skillPath,
|
|
245
|
+
enabled: !entry.name.endsWith('.disabled') // Simplified logic
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
res.json(skills);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
app.get('/api/skills/:name', (req, res) => {
|
|
254
|
+
const skillDir = getSkillDir();
|
|
255
|
+
if (!skillDir) return res.status(404).json({ error: 'No config' });
|
|
256
|
+
|
|
257
|
+
const name = req.params.name;
|
|
258
|
+
const skillPath = path.join(skillDir, name, 'SKILL.md');
|
|
259
|
+
|
|
260
|
+
if (!fs.existsSync(skillPath)) {
|
|
261
|
+
return res.status(404).json({ error: 'Skill not found' });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const content = fs.readFileSync(skillPath, 'utf8');
|
|
265
|
+
res.json({ name, content });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
app.post('/api/skills/:name', (req, res) => {
|
|
269
|
+
const skillDir = getSkillDir();
|
|
270
|
+
if (!skillDir) return res.status(404).json({ error: 'No config' });
|
|
271
|
+
|
|
272
|
+
const name = req.params.name;
|
|
273
|
+
const { content } = req.body;
|
|
274
|
+
const dirPath = path.join(skillDir, name);
|
|
275
|
+
|
|
276
|
+
if (!fs.existsSync(dirPath)) {
|
|
277
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
fs.writeFileSync(path.join(dirPath, 'SKILL.md'), content, 'utf8');
|
|
281
|
+
res.json({ success: true });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
app.delete('/api/skills/:name', (req, res) => {
|
|
285
|
+
const skillDir = getSkillDir();
|
|
286
|
+
if (!skillDir) return res.status(404).json({ error: 'No config' });
|
|
287
|
+
|
|
288
|
+
const name = req.params.name;
|
|
289
|
+
const dirPath = path.join(skillDir, name);
|
|
290
|
+
|
|
291
|
+
if (fs.existsSync(dirPath)) {
|
|
292
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
res.json({ success: true });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
app.post('/api/skills/:name/toggle', (req, res) => {
|
|
298
|
+
// Simplified: renaming not implemented for now to avoid complexity
|
|
299
|
+
// In real implementation we would rename folder to .disabled
|
|
300
|
+
res.json({ success: true, enabled: true });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Helper to get plugin dir
|
|
304
|
+
const getPluginDir = () => {
|
|
305
|
+
const configPath = getConfigPath();
|
|
306
|
+
if (!configPath) return null;
|
|
307
|
+
return path.join(path.dirname(configPath), 'plugin');
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
app.get('/api/plugins', (req, res) => {
|
|
311
|
+
const pluginDir = getPluginDir();
|
|
312
|
+
if (!pluginDir || !fs.existsSync(pluginDir)) {
|
|
313
|
+
return res.json([]);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const plugins = [];
|
|
317
|
+
const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
|
|
318
|
+
|
|
319
|
+
for (const entry of entries) {
|
|
320
|
+
if (entry.isDirectory()) {
|
|
321
|
+
// Check for index.js or index.ts
|
|
322
|
+
const jsPath = path.join(pluginDir, entry.name, 'index.js');
|
|
323
|
+
const tsPath = path.join(pluginDir, entry.name, 'index.ts');
|
|
324
|
+
|
|
325
|
+
if (fs.existsSync(jsPath) || fs.existsSync(tsPath)) {
|
|
326
|
+
plugins.push({
|
|
327
|
+
name: entry.name,
|
|
328
|
+
path: fs.existsSync(jsPath) ? jsPath : tsPath,
|
|
329
|
+
enabled: true
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
res.json(plugins);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
app.get('/api/plugins/:name', (req, res) => {
|
|
338
|
+
const pluginDir = getPluginDir();
|
|
339
|
+
if (!pluginDir) return res.status(404).json({ error: 'No config' });
|
|
340
|
+
|
|
341
|
+
const name = req.params.name;
|
|
342
|
+
const dirPath = path.join(pluginDir, name);
|
|
343
|
+
|
|
344
|
+
let content = '';
|
|
345
|
+
let filename = '';
|
|
346
|
+
|
|
347
|
+
if (fs.existsSync(path.join(dirPath, 'index.js'))) {
|
|
348
|
+
content = fs.readFileSync(path.join(dirPath, 'index.js'), 'utf8');
|
|
349
|
+
filename = 'index.js';
|
|
350
|
+
} else if (fs.existsSync(path.join(dirPath, 'index.ts'))) {
|
|
351
|
+
content = fs.readFileSync(path.join(dirPath, 'index.ts'), 'utf8');
|
|
352
|
+
filename = 'index.ts';
|
|
353
|
+
} else {
|
|
354
|
+
return res.status(404).json({ error: 'Plugin not found' });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
res.json({ name, content, filename });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
app.post('/api/plugins/:name', (req, res) => {
|
|
361
|
+
const pluginDir = getPluginDir();
|
|
362
|
+
if (!pluginDir) return res.status(404).json({ error: 'No config' });
|
|
363
|
+
|
|
364
|
+
const name = req.params.name;
|
|
365
|
+
const { content } = req.body;
|
|
366
|
+
const dirPath = path.join(pluginDir, name);
|
|
367
|
+
|
|
368
|
+
if (!fs.existsSync(dirPath)) {
|
|
369
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Determine file extension based on content? Default to .js if new
|
|
373
|
+
const filePath = path.join(dirPath, 'index.js');
|
|
374
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
375
|
+
res.json({ success: true });
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
app.delete('/api/plugins/:name', (req, res) => {
|
|
379
|
+
const pluginDir = getPluginDir();
|
|
380
|
+
if (!pluginDir) return res.status(404).json({ error: 'No config' });
|
|
381
|
+
|
|
382
|
+
const name = req.params.name;
|
|
383
|
+
const dirPath = path.join(pluginDir, name);
|
|
384
|
+
|
|
385
|
+
if (fs.existsSync(dirPath)) {
|
|
386
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
387
|
+
}
|
|
388
|
+
res.json({ success: true });
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
app.post('/api/plugins/:name/toggle', (req, res) => {
|
|
392
|
+
res.json({ success: true, enabled: true });
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
app.post('/api/fetch-url', async (req, res) => {
|
|
396
|
+
const { url } = req.body;
|
|
397
|
+
if (!url) return res.status(400).json({ error: 'URL required' });
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const fetch = (await import('node-fetch')).default;
|
|
401
|
+
const response = await fetch(url);
|
|
402
|
+
if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
|
|
403
|
+
const content = await response.text();
|
|
404
|
+
const filename = path.basename(new URL(url).pathname) || 'file.txt';
|
|
405
|
+
|
|
406
|
+
res.json({ content, filename, url });
|
|
407
|
+
} catch (err) {
|
|
408
|
+
res.status(500).json({ error: err.message });
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
app.post('/api/bulk-fetch', async (req, res) => {
|
|
413
|
+
const { urls } = req.body;
|
|
414
|
+
if (!Array.isArray(urls)) return res.status(400).json({ error: 'URLs array required' });
|
|
415
|
+
|
|
416
|
+
const fetch = (await import('node-fetch')).default;
|
|
417
|
+
const results = [];
|
|
418
|
+
|
|
419
|
+
// Limit concurrency? For now sequential is safer
|
|
420
|
+
for (const url of urls) {
|
|
421
|
+
try {
|
|
422
|
+
const response = await fetch(url);
|
|
423
|
+
if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
|
|
424
|
+
const content = await response.text();
|
|
425
|
+
const filename = path.basename(new URL(url).pathname) || 'file.txt';
|
|
426
|
+
|
|
427
|
+
// Try to extract name/description from content (simple regex for markdown/js)
|
|
428
|
+
// This is basic heuristic
|
|
429
|
+
results.push({
|
|
430
|
+
url,
|
|
431
|
+
success: true,
|
|
432
|
+
content,
|
|
433
|
+
filename,
|
|
434
|
+
name: filename.replace(/\.(md|js|ts)$/, ''),
|
|
435
|
+
});
|
|
436
|
+
} catch (err) {
|
|
437
|
+
results.push({
|
|
438
|
+
url,
|
|
439
|
+
success: false,
|
|
440
|
+
error: err.message
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
res.json({ results });
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
app.get('/api/backup', (req, res) => {
|
|
449
|
+
const studioConfig = loadStudioConfig();
|
|
450
|
+
const opencodeConfig = loadConfig();
|
|
451
|
+
|
|
452
|
+
const skills = [];
|
|
453
|
+
const skillDir = getSkillDir();
|
|
454
|
+
if (skillDir && fs.existsSync(skillDir)) {
|
|
455
|
+
const entries = fs.readdirSync(skillDir, { withFileTypes: true });
|
|
456
|
+
for (const entry of entries) {
|
|
457
|
+
if (entry.isDirectory()) {
|
|
458
|
+
const p = path.join(skillDir, entry.name, 'SKILL.md');
|
|
459
|
+
if (fs.existsSync(p)) {
|
|
460
|
+
skills.push({ name: entry.name, content: fs.readFileSync(p, 'utf8') });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const plugins = [];
|
|
467
|
+
const pluginDir = getPluginDir();
|
|
468
|
+
if (pluginDir && fs.existsSync(pluginDir)) {
|
|
469
|
+
const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
|
|
470
|
+
for (const entry of entries) {
|
|
471
|
+
if (entry.isDirectory()) {
|
|
472
|
+
const p = path.join(pluginDir, entry.name, 'index.js');
|
|
473
|
+
if (fs.existsSync(p)) {
|
|
474
|
+
plugins.push({ name: entry.name, content: fs.readFileSync(p, 'utf8') });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
res.json({
|
|
481
|
+
version: 1,
|
|
482
|
+
timestamp: new Date().toISOString(),
|
|
483
|
+
studioConfig,
|
|
484
|
+
opencodeConfig,
|
|
485
|
+
skills,
|
|
486
|
+
plugins
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
app.post('/api/restore', (req, res) => {
|
|
491
|
+
const backup = req.body;
|
|
492
|
+
|
|
493
|
+
if (backup.studioConfig) {
|
|
494
|
+
saveStudioConfig(backup.studioConfig);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (backup.opencodeConfig) {
|
|
498
|
+
saveConfig(backup.opencodeConfig);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (Array.isArray(backup.skills)) {
|
|
502
|
+
const skillDir = getSkillDir();
|
|
503
|
+
if (skillDir) {
|
|
504
|
+
if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
|
|
505
|
+
for (const skill of backup.skills) {
|
|
506
|
+
const dir = path.join(skillDir, skill.name);
|
|
507
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
508
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), skill.content, 'utf8');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (Array.isArray(backup.plugins)) {
|
|
514
|
+
const pluginDir = getPluginDir();
|
|
515
|
+
if (pluginDir) {
|
|
516
|
+
if (!fs.existsSync(pluginDir)) fs.mkdirSync(pluginDir, { recursive: true });
|
|
517
|
+
for (const plugin of backup.plugins) {
|
|
518
|
+
const dir = path.join(pluginDir, plugin.name);
|
|
519
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
520
|
+
fs.writeFileSync(path.join(dir, 'index.js'), plugin.content, 'utf8');
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
res.json({ success: true });
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Auth Handlers
|
|
529
|
+
function loadAuthConfig() {
|
|
530
|
+
const configPath = getConfigPath();
|
|
531
|
+
if (!configPath) return null;
|
|
532
|
+
|
|
533
|
+
// Auth config is usually in ~/.config/opencode/auth.json ?
|
|
534
|
+
// Or inside opencode.json?
|
|
535
|
+
// Based on opencode CLI, it seems to store auth in separate files or system keychain.
|
|
536
|
+
// But for this studio, we might just look at opencode.json "providers" section or similar.
|
|
537
|
+
|
|
538
|
+
// ACTUALLY, opencode stores auth tokens in ~/.config/opencode/auth.json
|
|
539
|
+
const authPath = path.join(path.dirname(configPath), 'auth.json');
|
|
540
|
+
if (!fs.existsSync(authPath)) return null;
|
|
541
|
+
try {
|
|
542
|
+
return JSON.parse(fs.readFileSync(authPath, 'utf8'));
|
|
543
|
+
} catch {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
app.get('/api/auth', (req, res) => {
|
|
549
|
+
const authConfig = loadAuthConfig();
|
|
550
|
+
res.json(authConfig || {});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
app.post('/api/auth/login', (req, res) => {
|
|
554
|
+
const { provider } = req.body;
|
|
555
|
+
// Launch opencode CLI auth login
|
|
556
|
+
// This requires 'opencode' to be in PATH
|
|
557
|
+
|
|
558
|
+
const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
|
|
559
|
+
|
|
560
|
+
// We spawn it so it opens the browser
|
|
561
|
+
const child = spawn(cmd, ['auth', 'login', provider], {
|
|
562
|
+
stdio: 'inherit',
|
|
563
|
+
shell: true
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
res.json({ success: true, message: 'Launched auth flow', note: 'Please check the terminal window where the server is running' });
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
app.delete('/api/auth/:provider', (req, res) => {
|
|
570
|
+
// Logout logic
|
|
571
|
+
// Maybe just delete from auth.json? Or run opencode auth logout?
|
|
572
|
+
const { provider } = req.params;
|
|
573
|
+
const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
|
|
574
|
+
|
|
575
|
+
spawn(cmd, ['auth', 'logout', provider], {
|
|
576
|
+
stdio: 'inherit',
|
|
577
|
+
shell: true
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
res.json({ success: true });
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
app.get('/api/auth/providers', (req, res) => {
|
|
584
|
+
// List of supported providers (hardcoded for now as it's not easily discoverable via CLI)
|
|
585
|
+
const providers = [
|
|
586
|
+
{ id: 'google', name: 'Google AI', type: 'oauth', description: 'Use Google Gemini models' },
|
|
587
|
+
{ id: 'anthropic', name: 'Anthropic', type: 'api', description: 'Use Claude models' },
|
|
588
|
+
{ id: 'openai', name: 'OpenAI', type: 'api', description: 'Use GPT models' },
|
|
589
|
+
{ id: 'xai', name: 'xAI', type: 'api', description: 'Use Grok models' },
|
|
590
|
+
{ id: 'groq', name: 'Groq', type: 'api', description: 'Fast inference' },
|
|
591
|
+
{ id: 'together', name: 'Together AI', type: 'api', description: 'Open source models' },
|
|
592
|
+
{ id: 'mistral', name: 'Mistral', type: 'api', description: 'Mistral models' },
|
|
593
|
+
{ id: 'deepseek', name: 'DeepSeek', type: 'api', description: 'DeepSeek models' },
|
|
594
|
+
{ id: 'openrouter', name: 'OpenRouter', type: 'api', description: 'Multiple providers' },
|
|
595
|
+
{ id: 'amazon-bedrock', name: 'Amazon Bedrock', type: 'api', description: 'AWS models' },
|
|
596
|
+
{ id: 'azure', name: 'Azure OpenAI', type: 'api', description: 'Azure GPT models' },
|
|
597
|
+
];
|
|
598
|
+
res.json(providers);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const AUTH_PROFILES_DIR = path.join(HOME_DIR, '.config', 'opencode-studio', 'auth-profiles');
|
|
602
|
+
|
|
603
|
+
function ensureAuthProfilesDir() {
|
|
604
|
+
if (!fs.existsSync(AUTH_PROFILES_DIR)) {
|
|
605
|
+
fs.mkdirSync(AUTH_PROFILES_DIR, { recursive: true });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function getProviderProfilesDir(provider) {
|
|
610
|
+
return path.join(AUTH_PROFILES_DIR, provider);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function listAuthProfiles(provider) {
|
|
614
|
+
const dir = getProviderProfilesDir(provider);
|
|
615
|
+
if (!fs.existsSync(dir)) return [];
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
return fs.readdirSync(dir)
|
|
619
|
+
.filter(f => f.endsWith('.json'))
|
|
620
|
+
.map(f => f.replace('.json', ''));
|
|
621
|
+
} catch {
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function getNextProfileName(provider) {
|
|
627
|
+
const existing = listAuthProfiles(provider);
|
|
628
|
+
let num = 1;
|
|
629
|
+
while (existing.includes(`account-${num}`)) {
|
|
630
|
+
num++;
|
|
631
|
+
}
|
|
632
|
+
return `account-${num}`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function saveAuthProfile(provider, profileName, data) {
|
|
636
|
+
ensureAuthProfilesDir();
|
|
637
|
+
const dir = getProviderProfilesDir(provider);
|
|
638
|
+
if (!fs.existsSync(dir)) {
|
|
639
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
640
|
+
}
|
|
641
|
+
const filePath = path.join(dir, `${profileName}.json`);
|
|
642
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function loadAuthProfile(provider, profileName) {
|
|
647
|
+
const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
|
|
648
|
+
if (!fs.existsSync(filePath)) return null;
|
|
649
|
+
try {
|
|
650
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
651
|
+
} catch {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function deleteAuthProfile(provider, profileName) {
|
|
657
|
+
const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
|
|
658
|
+
if (fs.existsSync(filePath)) {
|
|
659
|
+
fs.unlinkSync(filePath);
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function getActiveProfiles() {
|
|
666
|
+
const studioConfig = loadStudioConfig();
|
|
667
|
+
return studioConfig.activeProfiles || {};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function setActiveProfile(provider, profileName) {
|
|
671
|
+
const studioConfig = loadStudioConfig();
|
|
672
|
+
studioConfig.activeProfiles = studioConfig.activeProfiles || {};
|
|
673
|
+
studioConfig.activeProfiles[provider] = profileName;
|
|
674
|
+
saveStudioConfig(studioConfig);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function verifyActiveProfile(p, n, c) { console.log("Verifying:", p, n); if (!n || !c) return false; const d = loadAuthProfile(p, n); if (d && c) { console.log("Profile refresh:", d.refresh); console.log("Current refresh:", c.refresh); } if (!d) return false; return JSON.stringify(d) === JSON.stringify(c); }
|
|
678
|
+
|
|
679
|
+
app.get('/api/auth/profiles', (req, res) => {
|
|
680
|
+
ensureAuthProfilesDir();
|
|
681
|
+
const activeProfiles = getActiveProfiles();
|
|
682
|
+
const authConfig = loadAuthConfig() || {};
|
|
683
|
+
|
|
684
|
+
const profiles = {};
|
|
685
|
+
const providers = [
|
|
686
|
+
'google', 'anthropic', 'openai', 'xai', 'groq',
|
|
687
|
+
'together', 'mistral', 'deepseek', 'openrouter',
|
|
688
|
+
'amazon-bedrock', 'azure'
|
|
689
|
+
];
|
|
690
|
+
|
|
691
|
+
providers.forEach(p => {
|
|
692
|
+
const saved = listAuthProfiles(p);
|
|
693
|
+
const active = activeProfiles[p];
|
|
694
|
+
const current = authConfig[p];
|
|
695
|
+
|
|
696
|
+
if (saved.length > 0 || current) {
|
|
697
|
+
profiles[p] = {
|
|
698
|
+
active: active,
|
|
699
|
+
saved: saved,
|
|
700
|
+
hasCurrent: !!current
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
res.json(profiles);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
app.get('/api/debug/paths', (req, res) => {
|
|
709
|
+
const home = os.homedir();
|
|
710
|
+
const candidatePaths = [
|
|
711
|
+
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'opencode', 'storage', 'message') : null,
|
|
712
|
+
path.join(home, '.local', 'share', 'opencode', 'storage', 'message'),
|
|
713
|
+
path.join(home, '.opencode', 'storage', 'message')
|
|
714
|
+
].filter(p => p);
|
|
715
|
+
|
|
716
|
+
const results = candidatePaths.map(p => ({
|
|
717
|
+
path: p,
|
|
718
|
+
exists: fs.existsSync(p),
|
|
719
|
+
isDirectory: fs.existsSync(p) && fs.statSync(p).isDirectory(),
|
|
720
|
+
fileCount: (fs.existsSync(p) && fs.statSync(p).isDirectory()) ? fs.readdirSync(p).length : 0
|
|
721
|
+
}));
|
|
722
|
+
|
|
723
|
+
res.json({
|
|
724
|
+
home,
|
|
725
|
+
platform: process.platform,
|
|
726
|
+
candidates: results
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
app.get('/api/usage', async (req, res) => {
|
|
731
|
+
try {
|
|
732
|
+
const home = os.homedir();
|
|
733
|
+
const candidatePaths = [
|
|
734
|
+
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'opencode', 'storage', 'message') : null,
|
|
735
|
+
path.join(home, '.local', 'share', 'opencode', 'storage', 'message'),
|
|
736
|
+
path.join(home, '.opencode', 'storage', 'message')
|
|
737
|
+
].filter(p => p && fs.existsSync(p));
|
|
738
|
+
|
|
739
|
+
const stats = {
|
|
740
|
+
totalCost: 0,
|
|
741
|
+
totalTokens: 0,
|
|
742
|
+
byModel: {},
|
|
743
|
+
byDay: {}
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const processedFiles = new Set();
|
|
747
|
+
|
|
748
|
+
const processMessage = (filePath) => {
|
|
749
|
+
if (processedFiles.has(filePath)) return;
|
|
750
|
+
processedFiles.add(filePath);
|
|
751
|
+
|
|
752
|
+
try {
|
|
753
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
754
|
+
const msg = JSON.parse(content);
|
|
755
|
+
|
|
756
|
+
const model = msg.modelID || (msg.model && (msg.model.modelID || msg.model.id)) || 'unknown';
|
|
757
|
+
|
|
758
|
+
if (msg.role === 'assistant' && msg.tokens) {
|
|
759
|
+
const cost = msg.cost || 0;
|
|
760
|
+
const tokens = (msg.tokens.input || 0) + (msg.tokens.output || 0);
|
|
761
|
+
const date = new Date(msg.time.created).toISOString().split('T')[0];
|
|
762
|
+
|
|
763
|
+
if (tokens > 0) {
|
|
764
|
+
stats.totalCost += cost;
|
|
765
|
+
stats.totalTokens += tokens;
|
|
766
|
+
|
|
767
|
+
if (!stats.byModel[model]) {
|
|
768
|
+
stats.byModel[model] = { name: model, cost: 0, tokens: 0 };
|
|
769
|
+
}
|
|
770
|
+
stats.byModel[model].cost += cost;
|
|
771
|
+
stats.byModel[model].tokens += tokens;
|
|
772
|
+
|
|
773
|
+
if (!stats.byDay[date]) {
|
|
774
|
+
stats.byDay[date] = { date, cost: 0, tokens: 0 };
|
|
775
|
+
}
|
|
776
|
+
stats.byDay[date].cost += cost;
|
|
777
|
+
stats.byDay[date].tokens += tokens;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
} catch (err) {
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
for (const logDir of candidatePaths) {
|
|
785
|
+
try {
|
|
786
|
+
const sessions = fs.readdirSync(logDir);
|
|
787
|
+
for (const session of sessions) {
|
|
788
|
+
if (!session.startsWith('ses_')) continue;
|
|
789
|
+
|
|
790
|
+
const sessionDir = path.join(logDir, session);
|
|
791
|
+
if (fs.statSync(sessionDir).isDirectory()) {
|
|
792
|
+
const messages = fs.readdirSync(sessionDir);
|
|
793
|
+
for (const msgFile of messages) {
|
|
794
|
+
if (msgFile.endsWith('.json')) {
|
|
795
|
+
processMessage(path.join(sessionDir, msgFile));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
} catch (err) {
|
|
801
|
+
console.error(`Error reading log dir ${logDir}:`, err);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const response = {
|
|
806
|
+
totalCost: stats.totalCost,
|
|
807
|
+
totalTokens: stats.totalTokens,
|
|
808
|
+
byModel: Object.values(stats.byModel).sort((a, b) => b.cost - a.cost),
|
|
809
|
+
byDay: Object.values(stats.byDay).sort((a, b) => a.date.localeCompare(b.date))
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
res.json(response);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
console.error('Error fetching usage stats:', error);
|
|
815
|
+
res.status(500).json({ error: 'Failed to fetch usage stats' });
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
app.get('/api/auth/profiles/:provider', (req, res) => {
|
|
820
|
+
const { provider } = req.params;
|
|
821
|
+
const providerProfiles = listAuthProfiles(provider);
|
|
822
|
+
const activeProfiles = getActiveProfiles();
|
|
823
|
+
const authConfig = loadAuthConfig() || {};
|
|
824
|
+
|
|
825
|
+
res.json({
|
|
826
|
+
profiles: providerProfiles,
|
|
827
|
+
active: (activeProfiles[provider] && verifyActiveProfile(provider, activeProfiles[provider], authConfig[provider])) ? activeProfiles[provider] : null,
|
|
828
|
+
hasCurrentAuth: !!authConfig[provider],
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
app.post('/api/auth/profiles/:provider', (req, res) => {
|
|
833
|
+
const { provider } = req.params;
|
|
834
|
+
const { name } = req.body;
|
|
835
|
+
|
|
836
|
+
const authConfig = loadAuthConfig();
|
|
837
|
+
if (!authConfig || !authConfig[provider]) {
|
|
838
|
+
return res.status(400).json({ error: `No active auth for ${provider} to save` });
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const profileName = name || getNextProfileName(provider);
|
|
842
|
+
const data = authConfig[provider];
|
|
843
|
+
|
|
844
|
+
try {
|
|
845
|
+
saveAuthProfile(provider, profileName, data);
|
|
846
|
+
setActiveProfile(provider, profileName);
|
|
847
|
+
res.json({ success: true, name: profileName });
|
|
848
|
+
} catch (err) {
|
|
849
|
+
res.status(500).json({ error: 'Failed to save profile', details: err.message });
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
|
|
854
|
+
const { provider, name } = req.params;
|
|
855
|
+
|
|
856
|
+
const profileData = loadAuthProfile(provider, name);
|
|
857
|
+
if (!profileData) {
|
|
858
|
+
return res.status(404).json({ error: 'Profile not found' });
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const authConfig = loadAuthConfig() || {};
|
|
862
|
+
authConfig[provider] = profileData;
|
|
863
|
+
|
|
864
|
+
// Save to ~/.config/opencode/auth.json
|
|
865
|
+
const configPath = getConfigPath();
|
|
866
|
+
if (configPath) {
|
|
867
|
+
const authPath = path.join(path.dirname(configPath), 'auth.json');
|
|
868
|
+
try {
|
|
869
|
+
fs.writeFileSync(authPath, JSON.stringify(authConfig, null, 2), 'utf8');
|
|
870
|
+
setActiveProfile(provider, name);
|
|
871
|
+
res.json({ success: true });
|
|
872
|
+
} catch (err) {
|
|
873
|
+
res.status(500).json({ error: 'Failed to write auth config', details: err.message });
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
res.status(500).json({ error: 'Config path not found' });
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
app.delete('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
881
|
+
const { provider, name } = req.params;
|
|
882
|
+
const success = deleteAuthProfile(provider, name);
|
|
883
|
+
if (success) {
|
|
884
|
+
const activeProfiles = getActiveProfiles();
|
|
885
|
+
if (activeProfiles[provider] === name) {
|
|
886
|
+
const studioConfig = loadStudioConfig();
|
|
887
|
+
if (studioConfig.activeProfiles) {
|
|
888
|
+
delete studioConfig.activeProfiles[provider];
|
|
889
|
+
saveStudioConfig(studioConfig);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
res.json({ success: true });
|
|
893
|
+
} else {
|
|
894
|
+
res.status(404).json({ error: 'Profile not found' });
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
app.put('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
899
|
+
const { provider, name } = req.params;
|
|
900
|
+
const { newName } = req.body;
|
|
901
|
+
|
|
902
|
+
if (!newName) return res.status(400).json({ error: 'New name required' });
|
|
903
|
+
|
|
904
|
+
const profileData = loadAuthProfile(provider, name);
|
|
905
|
+
if (!profileData) return res.status(404).json({ error: 'Profile not found' });
|
|
906
|
+
|
|
907
|
+
const success = saveAuthProfile(provider, newName, profileData);
|
|
908
|
+
if (success) {
|
|
909
|
+
deleteAuthProfile(provider, name);
|
|
910
|
+
|
|
911
|
+
const activeProfiles = getActiveProfiles();
|
|
912
|
+
if (activeProfiles[provider] === name) {
|
|
913
|
+
setActiveProfile(provider, newName);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
res.json({ success: true, name: newName });
|
|
917
|
+
} else {
|
|
918
|
+
res.status(500).json({ error: 'Failed to rename profile' });
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
app.post('/api/plugins/config/add', (req, res) => {
|
|
923
|
+
const { plugins } = req.body;
|
|
924
|
+
if (!Array.isArray(plugins) || plugins.length === 0) {
|
|
925
|
+
return res.status(400).json({ error: 'Plugins array required' });
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const config = loadConfig();
|
|
929
|
+
if (!config) return res.status(404).json({ error: 'Config not found' });
|
|
930
|
+
|
|
931
|
+
if (!config.plugins) config.plugins = {};
|
|
932
|
+
|
|
933
|
+
const result = {
|
|
934
|
+
added: [],
|
|
935
|
+
skipped: []
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
for (const pluginName of plugins) {
|
|
939
|
+
// Check if plugin exists in studio (for validation)
|
|
940
|
+
const pluginDir = getPluginDir();
|
|
941
|
+
const dirPath = path.join(pluginDir, pluginName);
|
|
942
|
+
const hasJs = fs.existsSync(path.join(dirPath, 'index.js'));
|
|
943
|
+
const hasTs = fs.existsSync(path.join(dirPath, 'index.ts'));
|
|
944
|
+
|
|
945
|
+
if (!hasJs && !hasTs) {
|
|
946
|
+
result.skipped.push(`${pluginName} (not found)`);
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Add to config
|
|
951
|
+
// Default config for a plugin? Usually empty object or enabled: true
|
|
952
|
+
if (config.plugins[pluginName]) {
|
|
953
|
+
result.skipped.push(`${pluginName} (already configured)`);
|
|
954
|
+
} else {
|
|
955
|
+
config.plugins[pluginName] = { enabled: true };
|
|
956
|
+
result.added.push(pluginName);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (result.added.length > 0) {
|
|
961
|
+
saveConfig(config);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
res.json(result);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
app.delete('/api/plugins/config/:name', (req, res) => {
|
|
968
|
+
const pluginName = decodeURIComponent(req.params.name);
|
|
969
|
+
const config = loadConfig();
|
|
970
|
+
|
|
971
|
+
if (!config || !config.plugins) {
|
|
972
|
+
return res.status(404).json({ error: 'Config not found or no plugins' });
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (config.plugins[pluginName]) {
|
|
976
|
+
delete config.plugins[pluginName];
|
|
977
|
+
saveConfig(config);
|
|
978
|
+
res.json({ success: true });
|
|
979
|
+
} else {
|
|
980
|
+
res.status(404).json({ error: 'Plugin not in config' });
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
app.listen(PORT, () => {
|
|
985
|
+
console.log(`Server running at http://localhost:${PORT}`);
|
|
986
|
+
});
|