@openchamber/web 1.11.5 → 1.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/assets/{MarkdownRendererImpl-C3-ZpwEx.js → MarkdownRendererImpl-COdbjw73.js} +3 -3
  2. package/dist/assets/{MultiRunWindow-BDfPzMDy.js → MultiRunWindow-BKSHxjMq.js} +1 -1
  3. package/dist/assets/{OnboardingScreen-DGgh4IXB.js → OnboardingScreen-Chjg337p.js} +1 -1
  4. package/dist/assets/{SettingsWindow-B8QKr5dB.js → SettingsWindow-C0lRRW8M.js} +1 -1
  5. package/dist/assets/{TerminalView-D7IIkSGJ.js → TerminalView-Bvil3j1u.js} +4 -4
  6. package/dist/assets/es-BZIAUghG.js +15 -0
  7. package/dist/assets/index-UcCH2KN9.css +1 -0
  8. package/dist/assets/ko-DU9l-zox.js +15 -0
  9. package/dist/assets/{main-VVcyjpiF.js → main-Blhx9Fp5.js} +2 -2
  10. package/dist/assets/main-d2-dY4er.js +232 -0
  11. package/dist/assets/miniChat-CJ7-rZFl.js +2 -0
  12. package/dist/assets/{modelPrefsAutoSave-Ctdc3cCY.js → modelPrefsAutoSave-DRJSYigo.js} +96 -96
  13. package/dist/assets/{pl-C577DpsX.js → pl-CdqzokG-.js} +1 -1
  14. package/dist/assets/pt-BR-Bknbr_Y3.js +15 -0
  15. package/dist/assets/{renderElectronMiniChatApp-CsddCM3q.js → renderElectronMiniChatApp-BxZRI73j.js} +2 -2
  16. package/dist/assets/uk-Be4E8ZNO.js +15 -0
  17. package/dist/assets/zh-CN-qpPiaZMg.js +15 -0
  18. package/dist/index.html +3 -3
  19. package/dist/mini-chat.html +3 -3
  20. package/package.json +1 -1
  21. package/server/index.js +2 -0
  22. package/server/lib/cloudflare-tunnel.js +3 -5
  23. package/server/lib/ngrok-tunnel.js +209 -0
  24. package/server/lib/opencode/core-routes.js +1 -0
  25. package/server/lib/opencode/feature-routes-runtime.js +35 -0
  26. package/server/lib/opencode/index.js +19 -0
  27. package/server/lib/opencode/npm-registry.js +157 -0
  28. package/server/lib/opencode/npm-registry.test.js +179 -0
  29. package/server/lib/opencode/plugin-routes.js +373 -0
  30. package/server/lib/opencode/plugin-routes.test.js +384 -0
  31. package/server/lib/opencode/plugin-spec.js +107 -0
  32. package/server/lib/opencode/plugin-spec.test.js +154 -0
  33. package/server/lib/opencode/plugins.js +393 -0
  34. package/server/lib/opencode/plugins.test.js +176 -0
  35. package/server/lib/opencode/settings-helpers.js +3 -0
  36. package/server/lib/opencode/settings-helpers.test.js +11 -0
  37. package/server/lib/tunnels/DOCUMENTATION.md +1 -0
  38. package/server/lib/tunnels/providers/ngrok.js +117 -0
  39. package/server/lib/tunnels/types.js +2 -0
  40. package/dist/assets/es-dIVpApmS.js +0 -15
  41. package/dist/assets/index-Bk9IWJe1.css +0 -1
  42. package/dist/assets/ko-Cqf3E9-d.js +0 -15
  43. package/dist/assets/main-D45l3Dxw.js +0 -232
  44. package/dist/assets/miniChat-a9w7WM0c.js +0 -2
  45. package/dist/assets/pt-BR-BeeF6VlK.js +0 -15
  46. package/dist/assets/uk-CZ7XVz_D.js +0 -15
  47. package/dist/assets/zh-CN-BMSSqdyO.js +0 -15
  48. /package/dist/assets/{index-DHluop4D.js → index-B9LvUHdG.js} +0 -0
