opencode-studio-server 1.0.12 → 1.0.13
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 +928 -1085
- package/launcher.vbs +1 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
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
|
-
|
|
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
59
|
let pendingActionMemory = null;
|
|
60
60
|
|
|
61
61
|
function loadStudioConfig() {
|
|
@@ -84,911 +84,596 @@ function saveStudioConfig(config) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
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
|
-
function clearPendingAction() {
|
|
106
|
-
pendingActionMemory = null;
|
|
107
|
-
if (fs.existsSync(PENDING_ACTION_PATH)) {
|
|
108
|
-
try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function setPendingAction(action) {
|
|
113
|
-
pendingActionMemory = { ...action, timestamp: Date.now() };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const AUTH_CANDIDATE_PATHS = [
|
|
117
|
-
path.join(HOME_DIR, '.local', 'share', 'opencode', 'auth.json'),
|
|
118
|
-
path.join(process.env.LOCALAPPDATA || '', 'opencode', 'auth.json'),
|
|
119
|
-
path.join(process.env.APPDATA || '', 'opencode', 'auth.json'),
|
|
120
|
-
];
|
|
121
|
-
|
|
122
|
-
const CANDIDATE_PATHS = [
|
|
123
|
-
path.join(HOME_DIR, '.config', 'opencode'),
|
|
124
|
-
path.join(HOME_DIR, '.opencode'),
|
|
125
|
-
path.join(process.env.APPDATA || '', 'opencode'),
|
|
126
|
-
path.join(process.env.LOCALAPPDATA || '', 'opencode'),
|
|
127
|
-
];
|
|
128
|
-
|
|
129
|
-
function getConfigDir() {
|
|
130
|
-
for (const candidate of CANDIDATE_PATHS) {
|
|
131
|
-
if (fs.existsSync(candidate)) {
|
|
132
|
-
return candidate;
|
|
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 {}
|
|
133
100
|
}
|
|
134
101
|
}
|
|
135
102
|
return null;
|
|
136
103
|
}
|
|
137
104
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
};
|
|
151
152
|
};
|
|
152
153
|
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
app.
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (configPath) {
|
|
248
|
-
const configFile = path.join(configPath, 'opencode.json');
|
|
249
|
-
if (!fs.existsSync(configFile)) {
|
|
250
|
-
return res.status(400).json({ error: 'opencode.json not found at specified path' });
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const studioConfig = loadStudioConfig();
|
|
255
|
-
studioConfig.configPath = configPath || null;
|
|
256
|
-
saveStudioConfig(studioConfig);
|
|
257
|
-
|
|
258
|
-
res.json({ success: true, current: getConfigDir() });
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
app.get('/api/config', (req, res) => {
|
|
262
|
-
const paths = getPaths();
|
|
263
|
-
if (!paths) {
|
|
264
|
-
return res.status(404).json({ error: 'Opencode installation not found', notFound: true });
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (!fs.existsSync(paths.opencodeJson)) {
|
|
268
|
-
return res.status(404).json({ error: 'Config file not found' });
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
try {
|
|
272
|
-
const opencodeData = fs.readFileSync(paths.opencodeJson, 'utf8');
|
|
273
|
-
let opencodeConfig = JSON.parse(opencodeData);
|
|
274
|
-
let changed = false;
|
|
275
|
-
|
|
276
|
-
// Migration: Move root providers to model.providers
|
|
277
|
-
if (opencodeConfig.providers) {
|
|
278
|
-
if (typeof opencodeConfig.model !== 'string') {
|
|
279
|
-
const providers = opencodeConfig.providers;
|
|
280
|
-
delete opencodeConfig.providers;
|
|
281
|
-
|
|
282
|
-
opencodeConfig.model = {
|
|
283
|
-
...(opencodeConfig.model || {}),
|
|
284
|
-
providers: providers
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
fs.writeFileSync(paths.opencodeJson, JSON.stringify(opencodeConfig, null, 2), 'utf8');
|
|
288
|
-
changed = true;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
res.json(opencodeConfig);
|
|
293
|
-
} catch (err) {
|
|
294
|
-
res.status(500).json({ error: 'Failed to parse config', details: err.message });
|
|
295
|
-
}
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
app.post('/api/config', (req, res) => {
|
|
299
|
-
const paths = getPaths();
|
|
300
|
-
if (!paths) {
|
|
301
|
-
return res.status(404).json({ error: 'Opencode installation not found' });
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
try {
|
|
305
|
-
fs.writeFileSync(paths.opencodeJson, JSON.stringify(req.body, null, 2), 'utf8');
|
|
306
|
-
res.json({ success: true });
|
|
307
|
-
} catch (err) {
|
|
308
|
-
res.status(500).json({ error: 'Failed to write config', details: err.message });
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
app.get('/api/skills', (req, res) => {
|
|
313
|
-
const paths = getPaths();
|
|
314
|
-
if (!paths) return res.json([]);
|
|
315
|
-
if (!fs.existsSync(paths.skillDir)) return res.json([]);
|
|
316
|
-
|
|
317
|
-
const studioConfig = loadStudioConfig();
|
|
318
|
-
const disabledSkills = studioConfig.disabledSkills || [];
|
|
319
|
-
const skills = [];
|
|
320
|
-
|
|
321
|
-
const entries = fs.readdirSync(paths.skillDir, { withFileTypes: true });
|
|
322
|
-
for (const entry of entries) {
|
|
323
|
-
if (entry.isDirectory()) {
|
|
324
|
-
const skillFile = path.join(paths.skillDir, entry.name, 'SKILL.md');
|
|
325
|
-
if (fs.existsSync(skillFile)) {
|
|
326
|
-
try {
|
|
327
|
-
const content = fs.readFileSync(skillFile, 'utf8');
|
|
328
|
-
const { frontmatter } = parseSkillFrontmatter(content);
|
|
329
|
-
skills.push({
|
|
330
|
-
name: entry.name,
|
|
331
|
-
description: frontmatter.description || '',
|
|
332
|
-
enabled: !disabledSkills.includes(entry.name),
|
|
333
|
-
});
|
|
334
|
-
} catch {}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
res.json(skills);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
app.post('/api/skills/:name/toggle', (req, res) => {
|
|
343
|
-
const skillName = req.params.name;
|
|
344
|
-
const studioConfig = loadStudioConfig();
|
|
345
|
-
const disabledSkills = studioConfig.disabledSkills || [];
|
|
346
|
-
|
|
347
|
-
if (disabledSkills.includes(skillName)) {
|
|
348
|
-
studioConfig.disabledSkills = disabledSkills.filter(s => s !== skillName);
|
|
349
|
-
} else {
|
|
350
|
-
studioConfig.disabledSkills = [...disabledSkills, skillName];
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
saveStudioConfig(studioConfig);
|
|
354
|
-
res.json({ success: true, enabled: !studioConfig.disabledSkills.includes(skillName) });
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
app.get('/api/skills/:name', (req, res) => {
|
|
358
|
-
const paths = getPaths();
|
|
359
|
-
if (!paths) return res.status(404).json({ error: 'Opencode not found' });
|
|
360
|
-
|
|
361
|
-
const skillName = path.basename(req.params.name);
|
|
362
|
-
const skillDir = path.join(paths.skillDir, skillName);
|
|
363
|
-
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
364
|
-
|
|
365
|
-
if (!fs.existsSync(skillFile)) {
|
|
366
|
-
return res.status(404).json({ error: 'Skill not found' });
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const content = fs.readFileSync(skillFile, 'utf8');
|
|
370
|
-
const { frontmatter, body } = parseSkillFrontmatter(content);
|
|
371
|
-
|
|
372
|
-
res.json({
|
|
373
|
-
name: skillName,
|
|
374
|
-
description: frontmatter.description || '',
|
|
375
|
-
content: body,
|
|
376
|
-
rawContent: content,
|
|
377
|
-
});
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
app.post('/api/skills/:name', (req, res) => {
|
|
381
|
-
const paths = getPaths();
|
|
382
|
-
if (!paths) return res.status(404).json({ error: 'Opencode not found' });
|
|
383
|
-
|
|
384
|
-
const skillName = path.basename(req.params.name);
|
|
385
|
-
const { description, content } = req.body;
|
|
386
|
-
|
|
387
|
-
const skillDir = path.join(paths.skillDir, skillName);
|
|
388
|
-
if (!fs.existsSync(skillDir)) {
|
|
389
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const fullContent = createSkillContent(skillName, description || '', content || '');
|
|
393
|
-
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
394
|
-
fs.writeFileSync(skillFile, fullContent, 'utf8');
|
|
395
|
-
|
|
396
|
-
res.json({ success: true });
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
app.delete('/api/skills/:name', (req, res) => {
|
|
400
|
-
const paths = getPaths();
|
|
401
|
-
if (!paths) return res.status(404).json({ error: 'Opencode not found' });
|
|
402
|
-
|
|
403
|
-
const skillName = path.basename(req.params.name);
|
|
404
|
-
const skillDir = path.join(paths.skillDir, skillName);
|
|
405
|
-
if (fs.existsSync(skillDir)) {
|
|
406
|
-
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
407
|
-
}
|
|
408
|
-
res.json({ success: true });
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
app.get('/api/plugins', (req, res) => {
|
|
412
|
-
const paths = getPaths();
|
|
413
|
-
if (!paths) return res.json([]);
|
|
414
|
-
|
|
415
|
-
const studioConfig = loadStudioConfig();
|
|
416
|
-
const disabledPlugins = studioConfig.disabledPlugins || [];
|
|
417
|
-
const plugins = [];
|
|
418
|
-
|
|
419
|
-
if (fs.existsSync(paths.pluginDir)) {
|
|
420
|
-
const files = fs.readdirSync(paths.pluginDir).filter(f => f.endsWith('.js') || f.endsWith('.ts'));
|
|
421
|
-
for (const f of files) {
|
|
422
|
-
plugins.push({
|
|
423
|
-
name: f,
|
|
424
|
-
type: 'file',
|
|
425
|
-
enabled: !disabledPlugins.includes(f),
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if (fs.existsSync(paths.opencodeJson)) {
|
|
431
|
-
try {
|
|
432
|
-
const config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
|
|
433
|
-
if (Array.isArray(config.plugin)) {
|
|
434
|
-
for (const p of config.plugin) {
|
|
435
|
-
if (typeof p === 'string') {
|
|
436
|
-
plugins.push({
|
|
437
|
-
name: p,
|
|
438
|
-
type: 'npm',
|
|
439
|
-
enabled: !disabledPlugins.includes(p),
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
} catch {}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
res.json(plugins);
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
app.post('/api/plugins/:name/toggle', (req, res) => {
|
|
451
|
-
const pluginName = req.params.name;
|
|
452
|
-
const studioConfig = loadStudioConfig();
|
|
453
|
-
const disabledPlugins = studioConfig.disabledPlugins || [];
|
|
454
|
-
|
|
455
|
-
if (disabledPlugins.includes(pluginName)) {
|
|
456
|
-
studioConfig.disabledPlugins = disabledPlugins.filter(p => p !== pluginName);
|
|
457
|
-
} else {
|
|
458
|
-
studioConfig.disabledPlugins = [...disabledPlugins, pluginName];
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
saveStudioConfig(studioConfig);
|
|
462
|
-
res.json({ success: true, enabled: !studioConfig.disabledPlugins.includes(pluginName) });
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
app.get('/api/plugins/:name', (req, res) => {
|
|
466
|
-
const paths = getPaths();
|
|
467
|
-
if (!paths) return res.status(404).json({ error: 'Opencode not found' });
|
|
468
|
-
|
|
469
|
-
const pluginName = path.basename(req.params.name);
|
|
470
|
-
const filePath = path.join(paths.pluginDir, pluginName);
|
|
471
|
-
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Plugin not found' });
|
|
472
|
-
|
|
473
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
474
|
-
res.json({ name: pluginName, content });
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
app.post('/api/plugins/:name', (req, res) => {
|
|
478
|
-
const paths = getPaths();
|
|
479
|
-
if (!paths) return res.status(404).json({ error: 'Opencode not found' });
|
|
480
|
-
|
|
481
|
-
if (!fs.existsSync(paths.pluginDir)) {
|
|
482
|
-
fs.mkdirSync(paths.pluginDir, { recursive: true });
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const pluginName = path.basename(req.params.name);
|
|
486
|
-
const filePath = path.join(paths.pluginDir, pluginName);
|
|
487
|
-
fs.writeFileSync(filePath, req.body.content, 'utf8');
|
|
488
|
-
res.json({ success: true });
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
app.delete('/api/plugins/:name', (req, res) => {
|
|
492
|
-
const paths = getPaths();
|
|
493
|
-
if (!paths) return res.status(404).json({ error: 'Opencode not found' });
|
|
494
|
-
|
|
495
|
-
const pluginName = path.basename(req.params.name);
|
|
496
|
-
const filePath = path.join(paths.pluginDir, pluginName);
|
|
497
|
-
if (fs.existsSync(filePath)) {
|
|
498
|
-
fs.unlinkSync(filePath);
|
|
499
|
-
}
|
|
500
|
-
res.json({ success: true });
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
// Add npm-style plugins to opencode.json config
|
|
504
|
-
app.post('/api/plugins/config/add', (req, res) => {
|
|
505
|
-
const paths = getPaths();
|
|
506
|
-
if (!paths) return res.status(404).json({ error: 'Opencode not found' });
|
|
507
|
-
|
|
508
|
-
const { plugins } = req.body;
|
|
509
|
-
|
|
510
|
-
if (!plugins || !Array.isArray(plugins) || plugins.length === 0) {
|
|
511
|
-
return res.status(400).json({ error: 'plugins array is required' });
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
try {
|
|
515
|
-
let config = {};
|
|
516
|
-
if (fs.existsSync(paths.opencodeJson)) {
|
|
517
|
-
config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
if (!config.plugin) {
|
|
521
|
-
config.plugin = [];
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
const added = [];
|
|
525
|
-
const skipped = [];
|
|
526
|
-
|
|
527
|
-
for (const plugin of plugins) {
|
|
528
|
-
if (typeof plugin !== 'string') continue;
|
|
529
|
-
const trimmed = plugin.trim();
|
|
530
|
-
if (!trimmed) continue;
|
|
531
|
-
|
|
532
|
-
if (config.plugin.includes(trimmed)) {
|
|
533
|
-
skipped.push(trimmed);
|
|
534
|
-
} else {
|
|
535
|
-
config.plugin.push(trimmed);
|
|
536
|
-
added.push(trimmed);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
fs.writeFileSync(paths.opencodeJson, JSON.stringify(config, null, 2), 'utf8');
|
|
541
|
-
|
|
542
|
-
res.json({ success: true, added, skipped });
|
|
543
|
-
} catch (err) {
|
|
544
|
-
res.status(500).json({ error: 'Failed to update config', details: err.message });
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
app.delete('/api/plugins/config/:name', (req, res) => {
|
|
549
|
-
const paths = getPaths();
|
|
550
|
-
if (!paths) return res.status(404).json({ error: 'Opencode not found' });
|
|
551
|
-
|
|
552
|
-
const pluginName = req.params.name;
|
|
553
|
-
|
|
554
|
-
try {
|
|
555
|
-
let config = {};
|
|
556
|
-
if (fs.existsSync(paths.opencodeJson)) {
|
|
557
|
-
config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
if (!Array.isArray(config.plugin)) {
|
|
561
|
-
return res.status(404).json({ error: 'Plugin not found in config' });
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
const index = config.plugin.indexOf(pluginName);
|
|
565
|
-
if (index === -1) {
|
|
566
|
-
return res.status(404).json({ error: 'Plugin not found in config' });
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
config.plugin.splice(index, 1);
|
|
570
|
-
fs.writeFileSync(paths.opencodeJson, JSON.stringify(config, null, 2), 'utf8');
|
|
571
|
-
|
|
572
|
-
res.json({ success: true });
|
|
573
|
-
} catch (err) {
|
|
574
|
-
res.status(500).json({ error: 'Failed to remove plugin', details: err.message });
|
|
575
|
-
}
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
app.get('/api/backup', (req, res) => {
|
|
579
|
-
const paths = getPaths();
|
|
580
|
-
if (!paths) {
|
|
581
|
-
return res.status(404).json({ error: 'Opencode installation not found' });
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
const backup = {
|
|
585
|
-
version: 2,
|
|
586
|
-
timestamp: new Date().toISOString(),
|
|
587
|
-
studioConfig: loadStudioConfig(),
|
|
588
|
-
opencodeConfig: null,
|
|
589
|
-
skills: [],
|
|
590
|
-
plugins: [],
|
|
591
|
-
};
|
|
592
|
-
|
|
593
|
-
if (fs.existsSync(paths.opencodeJson)) {
|
|
594
|
-
try {
|
|
595
|
-
backup.opencodeConfig = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
|
|
596
|
-
} catch {}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
if (fs.existsSync(paths.skillDir)) {
|
|
600
|
-
const entries = fs.readdirSync(paths.skillDir, { withFileTypes: true });
|
|
601
|
-
for (const entry of entries) {
|
|
602
|
-
if (entry.isDirectory()) {
|
|
603
|
-
const skillFile = path.join(paths.skillDir, entry.name, 'SKILL.md');
|
|
604
|
-
if (fs.existsSync(skillFile)) {
|
|
605
|
-
try {
|
|
606
|
-
const content = fs.readFileSync(skillFile, 'utf8');
|
|
607
|
-
backup.skills.push({ name: entry.name, content });
|
|
608
|
-
} catch {}
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
if (fs.existsSync(paths.pluginDir)) {
|
|
615
|
-
const pluginFiles = fs.readdirSync(paths.pluginDir).filter(f => f.endsWith('.js') || f.endsWith('.ts'));
|
|
616
|
-
for (const file of pluginFiles) {
|
|
617
|
-
try {
|
|
618
|
-
const content = fs.readFileSync(path.join(paths.pluginDir, file), 'utf8');
|
|
619
|
-
backup.plugins.push({ name: file, content });
|
|
620
|
-
} catch {}
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
res.json(backup);
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
app.post('/api/fetch-url', async (req, res) => {
|
|
628
|
-
const { url } = req.body;
|
|
629
|
-
|
|
630
|
-
if (!url || typeof url !== 'string') {
|
|
631
|
-
return res.status(400).json({ error: 'URL is required' });
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
try {
|
|
635
|
-
const parsedUrl = new URL(url);
|
|
636
|
-
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
637
|
-
return res.status(400).json({ error: 'Only HTTP/HTTPS URLs are allowed' });
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const response = await fetch(url, {
|
|
641
|
-
headers: { 'User-Agent': 'OpenCode-Studio/1.0' },
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
if (!response.ok) {
|
|
645
|
-
return res.status(response.status).json({ error: `Failed to fetch: ${response.statusText}` });
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
const content = await response.text();
|
|
649
|
-
const filename = parsedUrl.pathname.split('/').pop() || 'file';
|
|
650
|
-
|
|
651
|
-
res.json({ content, filename, url });
|
|
652
|
-
} catch (err) {
|
|
653
|
-
res.status(500).json({ error: 'Failed to fetch URL', details: err.message });
|
|
654
|
-
}
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
app.post('/api/bulk-fetch', async (req, res) => {
|
|
658
|
-
const { urls } = req.body;
|
|
659
|
-
|
|
660
|
-
if (!urls || !Array.isArray(urls) || urls.length === 0) {
|
|
661
|
-
return res.status(400).json({ error: 'urls array is required' });
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
if (urls.length > 50) {
|
|
665
|
-
return res.status(400).json({ error: 'Maximum 50 URLs allowed per request' });
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const results = [];
|
|
669
|
-
|
|
670
|
-
for (const url of urls) {
|
|
671
|
-
if (!url || typeof url !== 'string') {
|
|
672
|
-
results.push({ url, success: false, error: 'Invalid URL' });
|
|
673
|
-
continue;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
try {
|
|
677
|
-
const parsedUrl = new URL(url.trim());
|
|
678
|
-
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
679
|
-
results.push({ url, success: false, error: 'Only HTTP/HTTPS allowed' });
|
|
680
|
-
continue;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
const response = await fetch(url.trim(), {
|
|
684
|
-
headers: { 'User-Agent': 'OpenCode-Studio/1.0' },
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
if (!response.ok) {
|
|
688
|
-
results.push({ url, success: false, error: `HTTP ${response.status}` });
|
|
689
|
-
continue;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
const content = await response.text();
|
|
693
|
-
const filename = parsedUrl.pathname.split('/').pop() || 'file';
|
|
694
|
-
const { frontmatter, body } = parseSkillFrontmatter(content);
|
|
695
|
-
|
|
696
|
-
const pathParts = parsedUrl.pathname.split('/').filter(Boolean);
|
|
697
|
-
let extractedName = '';
|
|
698
|
-
const filenameIdx = pathParts.length - 1;
|
|
699
|
-
if (pathParts[filenameIdx]?.toLowerCase() === 'skill.md' && filenameIdx > 0) {
|
|
700
|
-
extractedName = pathParts[filenameIdx - 1];
|
|
701
|
-
} else {
|
|
702
|
-
extractedName = filename.replace(/\.(md|js|ts)$/i, '').toLowerCase();
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
results.push({
|
|
706
|
-
url,
|
|
707
|
-
success: true,
|
|
708
|
-
content,
|
|
709
|
-
body,
|
|
710
|
-
filename,
|
|
711
|
-
name: frontmatter.name || extractedName,
|
|
712
|
-
description: frontmatter.description || '',
|
|
713
|
-
});
|
|
714
|
-
} catch (err) {
|
|
715
|
-
results.push({ url, success: false, error: err.message });
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
res.json({ results });
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
app.post('/api/restore', (req, res) => {
|
|
723
|
-
const paths = getPaths();
|
|
724
|
-
if (!paths) {
|
|
725
|
-
return res.status(404).json({ error: 'Opencode installation not found' });
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
const backup = req.body;
|
|
729
|
-
|
|
730
|
-
if (!backup || (backup.version !== 1 && backup.version !== 2)) {
|
|
731
|
-
return res.status(400).json({ error: 'Invalid backup file' });
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
try {
|
|
735
|
-
if (backup.studioConfig) {
|
|
736
|
-
saveStudioConfig(backup.studioConfig);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
if (backup.opencodeConfig) {
|
|
740
|
-
fs.writeFileSync(paths.opencodeJson, JSON.stringify(backup.opencodeConfig, null, 2), 'utf8');
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (backup.skills && backup.skills.length > 0) {
|
|
744
|
-
if (!fs.existsSync(paths.skillDir)) {
|
|
745
|
-
fs.mkdirSync(paths.skillDir, { recursive: true });
|
|
746
|
-
}
|
|
747
|
-
for (const skill of backup.skills) {
|
|
748
|
-
const skillName = path.basename(skill.name.replace('.md', ''));
|
|
749
|
-
const skillDir = path.join(paths.skillDir, skillName);
|
|
750
|
-
if (!fs.existsSync(skillDir)) {
|
|
751
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
752
|
-
}
|
|
753
|
-
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skill.content, 'utf8');
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
if (backup.plugins && backup.plugins.length > 0) {
|
|
758
|
-
if (!fs.existsSync(paths.pluginDir)) {
|
|
759
|
-
fs.mkdirSync(paths.pluginDir, { recursive: true });
|
|
760
|
-
}
|
|
761
|
-
for (const plugin of backup.plugins) {
|
|
762
|
-
const pluginName = path.basename(plugin.name);
|
|
763
|
-
fs.writeFileSync(path.join(paths.pluginDir, pluginName), plugin.content, 'utf8');
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
res.json({ success: true });
|
|
768
|
-
} catch (err) {
|
|
769
|
-
res.status(500).json({ error: 'Failed to restore backup', details: err.message });
|
|
770
|
-
}
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
// Auth endpoints
|
|
774
|
-
function getAuthFile() {
|
|
775
|
-
console.log('Searching for auth file in:', AUTH_CANDIDATE_PATHS);
|
|
776
|
-
for (const candidate of AUTH_CANDIDATE_PATHS) {
|
|
777
|
-
if (fs.existsSync(candidate)) {
|
|
778
|
-
console.log('Found auth file at:', candidate);
|
|
779
|
-
return candidate;
|
|
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
|
+
}
|
|
780
248
|
}
|
|
781
249
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
return
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
app.get('/api/auth', (req, res) => {
|
|
812
|
-
const authConfig = loadAuthConfig();
|
|
813
|
-
const authFile = getAuthFile();
|
|
814
|
-
const paths = getPaths();
|
|
815
|
-
|
|
816
|
-
let hasGeminiAuthPlugin = false;
|
|
817
|
-
if (paths && fs.existsSync(paths.opencodeJson)) {
|
|
818
|
-
try {
|
|
819
|
-
const config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
|
|
820
|
-
if (Array.isArray(config.plugin)) {
|
|
821
|
-
hasGeminiAuthPlugin = config.plugin.some(p =>
|
|
822
|
-
typeof p === 'string' && p.includes('opencode-gemini-auth')
|
|
823
|
-
);
|
|
824
|
-
}
|
|
825
|
-
} catch {}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
if (!authConfig) {
|
|
829
|
-
return res.json({
|
|
830
|
-
credentials: [],
|
|
831
|
-
authFile: null,
|
|
832
|
-
message: 'No auth file found',
|
|
833
|
-
hasGeminiAuthPlugin,
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
const credentials = Object.entries(authConfig).map(([id, config]) => {
|
|
838
|
-
const isExpired = config.expires ? Date.now() > config.expires : false;
|
|
839
|
-
return {
|
|
840
|
-
id,
|
|
841
|
-
name: PROVIDER_DISPLAY_NAMES[id] || id,
|
|
842
|
-
type: config.type,
|
|
843
|
-
isExpired,
|
|
844
|
-
expiresAt: config.expires || null,
|
|
845
|
-
};
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
res.json({ credentials, authFile, hasGeminiAuthPlugin });
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
app.post('/api/auth/login', (req, res) => {
|
|
852
|
-
// opencode auth login is interactive and requires a terminal
|
|
853
|
-
const isWindows = process.platform === 'win32';
|
|
854
|
-
const isMac = process.platform === 'darwin';
|
|
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);
|
|
855
275
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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';
|
|
861
353
|
} else {
|
|
862
|
-
|
|
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
|
+
}
|
|
863
523
|
}
|
|
864
524
|
|
|
865
|
-
|
|
866
|
-
|
|
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
|
|
867
564
|
});
|
|
868
565
|
|
|
869
|
-
res.json({
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
|
873
578
|
});
|
|
579
|
+
|
|
580
|
+
res.json({ success: true });
|
|
874
581
|
});
|
|
875
|
-
|
|
876
|
-
app.
|
|
877
|
-
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
return
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
function
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
function deleteAuthProfile(provider, profileName) {
|
|
972
|
-
const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
|
|
973
|
-
if (fs.existsSync(filePath)) {
|
|
974
|
-
fs.unlinkSync(filePath);
|
|
975
|
-
return true;
|
|
976
|
-
}
|
|
977
|
-
return false;
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
function getActiveProfiles() {
|
|
981
|
-
const studioConfig = loadStudioConfig();
|
|
982
|
-
return studioConfig.activeProfiles || {};
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
function setActiveProfile(provider, profileName) {
|
|
986
|
-
const studioConfig = loadStudioConfig();
|
|
987
|
-
studioConfig.activeProfiles = studioConfig.activeProfiles || {};
|
|
988
|
-
studioConfig.activeProfiles[provider] = profileName;
|
|
989
|
-
saveStudioConfig(studioConfig);
|
|
990
|
-
}
|
|
991
|
-
|
|
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
|
+
|
|
992
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); }
|
|
993
678
|
|
|
994
679
|
app.get('/api/auth/profiles', (req, res) => {
|
|
@@ -997,147 +682,305 @@ app.get('/api/auth/profiles', (req, res) => {
|
|
|
997
682
|
const authConfig = loadAuthConfig() || {};
|
|
998
683
|
|
|
999
684
|
const profiles = {};
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
if (existingProfiles.includes(newName)) {
|
|
1097
|
-
return res.status(400).json({ error: 'Profile name already exists' });
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
try {
|
|
1101
|
-
saveAuthProfile(provider, newName, profileData);
|
|
1102
|
-
deleteAuthProfile(provider, name);
|
|
1103
|
-
|
|
1104
|
-
const activeProfiles = getActiveProfiles();
|
|
1105
|
-
if (activeProfiles[provider] === name) {
|
|
1106
|
-
setActiveProfile(provider, newName);
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
res.json({ success: true, name: newName });
|
|
1110
|
-
} catch (err) {
|
|
1111
|
-
res.status(500).json({ error: 'Failed to rename profile', details: err.message });
|
|
1112
|
-
}
|
|
1113
|
-
});
|
|
1114
|
-
|
|
1115
|
-
app.listen(PORT, () => {
|
|
1116
|
-
const url = 'https://opencode-studio.micr.dev';
|
|
1117
|
-
console.log(`Server running on http://localhost:${PORT}`);
|
|
1118
|
-
console.log(`To manage your OpenCode configuration, visit: ${url}`);
|
|
1119
|
-
console.log('\nPress [Enter] to open in your browser...');
|
|
1120
|
-
|
|
1121
|
-
process.stdin.resume();
|
|
1122
|
-
process.stdin.on('data', (data) => {
|
|
1123
|
-
const input = data.toString();
|
|
1124
|
-
if (input === '\n' || input === '\r\n' || input === '\r') {
|
|
1125
|
-
const platform = os.platform();
|
|
1126
|
-
let cmd;
|
|
1127
|
-
if (platform === 'win32') {
|
|
1128
|
-
cmd = `start "" "${url}"`;
|
|
1129
|
-
} else if (platform === 'darwin') {
|
|
1130
|
-
cmd = `open "${url}"`;
|
|
1131
|
-
} else {
|
|
1132
|
-
cmd = `xdg-open "${url}"`;
|
|
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) {
|
|
1133
781
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
+
}
|
|
1139
799
|
}
|
|
1140
|
-
})
|
|
800
|
+
} catch (err) {
|
|
801
|
+
console.error(`Error reading log dir ${logDir}:`, err);
|
|
802
|
+
}
|
|
1141
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],
|
|
1142
829
|
});
|
|
1143
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
|
+
});
|