opencode-studio-server 1.0.12 → 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 +558 -715
- package/launcher.vbs +6 -6
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -56,34 +56,34 @@ const HOME_DIR = os.homedir();
|
|
|
56
56
|
const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
|
|
57
57
|
const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
|
|
58
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() {
|
|
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
87
|
if (pendingActionMemory) return pendingActionMemory;
|
|
88
88
|
|
|
89
89
|
if (fs.existsSync(PENDING_ACTION_PATH)) {
|
|
@@ -102,802 +102,487 @@ function loadPendingAction() {
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
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) => {
|
|
106
111
|
pendingActionMemory = null;
|
|
107
112
|
if (fs.existsSync(PENDING_ACTION_PATH)) {
|
|
108
113
|
try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
|
|
109
114
|
}
|
|
110
|
-
}
|
|
115
|
+
res.json({ success: true });
|
|
116
|
+
});
|
|
111
117
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
}
|
|
115
134
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
}
|
|
121
145
|
|
|
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;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const PROVIDER_DISPLAY_NAMES = {
|
|
139
|
-
'github-copilot': 'GitHub Copilot',
|
|
140
|
-
'google': 'Google AI',
|
|
141
|
-
'anthropic': 'Anthropic',
|
|
142
|
-
'openai': 'OpenAI',
|
|
143
|
-
'xai': 'xAI',
|
|
144
|
-
'groq': 'Groq',
|
|
145
|
-
'together': 'Together AI',
|
|
146
|
-
'mistral': 'Mistral',
|
|
147
|
-
'deepseek': 'DeepSeek',
|
|
148
|
-
'openrouter': 'OpenRouter',
|
|
149
|
-
'amazon-bedrock': 'Amazon Bedrock',
|
|
150
|
-
'azure': 'Azure OpenAI',
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
function getPaths() {
|
|
154
|
-
const configDir = getConfigDir();
|
|
155
|
-
if (!configDir) return null;
|
|
156
146
|
return {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
147
|
+
detected,
|
|
148
|
+
manual: manualPath,
|
|
149
|
+
current: manualPath || detected,
|
|
150
|
+
candidates
|
|
161
151
|
};
|
|
162
|
-
}
|
|
152
|
+
};
|
|
163
153
|
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
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;
|
|
168
163
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const lines = frontmatterText.split(/\r?\n/);
|
|
175
|
-
for (const line of lines) {
|
|
176
|
-
const colonIndex = line.indexOf(':');
|
|
177
|
-
if (colonIndex > 0) {
|
|
178
|
-
const key = line.slice(0, colonIndex).trim();
|
|
179
|
-
let value = line.slice(colonIndex + 1).trim();
|
|
180
|
-
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
181
|
-
(value.startsWith("'") && value.endsWith("'"))) {
|
|
182
|
-
value = value.slice(1, -1);
|
|
183
|
-
}
|
|
184
|
-
frontmatter[key] = value;
|
|
185
|
-
}
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
186
168
|
}
|
|
187
|
-
|
|
188
|
-
return { frontmatter, body };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function createSkillContent(name, description, body) {
|
|
192
|
-
return `---
|
|
193
|
-
name: ${name}
|
|
194
|
-
description: ${description}
|
|
195
|
-
---
|
|
196
|
-
|
|
197
|
-
${body}`;
|
|
198
|
-
}
|
|
169
|
+
};
|
|
199
170
|
|
|
200
|
-
|
|
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
|
+
};
|
|
201
182
|
|
|
202
183
|
app.get('/api/health', (req, res) => {
|
|
203
|
-
res.json({ status: 'ok'
|
|
184
|
+
res.json({ status: 'ok' });
|
|
204
185
|
});
|
|
205
186
|
|
|
206
187
|
app.post('/api/shutdown', (req, res) => {
|
|
207
|
-
res.json({ success: true, message: 'Server shutting down' });
|
|
208
|
-
setTimeout(() => process.exit(0), 100);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
app.get('/api/pending-action', (req, res) => {
|
|
212
|
-
const action = loadPendingAction();
|
|
213
|
-
res.json({ action });
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
app.delete('/api/pending-action', (req, res) => {
|
|
217
|
-
clearPendingAction();
|
|
218
188
|
res.json({ success: true });
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
app.post('/api/pending-action', (req, res) => {
|
|
222
|
-
const { action } = req.body;
|
|
223
|
-
if (action && action.type) {
|
|
224
|
-
setPendingAction(action);
|
|
225
|
-
res.json({ success: true });
|
|
226
|
-
} else {
|
|
227
|
-
res.status(400).json({ error: 'Invalid action' });
|
|
228
|
-
}
|
|
189
|
+
setTimeout(() => process.exit(0), 100);
|
|
229
190
|
});
|
|
230
191
|
|
|
231
192
|
app.get('/api/paths', (req, res) => {
|
|
232
|
-
|
|
233
|
-
const studioConfig = loadStudioConfig();
|
|
234
|
-
const current = getConfigDir();
|
|
235
|
-
|
|
236
|
-
res.json({
|
|
237
|
-
detected,
|
|
238
|
-
manual: studioConfig.configPath || null,
|
|
239
|
-
current,
|
|
240
|
-
candidates: CANDIDATE_PATHS,
|
|
241
|
-
});
|
|
193
|
+
res.json(getPaths());
|
|
242
194
|
});
|
|
243
195
|
|
|
244
196
|
app.post('/api/paths', (req, res) => {
|
|
245
197
|
const { configPath } = req.body;
|
|
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
198
|
const studioConfig = loadStudioConfig();
|
|
255
|
-
studioConfig.configPath = configPath
|
|
199
|
+
studioConfig.configPath = configPath;
|
|
256
200
|
saveStudioConfig(studioConfig);
|
|
257
|
-
|
|
258
|
-
res.json({ success: true, current: getConfigDir() });
|
|
201
|
+
res.json({ success: true, current: getConfigPath() });
|
|
259
202
|
});
|
|
260
203
|
|
|
261
204
|
app.get('/api/config', (req, res) => {
|
|
262
|
-
const
|
|
263
|
-
if (!
|
|
264
|
-
return res.status(404).json({ error: '
|
|
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 });
|
|
205
|
+
const config = loadConfig();
|
|
206
|
+
if (!config) {
|
|
207
|
+
return res.status(404).json({ error: 'Config not found' });
|
|
295
208
|
}
|
|
209
|
+
res.json(config);
|
|
296
210
|
});
|
|
297
211
|
|
|
298
212
|
app.post('/api/config', (req, res) => {
|
|
299
|
-
const
|
|
300
|
-
if (!paths) {
|
|
301
|
-
return res.status(404).json({ error: 'Opencode installation not found' });
|
|
302
|
-
}
|
|
303
|
-
|
|
213
|
+
const config = req.body;
|
|
304
214
|
try {
|
|
305
|
-
|
|
215
|
+
saveConfig(config);
|
|
306
216
|
res.json({ success: true });
|
|
307
217
|
} catch (err) {
|
|
308
|
-
res.status(500).json({ error:
|
|
218
|
+
res.status(500).json({ error: err.message });
|
|
309
219
|
}
|
|
310
220
|
});
|
|
311
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
|
+
|
|
312
229
|
app.get('/api/skills', (req, res) => {
|
|
313
|
-
const
|
|
314
|
-
if (!
|
|
315
|
-
|
|
230
|
+
const skillDir = getSkillDir();
|
|
231
|
+
if (!skillDir || !fs.existsSync(skillDir)) {
|
|
232
|
+
return res.json([]);
|
|
233
|
+
}
|
|
316
234
|
|
|
317
|
-
const studioConfig = loadStudioConfig();
|
|
318
|
-
const disabledSkills = studioConfig.disabledSkills || [];
|
|
319
235
|
const skills = [];
|
|
236
|
+
const entries = fs.readdirSync(skillDir, { withFileTypes: true });
|
|
320
237
|
|
|
321
|
-
const entries = fs.readdirSync(paths.skillDir, { withFileTypes: true });
|
|
322
238
|
for (const entry of entries) {
|
|
323
239
|
if (entry.isDirectory()) {
|
|
324
|
-
const
|
|
325
|
-
if (fs.existsSync(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
description: frontmatter.description || '',
|
|
332
|
-
enabled: !disabledSkills.includes(entry.name),
|
|
333
|
-
});
|
|
334
|
-
} catch {}
|
|
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
|
+
});
|
|
335
247
|
}
|
|
336
248
|
}
|
|
337
249
|
}
|
|
338
|
-
|
|
339
250
|
res.json(skills);
|
|
340
251
|
});
|
|
341
252
|
|
|
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
253
|
app.get('/api/skills/:name', (req, res) => {
|
|
358
|
-
const
|
|
359
|
-
if (!
|
|
254
|
+
const skillDir = getSkillDir();
|
|
255
|
+
if (!skillDir) return res.status(404).json({ error: 'No config' });
|
|
360
256
|
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
257
|
+
const name = req.params.name;
|
|
258
|
+
const skillPath = path.join(skillDir, name, 'SKILL.md');
|
|
364
259
|
|
|
365
|
-
if (!fs.existsSync(
|
|
260
|
+
if (!fs.existsSync(skillPath)) {
|
|
366
261
|
return res.status(404).json({ error: 'Skill not found' });
|
|
367
262
|
}
|
|
368
263
|
|
|
369
|
-
const content = fs.readFileSync(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
res.json({
|
|
373
|
-
name: skillName,
|
|
374
|
-
description: frontmatter.description || '',
|
|
375
|
-
content: body,
|
|
376
|
-
rawContent: content,
|
|
377
|
-
});
|
|
264
|
+
const content = fs.readFileSync(skillPath, 'utf8');
|
|
265
|
+
res.json({ name, content });
|
|
378
266
|
});
|
|
379
267
|
|
|
380
268
|
app.post('/api/skills/:name', (req, res) => {
|
|
381
|
-
const
|
|
382
|
-
if (!
|
|
269
|
+
const skillDir = getSkillDir();
|
|
270
|
+
if (!skillDir) return res.status(404).json({ error: 'No config' });
|
|
383
271
|
|
|
384
|
-
const
|
|
385
|
-
const {
|
|
272
|
+
const name = req.params.name;
|
|
273
|
+
const { content } = req.body;
|
|
274
|
+
const dirPath = path.join(skillDir, name);
|
|
386
275
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
276
|
+
if (!fs.existsSync(dirPath)) {
|
|
277
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
390
278
|
}
|
|
391
279
|
|
|
392
|
-
|
|
393
|
-
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
394
|
-
fs.writeFileSync(skillFile, fullContent, 'utf8');
|
|
395
|
-
|
|
280
|
+
fs.writeFileSync(path.join(dirPath, 'SKILL.md'), content, 'utf8');
|
|
396
281
|
res.json({ success: true });
|
|
397
282
|
});
|
|
398
283
|
|
|
399
284
|
app.delete('/api/skills/:name', (req, res) => {
|
|
400
|
-
const
|
|
401
|
-
if (!
|
|
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);
|
|
402
290
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (fs.existsSync(skillDir)) {
|
|
406
|
-
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
291
|
+
if (fs.existsSync(dirPath)) {
|
|
292
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
407
293
|
}
|
|
408
294
|
res.json({ success: true });
|
|
409
295
|
});
|
|
410
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
|
+
|
|
411
310
|
app.get('/api/plugins', (req, res) => {
|
|
412
|
-
const
|
|
413
|
-
if (!
|
|
311
|
+
const pluginDir = getPluginDir();
|
|
312
|
+
if (!pluginDir || !fs.existsSync(pluginDir)) {
|
|
313
|
+
return res.json([]);
|
|
314
|
+
}
|
|
414
315
|
|
|
415
|
-
const studioConfig = loadStudioConfig();
|
|
416
|
-
const disabledPlugins = studioConfig.disabledPlugins || [];
|
|
417
316
|
const plugins = [];
|
|
317
|
+
const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
|
|
418
318
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
}
|
|
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
|
+
});
|
|
443
331
|
}
|
|
444
|
-
}
|
|
332
|
+
}
|
|
445
333
|
}
|
|
446
|
-
|
|
447
334
|
res.json(plugins);
|
|
448
335
|
});
|
|
449
336
|
|
|
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
337
|
app.get('/api/plugins/:name', (req, res) => {
|
|
466
|
-
const
|
|
467
|
-
if (!
|
|
338
|
+
const pluginDir = getPluginDir();
|
|
339
|
+
if (!pluginDir) return res.status(404).json({ error: 'No config' });
|
|
468
340
|
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Plugin not found' });
|
|
341
|
+
const name = req.params.name;
|
|
342
|
+
const dirPath = path.join(pluginDir, name);
|
|
472
343
|
|
|
473
|
-
|
|
474
|
-
|
|
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' });
|
|
344
|
+
let content = '';
|
|
345
|
+
let filename = '';
|
|
480
346
|
|
|
481
|
-
if (
|
|
482
|
-
fs.
|
|
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' });
|
|
483
355
|
}
|
|
484
356
|
|
|
485
|
-
|
|
486
|
-
const filePath = path.join(paths.pluginDir, pluginName);
|
|
487
|
-
fs.writeFileSync(filePath, req.body.content, 'utf8');
|
|
488
|
-
res.json({ success: true });
|
|
357
|
+
res.json({ name, content, filename });
|
|
489
358
|
});
|
|
490
359
|
|
|
491
|
-
app.
|
|
492
|
-
const
|
|
493
|
-
if (!
|
|
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' });
|
|
360
|
+
app.post('/api/plugins/:name', (req, res) => {
|
|
361
|
+
const pluginDir = getPluginDir();
|
|
362
|
+
if (!pluginDir) return res.status(404).json({ error: 'No config' });
|
|
507
363
|
|
|
508
|
-
const
|
|
364
|
+
const name = req.params.name;
|
|
365
|
+
const { content } = req.body;
|
|
366
|
+
const dirPath = path.join(pluginDir, name);
|
|
509
367
|
|
|
510
|
-
if (!
|
|
511
|
-
|
|
368
|
+
if (!fs.existsSync(dirPath)) {
|
|
369
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
512
370
|
}
|
|
513
371
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
}
|
|
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 });
|
|
546
376
|
});
|
|
547
377
|
|
|
548
|
-
app.delete('/api/plugins
|
|
549
|
-
const
|
|
550
|
-
if (!
|
|
378
|
+
app.delete('/api/plugins/:name', (req, res) => {
|
|
379
|
+
const pluginDir = getPluginDir();
|
|
380
|
+
if (!pluginDir) return res.status(404).json({ error: 'No config' });
|
|
551
381
|
|
|
552
|
-
const
|
|
382
|
+
const name = req.params.name;
|
|
383
|
+
const dirPath = path.join(pluginDir, name);
|
|
553
384
|
|
|
554
|
-
|
|
555
|
-
|
|
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 });
|
|
385
|
+
if (fs.existsSync(dirPath)) {
|
|
386
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
575
387
|
}
|
|
388
|
+
res.json({ success: true });
|
|
576
389
|
});
|
|
577
390
|
|
|
578
|
-
app.
|
|
579
|
-
|
|
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);
|
|
391
|
+
app.post('/api/plugins/:name/toggle', (req, res) => {
|
|
392
|
+
res.json({ success: true, enabled: true });
|
|
625
393
|
});
|
|
626
394
|
|
|
627
395
|
app.post('/api/fetch-url', async (req, res) => {
|
|
628
396
|
const { url } = req.body;
|
|
629
|
-
|
|
630
|
-
if (!url || typeof url !== 'string') {
|
|
631
|
-
return res.status(400).json({ error: 'URL is required' });
|
|
632
|
-
}
|
|
397
|
+
if (!url) return res.status(400).json({ error: 'URL required' });
|
|
633
398
|
|
|
634
399
|
try {
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
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}`);
|
|
648
403
|
const content = await response.text();
|
|
649
|
-
const filename =
|
|
404
|
+
const filename = path.basename(new URL(url).pathname) || 'file.txt';
|
|
650
405
|
|
|
651
406
|
res.json({ content, filename, url });
|
|
652
407
|
} catch (err) {
|
|
653
|
-
res.status(500).json({ error:
|
|
408
|
+
res.status(500).json({ error: err.message });
|
|
654
409
|
}
|
|
655
410
|
});
|
|
656
411
|
|
|
657
412
|
app.post('/api/bulk-fetch', async (req, res) => {
|
|
658
413
|
const { urls } = req.body;
|
|
414
|
+
if (!Array.isArray(urls)) return res.status(400).json({ error: 'URLs array required' });
|
|
659
415
|
|
|
660
|
-
|
|
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
|
-
|
|
416
|
+
const fetch = (await import('node-fetch')).default;
|
|
668
417
|
const results = [];
|
|
669
418
|
|
|
419
|
+
// Limit concurrency? For now sequential is safer
|
|
670
420
|
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
421
|
try {
|
|
677
|
-
const
|
|
678
|
-
if (!
|
|
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
|
-
|
|
422
|
+
const response = await fetch(url);
|
|
423
|
+
if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
|
|
692
424
|
const content = await response.text();
|
|
693
|
-
const filename =
|
|
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
|
-
}
|
|
425
|
+
const filename = path.basename(new URL(url).pathname) || 'file.txt';
|
|
704
426
|
|
|
427
|
+
// Try to extract name/description from content (simple regex for markdown/js)
|
|
428
|
+
// This is basic heuristic
|
|
705
429
|
results.push({
|
|
706
430
|
url,
|
|
707
431
|
success: true,
|
|
708
432
|
content,
|
|
709
|
-
body,
|
|
710
433
|
filename,
|
|
711
|
-
name:
|
|
712
|
-
description: frontmatter.description || '',
|
|
434
|
+
name: filename.replace(/\.(md|js|ts)$/, ''),
|
|
713
435
|
});
|
|
714
436
|
} catch (err) {
|
|
715
|
-
results.push({
|
|
437
|
+
results.push({
|
|
438
|
+
url,
|
|
439
|
+
success: false,
|
|
440
|
+
error: err.message
|
|
441
|
+
});
|
|
716
442
|
}
|
|
717
443
|
}
|
|
718
444
|
|
|
719
445
|
res.json({ results });
|
|
720
446
|
});
|
|
721
447
|
|
|
722
|
-
app.
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
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
|
+
}
|
|
726
464
|
}
|
|
727
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) => {
|
|
728
491
|
const backup = req.body;
|
|
729
492
|
|
|
730
|
-
if (
|
|
731
|
-
|
|
493
|
+
if (backup.studioConfig) {
|
|
494
|
+
saveStudioConfig(backup.studioConfig);
|
|
732
495
|
}
|
|
733
496
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
if (backup.skills && backup.skills.length > 0) {
|
|
744
|
-
if (!fs.existsSync(paths.skillDir)) {
|
|
745
|
-
fs.mkdirSync(paths.skillDir, { recursive: true });
|
|
746
|
-
}
|
|
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 });
|
|
747
505
|
for (const skill of backup.skills) {
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
752
|
-
}
|
|
753
|
-
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skill.content, 'utf8');
|
|
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');
|
|
754
509
|
}
|
|
755
510
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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 });
|
|
761
517
|
for (const plugin of backup.plugins) {
|
|
762
|
-
const
|
|
763
|
-
fs.
|
|
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');
|
|
764
521
|
}
|
|
765
522
|
}
|
|
766
|
-
|
|
767
|
-
res.json({ success: true });
|
|
768
|
-
} catch (err) {
|
|
769
|
-
res.status(500).json({ error: 'Failed to restore backup', details: err.message });
|
|
770
523
|
}
|
|
524
|
+
|
|
525
|
+
res.json({ success: true });
|
|
771
526
|
});
|
|
772
527
|
|
|
773
|
-
// Auth
|
|
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;
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
console.log('No auth file found');
|
|
783
|
-
return null;
|
|
784
|
-
}
|
|
785
|
-
|
|
528
|
+
// Auth Handlers
|
|
786
529
|
function loadAuthConfig() {
|
|
787
|
-
const
|
|
788
|
-
if (!
|
|
530
|
+
const configPath = getConfigPath();
|
|
531
|
+
if (!configPath) return null;
|
|
789
532
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
function saveAuthConfig(config) {
|
|
798
|
-
const authFile = getAuthFile();
|
|
799
|
-
if (!authFile) return false;
|
|
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.
|
|
800
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;
|
|
801
541
|
try {
|
|
802
|
-
|
|
803
|
-
return true;
|
|
542
|
+
return JSON.parse(fs.readFileSync(authPath, 'utf8'));
|
|
804
543
|
} catch {
|
|
805
|
-
return
|
|
544
|
+
return null;
|
|
806
545
|
}
|
|
807
546
|
}
|
|
808
547
|
|
|
809
|
-
|
|
810
|
-
|
|
811
548
|
app.get('/api/auth', (req, res) => {
|
|
812
549
|
const authConfig = loadAuthConfig();
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
}
|
|
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
|
|
827
557
|
|
|
828
|
-
|
|
829
|
-
return res.json({
|
|
830
|
-
credentials: [],
|
|
831
|
-
authFile: null,
|
|
832
|
-
message: 'No auth file found',
|
|
833
|
-
hasGeminiAuthPlugin,
|
|
834
|
-
});
|
|
835
|
-
}
|
|
558
|
+
const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
|
|
836
559
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
name: PROVIDER_DISPLAY_NAMES[id] || id,
|
|
842
|
-
type: config.type,
|
|
843
|
-
isExpired,
|
|
844
|
-
expiresAt: config.expires || null,
|
|
845
|
-
};
|
|
560
|
+
// We spawn it so it opens the browser
|
|
561
|
+
const child = spawn(cmd, ['auth', 'login', provider], {
|
|
562
|
+
stdio: 'inherit',
|
|
563
|
+
shell: true
|
|
846
564
|
});
|
|
847
565
|
|
|
848
|
-
res.json({
|
|
566
|
+
res.json({ success: true, message: 'Launched auth flow', note: 'Please check the terminal window where the server is running' });
|
|
849
567
|
});
|
|
850
568
|
|
|
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';
|
|
855
|
-
|
|
856
|
-
let command;
|
|
857
|
-
if (isWindows) {
|
|
858
|
-
command = 'start cmd /k "opencode auth login"';
|
|
859
|
-
} else if (isMac) {
|
|
860
|
-
command = 'osascript -e \'tell app "Terminal" to do script "opencode auth login"\'';
|
|
861
|
-
} else {
|
|
862
|
-
command = 'x-terminal-emulator -e "opencode auth login" || gnome-terminal -- opencode auth login || xterm -e "opencode auth login"';
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
exec(command, { shell: true }, (err) => {
|
|
866
|
-
if (err) console.error('Failed to open terminal:', err);
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
res.json({
|
|
870
|
-
success: true,
|
|
871
|
-
message: 'Opening terminal for authentication...',
|
|
872
|
-
note: 'Complete authentication in the terminal window, then refresh this page.'
|
|
873
|
-
});
|
|
874
|
-
});
|
|
875
|
-
|
|
876
569
|
app.delete('/api/auth/:provider', (req, res) => {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
return res.status(404).json({ error: 'No auth configuration found' });
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
if (!authConfig[provider]) {
|
|
885
|
-
return res.status(404).json({ error: `Provider ${provider} not found` });
|
|
886
|
-
}
|
|
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';
|
|
887
574
|
|
|
888
|
-
|
|
575
|
+
spawn(cmd, ['auth', 'logout', provider], {
|
|
576
|
+
stdio: 'inherit',
|
|
577
|
+
shell: true
|
|
578
|
+
});
|
|
889
579
|
|
|
890
|
-
|
|
891
|
-
res.json({ success: true });
|
|
892
|
-
} else {
|
|
893
|
-
res.status(500).json({ error: 'Failed to save auth configuration' });
|
|
894
|
-
}
|
|
580
|
+
res.json({ success: true });
|
|
895
581
|
});
|
|
896
582
|
|
|
897
|
-
// Get available providers for login
|
|
898
583
|
app.get('/api/auth/providers', (req, res) => {
|
|
584
|
+
// List of supported providers (hardcoded for now as it's not easily discoverable via CLI)
|
|
899
585
|
const providers = [
|
|
900
|
-
{ id: 'github-copilot', name: 'GitHub Copilot', type: 'oauth', description: 'Use GitHub Copilot API' },
|
|
901
586
|
{ id: 'google', name: 'Google AI', type: 'oauth', description: 'Use Google Gemini models' },
|
|
902
587
|
{ id: 'anthropic', name: 'Anthropic', type: 'api', description: 'Use Claude models' },
|
|
903
588
|
{ id: 'openai', name: 'OpenAI', type: 'api', description: 'Use GPT models' },
|
|
@@ -989,27 +674,148 @@ function setActiveProfile(provider, profileName) {
|
|
|
989
674
|
saveStudioConfig(studioConfig);
|
|
990
675
|
}
|
|
991
676
|
|
|
992
|
-
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
|
-
|
|
994
|
-
app.get('/api/auth/profiles', (req, res) => {
|
|
995
|
-
ensureAuthProfilesDir();
|
|
996
|
-
const activeProfiles = getActiveProfiles();
|
|
997
|
-
const authConfig = loadAuthConfig() || {};
|
|
998
|
-
|
|
999
|
-
const profiles = {};
|
|
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() || {};
|
|
1000
683
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
+
}
|
|
1008
703
|
});
|
|
1009
704
|
|
|
1010
705
|
res.json(profiles);
|
|
1011
706
|
});
|
|
1012
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
|
+
|
|
1013
819
|
app.get('/api/auth/profiles/:provider', (req, res) => {
|
|
1014
820
|
const { provider } = req.params;
|
|
1015
821
|
const providerProfiles = listAuthProfiles(provider);
|
|
@@ -1055,23 +861,33 @@ app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
|
|
|
1055
861
|
const authConfig = loadAuthConfig() || {};
|
|
1056
862
|
authConfig[provider] = profileData;
|
|
1057
863
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
+
}
|
|
1061
875
|
} else {
|
|
1062
|
-
res.status(500).json({ error: '
|
|
876
|
+
res.status(500).json({ error: 'Config path not found' });
|
|
1063
877
|
}
|
|
1064
878
|
});
|
|
1065
879
|
|
|
1066
880
|
app.delete('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
1067
881
|
const { provider, name } = req.params;
|
|
1068
|
-
|
|
1069
|
-
if (
|
|
882
|
+
const success = deleteAuthProfile(provider, name);
|
|
883
|
+
if (success) {
|
|
1070
884
|
const activeProfiles = getActiveProfiles();
|
|
1071
885
|
if (activeProfiles[provider] === name) {
|
|
1072
886
|
const studioConfig = loadStudioConfig();
|
|
1073
|
-
|
|
1074
|
-
|
|
887
|
+
if (studioConfig.activeProfiles) {
|
|
888
|
+
delete studioConfig.activeProfiles[provider];
|
|
889
|
+
saveStudioConfig(studioConfig);
|
|
890
|
+
}
|
|
1075
891
|
}
|
|
1076
892
|
res.json({ success: true });
|
|
1077
893
|
} else {
|
|
@@ -1083,22 +899,13 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
|
1083
899
|
const { provider, name } = req.params;
|
|
1084
900
|
const { newName } = req.body;
|
|
1085
901
|
|
|
1086
|
-
if (!newName
|
|
1087
|
-
return res.status(400).json({ error: 'New name is required and must be different' });
|
|
1088
|
-
}
|
|
902
|
+
if (!newName) return res.status(400).json({ error: 'New name required' });
|
|
1089
903
|
|
|
1090
904
|
const profileData = loadAuthProfile(provider, name);
|
|
1091
|
-
if (!profileData) {
|
|
1092
|
-
return res.status(404).json({ error: 'Profile not found' });
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
const existingProfiles = listAuthProfiles(provider);
|
|
1096
|
-
if (existingProfiles.includes(newName)) {
|
|
1097
|
-
return res.status(400).json({ error: 'Profile name already exists' });
|
|
1098
|
-
}
|
|
905
|
+
if (!profileData) return res.status(404).json({ error: 'Profile not found' });
|
|
1099
906
|
|
|
1100
|
-
|
|
1101
|
-
|
|
907
|
+
const success = saveAuthProfile(provider, newName, profileData);
|
|
908
|
+
if (success) {
|
|
1102
909
|
deleteAuthProfile(provider, name);
|
|
1103
910
|
|
|
1104
911
|
const activeProfiles = getActiveProfiles();
|
|
@@ -1107,37 +914,73 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
|
1107
914
|
}
|
|
1108
915
|
|
|
1109
916
|
res.json({ success: true, name: newName });
|
|
1110
|
-
}
|
|
1111
|
-
res.status(500).json({ error: 'Failed to rename profile'
|
|
917
|
+
} else {
|
|
918
|
+
res.status(500).json({ error: 'Failed to rename profile' });
|
|
1112
919
|
}
|
|
1113
920
|
});
|
|
1114
921
|
|
|
1115
|
-
app.
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
+
});
|