@@ -0,0 +1,179 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import * as npm from './npm-registry.js';
3
+
4
+ const originalFetch = globalThis.fetch;
5
+ const originalDateNow = Date.now;
6
+
7
+ let fetchMock;
8
+
9
+ function jsonResponse(body, status = 200) {
10
+ return Promise.resolve(new Response(JSON.stringify(body), {
11
+ status,
12
+ headers: { 'content-type': 'application/json' },
13
+ }));
14
+ }
15
+
16
+ describe('npm registry client', () => {
17
+ beforeEach(() => {
18
+ npm.clearCache();
19
+ Date.now = originalDateNow;
20
+ fetchMock = mock(() => jsonResponse({}));
21
+ globalThis.fetch = fetchMock;
22
+ });
23
+
24
+ afterEach(() => {
25
+ npm.clearCache();
26
+ globalThis.fetch = originalFetch;
27
+ Date.now = originalDateNow;
28
+ });
29
+
30
+ test('200 success returns latest versions and dist tags', async () => {
31
+ fetchMock.mockImplementation(() => jsonResponse({
32
+ 'dist-tags': { latest: '1.2.0' },
33
+ versions: { '1.0.0': {}, '1.2.0': {} },
34
+ }));
35
+
36
+ const result = await npm.lookupNpmPackage('foo');
37
+
38
+ expect(result).toEqual({
39
+ ok: true,
40
+ latest: '1.2.0',
41
+ versions: ['1.0.0', '1.2.0'],
42
+ distTags: { latest: '1.2.0' },
43
+ });
44
+ });
45
+
46
+ test('200 success handles missing dist-tags and versions', async () => {
47
+ fetchMock.mockImplementation(() => jsonResponse({}));
48
+
49
+ const result = await npm.lookupNpmPackage('foo');
50
+
51
+ expect(result).toEqual({ ok: true, latest: null, versions: [], distTags: {} });
52
+ });
53
+
54
+ test('404 returns package not found', async () => {
55
+ fetchMock.mockImplementation(() => jsonResponse({}, 404));
56
+
57
+ const result = await npm.lookupNpmPackage('missing');
58
+
59
+ expect(result).toEqual({ ok: false, status: 404, error: 'Package not found' });
60
+ });
61
+
62
+ test('500 returns registry error', async () => {
63
+ fetchMock.mockImplementation(() => jsonResponse({}, 500));
64
+
65
+ const result = await npm.lookupNpmPackage('foo');
66
+
67
+ expect(result).toEqual({ ok: false, status: 500, error: 'Registry returned 500' });
68
+ });
69
+
70
+ test('network error returns network status', async () => {
71
+ fetchMock.mockImplementation(() => Promise.reject(new Error('socket closed')));
72
+
73
+ const result = await npm.lookupNpmPackage('foo');
74
+
75
+ expect(result.ok).toBe(false);
76
+ expect(result.status).toBe('network');
77
+ expect(result.error).toBe('socket closed');
78
+ });
79
+
80
+ test('timeout plumbs AbortSignal to fetch', async () => {
81
+ fetchMock.mockImplementation((_url, init) => {
82
+ expect(init.signal).toBeInstanceOf(AbortSignal);
83
+ return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
84
+ });
85
+
86
+ const result = await npm.lookupNpmPackage('foo');
87
+
88
+ expect(result.ok).toBe(false);
89
+ expect(result.status).toBe('network');
90
+ expect(result.error).toContain('aborted');
91
+ });
92
+
93
+ test('cache hit reuses definitive success', async () => {
94
+ fetchMock.mockImplementation(() => jsonResponse({ 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': {} } }));
95
+
96
+ const first = await npm.getNpmInfo('foo');
97
+ const second = await npm.getNpmInfo('foo');
98
+
99
+ expect(first).toEqual(second);
100
+ expect(fetchMock).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ test('cache miss after ttl fetches again', async () => {
104
+ let now = 1_000;
105
+ Date.now = mock(() => now);
106
+ fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));
107
+
108
+ await npm.getNpmInfo('foo');
109
+ now += 3_600_001;
110
+ await npm.getNpmInfo('foo');
111
+
112
+ expect(fetchMock).toHaveBeenCalledTimes(2);
113
+ });
114
+
115
+ test('forceRefresh bypasses cache', async () => {
116
+ fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));
117
+
118
+ await npm.getNpmInfo('foo');
119
+ await npm.getNpmInfo('foo', { forceRefresh: true });
120
+
121
+ expect(fetchMock).toHaveBeenCalledTimes(2);
122
+ });
123
+
124
+ test('in-flight requests dedup by package name', async () => {
125
+ let release;
126
+ const wait = new Promise((resolve) => {
127
+ release = resolve;
128
+ });
129
+ fetchMock.mockImplementation(async () => {
130
+ await wait;
131
+ return new Response(JSON.stringify({ versions: { '1.0.0': {} } }), { status: 200 });
132
+ });
133
+
134
+ const requests = Promise.all([
135
+ npm.getNpmInfo('foo'),
136
+ npm.getNpmInfo('foo'),
137
+ npm.getNpmInfo('foo'),
138
+ ]);
139
+ release();
140
+ const results = await requests;
141
+
142
+ expect(results.every((result) => result.ok)).toBe(true);
143
+ expect(fetchMock).toHaveBeenCalledTimes(1);
144
+ });
145
+
146
+ test('network failure is not cached', async () => {
147
+ fetchMock
148
+ .mockImplementationOnce(() => Promise.reject(new Error('down')))
149
+ .mockImplementationOnce(() => jsonResponse({ versions: {} }));
150
+
151
+ const first = await npm.getNpmInfo('foo');
152
+ const second = await npm.getNpmInfo('foo');
153
+
154
+ expect(first.status).toBe('network');
155
+ expect(second.ok).toBe(true);
156
+ expect(fetchMock).toHaveBeenCalledTimes(2);
157
+ });
158
+
159
+ test('404 is cached', async () => {
160
+ fetchMock.mockImplementation(() => jsonResponse({}, 404));
161
+
162
+ await npm.getNpmInfo('missing');
163
+ await npm.getNpmInfo('missing');
164
+
165
+ expect(fetchMock).toHaveBeenCalledTimes(1);
166
+ });
167
+
168
+ test('scoped names encode slash in registry url', async () => {
169
+ await npm.getNpmInfo('@scope/pkg');
170
+
171
+ expect(fetchMock.mock.calls[0][0]).toBe('https://registry.npmjs.org/@scope%2Fpkg');
172
+ });
173
+
174
+ test('user-agent header is present', async () => {
175
+ await npm.getNpmInfo('foo');
176
+
177
+ expect(fetchMock.mock.calls[0][1].headers['User-Agent']).toMatch(/^openchamber-server\//);
178
+ });
179
+ });
@@ -0,0 +1,373 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+
4
+ import { getNpmInfo as defaultGetNpmInfo } from './npm-registry.js';
5
+ import { isExactSemver as defaultIsExactSemver, isPathSpec as defaultIsPathSpec, parseNpmSpec as defaultParseNpmSpec, parsePathSpec as defaultParsePathSpec } from './plugin-spec.js';
6
+
7
+ const ENTRY_EXISTS_CODES = new Set(['ENTRY_EXISTS', 'EEXIST']);
8
+ const FILE_EXISTS_CODES = new Set(['FILE_EXISTS', 'EEXIST']);
9
+ const NOT_FOUND_CODES = new Set(['NOT_FOUND', 'ENOENT']);
10
+ const BAD_REQUEST_CODES = new Set(['INVALID_FILENAME', 'INVALID_SCOPE', 'INVALID_SPEC', 'EINVAL']);
11
+
12
+ export const registerPluginRoutes = (app, dependencies) => {
13
+ const {
14
+ resolveOptionalProjectDirectory,
15
+ refreshOpenCodeAfterConfigChange,
16
+ clientReloadDelayMs,
17
+ listPluginEntries,
18
+ getPluginEntry,
19
+ createPluginEntry,
20
+ updatePluginEntry,
21
+ deletePluginEntry,
22
+ listPluginDirFiles,
23
+ readPluginDirFile,
24
+ writePluginDirFile,
25
+ deletePluginDirFile,
26
+ encodePluginId,
27
+ decodePluginId,
28
+ getNpmInfo = defaultGetNpmInfo,
29
+ parseNpmSpec = defaultParseNpmSpec,
30
+ parsePathSpec = defaultParsePathSpec,
31
+ isExactSemver = defaultIsExactSemver,
32
+ isPathSpec = defaultIsPathSpec,
33
+ } = dependencies;
34
+
35
+ const parsedKindForSpec = (spec) => (isPathSpec(spec) ? 'path' : 'npm');
36
+
37
+ const resolveDirectory = async (req, res) => {
38
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
39
+ if (error) {
40
+ res.status(400).json({ error });
41
+ return null;
42
+ }
43
+ return directory || null;
44
+ };
45
+
46
+ const successPayload = (message) => ({
47
+ success: true,
48
+ requiresReload: true,
49
+ message,
50
+ reloadDelayMs: clientReloadDelayMs,
51
+ reloadFailed: false,
52
+ warning: undefined,
53
+ });
54
+
55
+ const completePluginMutation = async (res, operation, _noun, applyChange) => {
56
+ applyChange();
57
+
58
+ const pastTense = operation.replace(/ion$/, 'ed').replace(/update$/, 'updated');
59
+
60
+ try {
61
+ await refreshOpenCodeAfterConfigChange(`plugin ${operation}`);
62
+ return res.json(successPayload(`Plugin ${pastTense}. Reloading interface…`));
63
+ } catch (error) {
64
+ console.error(`[API:plugin ${operation}] Reload failed after config write:`, error);
65
+ return res.json({
66
+ success: true,
67
+ requiresReload: false,
68
+ message: `Plugin ${pastTense}, but OpenCode reload failed.`,
69
+ reloadDelayMs: clientReloadDelayMs,
70
+ reloadFailed: true,
71
+ warning: error.message || 'OpenCode reload failed after plugin config changed',
72
+ });
73
+ }
74
+ };
75
+
76
+ const validateEntryId = (id) => {
77
+ const decoded = decodePluginId(id);
78
+ if (decoded.prefix !== 'config') {
79
+ const error = new Error('Plugin entry not found');
80
+ error.code = 'NOT_FOUND';
81
+ throw error;
82
+ }
83
+ };
84
+
85
+ const validateFileId = (id) => {
86
+ const decoded = decodePluginId(id);
87
+ if (decoded.prefix !== 'file') {
88
+ const error = new Error('Plugin file not found');
89
+ error.code = 'NOT_FOUND';
90
+ throw error;
91
+ }
92
+ };
93
+
94
+ const handlePluginError = (res, error, fallbackMessage, context, existsKind = null) => {
95
+ const code = error?.code;
96
+ if ((existsKind === 'entry' && ENTRY_EXISTS_CODES.has(code)) || (existsKind === 'file' && FILE_EXISTS_CODES.has(code))) {
97
+ return res.status(409).json({ error: error.message });
98
+ }
99
+ if (NOT_FOUND_CODES.has(code)) {
100
+ return res.status(404).json({ error: error.message });
101
+ }
102
+ if (BAD_REQUEST_CODES.has(code)) {
103
+ return res.status(400).json({ error: error.message });
104
+ }
105
+
106
+ console.error(context, error);
107
+ return res.status(500).json({ error: fallbackMessage });
108
+ };
109
+
110
+ app.get('/api/config/plugins', async (req, res) => {
111
+ try {
112
+ const directory = await resolveDirectory(req, res);
113
+ if (directory === null && res.headersSent) return;
114
+
115
+ res.json({
116
+ entries: listPluginEntries(directory),
117
+ files: listPluginDirFiles(directory),
118
+ });
119
+ } catch (error) {
120
+ console.error('[API:GET /api/config/plugins] Failed:', error);
121
+ res.status(500).json({ error: 'Failed to list plugins' });
122
+ }
123
+ });
124
+
125
+ app.get('/api/config/plugins/registry', async (req, res) => {
126
+ try {
127
+ const { directory, error: directoryError } = await resolveOptionalProjectDirectory(req);
128
+ if (directoryError) {
129
+ return res.status(400).json({ error: directoryError });
130
+ }
131
+ const rawSpecs = (req.query.specs || '').toString();
132
+ const specs = rawSpecs
133
+ ? rawSpecs.split(',').map((spec) => {
134
+ try {
135
+ return decodeURIComponent(spec);
136
+ } catch {
137
+ return spec;
138
+ }
139
+ }).filter((spec) => spec.length > 0)
140
+ : [];
141
+ const uniqueSpecs = Array.from(new Set(specs));
142
+ if (uniqueSpecs.length > 100) {
143
+ return res.status(400).json({ error: 'too many specs' });
144
+ }
145
+
146
+ const refresh = req.query.refresh === 'true';
147
+ const npmJobs = new Map();
148
+ const malformedSpecs = new Set();
149
+
150
+ for (const spec of uniqueSpecs) {
151
+ if (parsedKindForSpec(spec) !== 'npm') continue;
152
+
153
+ const parsed = parseNpmSpec(spec);
154
+ if (parsed.malformed) {
155
+ malformedSpecs.add(spec);
156
+ continue;
157
+ }
158
+
159
+ const job = npmJobs.get(parsed.name) || { specs: [], parsedBySpec: new Map() };
160
+ job.specs.push(spec);
161
+ job.parsedBySpec.set(spec, parsed);
162
+ npmJobs.set(parsed.name, job);
163
+ }
164
+
165
+ const npmInfoByName = new Map();
166
+ await Promise.all(Array.from(npmJobs.keys()).map(async (name) => {
167
+ npmInfoByName.set(name, await getNpmInfo(name, { forceRefresh: refresh }));
168
+ }));
169
+
170
+ const results = [];
171
+ for (const spec of uniqueSpecs) {
172
+ if (malformedSpecs.has(spec)) {
173
+ results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' });
174
+ continue;
175
+ }
176
+
177
+ if (parsedKindForSpec(spec) === 'path') {
178
+ const { absolutePath } = parsePathSpec(spec, { homedir: os.homedir(), cwd: directory || os.homedir() });
179
+ try {
180
+ fs.statSync(absolutePath);
181
+ } catch {
182
+ results.push({ kind: 'path-missing', spec, absolutePath });
183
+ continue;
184
+ }
185
+
186
+ try {
187
+ fs.accessSync(absolutePath, fs.constants.R_OK);
188
+ results.push({ kind: 'path-ok', spec, absolutePath });
189
+ } catch {
190
+ results.push({ kind: 'path-unreadable', spec, absolutePath });
191
+ }
192
+ continue;
193
+ }
194
+
195
+ const parsed = parseNpmSpec(spec);
196
+ const info = npmInfoByName.get(parsed.name);
197
+ if (!info.ok) {
198
+ if (info.status === 404) {
199
+ results.push({ kind: 'npm-missing-package', spec, name: parsed.name, error: info.error });
200
+ continue;
201
+ }
202
+
203
+ results.push({ kind: 'npm-network', spec, error: info.status === 'network' ? info.error : `Registry returned ${info.status}` });
204
+ continue;
205
+ }
206
+
207
+ const currentVersion = parsed.version;
208
+ if (currentVersion !== null && isExactSemver(currentVersion) && !info.versions.includes(currentVersion)) {
209
+ results.push({
210
+ kind: 'npm-missing-version',
211
+ spec,
212
+ name: parsed.name,
213
+ currentVersion,
214
+ latestVersion: info.latest,
215
+ versions: info.versions,
216
+ });
217
+ continue;
218
+ }
219
+
220
+ results.push({
221
+ kind: 'npm-ok',
222
+ spec,
223
+ name: parsed.name,
224
+ currentVersion,
225
+ latestVersion: info.latest,
226
+ versions: info.versions,
227
+ hasUpdate: currentVersion !== null && isExactSemver(currentVersion) && currentVersion !== info.latest,
228
+ });
229
+ }
230
+
231
+ return res.json({ results });
232
+ } catch (error) {
233
+ console.error('[API:GET /api/config/plugins/registry]', error);
234
+ return res.status(500).json({ error: 'Failed to query npm registry' });
235
+ }
236
+ });
237
+
238
+ app.get('/api/config/plugins/entry/:id', async (req, res) => {
239
+ try {
240
+ const directory = await resolveDirectory(req, res);
241
+ if (directory === null && res.headersSent) return;
242
+ validateEntryId(req.params.id);
243
+
244
+ const entry = getPluginEntry(req.params.id, directory);
245
+ if (!entry) {
246
+ return res.status(404).json({ error: 'Plugin entry not found' });
247
+ }
248
+ return res.json(entry);
249
+ } catch (error) {
250
+ return handlePluginError(res, error, 'Failed to get plugin entry', '[API:GET /api/config/plugins/entry/:id] Failed:');
251
+ }
252
+ });
253
+
254
+ app.post('/api/config/plugins/entry', async (req, res) => {
255
+ try {
256
+ const directory = await resolveDirectory(req, res);
257
+ if (directory === null && res.headersSent) return;
258
+
259
+ await completePluginMutation(res, 'entry creation', 'entry', () => {
260
+ createPluginEntry({
261
+ spec: req.body?.spec,
262
+ options: req.body?.options,
263
+ scope: req.body?.scope,
264
+ }, directory);
265
+ });
266
+ } catch (error) {
267
+ return handlePluginError(res, error, 'Failed to create plugin entry', '[API:POST /api/config/plugins/entry] Failed:', 'entry');
268
+ }
269
+ });
270
+
271
+ app.patch('/api/config/plugins/entry/:id', async (req, res) => {
272
+ try {
273
+ const directory = await resolveDirectory(req, res);
274
+ if (directory === null && res.headersSent) return;
275
+ validateEntryId(req.params.id);
276
+
277
+ await completePluginMutation(res, 'entry update', 'entry', () => {
278
+ updatePluginEntry(req.params.id, {
279
+ spec: req.body?.spec,
280
+ options: req.body?.options,
281
+ }, directory);
282
+ });
283
+ } catch (error) {
284
+ return handlePluginError(res, error, 'Failed to update plugin entry', '[API:PATCH /api/config/plugins/entry/:id] Failed:', 'entry');
285
+ }
286
+ });
287
+
288
+ app.delete('/api/config/plugins/entry/:id', async (req, res) => {
289
+ try {
290
+ const directory = await resolveDirectory(req, res);
291
+ if (directory === null && res.headersSent) return;
292
+ validateEntryId(req.params.id);
293
+
294
+ await completePluginMutation(res, 'entry deletion', 'entry', () => {
295
+ deletePluginEntry(req.params.id, directory);
296
+ });
297
+ } catch (error) {
298
+ return handlePluginError(res, error, 'Failed to delete plugin entry', '[API:DELETE /api/config/plugins/entry/:id] Failed:', 'entry');
299
+ }
300
+ });
301
+
302
+ app.get('/api/config/plugins/file/:id', async (req, res) => {
303
+ try {
304
+ const directory = await resolveDirectory(req, res);
305
+ if (directory === null && res.headersSent) return;
306
+ validateFileId(req.params.id);
307
+
308
+ const file = readPluginDirFile(req.params.id, directory);
309
+ if (!file) {
310
+ return res.status(404).json({ error: 'Plugin file not found' });
311
+ }
312
+ return res.json(file);
313
+ } catch (error) {
314
+ return handlePluginError(res, error, 'Failed to read plugin file', '[API:GET /api/config/plugins/file/:id] Failed:');
315
+ }
316
+ });
317
+
318
+ app.post('/api/config/plugins/file', async (req, res) => {
319
+ try {
320
+ const directory = await resolveDirectory(req, res);
321
+ if (directory === null && res.headersSent) return;
322
+ const id = encodePluginId('file', `${req.body?.scope || 'user'}:${req.body?.fileName || ''}`);
323
+
324
+ await completePluginMutation(res, 'file creation', 'file', () => {
325
+ validateFileId(id);
326
+ writePluginDirFile({
327
+ fileName: req.body?.fileName,
328
+ content: req.body?.content,
329
+ scope: req.body?.scope,
330
+ }, directory);
331
+ });
332
+ } catch (error) {
333
+ return handlePluginError(res, error, 'Failed to create plugin file', '[API:POST /api/config/plugins/file] Failed:', 'file');
334
+ }
335
+ });
336
+
337
+ app.put('/api/config/plugins/file/:id', async (req, res) => {
338
+ try {
339
+ const directory = await resolveDirectory(req, res);
340
+ if (directory === null && res.headersSent) return;
341
+ validateFileId(req.params.id);
342
+
343
+ const existing = readPluginDirFile(req.params.id, directory);
344
+ if (!existing) {
345
+ return res.status(404).json({ error: 'Plugin file not found' });
346
+ }
347
+
348
+ await completePluginMutation(res, 'file update', 'file', () => {
349
+ writePluginDirFile({
350
+ fileName: existing.fileName,
351
+ content: req.body?.content,
352
+ scope: existing.scope,
353
+ }, directory, { overwrite: true });
354
+ });
355
+ } catch (error) {
356
+ return handlePluginError(res, error, 'Failed to update plugin file', '[API:PUT /api/config/plugins/file/:id] Failed:', 'file');
357
+ }
358
+ });
359
+
360
+ app.delete('/api/config/plugins/file/:id', async (req, res) => {
361
+ try {
362
+ const directory = await resolveDirectory(req, res);
363
+ if (directory === null && res.headersSent) return;
364
+ validateFileId(req.params.id);
365
+
366
+ await completePluginMutation(res, 'file deletion', 'file', () => {
367
+ deletePluginDirFile(req.params.id, directory);
368
+ });
369
+ } catch (error) {
370
+ return handlePluginError(res, error, 'Failed to delete plugin file', '[API:DELETE /api/config/plugins/file/:id] Failed:', 'file');
371
+ }
372
+ });
373
+ };